diff --git a/README.md b/README.md index bddc4257..0fc6447d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Please note that running the above install command can fail on some versions of ## Requirements -python 3.x+ +python 3.5+ Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata: diff --git a/cps.py b/cps.py index 46eb1440..ab9896ce 100755 --- a/cps.py +++ b/cps.py @@ -17,18 +17,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import absolute_import, division, print_function, unicode_literals import sys import os # Insert local directories into path -if sys.version_info < (3, 0): - sys.path.append(os.path.dirname(os.path.abspath(__file__.decode('utf-8')))) - sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__.decode('utf-8'))), 'vendor')) -else: - sys.path.append(os.path.dirname(os.path.abspath(__file__))) - sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')) +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')) from cps import create_app @@ -42,13 +37,14 @@ from cps.admin import admi from cps.gdrive import gdrive from cps.editbooks import editbook from cps.remotelogin import remotelogin +from cps.search_metadata import meta from cps.error_handler import init_errorhandler try: from cps.kobo import kobo, get_kobo_activated from cps.kobo_auth import kobo_auth kobo_available = get_kobo_activated() -except ImportError: +except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator) kobo_available = False try: @@ -70,6 +66,7 @@ def main(): app.register_blueprint(shelf) app.register_blueprint(admi) app.register_blueprint(remotelogin) + app.register_blueprint(meta) app.register_blueprint(gdrive) app.register_blueprint(editbook) if kobo_available: diff --git a/cps/MyLoginManager.py b/cps/MyLoginManager.py new file mode 100644 index 00000000..7c916bd5 --- /dev/null +++ b/cps/MyLoginManager.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11, +# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh, +# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe, +# ruben-herold, marblepebble, JackED42, SiphonSquirrel, +# apetresc, nanu-c, mutschler, GammaC0de, vuolter +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from flask_login import LoginManager +from flask import session + + +class MyLoginManager(LoginManager): + def _session_protection_failed(self): + sess = session._get_current_object() + ident = self._session_identifier_generator() + if(sess and not (len(sess) == 1 and sess.get('csrf_token', None))) and ident != sess.get('_id', None): + return super(). _session_protection_failed() + return False diff --git a/cps/__init__.py b/cps/__init__.py index 3b100907..a6305f0a 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os import mimetypes @@ -29,7 +28,7 @@ from babel import Locale as LC from babel import negotiate_locale from babel.core import UnknownLocaleError from flask import Flask, request, g -from flask_login import LoginManager +from .MyLoginManager import MyLoginManager from flask_babel import Babel from flask_principal import Principal @@ -43,6 +42,12 @@ try: except ImportError: lxml_present = False +try: + from flask_wtf.csrf import CSRFProtect + wtf_present = True +except ImportError: + wtf_present = False + mimetypes.init() mimetypes.add_type('application/xhtml+xml', '.xhtml') mimetypes.add_type('application/epub+zip', '.epub') @@ -70,11 +75,17 @@ app.config.update( ) -lm = LoginManager() +lm = MyLoginManager() lm.login_view = 'web.login' lm.anonymous_user = ub.Anonymous lm.session_protection = 'strong' +if wtf_present: + csrf = CSRFProtect() + csrf.init_app(app) +else: + csrf = None + ub.init_db(cli.settingspath) # pylint: disable=no-member config = config_sql.load_configuration(ub.session) @@ -105,12 +116,12 @@ def create_app(): log.info('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***') print('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***') sys.exit(6) + if not wtf_present: + log.info('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***') + print('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***') + # sys.exit(7) + app.wsgi_app = ReverseProxied(app.wsgi_app) - # For python2 convert path to unicode - if sys.version_info < (3, 0): - app.static_folder = app.static_folder.decode('utf-8') - app.root_path = app.root_path.decode('utf-8') - app.instance_path = app.instance_path.decode('utf-8') if os.environ.get('FLASK_DEBUG'): cache_buster.init_cache_busting(app) @@ -139,7 +150,6 @@ def create_app(): def get_locale(): # if a user is logged in, use the locale from the user settings user = getattr(g, 'user', None) - # user = None if user is not None and hasattr(user, "locale"): if user.name != 'Guest': # if the account is the guest account bypass the config lang settings return user.locale @@ -160,6 +170,7 @@ def get_timezone(): user = getattr(g, 'user', None) return user.timezone if user else None + from .updater import Updater updater_thread = Updater() updater_thread.start() diff --git a/cps/about.py b/cps/about.py index 66c0ef40..f3c8b95f 100644 --- a/cps/about.py +++ b/cps/about.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import platform import sqlite3 @@ -29,6 +28,10 @@ from collections import OrderedDict import babel, pytz, requests, sqlalchemy import werkzeug, flask, flask_login, flask_principal, jinja2 from flask_babel import gettext as _ +try: + from flask_wtf import __version__ as flaskwtf_version +except ImportError: + flaskwtf_version = _(u'not installed') from . import db, calibre_db, converter, uploader, server, isoLanguages, constants from .render_template import render_title_template @@ -75,6 +78,7 @@ _VERSIONS = OrderedDict( Flask=flask.__version__, Flask_Login=flask_loginVersion, Flask_Principal=flask_principal.__version__, + Flask_WTF=flaskwtf_version, Werkzeug=werkzeug.__version__, Babel=babel.__version__, Jinja2=jinja2.__version__, @@ -84,14 +88,14 @@ _VERSIONS = OrderedDict( SQLite=sqlite3.sqlite_version, iso639=isoLanguages.__version__, pytz=pytz.__version__, - Unidecode = unidecode_version, - Scholarly = scholarly_version, - Flask_SimpleLDAP = u'installed' if bool(services.ldap) else None, - python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None, - Goodreads = u'installed' if bool(services.goodreads_support) else None, - jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None, - flask_dance = flask_danceVersion, - greenlet = greenlet_Version + Unidecode=unidecode_version, + Scholarly=scholarly_version, + Flask_SimpleLDAP=u'installed' if bool(services.ldap) else None, + python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None, + Goodreads=u'installed' if bool(services.goodreads_support) else None, + jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None, + flask_dance=flask_danceVersion, + greenlet=greenlet_Version ) _VERSIONS.update(uploader.get_versions()) diff --git a/cps/admin.py b/cps/admin.py index 7d2a1e8b..d811e4fb 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os import re import base64 @@ -41,7 +40,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_, text from . import constants, logger, helper, services -from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils +from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, kobo_sync_status from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ valid_email, check_username from .gdriveutils import is_gdrive_ready, gdrive_support @@ -236,8 +235,12 @@ def view_configuration(): .filter(and_(db.Custom_Columns.datatype == 'bool', db.Custom_Columns.mark_for_delete == 0)).all() restrict_columns = calibre_db.session.query(db.Custom_Columns)\ .filter(and_(db.Custom_Columns.datatype == 'text', db.Custom_Columns.mark_for_delete == 0)).all() + languages = calibre_db.speaking_language() + translations = [LC('en')] + babel.list_translations() return render_title_template("config_view_edit.html", conf=config, readColumns=read_column, restrictColumns=restrict_columns, + languages=languages, + translations=translations, title=_(u"UI Configuration"), page="uiconfig") @admi.route("/admin/usertable") @@ -515,16 +518,12 @@ def check_valid_restricted_column(column): return True - @admi.route("/admin/viewconfig", methods=["POST"]) @login_required @admin_required def update_view_configuration(): to_save = request.form.to_dict() - # _config_string = lambda x: config.set_from_dictionary(to_save, x, lambda y: y.strip() if y else y) - # _config_int = lambda x: config.set_from_dictionary(to_save, x, int) - _config_string(to_save, "config_calibre_web_title") _config_string(to_save, "config_columns_to_ignore") if _config_string(to_save, "config_title_regex"): @@ -546,6 +545,8 @@ def update_view_configuration(): _config_int(to_save, "config_random_books") _config_int(to_save, "config_books_per_page") _config_int(to_save, "config_authors_max") + _config_string(to_save, "config_default_language") + _config_string(to_save, "config_default_locale") config.config_default_role = constants.selected_roles(to_save) @@ -1208,6 +1209,7 @@ def _configuration_update_helper(): return _configuration_result(_('Certfile Location is not Valid, Please Enter Correct Path')) _config_checkbox_int(to_save, "config_uploading") + _config_checkbox_int(to_save, "config_unicode_filename") # Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse") and config.config_login_type == constants.LOGIN_LDAP) @@ -1310,6 +1312,8 @@ def _db_configuration_result(error_flash=None, gdrive_error=None): log.error(error_flash) config.load() flash(error_flash, category="error") + elif request.method == "POST" and not gdrive_error: + flash(_("Database Settings updated"), category="success") return render_title_template("config_db.html", config=config, @@ -1428,8 +1432,13 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support): else: content.sidebar_view &= ~constants.DETAIL_RANDOM + old_state = content.kobo_only_shelves_sync content.kobo_only_shelves_sync = int(to_save.get("kobo_only_shelves_sync") == "on") or 0 - + # 1 -> 0: nothing has to be done + # 0 -> 1: all synced books have to be added to archived books, + currently synced shelfs + # which don't have to be synced have to be removed (added to Shelf archive) + if old_state == 0 and content.kobo_only_shelves_sync == 1: + kobo_sync_status.update_on_sync_shelfs(content.id) if to_save.get("default_language"): content.default_language = to_save["default_language"] if to_save.get("locale"): @@ -1485,6 +1494,8 @@ def new_user(): else: content.role = config.config_default_role content.sidebar_view = config.config_default_show + content.locale = config.config_default_locale + content.default_language = config.config_default_language return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, languages=languages, title=_(u"Add new user"), page="newuser", kobo_support=kobo_support, registered_oauth=oauth_check) @@ -1682,7 +1693,8 @@ def get_updater_status(): "9": _(u'Update failed:') + u' ' + _(u'Connection error'), "10": _(u'Update failed:') + u' ' + _(u'Timeout while establishing connection'), "11": _(u'Update failed:') + u' ' + _(u'General error'), - "12": _(u'Update failed:') + u' ' + _(u'Update File Could Not be Saved in Temp Dir') + "12": _(u'Update failed:') + u' ' + _(u'Update file could not be saved in temp dir'), + "13": _(u'Update failed:') + u' ' + _(u'Files could not be replaced during update') } status['text'] = text updater_thread.status = 0 @@ -1736,6 +1748,8 @@ def ldap_import_create_user(user, user_data): content.password = '' # dummy password which will be replaced by ldap one content.email = useremail content.kindle_mail = kindlemail + content.default_language = config.config_default_language + content.locale = config.config_default_locale content.role = config.config_default_role content.sidebar_view = config.config_default_show content.allowed_tags = config.config_allowed_tags diff --git a/cps/cache_buster.py b/cps/cache_buster.py index 8c521fe1..9619d605 100644 --- a/cps/cache_buster.py +++ b/cps/cache_buster.py @@ -19,7 +19,6 @@ # Inspired by https://github.com/ChrisTM/Flask-CacheBust # Uses query strings so CSS font files are found without having to resort to absolute URLs -from __future__ import division, print_function, unicode_literals import os import hashlib diff --git a/cps/cli.py b/cps/cli.py index 3bb08c1f..3685e8e2 100644 --- a/cps/cli.py +++ b/cps/cli.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os import argparse @@ -48,19 +47,6 @@ parser.add_argument('-s', metavar='user:pass', help='Sets specific username to n parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version') args = parser.parse_args() -if sys.version_info < (3, 0): - if args.p: - args.p = args.p.decode('utf-8') - if args.g: - args.g = args.g.decode('utf-8') - if args.k: - args.k = args.k.decode('utf-8') - if args.c: - args.c = args.c.decode('utf-8') - if args.s: - args.s = args.s.decode('utf-8') - - settingspath = args.p or os.path.join(_CONFIG_DIR, "app.db") gdpath = args.g or os.path.join(_CONFIG_DIR, "gdrive.db") diff --git a/cps/comic.py b/cps/comic.py index 462c11f0..b094c60f 100644 --- a/cps/comic.py +++ b/cps/comic.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os from . import logger, isoLanguages diff --git a/cps/config_sql.py b/cps/config_sql.py index f3e82e7e..0b45059d 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . - -from __future__ import division, print_function, unicode_literals import os import sys import json @@ -91,6 +89,8 @@ class _Settings(_Base): config_default_role = Column(SmallInteger, default=0) config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR) + config_default_language = Column(String(3), default="all") + config_default_locale = Column(String(2), default="en") config_columns_to_ignore = Column(String) config_denied_tags = Column(String, default="") @@ -133,6 +133,7 @@ class _Settings(_Base): config_calibre = Column(String) config_rarfile_location = Column(String, default=None) config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD)) + config_unicode_filename =Column(Boolean, default=False) config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE) @@ -360,10 +361,6 @@ def _migrate_table(session, orm_class): session.query(column).first() except OperationalError as err: log.debug("%s: %s", column_name, err.args[0]) - if column.default is not None: - if sys.version_info < (3, 0): - if isinstance(column.default.arg, unicode): - column.default.arg = column.default.arg.encode('utf-8') if column.default is None: column_default = "" else: diff --git a/cps/constants.py b/cps/constants.py index 5aaf6cc8..367bc29d 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os from collections import namedtuple @@ -31,12 +30,7 @@ HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file UPDATER_AVAILABLE = True # Base dir is parent of current file, necessary if called from different folder -if sys.version_info < (3, 0): - BASE_DIR = os.path.abspath(os.path.join( - os.path.dirname(os.path.abspath(__file__)),os.pardir)).decode('utf-8') -else: - BASE_DIR = os.path.abspath(os.path.join( - os.path.dirname(os.path.abspath(__file__)),os.pardir)) +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),os.pardir)) STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') @@ -157,7 +151,7 @@ def selected_roles(dictionary): BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, ' 'series_id, languages, publisher') -STABLE_VERSION = {'version': '0.6.13'} +STABLE_VERSION = {'version': '0.6.14 Beta'} NIGHTLY_VERSION = {} NIGHTLY_VERSION[0] = '$Format:%H$' diff --git a/cps/converter.py b/cps/converter.py index 6b0f22e4..fcbabbfc 100644 --- a/cps/converter.py +++ b/cps/converter.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os import re from flask_babel import gettext as _ diff --git a/cps/db.py b/cps/db.py index cfb2ed7c..d8709335 100644 --- a/cps/db.py +++ b/cps/db.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os import re @@ -387,8 +386,6 @@ class Custom_Columns(Base): def get_display_dict(self): display_dict = ast.literal_eval(self.display) - if sys.version_info < (3, 0): - display_dict['enum_values'] = [x.decode('unicode_escape') for x in display_dict['enum_values']] return display_dict @@ -740,6 +737,7 @@ class CalibreDB(): self.session.connection().connection.connection.create_function("lower", 1, lcase) entries = self.session.query(database).filter(tag_filter). \ filter(func.lower(database.name).ilike("%" + query + "%")).all() + # json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries]) json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries]) return json_dumps diff --git a/cps/editbooks.py b/cps/editbooks.py index dd506fa6..d3e1f01e 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os from datetime import datetime import json @@ -40,14 +39,12 @@ try: except ImportError: have_scholar = False -from babel import Locale as LC -from babel.core import UnknownLocaleError from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response from flask_babel import gettext as _ from flask_login import current_user, login_required from sqlalchemy.exc import OperationalError, IntegrityError from sqlite3 import OperationalError as sqliteOperationalError -from . import constants, logger, isoLanguages, gdriveutils, uploader, helper +from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status from . import config, get_locale, ub, db from . import calibre_db from .services.worker import WorkerThread @@ -826,6 +823,8 @@ def edit_book(book_id): if modif_date: book.last_modified = datetime.utcnow() + kobo_sync_status.remove_synced_book(edited_books_id) + calibre_db.session.merge(book) calibre_db.session.commit() if config.config_use_google_drive: @@ -1131,10 +1130,11 @@ def edit_list_book(param): else: lang_names = list() for lang in book.languages: - try: - lang_names.append(LC.parse(lang.lang_code).get_language_name(get_locale())) - except UnknownLocaleError: - lang_names.append(_(isoLanguages.get(part3=lang.lang_code).name)) + lang_names.append(isoLanguages.get_language_name(get_locale(), lang.lang_code)) + #try: + # lang_names.append(LC.parse(lang.lang_code).get_language_name(get_locale())) + #except UnknownLocaleError: + # lang_names.append(_(isoLanguages.get(part3=lang.lang_code).name)) ret = Response(json.dumps({'success': True, 'newValue': ', '.join(lang_names)}), mimetype='application/json') elif param =='author_sort': diff --git a/cps/epub.py b/cps/epub.py index 998dbfa6..cbbdcbbd 100644 --- a/cps/epub.py +++ b/cps/epub.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os import zipfile from lxml import etree diff --git a/cps/fb2.py b/cps/fb2.py index af4a29a7..21586736 100644 --- a/cps/fb2.py +++ b/cps/fb2.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals from lxml import etree from .constants import BookMeta diff --git a/cps/gdrive.py b/cps/gdrive.py index c0764015..267607de 100644 --- a/cps/gdrive.py +++ b/cps/gdrive.py @@ -20,9 +20,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os -import sys import hashlib import json import tempfile @@ -34,7 +32,7 @@ from flask import Blueprint, flash, request, redirect, url_for, abort from flask_babel import gettext as _ from flask_login import login_required -from . import logger, gdriveutils, config, ub, calibre_db +from . import logger, gdriveutils, config, ub, calibre_db, csrf from .admin import admin_required gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive') @@ -118,6 +116,7 @@ def revoke_watch_gdrive(): return redirect(url_for('admin.db_configuration')) +@csrf.exempt @gdrive.route("/watch/callback", methods=['GET', 'POST']) def on_received_watch_confirmation(): if not config.config_google_drive_watch_changes_response: @@ -137,10 +136,7 @@ def on_received_watch_confirmation(): response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id']) log.debug('%r', response) if response: - if sys.version_info < (3, 0): - dbpath = os.path.join(config.config_calibre_dir, "metadata.db") - else: - dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode() + dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode() if not response['deleted'] and response['file']['title'] == 'metadata.db' \ and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web') diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index c63b0393..d3277814 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os import json import shutil diff --git a/cps/helper.py b/cps/helper.py index d3e0dbbb..df7019e6 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os import io @@ -234,16 +233,14 @@ def get_valid_filename(value, replace_whitespace=True): value = value[:-1]+u'_' value = value.replace("/", "_").replace(":", "_").strip('\0') if use_unidecode: - value = (unidecode.unidecode(value)) + if not config.config_unicode_filename: + value = (unidecode.unidecode(value)) else: value = value.replace(u'§', u'SS') value = value.replace(u'ß', u'ss') value = unicodedata.normalize('NFKD', value) re_slugify = re.compile(r'[\W\s-]', re.UNICODE) - if isinstance(value, str): # Python3 str, Python2 unicode - value = re_slugify.sub('', value) - else: - value = unicode(re_slugify.sub('', value)) + value = re_slugify.sub('', value) if replace_whitespace: # *+:\"/<>? are replaced by _ value = re.sub(r'[*+:\\\"/<>?]+', u'_', value, flags=re.U) @@ -252,10 +249,7 @@ def get_valid_filename(value, replace_whitespace=True): value = value[:128].strip() if not value: raise ValueError("Filename cannot be empty") - if sys.version_info.major == 3: - return value - else: - return value.decode('utf-8') + return value def split_authors(values): @@ -495,10 +489,7 @@ def reset_password(user_id): def generate_random_password(): s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?" passlen = 8 - if sys.version_info < (3, 0): - return "".join(s[ord(c) % len(s)] for c in os.urandom(passlen)) - else: - return "".join(s[c % len(s)] for c in os.urandom(passlen)) + return "".join(s[c % len(s)] for c in os.urandom(passlen)) def uniq(inpt): @@ -712,8 +703,6 @@ def check_unrar(unrarLocation): return _('Unrar binary file not found') try: - if sys.version_info < (3, 0): - unrarLocation = unrarLocation.encode(sys.getfilesystemencoding()) unrarLocation = [unrarLocation] value = process_wait(unrarLocation, pattern='UNRAR (.*) freeware') if value: @@ -842,8 +831,8 @@ def get_download_link(book_id, book_format, client): ub.update_download(book_id, int(current_user.id)) file_name = book.title if len(book.authors) > 0: - file_name = book.authors[0].name + '_' + file_name - file_name = get_valid_filename(file_name) + file_name = file_name + ' - ' + book.authors[0].name + file_name = get_valid_filename(file_name, replace_whitespace=False) headers = Headers() headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream") headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % ( diff --git a/cps/isoLanguages.py b/cps/isoLanguages.py index 35d9f0a7..6c66a583 100644 --- a/cps/isoLanguages.py +++ b/cps/isoLanguages.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals - from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES diff --git a/cps/iso_language_names.py b/cps/iso_language_names.py index d07158a0..9966ecd9 100644 --- a/cps/iso_language_names.py +++ b/cps/iso_language_names.py @@ -6,8 +6,6 @@ # pylint: disable=too-many-lines,bad-continuation -from __future__ import unicode_literals - # This file is autogenerated, do NOT add, change, or delete ANY string # If you need help or assistance for adding a new language, please contact the project team @@ -7855,4 +7853,4 @@ LANGUAGE_NAMES = { "zxx": "No linguistic content", "zza": "Zaza" } -} \ No newline at end of file +} diff --git a/cps/jinjia.py b/cps/jinjia.py index 554bc791..06e99141 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -22,7 +22,6 @@ # custom jinja filters -from __future__ import division, print_function, unicode_literals import datetime import mimetypes from uuid import uuid4 diff --git a/cps/kobo.py b/cps/kobo.py index 6952a692..a415ec5f 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -19,7 +19,6 @@ import base64 import datetime -import sys import os import uuid from time import gmtime, strftime @@ -47,7 +46,8 @@ from sqlalchemy.exc import StatementError from sqlalchemy.sql import select import requests -from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub + +from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status from .constants import sqlalchemy_version2 from .helper import get_download_link from .services import SyncToken as SyncToken @@ -170,9 +170,12 @@ def HandleSyncRequest(): ub.ArchivedBook.is_archived) changed_entries = (changed_entries .join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id) - .filter(or_(db.Books.last_modified > sync_token.books_last_modified, - ub.BookShelf.date_added > sync_token.books_last_modified)) - .filter(db.Data.format.in_(KOBO_FORMATS)).filter(calibre_db.common_filters()) + .join(ub.KoboSyncedBooks, ub.KoboSyncedBooks.book_id == db.Books.id, isouter=True) + .filter(or_(ub.KoboSyncedBooks.user_id != current_user.id, + ub.KoboSyncedBooks.book_id == None)) + .filter(ub.BookShelf.date_added > sync_token.books_last_modified) + .filter(db.Data.format.in_(KOBO_FORMATS)) + .filter(calibre_db.common_filters(allow_show_archived=True)) .order_by(db.Books.id) .order_by(ub.ArchivedBook.last_modified) .join(ub.BookShelf, db.Books.id == ub.BookShelf.book_id) @@ -189,16 +192,16 @@ def HandleSyncRequest(): ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived) changed_entries = (changed_entries - .join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id) - .filter(db.Books.last_modified > sync_token.books_last_modified) - .filter(calibre_db.common_filters()) - .filter(db.Data.format.in_(KOBO_FORMATS)) - .order_by(db.Books.last_modified) - .order_by(db.Books.id) + .join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id) + .join(ub.KoboSyncedBooks, ub.KoboSyncedBooks.book_id == db.Books.id, isouter=True) + .filter(or_(ub.KoboSyncedBooks.user_id != current_user.id, + ub.KoboSyncedBooks.book_id == None)) + .filter(calibre_db.common_filters()) + .filter(db.Data.format.in_(KOBO_FORMATS)) + .order_by(db.Books.last_modified) + .order_by(db.Books.id) ) - if sync_token.books_last_id > -1: - changed_entries = changed_entries.filter(db.Books.id > sync_token.books_last_id) reading_states_in_new_entitlements = [] if sqlalchemy_version2: @@ -206,6 +209,7 @@ def HandleSyncRequest(): else: books = changed_entries.limit(SYNC_ITEM_LIMIT) for book in books: + kobo_sync_status.add_synced_books(book.Books.id) formats = [data.format for data in book.Books.data] if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats: helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name) @@ -263,11 +267,11 @@ def HandleSyncRequest(): entries = calibre_db.session.execute(changed_entries).all() book_count = len(entries) else: - entries = changed_entries.all() + #entries = changed_entries.all() book_count = changed_entries.count() # last entry: - books_last_id = entries[-1].Books.id or -1 if book_count else -1 - + # sync_cont = entries[-1].Books.id or -1 if book_count else -1 + log.debug("Remaining books to Sync: {}".format(book_count)) # generate reading state data changed_reading_states = ub.session.query(ub.KoboReadingState) @@ -305,7 +309,7 @@ def HandleSyncRequest(): sync_token.books_last_modified = new_books_last_modified sync_token.archive_last_modified = new_archived_last_modified sync_token.reading_state_last_modified = new_reading_state_last_modified - sync_token.books_last_id = books_last_id + # sync_token.books_last_id = books_last_id return generate_sync_response(sync_token, sync_results, book_count) @@ -330,7 +334,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False): extra_headers["x-kobo-sync"] = "continue" sync_token.to_headers(extra_headers) - log.debug("Kobo Sync Content: {}".format(sync_results)) + # log.debug("Kobo Sync Content: {}".format(sync_results)) response = make_response(jsonify(sync_results), extra_headers) return response @@ -483,10 +487,7 @@ def get_metadata(book): metadata.update(get_author(book)) if get_series(book): - if sys.version_info < (3, 0): - name = get_series(book).encode("utf-8") - else: - name = get_series(book) + name = get_series(book) metadata["Series"] = { "Name": get_series(book), "Number": get_seriesindex(book), # ToDo Check int() ? @@ -497,7 +498,7 @@ def get_metadata(book): return metadata - +@csrf.exempt @kobo.route("/v1/library/tags", methods=["POST", "DELETE"]) @requires_kobo_auth # Creates a Shelf with the given items, and returns the shelf's uuid. @@ -532,6 +533,7 @@ def HandleTagCreate(): return make_response(jsonify(str(shelf.uuid)), 201) +@csrf.exempt @kobo.route("/v1/library/tags/", methods=["DELETE", "PUT"]) @requires_kobo_auth def HandleTagUpdate(tag_id): @@ -587,6 +589,7 @@ def add_items_to_shelf(items, shelf): return items_unknown_to_calibre +@csrf.exempt @kobo.route("/v1/library/tags//items", methods=["POST"]) @requires_kobo_auth def HandleTagAddItem(tag_id): @@ -616,6 +619,7 @@ def HandleTagAddItem(tag_id): return make_response('', 201) +@csrf.exempt @kobo.route("/v1/library/tags//items/delete", methods=["POST"]) @requires_kobo_auth def HandleTagRemoveItem(tag_id): @@ -757,7 +761,7 @@ def create_kobo_tag(shelf): ) return {"Tag": tag} - +@csrf.exempt @kobo.route("/v1/library//state", methods=["GET", "PUT"]) @requires_kobo_auth def HandleStateRequest(book_uuid): @@ -932,6 +936,7 @@ def TopLevelEndpoint(): return make_response(jsonify({})) +@csrf.exempt @kobo.route("/v1/library/", methods=["DELETE"]) @requires_kobo_auth def HandleBookDeletionRequest(book_uuid): @@ -958,6 +963,7 @@ def HandleBookDeletionRequest(book_uuid): # TODO: Implement the following routes +@csrf.exempt @kobo.route("/v1/library/", methods=["DELETE", "GET"]) def HandleUnimplementedRequest(dummy=None): log.debug("Unimplemented Library Request received: %s", request.base_url) @@ -965,6 +971,7 @@ def HandleUnimplementedRequest(dummy=None): # TODO: Implement the following routes +@csrf.exempt @kobo.route("/v1/user/loyalty/", methods=["GET", "POST"]) @kobo.route("/v1/user/profile", methods=["GET", "POST"]) @kobo.route("/v1/user/wishlist", methods=["GET", "POST"]) @@ -975,6 +982,7 @@ def HandleUserRequest(dummy=None): return redirect_or_proxy_request() +@csrf.exempt @kobo.route("/v1/products//prices", methods=["GET", "POST"]) @kobo.route("/v1/products//recommendations", methods=["GET", "POST"]) @kobo.route("/v1/products//nextread", methods=["GET", "POST"]) @@ -1008,6 +1016,7 @@ def make_calibre_web_auth_response(): ) +@csrf.exempt @kobo.route("/v1/auth/device", methods=["POST"]) @requires_kobo_auth def HandleAuthRequest(): diff --git a/cps/kobo_sync_status.py b/cps/kobo_sync_status.py new file mode 100644 index 00000000..f4a66604 --- /dev/null +++ b/cps/kobo_sync_status.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from flask_login import current_user +from . import ub +import datetime +from sqlalchemy.sql.expression import or_ + + +def add_synced_books(book_id): + synced_book = ub.KoboSyncedBooks() + synced_book.user_id = current_user.id + synced_book.book_id = book_id + ub.session.add(synced_book) + ub.session_commit() + + +def remove_synced_book(book_id): + ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id).delete() + ub.session_commit() + +def add_archived_books(book_id): + archived_book = ( + ub.session.query(ub.ArchivedBook) + .filter(ub.ArchivedBook.book_id == book_id) + .first() + ) + if not archived_book: + archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id) + archived_book.is_archived = True + archived_book.last_modified = datetime.datetime.utcnow() + + ub.session.merge(archived_book) + ub.session_commit() + + + +# select all books which are synced by the current user and do not belong to a synced shelf and them to archive +# select all shelfs from current user which are synced and do not belong to the "only sync" shelfs +def update_on_sync_shelfs(content_id): + books_to_archive = (ub.session.query(ub.KoboSyncedBooks) + .join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_id, isouter=True) + .join(ub.Shelf, ub.Shelf.user_id == content_id, isouter=True) + .filter(or_(ub.Shelf.kobo_sync == 0, ub.Shelf.kobo_sync == None)) + .filter(ub.KoboSyncedBooks.user_id == content_id).all()) + for b in books_to_archive: + add_archived_books(b.book_id) + ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == b.book_id).filter(ub.KoboSyncedBooks.user_id == content_id).delete() + ub.session_commit() + + shelfs_to_archive = ub.session.query(ub.Shelf).filter(ub.Shelf.user_id == content_id).filter( + ub.Shelf.kobo_sync == 0).all() + for a in shelfs_to_archive: + ub.session.add(ub.ShelfArchive(uuid=a.uuid, user_id=content_id)) + ub.session_commit() diff --git a/cps/logger.py b/cps/logger.py index e2747f53..5847188b 100644 --- a/cps/logger.py +++ b/cps/logger.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import os import sys import inspect diff --git a/cps/metadata_provider/comicvine.py b/cps/metadata_provider/comicvine.py new file mode 100644 index 00000000..8f496608 --- /dev/null +++ b/cps/metadata_provider/comicvine.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# ComicVine api document: https://comicvine.gamespot.com/api/documentation + +import requests +from cps.services.Metadata import Metadata + + +class ComicVine(Metadata): + __name__ = "ComicVine" + __id__ = "comicvine" + + def search(self, query, __): + val = list() + apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6" + if self.active: + headers = { + 'User-Agent': 'Not Evil Browser' + } + + result = requests.get("https://comicvine.gamespot.com/api/search?api_key=" + + apikey + "&resources=issue&query=" + query + "&sort=name:desc&format=json", headers=headers) + for r in result.json()['results']: + seriesTitle = r['volume'].get('name', "") + if r.get('store_date'): + dateFomers = r.get('store_date') + else: + dateFomers = r.get('date_added') + v = dict() + v['id'] = r['id'] + v['title'] = seriesTitle + " #" + r.get('issue_number', "0") + " - " + ( r.get('name', "") or "") + v['authors'] = r.get('authors', []) + v['description'] = r.get('description', "") + v['publisher'] = "" + v['publishedDate'] = dateFomers + v['tags'] = ["Comics", seriesTitle] + v['rating'] = 0 + v['series'] = seriesTitle + v['cover'] = r['image'].get('original_url') + v['source'] = { + "id": self.__id__, + "description": "ComicVine Books", + "link": "https://comicvine.gamespot.com/" + } + v['url'] = r.get('site_detail_url', "") + val.append(v) + return val + + diff --git a/cps/metadata_provider/google.py b/cps/metadata_provider/google.py new file mode 100644 index 00000000..f3d02d8e --- /dev/null +++ b/cps/metadata_provider/google.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Google Books api document: https://developers.google.com/books/docs/v1/using + + +import requests +from cps.services.Metadata import Metadata + +class Google(Metadata): + __name__ = "Google" + __id__ = "google" + + def search(self, query, __): + if self.active: + val = list() + result = requests.get("https://www.googleapis.com/books/v1/volumes?q="+query.replace(" ","+")) + for r in result.json()['items']: + v = dict() + v['id'] = r['id'] + v['title'] = r['volumeInfo']['title'] + v['authors'] = r['volumeInfo'].get('authors', []) + v['description'] = r['volumeInfo'].get('description', "") + v['publisher'] = r['volumeInfo'].get('publisher', "") + v['publishedDate'] = r['volumeInfo'].get('publishedDate', "") + v['tags'] = r['volumeInfo'].get('categories', []) + v['rating'] = r['volumeInfo'].get('averageRating', 0) + if r['volumeInfo'].get('imageLinks'): + v['cover'] = r['volumeInfo']['imageLinks']['thumbnail'].replace("http://", "https://") + else: + v['cover'] = "/../../../static/generic_cover.jpg" + v['source'] = { + "id": self.__id__, + "description": "Google Books", + "link": "https://books.google.com/"} + v['url'] = "https://books.google.com/books?id=" + r['id'] + val.append(v) + return val + + diff --git a/cps/metadata_provider/scholar.py b/cps/metadata_provider/scholar.py new file mode 100644 index 00000000..6e13c768 --- /dev/null +++ b/cps/metadata_provider/scholar.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from scholarly import scholarly + +from cps.services.Metadata import Metadata + + +class scholar(Metadata): + __name__ = "Google Scholar" + __id__ = "googlescholar" + + def search(self, query, generic_cover=""): + val = list() + if self.active: + scholar_gen = scholarly.search_pubs(' '.join(query.split('+'))) + i = 0 + for publication in scholar_gen: + v = dict() + v['id'] = "1234" # publication['bib'].get('title') + v['title'] = publication['bib'].get('title') + v['authors'] = publication['bib'].get('author', []) + v['description'] = publication['bib'].get('abstract', "") + v['publisher'] = publication['bib'].get('venue', "") + if publication['bib'].get('pub_year'): + v['publishedDate'] = publication['bib'].get('pub_year')+"-01-01" + else: + v['publishedDate'] = "" + v['tags'] = "" + v['ratings'] = 0 + v['series'] = "" + v['cover'] = generic_cover + v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "", + v['source'] = { + "id": self.__id__, + "description": "Google Scholar", + "link": "https://scholar.google.com/" + } + val.append(v) + i += 1 + if (i >= 10): + break + return val + + + diff --git a/cps/oauth.py b/cps/oauth.py index a8995180..f8e5c1fd 100644 --- a/cps/oauth.py +++ b/cps/oauth.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -from __future__ import division, print_function, unicode_literals from flask import session try: diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py index c8cc2e3e..d9efd41e 100644 --- a/cps/oauth_bb.py +++ b/cps/oauth_bb.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -from __future__ import division, print_function, unicode_literals import json from functools import wraps diff --git a/cps/opds.py b/cps/opds.py index e444302a..92c51d1b 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import datetime from functools import wraps @@ -433,16 +432,17 @@ def feed_languagesindex(): if current_user.filter_language() == u"all": languages = calibre_db.speaking_language() else: - try: - cur_l = LC.parse(current_user.filter_language()) - except UnknownLocaleError: - cur_l = None + #try: + # cur_l = LC.parse(current_user.filter_language()) + #except UnknownLocaleError: + # cur_l = None languages = calibre_db.session.query(db.Languages).filter( db.Languages.lang_code == current_user.filter_language()).all() - if cur_l: - languages[0].name = cur_l.get_language_name(get_locale()) - else: - languages[0].name = _(isoLanguages.get(part3=languages[0].lang_code).name) + languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code) + #if cur_l: + # languages[0].name = cur_l.get_language_name(get_locale()) + #else: + # languages[0].name = _(isoLanguages.get(part3=languages[0].lang_code).name) pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, len(languages)) return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination) @@ -536,11 +536,10 @@ def feed_search(term): def check_auth(username, password): - if sys.version_info.major == 3: - try: - username = username.encode('windows-1252') - except UnicodeEncodeError: - username = username.encode('utf-8') + try: + username = username.encode('windows-1252') + except UnicodeEncodeError: + username = username.encode('utf-8') user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.decode('utf-8').lower()).first() if bool(user and check_password_hash(str(user.password), password)): diff --git a/cps/pagination.py b/cps/pagination.py index c29717c2..7a9bfb70 100644 --- a/cps/pagination.py +++ b/cps/pagination.py @@ -20,7 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals from math import ceil diff --git a/cps/redirect.py b/cps/redirect.py index 593afd0c..d491b353 100644 --- a/cps/redirect.py +++ b/cps/redirect.py @@ -27,7 +27,6 @@ # http://flask.pocoo.org/snippets/62/ -from __future__ import division, print_function, unicode_literals try: from urllib.parse import urlparse, urljoin except ImportError: diff --git a/cps/reverseproxy.py b/cps/reverseproxy.py index 3bcbd3b7..4acb8e45 100644 --- a/cps/reverseproxy.py +++ b/cps/reverseproxy.py @@ -36,8 +36,6 @@ # # Inspired by http://flask.pocoo.org/snippets/35/ -from __future__ import division, print_function, unicode_literals - class ReverseProxied(object): """Wrap the application in this middleware and configure the diff --git a/cps/search_metadata.py b/cps/search_metadata.py new file mode 100644 index 00000000..e837fe21 --- /dev/null +++ b/cps/search_metadata.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import json +import importlib +import sys +import inspect +import datetime +import concurrent.futures + +from flask import Blueprint, request, Response, url_for +from flask_login import current_user +from flask_login import login_required +from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy.exc import OperationalError, InvalidRequestError + +from . import constants, logger, ub +from cps.services.Metadata import Metadata + + +meta = Blueprint('metadata', __name__) + +log = logger.create() + +new_list = list() +meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider") +modules = os.listdir(os.path.join(constants.BASE_DIR, "cps", "metadata_provider")) +for f in modules: + if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith('__init__.py'): + a = os.path.basename(f)[:-3] + try: + importlib.import_module("cps.metadata_provider." + a) + new_list.append(a) + except ImportError: + log.error("Import error for metadata source: {}".format(a)) + pass + +def list_classes(provider_list): + classes = list() + for element in provider_list: + for name, obj in inspect.getmembers(sys.modules["cps.metadata_provider." + element]): + if inspect.isclass(obj) and name != "Metadata" and issubclass(obj, Metadata): + classes.append(obj()) + return classes + +cl = list_classes(new_list) + +@meta.route("/metadata/provider") +@login_required +def metadata_provider(): + active = current_user.view_settings.get('metadata', {}) + provider = list() + for c in cl: + ac = active.get(c.__id__, True) + provider.append({"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}) + return Response(json.dumps(provider), mimetype='application/json') + +@meta.route("/metadata/provider", methods=['POST']) +@meta.route("/metadata/provider/", methods=['POST']) +@login_required +def metadata_change_active_provider(prov_name): + new_state = request.get_json() + active = current_user.view_settings.get('metadata', {}) + active[new_state['id']] = new_state['value'] + current_user.view_settings['metadata'] = active + try: + try: + flag_modified(current_user, "view_settings") + except AttributeError: + pass + ub.session.commit() + except (InvalidRequestError, OperationalError): + log.error("Invalid request received: {}".format(request)) + return "Invalid request", 400 + if "initial" in new_state and prov_name: + for c in cl: + if c.__id__ == prov_name: + data = c.search(new_state.get('query', "")) + break + return Response(json.dumps(data), mimetype='application/json') + return "" + +@meta.route("/metadata/search", methods=['POST']) +@login_required +def metadata_search(): + query = request.form.to_dict().get('query') + data = list() + active = current_user.view_settings.get('metadata', {}) + if query: + static_cover = url_for('static', filename='generic_cover.jpg') + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + meta = {executor.submit(c.search, query, static_cover): c for c in cl if active.get(c.__id__, True)} + for future in concurrent.futures.as_completed(meta): + data.extend(future.result()) + return Response(json.dumps(data), mimetype='application/json') + + + + + + diff --git a/cps/server.py b/cps/server.py index 9b79f77d..e261c50a 100644 --- a/cps/server.py +++ b/cps/server.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os import errno diff --git a/cps/services/Metadata.py b/cps/services/Metadata.py new file mode 100644 index 00000000..d6e4e7d5 --- /dev/null +++ b/cps/services/Metadata.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class Metadata(): + __name__ = "Generic" + + def __init__(self): + self.active = True + + def set_status(self, state): + self.active = state diff --git a/cps/services/SyncToken.py b/cps/services/SyncToken.py index cc67542c..692aaa24 100644 --- a/cps/services/SyncToken.py +++ b/cps/services/SyncToken.py @@ -35,10 +35,7 @@ log = logger.create() def b64encode_json(json_data): - if sys.version_info < (3, 0): - return b64encode(json.dumps(json_data)) - else: - return b64encode(json.dumps(json_data).encode()) + return b64encode(json.dumps(json_data).encode()) # Python3 has a timestamp() method we could be calling, however it's not avaiable in python2. @@ -85,8 +82,8 @@ class SyncToken: "books_last_created": {"type": "string"}, "archive_last_modified": {"type": "string"}, "reading_state_last_modified": {"type": "string"}, - "tags_last_modified": {"type": "string"}, - "books_last_id": {"type": "integer", "optional": True} + "tags_last_modified": {"type": "string"} + # "books_last_id": {"type": "integer", "optional": True} }, } @@ -97,8 +94,8 @@ class SyncToken: books_last_modified=datetime.min, archive_last_modified=datetime.min, reading_state_last_modified=datetime.min, - tags_last_modified=datetime.min, - books_last_id=-1 + tags_last_modified=datetime.min + # books_last_id=-1 ): # nosec self.raw_kobo_store_token = raw_kobo_store_token self.books_last_created = books_last_created @@ -106,7 +103,7 @@ class SyncToken: self.archive_last_modified = archive_last_modified self.reading_state_last_modified = reading_state_last_modified self.tags_last_modified = tags_last_modified - self.books_last_id = books_last_id + # self.books_last_id = books_last_id @staticmethod def from_headers(headers): @@ -141,12 +138,12 @@ class SyncToken: archive_last_modified = get_datetime_from_json(data_json, "archive_last_modified") reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_last_modified") tags_last_modified = get_datetime_from_json(data_json, "tags_last_modified") - books_last_id = data_json["books_last_id"] + # books_last_id = data_json["books_last_id"] except TypeError: log.error("SyncToken timestamps don't parse to a datetime.") return SyncToken(raw_kobo_store_token=raw_kobo_store_token) - except KeyError: - books_last_id = -1 + #except KeyError: + # books_last_id = -1 return SyncToken( raw_kobo_store_token=raw_kobo_store_token, @@ -155,7 +152,7 @@ class SyncToken: archive_last_modified=archive_last_modified, reading_state_last_modified=reading_state_last_modified, tags_last_modified=tags_last_modified, - books_last_id=books_last_id + #books_last_id=books_last_id ) def set_kobo_store_header(self, store_headers): @@ -179,16 +176,16 @@ class SyncToken: "archive_last_modified": to_epoch_timestamp(self.archive_last_modified), "reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified), "tags_last_modified": to_epoch_timestamp(self.tags_last_modified), - "books_last_id":self.books_last_id + #"books_last_id":self.books_last_id }, } return b64encode_json(token) def __str__(self): - return "{},{},{},{},{},{},{}".format(self.raw_kobo_store_token, + return "{},{},{},{},{},{}".format(self.raw_kobo_store_token, self.books_last_created, self.books_last_modified, self.archive_last_modified, self.reading_state_last_modified, - self.tags_last_modified, - self.books_last_id) + self.tags_last_modified) + #self.books_last_id) diff --git a/cps/services/__init__.py b/cps/services/__init__.py index e6e5954c..32a9d485 100644 --- a/cps/services/__init__.py +++ b/cps/services/__init__.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals - from .. import logger diff --git a/cps/services/gmail.py b/cps/services/gmail.py index baada1f8..ff36b308 100644 --- a/cps/services/gmail.py +++ b/cps/services/gmail.py @@ -1,4 +1,21 @@ -from __future__ import print_function +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + import os.path from google_auth_oauthlib.flow import InstalledAppFlow from google.auth.transport.requests import Request diff --git a/cps/services/goodreads_support.py b/cps/services/goodreads_support.py index 9312bc0f..74e6eba9 100644 --- a/cps/services/goodreads_support.py +++ b/cps/services/goodreads_support.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import time from functools import reduce diff --git a/cps/services/simpleldap.py b/cps/services/simpleldap.py index 4125bdab..1ca7e5bf 100644 --- a/cps/services/simpleldap.py +++ b/cps/services/simpleldap.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import base64 from flask_simpleldap import LDAP, LDAPException diff --git a/cps/services/worker.py b/cps/services/worker.py index 1baf25fe..5952c705 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -1,5 +1,21 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 pwr +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import threading import abc import uuid @@ -205,10 +221,13 @@ class CalibreTask: # By default, we're good to clean a task if it's "Done" return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL) - @progress.setter - def progress(self, x): - # todo: throw error if outside of [0,1] - self._progress = x + '''@progress.setter + def progress(self, x): + if x > 1: + x = 1 + if x < 0: + x = 0 + self._progress = x''' def _handleError(self, error_message): self.stat = STAT_FAIL diff --git a/cps/shelf.py b/cps/shelf.py index d232e850..09fe8dd4 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -20,8 +20,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals - import sys from datetime import datetime diff --git a/cps/static/js/libs/djvu_html5/Djvu_html5.css b/cps/static/css/libs/Djvu_html5.css similarity index 93% rename from cps/static/js/libs/djvu_html5/Djvu_html5.css rename to cps/static/css/libs/Djvu_html5.css index 77e37c97..a7ed799e 100644 --- a/cps/static/js/libs/djvu_html5/Djvu_html5.css +++ b/cps/static/css/libs/Djvu_html5.css @@ -35,7 +35,7 @@ body { float: left; width: 40px; height: 40px; - background-image: url("img/toolbar-buttons.png"); + background-image: url("../../js/libs/djvu_html5/img/toolbar-buttons.png"); background-repeat: no-repeat; background-size: 500% 300%; } @@ -185,10 +185,10 @@ _:-ms-lang(x), .textLayer { width: 128px; height: 128px; margin: -72px 0 0 -64px; - background-image: url("img/status.png"); + background-image: url("../../js/libs/djvu_html5/img/status.png"); background-repeat: no-repeat; } .blankImage { - background-image: url("img/blank.jpg"); -} \ No newline at end of file + background-image: url("../../js/libs/djvu_html5/img/blank.jpg"); +} diff --git a/cps/static/css/libs/typeahead.css b/cps/static/css/libs/typeahead.css index fde250d6..fcc17a5b 100644 --- a/cps/static/css/libs/typeahead.css +++ b/cps/static/css/libs/typeahead.css @@ -145,7 +145,7 @@ fieldset[disabled] .twitter-typeahead .tt-input { cursor: not-allowed; background-color: #eeeeee !important; } -.tt-dropdown-menu { +.tt-menu { position: absolute; top: 100%; left: 0; @@ -166,7 +166,7 @@ fieldset[disabled] .twitter-typeahead .tt-input { *border-right-width: 2px; *border-bottom-width: 2px; } -.tt-dropdown-menu .tt-suggestion { +.tt-menu .tt-suggestion { display: block; padding: 3px 20px; clear: both; @@ -175,15 +175,15 @@ fieldset[disabled] .twitter-typeahead .tt-input { color: #333333; white-space: nowrap; } -.tt-dropdown-menu .tt-suggestion.tt-cursor { +.tt-menu .tt-suggestion.tt-cursor { text-decoration: none; outline: 0; background-color: #f5f5f5; color: #262626; } -.tt-dropdown-menu .tt-suggestion.tt-cursor a { +.tt-menu .tt-suggestion.tt-cursor a { color: #262626; } -.tt-dropdown-menu .tt-suggestion p { +.tt-menu .tt-suggestion p { margin: 0; } diff --git a/cps/static/css/style.css b/cps/static/css/style.css index 5b7715fd..2bbdf370 100644 --- a/cps/static/css/style.css +++ b/cps/static/css/style.css @@ -123,6 +123,10 @@ table .bg-dark-danger a { color: #fff; } flex-wrap: wrap; } +.row-fluid.text-center { + margin-top: -20px; +} + .container-fluid img { display: block; max-width: 100%; @@ -166,6 +170,10 @@ table .bg-dark-danger a { color: #fff; } box-shadow: 0 5px 8px -6px #777; } +.datepicker.form-control { + position: static; +} + .container-fluid .book .cover span img { position: relative; top: 0; @@ -322,7 +330,7 @@ table .bg-dark-danger:hover { background-color: #c9302c; } table .bg-primary:hover { background-color: #1c5484; } .block-label { display: block; } -.fake-input { +.form-control.fake-input { position: absolute; pointer-events: none; top: 0; diff --git a/cps/static/css/text.css b/cps/static/css/text.css new file mode 100644 index 00000000..74204680 --- /dev/null +++ b/cps/static/css/text.css @@ -0,0 +1,44 @@ +body { + background: white; +} + +#readmain { + position: absolute; + width: 100%; + height: 100%; +} + +#area { + width: 80%; + height: 80%; + margin: 5% auto; + max-width: 1250px; +} + +#area iframe { + border: none; +} + +xmp, pre, plaintext { + display: block; + font-family: -moz-fixed; + white-space: pre; + margin: 1em 0; +} + +#area{ + overflow:hidden; +} + +pre { + white-space: pre-wrap; + word-wrap: break-word; + font-family: -moz-fixed; + column-count:2; + -webkit-columns:2; + -moz-columns:2; + column-gap:20px; + -moz-column-gap:20px; + -webkit-column-gap:20px; + position:relative; +} diff --git a/cps/static/js/details.js b/cps/static/js/details.js index 951e404a..9caf9470 100644 --- a/cps/static/js/details.js +++ b/cps/static/js/details.js @@ -22,15 +22,17 @@ $(function() { }); $("#have_read_cb").on("change", function() { - $.post({ + $.ajax({ url: this.closest("form").action, + method:"post", + data: $(this).closest("form").serialize(), error: function(response) { var data = [{type:"danger", message:response.responseText}] $("#flash_success").remove(); $("#flash_danger").remove(); if (!jQuery.isEmptyObject(data)) { data.forEach(function (item) { - $(".navbar").after('
' + + $(".navbar").after('
' + '
' + item.message + '
' + '
'); }); diff --git a/cps/static/js/edit_books.js b/cps/static/js/edit_books.js index 7c812ae9..88057162 100644 --- a/cps/static/js/edit_books.js +++ b/cps/static/js/edit_books.js @@ -23,7 +23,6 @@ if ($(".tiny_editor").length) { $(".datepicker").datepicker({ format: "yyyy-mm-dd", - language: language }).on("change", function () { // Show localized date over top of the standard YYYY-MM-DD date var pubDate; @@ -47,90 +46,20 @@ $(".datepicker_delete").click(function() { Takes a prefix, query typeahead callback, Bloodhound typeahead adapter and returns the completions it gets from the bloodhound engine prefixed. */ -function prefixedSource(prefix, query, cb, bhAdapter) { - bhAdapter(query, function(retArray) { +function prefixedSource(prefix, query, cb, source) { + function async(retArray) { + retArray = retArray || []; var matches = []; for (var i = 0; i < retArray.length; i++) { var obj = {name : prefix + retArray[i].name}; matches.push(obj); } cb(matches); - }); + } + source.search(query, cb, async); } -var authors = new Bloodhound({ - name: "authors", - datumTokenizer: function datumTokenizer(datum) { - return [datum.name]; - }, - queryTokenizer: Bloodhound.tokenizers.whitespace, - remote: { - url: getPath() + "/get_authors_json?q=%QUERY" - } -}); - -var series = new Bloodhound({ - name: "series", - datumTokenizer: function datumTokenizer(datum) { - return [datum.name]; - }, - queryTokenizer: function queryTokenizer(query) { - return [query]; - }, - remote: { - url: getPath() + "/get_series_json?q=", - replace: function replace(url, query) { - return url + encodeURIComponent(query); - } - } -}); - - -var tags = new Bloodhound({ - name: "tags", - datumTokenizer: function datumTokenizer(datum) { - return [datum.name]; - }, - queryTokenizer: function queryTokenizer(query) { - var tokens = query.split(","); - tokens = [tokens[tokens.length - 1].trim()]; - return tokens; - }, - remote: { - url: getPath() + "/get_tags_json?q=%QUERY" - } -}); - -var languages = new Bloodhound({ - name: "languages", - datumTokenizer: function datumTokenizer(datum) { - return [datum.name]; - }, - queryTokenizer: function queryTokenizer(query) { - return [query]; - }, - remote: { - url: getPath() + "/get_languages_json?q=", - replace: function replace(url, query) { - return url + encodeURIComponent(query); - } - } -}); - -var publishers = new Bloodhound({ - name: "publisher", - datumTokenizer: function datumTokenizer(datum) { - return [datum.name]; - }, - queryTokenizer: Bloodhound.tokenizers.whitespace, - remote: { - url: getPath() + "/get_publishers_json?q=%QUERY" - } -}); - function sourceSplit(query, cb, split, source) { - var bhAdapter = source.ttAdapter(); - var tokens = query.split(split); var currentSource = tokens[tokens.length - 1].trim(); @@ -145,84 +74,148 @@ function sourceSplit(query, cb, split, source) { for (var i = 0; i < tokens.length; i++) { prefix += tokens[i].trim() + newSplit; } - prefixedSource(prefix, currentSource, cb, bhAdapter); + prefixedSource(prefix, currentSource, cb, source); } -var promiseAuthors = authors.initialize(); -promiseAuthors.done(function() { - $("#bookAuthor").typeahead( - { - highlight: true, minLength: 1, - hint: true - }, { - name: "authors", - displayKey: "name", - source: function source(query, cb) { - return sourceSplit(query, cb, "&", authors); //sourceSplit //("&") - } - } - ); +var authors = new Bloodhound({ + name: "authors", + identify: function(obj) { return obj.name; }, + datumTokenizer: function datumTokenizer(datum) { + return [datum.name]; + }, + queryTokenizer: Bloodhound.tokenizers.whitespace, + remote: { + url: getPath() + "/get_authors_json?q=%QUERY", + wildcard: '%QUERY', + }, }); -var promiseSeries = series.initialize(); -promiseSeries.done(function() { - $("#series").typeahead( - { - highlight: true, minLength: 0, - hint: true - }, { - name: "series", - displayKey: "name", - source: series.ttAdapter() +$(".form-group #bookAuthor").typeahead( + { + highlight: true, + minLength: 1, + hint: true + }, { + name: "authors", + display: 'name', + source: function source(query, cb, asyncResults) { + return sourceSplit(query, cb, "&", authors); } - ); + } +); + + +var series = new Bloodhound({ + name: "series", + datumTokenizer: function datumTokenizer(datum) { + return [datum.name]; + }, + // queryTokenizer: Bloodhound.tokenizers.whitespace, + queryTokenizer: function queryTokenizer(query) { + return [query]; + }, + remote: { + url: getPath() + "/get_series_json?q=%QUERY", + wildcard: '%QUERY', + /*replace: function replace(url, query) { + return url + encodeURIComponent(query); + }*/ + } +}); +$(".form-group #series").typeahead( + { + highlight: true, + minLength: 0, + hint: true + }, { + name: "series", + displayKey: "name", + source: series + } +); + +var tags = new Bloodhound({ + name: "tags", + datumTokenizer: function datumTokenizer(datum) { + return [datum.name]; + }, + queryTokenizer: function queryTokenizer(query) { + var tokens = query.split(","); + tokens = [tokens[tokens.length - 1].trim()]; + return tokens; + }, + remote: { + url: getPath() + "/get_tags_json?q=%QUERY", + wildcard: '%QUERY' + } }); -var promiseTags = tags.initialize(); -promiseTags.done(function() { - $("#tags").typeahead( - { - highlight: true, minLength: 0, - hint: true - }, { - name: "tags", - displayKey: "name", - source: function source(query, cb) { - return sourceSplit(query, cb, ",", tags); - } +$(".form-group #tags").typeahead( + { + highlight: true, + minLength: 0, + hint: true + }, { + name: "tags", + display: "name", + source: function source(query, cb, asyncResults) { + return sourceSplit(query, cb, ",", tags); } - ); + } +); + +var languages = new Bloodhound({ + name: "languages", + datumTokenizer: function datumTokenizer(datum) { + return [datum.name]; + }, + queryTokenizer: function queryTokenizer(query) { + return [query]; + }, + remote: { + url: getPath() + "/get_languages_json?q=%QUERY", + wildcard: '%QUERY' + /*replace: function replace(url, query) { + return url + encodeURIComponent(query); + }*/ + } }); -var promiseLanguages = languages.initialize(); -promiseLanguages.done(function() { - $("#languages").typeahead( - { - highlight: true, minLength: 0, - hint: true - }, { - name: "languages", - displayKey: "name", - source: function source(query, cb) { - return sourceSplit(query, cb, ",", languages); //(",") - } +$(".form-group #languages").typeahead( + { + highlight: true, minLength: 0, + hint: true + }, { + name: "languages", + display: "name", + source: function source(query, cb, asyncResults) { + return sourceSplit(query, cb, ",", languages); } - ); + } +); + +var publishers = new Bloodhound({ + name: "publisher", + datumTokenizer: function datumTokenizer(datum) { + return [datum.name]; + }, + queryTokenizer: Bloodhound.tokenizers.whitespace, + remote: { + url: getPath() + "/get_publishers_json?q=%QUERY", + wildcard: '%QUERY' + } }); -var promisePublishers = publishers.initialize(); -promisePublishers.done(function() { - $("#publisher").typeahead( - { - highlight: true, minLength: 0, - hint: true - }, { - name: "publishers", - displayKey: "name", - source: publishers.ttAdapter() - } - ); -}); +$(".form-group #publisher").typeahead( + { + highlight: true, minLength: 0, + hint: true + }, { + name: "publishers", + displayKey: "name", + source: publishers + } +); $("#search").on("change input.typeahead:selected", function(event) { if (event.target.type === "search" && event.target.tagName === "INPUT") { @@ -269,3 +262,4 @@ $("#xchange").click(function () { $("#book_title").val($("#bookAuthor").val()); $("#bookAuthor").val(title); }); + diff --git a/cps/static/js/get_meta.js b/cps/static/js/get_meta.js index 19f8e929..f64be699 100644 --- a/cps/static/js/get_meta.js +++ b/cps/static/js/get_meta.js @@ -14,380 +14,134 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -/* - * Get Metadata from Douban Books api and Google Books api and ComicVine - * Google Books api document: https://developers.google.com/books/docs/v1/using - * Douban Books api document: https://developers.douban.com/wiki/?title=book_v2 (Chinese Only) - * ComicVine api document: https://comicvine.gamespot.com/api/documentation - */ -/* global _, i18nMsg, tinymce */ -var dbResults = []; -var ggResults = []; -var cvResults = []; -var gsResults = []; +/* global _, i18nMsg, tinymce, getPath */ $(function () { - var msg = i18nMsg; - var douban = "https://api.douban.com"; - var dbSearch = "/v2/book/search"; - var dbDone = 0; + var msg = i18nMsg; + var keyword = "" - var google = "https://www.googleapis.com"; - var ggSearch = "/books/v1/volumes"; - var ggDone = 0; + var templates = { + bookResult: _.template( + $("#template-book-result").html() + ) + }; - var comicvine = "https://comicvine.gamespot.com"; - var cvSearch = "/api/search/"; - var cvDone = 0; - - var googlescholar = window.location.href.split('/admin/book')[0]; - var gsSearch = "/scholarsearch/" - var gsDone = 0; - - var showFlag = 0; - - var templates = { - bookResult: _.template( - $("#template-book-result").html() - ) - }; - - function populateForm (book) { - tinymce.get("description").setContent(book.description); - var uniqueTags = []; - $.each(book.tags, function(i, el) { - if ($.inArray(el, uniqueTags) === -1) uniqueTags.push(el); - }); - - var ampSeparatedAuthors = (book.authors || []).join(" & "); - $("#bookAuthor").val(ampSeparatedAuthors); - $("#book_title").val(book.title); - $("#tags").val(uniqueTags.join(",")); - $("#rating").data("rating").setValue(Math.round(book.rating)); - if(book.cover !== null){ - $(".cover img").attr("src", book.cover); - $("#cover_url").val(book.cover); - } - $("#pubdate").val(book.publishedDate); - $("#publisher").val(book.publisher); - if (typeof book.series !== "undefined") { - $("#series").val(book.series); - } - } - - function showResult () { - showFlag++; - if (showFlag === 1) { - $("#meta-info").html("
    "); - } - if ((ggDone === 3 || (ggDone === 1 && ggResults.length === 0)) && - (dbDone === 3 || (dbDone === 1 && dbResults.length === 0)) && - (cvDone === 3 || (cvDone === 1 && cvResults.length === 0)) && - (gsDone === 3 || (gsDone === 1 && gsResults.length === 0))) { - $("#meta-info").html("

    " + msg.no_result + "

    "); - return; - } - function formatDate (date) { - var d = new Date(date), - month = "" + (d.getMonth() + 1), - day = "" + d.getDate(), - year = d.getFullYear(); - - if (month.length < 2) { - month = "0" + month; - } - if (day.length < 2) { - day = "0" + day; - } - - return [year, month, day].join("-"); - } - function generateID (title) { - return title.split("").reduce(function(a,b){a=((a<<5)-a)+b.charCodeAt(0);return a&a},0).toString().substr(0,12); - } - - if (ggResults.length > 0) { - if (ggDone < 2) { - ggResults.forEach(function(result) { - var book = { - id: result.id, - title: result.volumeInfo.title, - authors: result.volumeInfo.authors || [], - description: result.volumeInfo.description || "", - publisher: result.volumeInfo.publisher || "", - publishedDate: result.volumeInfo.publishedDate || "", - tags: result.volumeInfo.categories || [], - rating: result.volumeInfo.averageRating || 0, - cover: result.volumeInfo.imageLinks ? - result.volumeInfo.imageLinks.thumbnail : location + "/../../../static/generic_cover.jpg", - url: "https://books.google.com/books?id=" + result.id, - source: { - id: "google", - description: "Google Books", - url: "https://books.google.com/" - } - }; - - var $book = $(templates.bookResult(book)); - $book.find("img").on("click", function () { - populateForm(book); - }); - - $("#book-list").append($book); + function populateForm (book) { + tinymce.get("description").setContent(book.description); + var uniqueTags = []; + $.each(book.tags, function(i, el) { + if ($.inArray(el, uniqueTags) === -1) uniqueTags.push(el); }); - ggDone = 2; - } else { - ggDone = 3; - } - } - - if (gsResults.length > 0) { - if (gsDone < 2) { - gsResults.forEach(function(result) { - var book = { - id: generateID(result.bib.title), - title: result.bib.title, - authors: result.bib.author || [], - description: result.bib.abstract || "", - publisher: result.bib.venue || "", - publishedDate: result.bib.pub_year ? result.bib.pub_year+"-01-01" : "", - tags: [], - rating: 0, - series: "", - cover: null, - url: result.pub_url || result.eprint_url || "", - source: { - id: "googlescholar", - description: "Google Scholar", - link: "https://scholar.google.com/" - } - } - var $book = $(templates.bookResult(book)); - $book.find("img").on("click", function () { - populateForm(book); - }); - - $("#book-list").append($book); - - }); - gsDone = 2; - } - else { - gsDone = 3; - } - } - - if (dbResults.length > 0) { - if (dbDone < 2) { - dbResults.forEach(function(result) { - var seriesTitle = ""; - if (result.series) { - seriesTitle = result.series.title; - } - var dateFomers = result.pubdate.split("-"); - var publishedYear = parseInt(dateFomers[0], 10); - var publishedMonth = parseInt(dateFomers[1], 10); - var publishedDate = new Date(publishedYear, publishedMonth - 1, 1); - - publishedDate = formatDate(publishedDate); - - var book = { - id: result.id, - title: result.title, - authors: result.author || [], - description: result.summary, - publisher: result.publisher || "", - publishedDate: publishedDate || "", - tags: result.tags.map(function(tag) { - return tag.title.toLowerCase().replace(/,/g, "_"); - }), - rating: result.rating.average || 0, - series: seriesTitle || "", - cover: result.image, - url: "https://book.douban.com/subject/" + result.id, - source: { - id: "douban", - description: "Douban Books", - url: "https://book.douban.com/" - } - }; - - if (book.rating > 0) { - book.rating /= 2; - } - - var $book = $(templates.bookResult(book)); - $book.find("img").on("click", function () { - populateForm(book); - }); - - $("#book-list").append($book); - }); - dbDone = 2; - } else { - dbDone = 3; - } - } - if (cvResults.length > 0) { - if (cvDone < 2) { - cvResults.forEach(function(result) { - var seriesTitle = ""; - if (result.volume.name) { - seriesTitle = result.volume.name; - } - var dateFomers = ""; - if (result.store_date) { - dateFomers = result.store_date.split("-"); - } else { - dateFomers = result.date_added.split("-"); - } - var publishedYear = parseInt(dateFomers[0], 10); - var publishedMonth = parseInt(dateFomers[1], 10); - var publishedDate = new Date(publishedYear, publishedMonth - 1, 1); - - publishedDate = formatDate(publishedDate); - - var book = { - id: result.id, - title: seriesTitle + " #" + ("00" + result.issue_number).slice(-3) + " - " + result.name, - authors: result.author || [], - description: result.description, - publisher: "", - publishedDate: publishedDate || "", - tags: ["Comics", seriesTitle], - rating: 0, - series: seriesTitle || "", - cover: result.image.original_url, - url: result.site_detail_url, - source: { - id: "comicvine", - description: "ComicVine Books", - url: "https://comicvine.gamespot.com/" - } - }; - - var $book = $(templates.bookResult(book)); - $book.find("img").on("click", function () { - populateForm(book); - }); - - $("#book-list").append($book); - }); - cvDone = 2; - } else { - cvDone = 3; - } - } - } - - function ggSearchBook (title) { - $.ajax({ - url: google + ggSearch + "?q=" + title.replace(/\s+/gm, "+"), - type: "GET", - dataType: "jsonp", - jsonp: "callback", - success: function success(data) { - if ("items" in data) { - ggResults = data.items; + var ampSeparatedAuthors = (book.authors || []).join(" & "); + $("#bookAuthor").val(ampSeparatedAuthors); + $("#book_title").val(book.title); + $("#tags").val(uniqueTags.join(",")); + $("#rating").data("rating").setValue(Math.round(book.rating)); + if(book.cover !== null){ + $(".cover img").attr("src", book.cover); + $("#cover_url").val(book.cover); + } + $("#pubdate").val(book.publishedDate); + $("#publisher").val(book.publisher); + if (typeof book.series !== "undefined") { + $("#series").val(book.series); } - }, - complete: function complete() { - ggDone = 1; - showResult(); - $("#show-google").trigger("change"); - } - }); - } - - function dbSearchBook (title) { - var apikey = "054022eaeae0b00e0fc068c0c0a2102a"; - $.ajax({ - url: douban + dbSearch + "?apikey=" + apikey + "&q=" + title + "&fields=all&count=10", - type: "GET", - dataType: "jsonp", - jsonp: "callback", - success: function success(data) { - dbResults = data.books; - }, - error: function error() { - $("#meta-info").html("

    " + msg.search_error + "!

    " + $("#meta-info")[0].innerHTML); - }, - complete: function complete() { - dbDone = 1; - showResult(); - $("#show-douban").trigger("change"); - } - }); - } - - function cvSearchBook (title) { - var apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6"; - title = encodeURIComponent(title); - $.ajax({ - url: comicvine + cvSearch + "?api_key=" + apikey + "&resources=issue&query=" + title + "&sort=name:desc&format=jsonp", - type: "GET", - dataType: "jsonp", - jsonp: "json_callback", - success: function success(data) { - cvResults = data.results; - }, - error: function error() { - $("#meta-info").html("

    " + msg.search_error + "!

    " + $("#meta-info")[0].innerHTML); - }, - complete: function complete() { - cvDone = 1; - showResult(); - $("#show-comics").trigger("change"); - } - }); - } - - function gsSearchBook (title) { - $.ajax({ - url: googlescholar + gsSearch + title.replace(/\s+/gm,'+'), - type: "GET", - dataType: "json", - success: function success(data) { - gsResults = data; - }, - complete: function complete() { - gsDone = 1; - showResult(); - $("#show-googlescholar").trigger("change"); - } - }) - } - - function doSearch (keyword) { - showFlag = 0; - dbDone = ggDone = cvDone = 0; - dbResults = []; - ggResults = []; - cvResults = []; - gsResults = []; - $("#meta-info").text(msg.loading); - if (keyword) { - dbSearchBook(keyword); - ggSearchBook(keyword); - cvSearchBook(keyword); - gsSearchBook(keyword); } - } - $("#meta-search").on("submit", function (e) { - e.preventDefault(); - var keyword = $("#keyword").val(); - if (keyword) { - doSearch(keyword); + function doSearch (keyword) { + if (keyword) { + $("#meta-info").text(msg.loading); + $.ajax({ + url: getPath() + "/metadata/search", + type: "POST", + data: {"query": keyword}, + dataType: "json", + success: function success(data) { + $("#meta-info").html("
      "); + data.forEach(function(book) { + var $book = $(templates.bookResult(book)); + $book.find("img").on("click", function () { + populateForm(book); + }); + $("#book-list").append($book); + }); + }, + error: function error() { + $("#meta-info").html("

      " + msg.search_error + "!

      " + $("#meta-info")[0].innerHTML); + }, + }); + } } - }); - $("#get_meta").click(function () { - var bookTitle = $("#book_title").val(); - if (bookTitle) { - $("#keyword").val(bookTitle); - doSearch(bookTitle); + function populate_provider() { + $("#metadata_provider").empty(); + $.ajax({ + url: getPath() + "/metadata/provider", + type: "get", + dataType: "json", + success: function success(data) { + data.forEach(function(provider) { + var checked = ""; + if (provider.active) { + checked = "checked"; + } + var $provider_button = '' + $("#metadata_provider").append($provider_button); + }); + }, + }); } - }); + $(document).on("change", ".pill", function () { + var element = $(this); + var id = element.data("control"); + var initial = element.data("initial"); + var val = element.prop('checked'); + var params = {id : id, value: val}; + if (!initial) { + params['initial'] = initial; + params['query'] = keyword; + } + $.ajax({ + method:"post", + contentType: "application/json; charset=utf-8", + dataType: "json", + url: getPath() + "/metadata/provider/" + id, + data: JSON.stringify(params), + success: function success(data) { + element.data("initial", "true"); + data.forEach(function(book) { + var $book = $(templates.bookResult(book)); + $book.find("img").on("click", function () { + populateForm(book); + }); + $("#book-list").append($book); + }); + } + }); + }); + + $("#meta-search").on("submit", function (e) { + e.preventDefault(); + keyword = $("#keyword").val(); + $('.pill').each(function(){ + // console.log($(this).data('control')); + $(this).data("initial", $(this).prop('checked')); + // console.log($(this).data('initial')); + }); + doSearch(keyword); + }); + + $("#get_meta").click(function () { + populate_provider(); + var bookTitle = $("#book_title").val(); + $("#keyword").val(bookTitle); + keyword = bookTitle; + doSearch(bookTitle); + }); + $("#metaModal").on("show.bs.modal", function(e) { + $(e.relatedTarget).one('focus', function (e) { + $(this).blur(); + }); + }); }); diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-af-ZA.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-af.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-af-ZA.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-af.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ar-SA.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ar.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ar-SA.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ar.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-bg-BG.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-bg.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-bg-BG.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-bg.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ca-ES.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ca.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ca-ES.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ca.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-cs-CZ.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-cs.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-cs-CZ.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-cs.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-da-DK.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-da.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-da-DK.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-da.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-de-DE.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-de.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-de-DE.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-de.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-el-GR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-el.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-el-GR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-el.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-ES.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es-ES.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-es.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-et-EE.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-et.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-et-EE.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-et.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-eu-EU.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-eu.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-eu-EU.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-eu.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fa-IR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fa.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fa-IR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fa.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fi-FI.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fi.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fi-FI.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fi.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr-FR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr-FR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-fr.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-he-IL.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-he.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-he-IL.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-he.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hr-HR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hr.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hr-HR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hr.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hu-HU.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hu.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hu-HU.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-hu.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-id-ID.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-id.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-id-ID.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-id.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-it-IT.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-it.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-it-IT.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-it.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ja-JP.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ja.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ja-JP.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ja.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ka-GE.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ka.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ka-GE.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ka.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ko-KR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ko.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ko-KR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ko.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nl-NL.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nl.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nl-NL.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-nl.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pl-PL.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pl.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pl-PL.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pl.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pt-BR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pt_BR.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pt-BR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-pt_BR.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ro-RO.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ro.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ro-RO.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ro.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ru-RU.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ru.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ru-RU.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-ru.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sk-SK.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sk.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sk-SK.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sk.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sv-SE.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sv.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sv-SE.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-sv.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-tr-TR.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-tr.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-tr-TR.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-tr.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-uk-UA.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-uk.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-uk-UA.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-uk.min.js diff --git a/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-zh-CN.min.js b/cps/static/js/libs/bootstrap-table/locale/bootstrap-table-zh_Hans_CN.min.js similarity index 100% rename from cps/static/js/libs/bootstrap-table/locale/bootstrap-table-zh-CN.min.js rename to cps/static/js/libs/bootstrap-table/locale/bootstrap-table-zh_Hans_CN.min.js diff --git a/cps/static/js/libs/typeahead.bundle.js b/cps/static/js/libs/typeahead.bundle.js index 8164a3c2..bfb5d010 100644 --- a/cps/static/js/libs/typeahead.bundle.js +++ b/cps/static/js/libs/typeahead.bundle.js @@ -1,10 +1,21 @@ /*! - * typeahead.js 0.10.5 - * https://github.com/twitter/typeahead.js - * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT + * typeahead.js 1.3.1 master from 10.10.2021 + * https://github.com/corejavascript/typeahead.js + * Copyright 2013-2020 Twitter, Inc. and other contributors; Licensed MIT */ -(function($) { + +(function(root, factory) { + if (typeof define === "function" && define.amd) { + define([ "jquery" ], function(a0) { + return root["Bloodhound"] = factory(a0); + }); + } else if (typeof module === "object" && module.exports) { + module.exports = factory(require("jquery")); + } else { + root["Bloodhound"] = factory(root["jQuery"]); + } +})(this, function($) { var _ = function() { "use strict"; return { @@ -29,6 +40,12 @@ isUndefined: function(obj) { return typeof obj === "undefined"; }, + isElement: function(obj) { + return !!(obj && obj.nodeType === 1); + }, + isJQuery: function(obj) { + return obj instanceof $; + }, toStr: function toStr(s) { return _.isUndefined(s) || s === null ? "" : s + ""; }, @@ -66,12 +83,18 @@ return !!result; }, mixin: $.extend, - getUniqueId: function() { + identity: function(x) { + return x; + }, + clone: function(obj) { + return $.extend(true, {}, obj); + }, + getIdGenerator: function() { var counter = 0; return function() { return counter++; }; - }(), + }, templatify: function templatify(obj) { return $.isFunction(obj) ? obj : template; function template() { @@ -123,18 +146,30 @@ return result; }; }, + stringify: function(val) { + return _.isString(val) ? val : JSON.stringify(val); + }, + guid: function() { + function _p8(s) { + var p = (Math.random().toString(16) + "000000000").substr(2, 8); + return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p; + } + return "tt-" + _p8() + _p8(true) + _p8(true) + _p8(); + }, noop: function() {} }; }(); - var VERSION = "0.10.5"; + var VERSION = "1.3.1"; var tokenizers = function() { "use strict"; return { nonword: nonword, whitespace: whitespace, + ngram: ngram, obj: { nonword: getObjTokenizer(nonword), - whitespace: getObjTokenizer(whitespace) + whitespace: getObjTokenizer(whitespace), + ngram: getObjTokenizer(ngram) } }; function whitespace(str) { @@ -145,12 +180,25 @@ str = _.toStr(str); return str ? str.split(/\W+/) : []; } + function ngram(str) { + str = _.toStr(str); + var tokens = [], word = ""; + _.each(str.split(""), function(char) { + if (char.match(/\s+/)) { + word = ""; + } else { + tokens.push(word + char); + word += char; + } + }); + return tokens; + } function getObjTokenizer(tokenizer) { - return function setKey() { - var args = [].slice.call(arguments, 0); + return function setKey(keys) { + keys = _.isArray(keys) ? keys : [].slice.call(arguments, 0); return function tokenize(o) { var tokens = []; - _.each(args, function(k) { + _.each(keys, function(k) { tokens = tokens.concat(tokenizer(_.toStr(o[k]))); }); return tokens; @@ -173,6 +221,7 @@ if (this.size >= this.maxSize) { this.list.remove(tailItem); delete this.hash[tailItem.key]; + this.size--; } if (node = this.hash[key]) { node.val = val; @@ -227,73 +276,72 @@ }(); var PersistentStorage = function() { "use strict"; - var ls, methods; + var LOCAL_STORAGE; try { - ls = window.localStorage; - ls.setItem("~~~", "!"); - ls.removeItem("~~~"); + LOCAL_STORAGE = window.localStorage; + LOCAL_STORAGE.setItem("~~~", "!"); + LOCAL_STORAGE.removeItem("~~~"); } catch (err) { - ls = null; + LOCAL_STORAGE = null; } - function PersistentStorage(namespace) { + function PersistentStorage(namespace, override) { this.prefix = [ "__", namespace, "__" ].join(""); this.ttlKey = "__ttl__"; this.keyMatcher = new RegExp("^" + _.escapeRegExChars(this.prefix)); + this.ls = override || LOCAL_STORAGE; + !this.ls && this._noop(); } - if (ls && window.JSON) { - methods = { - _prefix: function(key) { - return this.prefix + key; - }, - _ttlKey: function(key) { - return this._prefix(key) + this.ttlKey; - }, - get: function(key) { - if (this.isExpired(key)) { - this.remove(key); + _.mixin(PersistentStorage.prototype, { + _prefix: function(key) { + return this.prefix + key; + }, + _ttlKey: function(key) { + return this._prefix(key) + this.ttlKey; + }, + _noop: function() { + this.get = this.set = this.remove = this.clear = this.isExpired = _.noop; + }, + _safeSet: function(key, val) { + try { + this.ls.setItem(key, val); + } catch (err) { + if (err.name === "QuotaExceededError") { + this.clear(); + this._noop(); } - return decode(ls.getItem(this._prefix(key))); - }, - set: function(key, val, ttl) { - if (_.isNumber(ttl)) { - ls.setItem(this._ttlKey(key), encode(now() + ttl)); - } else { - ls.removeItem(this._ttlKey(key)); - } - return ls.setItem(this._prefix(key), encode(val)); - }, - remove: function(key) { - ls.removeItem(this._ttlKey(key)); - ls.removeItem(this._prefix(key)); - return this; - }, - clear: function() { - var i, key, keys = [], len = ls.length; - for (i = 0; i < len; i++) { - if ((key = ls.key(i)).match(this.keyMatcher)) { - keys.push(key.replace(this.keyMatcher, "")); - } - } - for (i = keys.length; i--; ) { - this.remove(keys[i]); - } - return this; - }, - isExpired: function(key) { - var ttl = decode(ls.getItem(this._ttlKey(key))); - return _.isNumber(ttl) && now() > ttl ? true : false; } - }; - } else { - methods = { - get: _.noop, - set: _.noop, - remove: _.noop, - clear: _.noop, - isExpired: _.noop - }; - } - _.mixin(PersistentStorage.prototype, methods); + }, + get: function(key) { + if (this.isExpired(key)) { + this.remove(key); + } + return decode(this.ls.getItem(this._prefix(key))); + }, + set: function(key, val, ttl) { + if (_.isNumber(ttl)) { + this._safeSet(this._ttlKey(key), encode(now() + ttl)); + } else { + this.ls.removeItem(this._ttlKey(key)); + } + return this._safeSet(this._prefix(key), encode(val)); + }, + remove: function(key) { + this.ls.removeItem(this._ttlKey(key)); + this.ls.removeItem(this._prefix(key)); + return this; + }, + clear: function() { + var i, keys = gatherMatchingKeys(this.keyMatcher); + for (i = keys.length; i--; ) { + this.remove(keys[i]); + } + return this; + }, + isExpired: function(key) { + var ttl = decode(this.ls.getItem(this._ttlKey(key))); + return _.isNumber(ttl) && now() > ttl ? true : false; + } + }); return PersistentStorage; function now() { return new Date().getTime(); @@ -302,105 +350,104 @@ return JSON.stringify(_.isUndefined(val) ? null : val); } function decode(val) { - return JSON.parse(val); + return $.parseJSON(val); + } + function gatherMatchingKeys(keyMatcher) { + var i, key, keys = [], len = LOCAL_STORAGE.length; + for (i = 0; i < len; i++) { + if ((key = LOCAL_STORAGE.key(i)).match(keyMatcher)) { + keys.push(key.replace(keyMatcher, "")); + } + } + return keys; } }(); var Transport = function() { "use strict"; - var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, sharedCache = new LruCache(10); + var pendingRequestsCount = 0, pendingRequests = {}, sharedCache = new LruCache(10); function Transport(o) { o = o || {}; + this.maxPendingRequests = o.maxPendingRequests || 6; this.cancelled = false; - this.lastUrl = null; - this._send = o.transport ? callbackToDeferred(o.transport) : $.ajax; - this._get = o.rateLimiter ? o.rateLimiter(this._get) : this._get; + this.lastReq = null; + this._send = o.transport; + this._get = o.limiter ? o.limiter(this._get) : this._get; this._cache = o.cache === false ? new LruCache(0) : sharedCache; } Transport.setMaxPendingRequests = function setMaxPendingRequests(num) { - maxPendingRequests = num; + this.maxPendingRequests = num; }; Transport.resetCache = function resetCache() { sharedCache.reset(); }; _.mixin(Transport.prototype, { - _get: function(url, o, cb) { - var that = this, jqXhr; - if (this.cancelled || url !== this.lastUrl) { + _fingerprint: function fingerprint(o) { + o = o || {}; + return o.url + o.type + $.param(o.data || {}); + }, + _get: function(o, cb) { + var that = this, fingerprint, jqXhr; + fingerprint = this._fingerprint(o); + if (this.cancelled || fingerprint !== this.lastReq) { return; } - if (jqXhr = pendingRequests[url]) { + if (jqXhr = pendingRequests[fingerprint]) { jqXhr.done(done).fail(fail); - } else if (pendingRequestsCount < maxPendingRequests) { + } else if (pendingRequestsCount < this.maxPendingRequests) { pendingRequestsCount++; - pendingRequests[url] = this._send(url, o).done(done).fail(fail).always(always); + pendingRequests[fingerprint] = this._send(o).done(done).fail(fail).always(always); } else { this.onDeckRequestArgs = [].slice.call(arguments, 0); } function done(resp) { - cb && cb(null, resp); - that._cache.set(url, resp); + cb(null, resp); + that._cache.set(fingerprint, resp); } function fail() { - cb && cb(true); + cb(true); } function always() { pendingRequestsCount--; - delete pendingRequests[url]; + delete pendingRequests[fingerprint]; if (that.onDeckRequestArgs) { that._get.apply(that, that.onDeckRequestArgs); that.onDeckRequestArgs = null; } } }, - get: function(url, o, cb) { - var resp; - if (_.isFunction(o)) { - cb = o; - o = {}; - } + get: function(o, cb) { + var resp, fingerprint; + cb = cb || $.noop; + o = _.isString(o) ? { + url: o + } : o || {}; + fingerprint = this._fingerprint(o); this.cancelled = false; - this.lastUrl = url; - if (resp = this._cache.get(url)) { - _.defer(function() { - cb && cb(null, resp); - }); + this.lastReq = fingerprint; + if (resp = this._cache.get(fingerprint)) { + cb(null, resp); } else { - this._get(url, o, cb); + this._get(o, cb); } - return !!resp; }, cancel: function() { this.cancelled = true; } }); return Transport; - function callbackToDeferred(fn) { - return function customSendWrapper(url, o) { - var deferred = $.Deferred(); - fn(url, o, onSuccess, onError); - return deferred; - function onSuccess(resp) { - _.defer(function() { - deferred.resolve(resp); - }); - } - function onError(err) { - _.defer(function() { - deferred.reject(err); - }); - } - }; - } }(); - var SearchIndex = function() { + var SearchIndex = window.SearchIndex = function() { "use strict"; + var CHILDREN = "c", IDS = "i"; function SearchIndex(o) { o = o || {}; if (!o.datumTokenizer || !o.queryTokenizer) { $.error("datumTokenizer and queryTokenizer are both required"); } + this.identify = o.identify || _.stringify; this.datumTokenizer = o.datumTokenizer; this.queryTokenizer = o.queryTokenizer; + this.matchAnyQueryToken = o.matchAnyQueryToken; this.reset(); } _.mixin(SearchIndex.prototype, { @@ -413,46 +460,61 @@ data = _.isArray(data) ? data : [ data ]; _.each(data, function(datum) { var id, tokens; - id = that.datums.push(datum) - 1; + that.datums[id = that.identify(datum)] = datum; tokens = normalizeTokens(that.datumTokenizer(datum)); _.each(tokens, function(token) { var node, chars, ch; node = that.trie; chars = token.split(""); while (ch = chars.shift()) { - node = node.children[ch] || (node.children[ch] = newNode()); - node.ids.push(id); + node = node[CHILDREN][ch] || (node[CHILDREN][ch] = newNode()); + node[IDS].push(id); } }); }); }, - get: function get(query) { + get: function get(ids) { + var that = this; + return _.map(ids, function(id) { + return that.datums[id]; + }); + }, + search: function search(query) { var that = this, tokens, matches; tokens = normalizeTokens(this.queryTokenizer(query)); _.each(tokens, function(token) { var node, chars, ch, ids; - if (matches && matches.length === 0) { + if (matches && matches.length === 0 && !that.matchAnyQueryToken) { return false; } node = that.trie; chars = token.split(""); while (node && (ch = chars.shift())) { - node = node.children[ch]; + node = node[CHILDREN][ch]; } if (node && chars.length === 0) { - ids = node.ids.slice(0); + ids = node[IDS].slice(0); matches = matches ? getIntersection(matches, ids) : ids; } else { - matches = []; - return false; + if (!that.matchAnyQueryToken) { + matches = []; + return false; + } } }); return matches ? _.map(unique(matches), function(id) { return that.datums[id]; }) : []; }, + all: function all() { + var values = []; + for (var key in this.datums) { + values.push(this.datums[key]); + } + return values; + }, reset: function reset() { - this.datums = []; + this.datums = {}; this.trie = newNode(); }, serialize: function serialize() { @@ -473,10 +535,10 @@ return tokens; } function newNode() { - return { - ids: [], - children: {} - }; + var node = {}; + node[IDS] = []; + node[CHILDREN] = {}; + return node; } function unique(array) { var seen = {}, uniques = []; @@ -490,8 +552,8 @@ } function getIntersection(arrayA, arrayB) { var ai = 0, bi = 0, intersection = []; - arrayA = arrayA.sort(compare); - arrayB = arrayB.sort(compare); + arrayA = arrayA.sort(); + arrayB = arrayB.sort(); var lenArrayA = arrayA.length, lenArrayB = arrayB.length; while (ai < lenArrayA && bi < lenArrayB) { if (arrayA[ai] < arrayB[bi]) { @@ -505,169 +567,331 @@ } } return intersection; - function compare(a, b) { - return a - b; - } } }(); - var oParser = function() { + var Prefetch = function() { "use strict"; - return { - local: getLocal, - prefetch: getPrefetch, - remote: getRemote - }; - function getLocal(o) { - return o.local || null; - } - function getPrefetch(o) { - var prefetch, defaults; - defaults = { - url: null, - thumbprint: "", - ttl: 24 * 60 * 60 * 1e3, - filter: null, - ajax: {} - }; - if (prefetch = o.prefetch || null) { - prefetch = _.isString(prefetch) ? { - url: prefetch - } : prefetch; - prefetch = _.mixin(defaults, prefetch); - prefetch.thumbprint = VERSION + prefetch.thumbprint; - prefetch.ajax.type = prefetch.ajax.type || "GET"; - prefetch.ajax.dataType = prefetch.ajax.dataType || "json"; - !prefetch.url && $.error("prefetch requires url to be set"); - } - return prefetch; - } - function getRemote(o) { - var remote, defaults; - defaults = { - url: null, - cache: true, - wildcard: "%QUERY", - replace: null, - rateLimitBy: "debounce", - rateLimitWait: 300, - send: null, - filter: null, - ajax: {} - }; - if (remote = o.remote || null) { - remote = _.isString(remote) ? { - url: remote - } : remote; - remote = _.mixin(defaults, remote); - remote.rateLimiter = /^throttle$/i.test(remote.rateLimitBy) ? byThrottle(remote.rateLimitWait) : byDebounce(remote.rateLimitWait); - remote.ajax.type = remote.ajax.type || "GET"; - remote.ajax.dataType = remote.ajax.dataType || "json"; - delete remote.rateLimitBy; - delete remote.rateLimitWait; - !remote.url && $.error("remote requires url to be set"); - } - return remote; - function byDebounce(wait) { - return function(fn) { - return _.debounce(fn, wait); - }; - } - function byThrottle(wait) { - return function(fn) { - return _.throttle(fn, wait); - }; - } - } - }(); - (function(root) { - "use strict"; - var old, keys; - old = root.Bloodhound; + var keys; keys = { data: "data", protocol: "protocol", thumbprint: "thumbprint" }; - root.Bloodhound = Bloodhound; - function Bloodhound(o) { - if (!o || !o.local && !o.prefetch && !o.remote) { - $.error("one of local, prefetch, or remote is required"); + function Prefetch(o) { + this.url = o.url; + this.ttl = o.ttl; + this.cache = o.cache; + this.prepare = o.prepare; + this.transform = o.transform; + this.transport = o.transport; + this.thumbprint = o.thumbprint; + this.storage = new PersistentStorage(o.cacheKey); + } + _.mixin(Prefetch.prototype, { + _settings: function settings() { + return { + url: this.url, + type: "GET", + dataType: "json" + }; + }, + store: function store(data) { + if (!this.cache) { + return; + } + this.storage.set(keys.data, data, this.ttl); + this.storage.set(keys.protocol, location.protocol, this.ttl); + this.storage.set(keys.thumbprint, this.thumbprint, this.ttl); + }, + fromCache: function fromCache() { + var stored = {}, isExpired; + if (!this.cache) { + return null; + } + stored.data = this.storage.get(keys.data); + stored.protocol = this.storage.get(keys.protocol); + stored.thumbprint = this.storage.get(keys.thumbprint); + isExpired = stored.thumbprint !== this.thumbprint || stored.protocol !== location.protocol; + return stored.data && !isExpired ? stored.data : null; + }, + fromNetwork: function(cb) { + var that = this, settings; + if (!cb) { + return; + } + settings = this.prepare(this._settings()); + this.transport(settings).fail(onError).done(onResponse); + function onError() { + cb(true); + } + function onResponse(resp) { + cb(null, that.transform(resp)); + } + }, + clear: function clear() { + this.storage.clear(); + return this; } - this.limit = o.limit || 5; - this.sorter = getSorter(o.sorter); - this.dupDetector = o.dupDetector || ignoreDuplicates; - this.local = oParser.local(o); - this.prefetch = oParser.prefetch(o); - this.remote = oParser.remote(o); - this.cacheKey = this.prefetch ? this.prefetch.cacheKey || this.prefetch.url : null; + }); + return Prefetch; + }(); + var Remote = function() { + "use strict"; + function Remote(o) { + this.url = o.url; + this.prepare = o.prepare; + this.transform = o.transform; + this.indexResponse = o.indexResponse; + this.transport = new Transport({ + cache: o.cache, + limiter: o.limiter, + transport: o.transport, + maxPendingRequests: o.maxPendingRequests + }); + } + _.mixin(Remote.prototype, { + _settings: function settings() { + return { + url: this.url, + type: "GET", + dataType: "json" + }; + }, + get: function get(query, cb) { + var that = this, settings; + if (!cb) { + return; + } + query = query || ""; + settings = this.prepare(query, this._settings()); + return this.transport.get(settings, onResponse); + function onResponse(err, resp) { + err ? cb([]) : cb(that.transform(resp)); + } + }, + cancelLastRequest: function cancelLastRequest() { + this.transport.cancel(); + } + }); + return Remote; + }(); + var oParser = function() { + "use strict"; + return function parse(o) { + var defaults, sorter; + defaults = { + initialize: true, + identify: _.stringify, + datumTokenizer: null, + queryTokenizer: null, + matchAnyQueryToken: false, + sufficient: 5, + indexRemote: false, + sorter: null, + local: [], + prefetch: null, + remote: null + }; + o = _.mixin(defaults, o || {}); + !o.datumTokenizer && $.error("datumTokenizer is required"); + !o.queryTokenizer && $.error("queryTokenizer is required"); + sorter = o.sorter; + o.sorter = sorter ? function(x) { + return x.sort(sorter); + } : _.identity; + o.local = _.isFunction(o.local) ? o.local() : o.local; + o.prefetch = parsePrefetch(o.prefetch); + o.remote = parseRemote(o.remote); + return o; + }; + function parsePrefetch(o) { + var defaults; + if (!o) { + return null; + } + defaults = { + url: null, + ttl: 24 * 60 * 60 * 1e3, + cache: true, + cacheKey: null, + thumbprint: "", + prepare: _.identity, + transform: _.identity, + transport: null + }; + o = _.isString(o) ? { + url: o + } : o; + o = _.mixin(defaults, o); + !o.url && $.error("prefetch requires url to be set"); + o.transform = o.filter || o.transform; + o.cacheKey = o.cacheKey || o.url; + o.thumbprint = VERSION + o.thumbprint; + o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; + return o; + } + function parseRemote(o) { + var defaults; + if (!o) { + return; + } + defaults = { + url: null, + cache: true, + prepare: null, + replace: null, + wildcard: null, + limiter: null, + rateLimitBy: "debounce", + rateLimitWait: 300, + transform: _.identity, + transport: null + }; + o = _.isString(o) ? { + url: o + } : o; + o = _.mixin(defaults, o); + !o.url && $.error("remote requires url to be set"); + o.transform = o.filter || o.transform; + o.prepare = toRemotePrepare(o); + o.limiter = toLimiter(o); + o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; + delete o.replace; + delete o.wildcard; + delete o.rateLimitBy; + delete o.rateLimitWait; + return o; + } + function toRemotePrepare(o) { + var prepare, replace, wildcard; + prepare = o.prepare; + replace = o.replace; + wildcard = o.wildcard; + if (prepare) { + return prepare; + } + if (replace) { + prepare = prepareByReplace; + } else if (o.wildcard) { + prepare = prepareByWildcard; + } else { + prepare = identityPrepare; + } + return prepare; + function prepareByReplace(query, settings) { + settings.url = replace(settings.url, query); + return settings; + } + function prepareByWildcard(query, settings) { + settings.url = settings.url.replace(wildcard, encodeURIComponent(query)); + return settings; + } + function identityPrepare(query, settings) { + return settings; + } + } + function toLimiter(o) { + var limiter, method, wait; + limiter = o.limiter; + method = o.rateLimitBy; + wait = o.rateLimitWait; + if (!limiter) { + limiter = /^throttle$/i.test(method) ? throttle(wait) : debounce(wait); + } + return limiter; + function debounce(wait) { + return function debounce(fn) { + return _.debounce(fn, wait); + }; + } + function throttle(wait) { + return function throttle(fn) { + return _.throttle(fn, wait); + }; + } + } + function callbackToDeferred(fn) { + return function wrapper(o) { + var deferred = $.Deferred(); + fn(o, onSuccess, onError); + return deferred; + function onSuccess(resp) { + _.defer(function() { + deferred.resolve(resp); + }); + } + function onError(err) { + _.defer(function() { + deferred.reject(err); + }); + } + }; + } + }(); + var Bloodhound = function() { + "use strict"; + var old; + old = window && window.Bloodhound; + function Bloodhound(o) { + o = oParser(o); + this.sorter = o.sorter; + this.identify = o.identify; + this.sufficient = o.sufficient; + this.indexRemote = o.indexRemote; + this.local = o.local; + this.remote = o.remote ? new Remote(o.remote) : null; + this.prefetch = o.prefetch ? new Prefetch(o.prefetch) : null; this.index = new SearchIndex({ + identify: this.identify, datumTokenizer: o.datumTokenizer, queryTokenizer: o.queryTokenizer }); - this.storage = this.cacheKey ? new PersistentStorage(this.cacheKey) : null; + o.initialize !== false && this.initialize(); } Bloodhound.noConflict = function noConflict() { - root.Bloodhound = old; + window && (window.Bloodhound = old); return Bloodhound; }; Bloodhound.tokenizers = tokenizers; _.mixin(Bloodhound.prototype, { - _loadPrefetch: function loadPrefetch(o) { - var that = this, serialized, deferred; - if (serialized = this._readFromStorage(o.thumbprint)) { + __ttAdapter: function ttAdapter() { + var that = this; + return this.remote ? withAsync : withoutAsync; + function withAsync(query, sync, async) { + return that.search(query, sync, async); + } + function withoutAsync(query, sync) { + return that.search(query, sync); + } + }, + _loadPrefetch: function loadPrefetch() { + var that = this, deferred, serialized; + deferred = $.Deferred(); + if (!this.prefetch) { + deferred.resolve(); + } else if (serialized = this.prefetch.fromCache()) { this.index.bootstrap(serialized); - deferred = $.Deferred().resolve(); + deferred.resolve(); } else { - deferred = $.ajax(o.url, o.ajax).done(handlePrefetchResponse); + this.prefetch.fromNetwork(done); } - return deferred; - function handlePrefetchResponse(resp) { - that.clear(); - that.add(o.filter ? o.filter(resp) : resp); - that._saveToStorage(that.index.serialize(), o.thumbprint, o.ttl); + return deferred.promise(); + function done(err, data) { + if (err) { + return deferred.reject(); + } + that.add(data); + that.prefetch.store(that.index.serialize()); + deferred.resolve(); } }, - _getFromRemote: function getFromRemote(query, cb) { - var that = this, url, uriEncodedQuery; - if (!this.transport) { - return; - } - query = query || ""; - uriEncodedQuery = encodeURIComponent(query); - url = this.remote.replace ? this.remote.replace(this.remote.url, query) : this.remote.url.replace(this.remote.wildcard, uriEncodedQuery); - return this.transport.get(url, this.remote.ajax, handleRemoteResponse); - function handleRemoteResponse(err, resp) { - err ? cb([]) : cb(that.remote.filter ? that.remote.filter(resp) : resp); - } - }, - _cancelLastRemoteRequest: function cancelLastRemoteRequest() { - this.transport && this.transport.cancel(); - }, - _saveToStorage: function saveToStorage(data, thumbprint, ttl) { - if (this.storage) { - this.storage.set(keys.data, data, ttl); - this.storage.set(keys.protocol, location.protocol, ttl); - this.storage.set(keys.thumbprint, thumbprint, ttl); - } - }, - _readFromStorage: function readFromStorage(thumbprint) { - var stored = {}, isExpired; - if (this.storage) { - stored.data = this.storage.get(keys.data); - stored.protocol = this.storage.get(keys.protocol); - stored.thumbprint = this.storage.get(keys.thumbprint); - } - isExpired = stored.thumbprint !== thumbprint || stored.protocol !== location.protocol; - return stored.data && !isExpired ? stored.data : null; - }, _initialize: function initialize() { - var that = this, local = this.local, deferred; - deferred = this.prefetch ? this._loadPrefetch(this.prefetch) : $.Deferred().resolve(); - local && deferred.done(addLocalToIndex); - this.transport = this.remote ? new Transport(this.remote) : null; - return this.initPromise = deferred.promise(); + var that = this, deferred; + this.clear(); + (this.initPromise = this._loadPrefetch()).done(addLocalToIndex); + return this.initPromise; function addLocalToIndex() { - that.add(_.isFunction(local) ? local() : local); + that.add(that.local); } }, initialize: function initialize(force) { @@ -675,129 +899,318 @@ }, add: function add(data) { this.index.add(data); + return this; }, - get: function get(query, cb) { - var that = this, matches = [], cacheHit = false; - matches = this.index.get(query); - matches = this.sorter(matches).slice(0, this.limit); - matches.length < this.limit ? cacheHit = this._getFromRemote(query, returnRemoteMatches) : this._cancelLastRemoteRequest(); - if (!cacheHit) { - (matches.length > 0 || !this.transport) && cb && cb(matches); + get: function get(ids) { + ids = _.isArray(ids) ? ids : [].slice.call(arguments); + return this.index.get(ids); + }, + search: function search(query, sync, async) { + var that = this, local; + sync = sync || _.noop; + async = async || _.noop; + local = this.sorter(this.index.search(query)); + sync(this.remote ? local.slice() : local); + if (this.remote && local.length < this.sufficient) { + this.remote.get(query, processRemote); + } else if (this.remote) { + this.remote.cancelLastRequest(); } - function returnRemoteMatches(remoteMatches) { - var matchesWithBackfill = matches.slice(0); - _.each(remoteMatches, function(remoteMatch) { - var isDuplicate; - isDuplicate = _.some(matchesWithBackfill, function(match) { - return that.dupDetector(remoteMatch, match); - }); - !isDuplicate && matchesWithBackfill.push(remoteMatch); - return matchesWithBackfill.length < that.limit; + return this; + function processRemote(remote) { + var nonDuplicates = []; + _.each(remote, function(r) { + !_.some(local, function(l) { + return that.identify(r) === that.identify(l); + }) && nonDuplicates.push(r); }); - cb && cb(that.sorter(matchesWithBackfill)); + that.indexRemote && that.add(nonDuplicates); + async(nonDuplicates); } }, + all: function all() { + return this.index.all(); + }, clear: function clear() { this.index.reset(); + return this; }, clearPrefetchCache: function clearPrefetchCache() { - this.storage && this.storage.clear(); + this.prefetch && this.prefetch.clear(); + return this; }, clearRemoteCache: function clearRemoteCache() { - this.transport && Transport.resetCache(); + Transport.resetCache(); + return this; }, ttAdapter: function ttAdapter() { - return _.bind(this.get, this); + return this.__ttAdapter(); } }); return Bloodhound; - function getSorter(sortFn) { - return _.isFunction(sortFn) ? sort : noSort; - function sort(array) { - return array.sort(sortFn); - } - function noSort(array) { - return array; - } - } - function ignoreDuplicates() { - return false; - } - })(this); - var html = function() { + }(); + return Bloodhound; +}); + +(function(root, factory) { + if (typeof define === "function" && define.amd) { + define([ "jquery" ], function(a0) { + return factory(a0); + }); + } else if (typeof module === "object" && module.exports) { + module.exports = factory(require("jquery")); + } else { + factory(root["jQuery"]); + } +})(this, function($) { + var _ = function() { + "use strict"; return { - wrapper: '', - dropdown: '', - dataset: '
      ', - suggestions: '', - suggestion: '
      ' + isMsie: function() { + return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; + }, + isBlankString: function(str) { + return !str || /^\s*$/.test(str); + }, + escapeRegExChars: function(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + }, + isString: function(obj) { + return typeof obj === "string"; + }, + isNumber: function(obj) { + return typeof obj === "number"; + }, + isArray: $.isArray, + isFunction: $.isFunction, + isObject: $.isPlainObject, + isUndefined: function(obj) { + return typeof obj === "undefined"; + }, + isElement: function(obj) { + return !!(obj && obj.nodeType === 1); + }, + isJQuery: function(obj) { + return obj instanceof $; + }, + toStr: function toStr(s) { + return _.isUndefined(s) || s === null ? "" : s + ""; + }, + bind: $.proxy, + each: function(collection, cb) { + $.each(collection, reverseArgs); + function reverseArgs(index, value) { + return cb(value, index); + } + }, + map: $.map, + filter: $.grep, + every: function(obj, test) { + var result = true; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (!(result = test.call(null, val, key, obj))) { + return false; + } + }); + return !!result; + }, + some: function(obj, test) { + var result = false; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (result = test.call(null, val, key, obj)) { + return false; + } + }); + return !!result; + }, + mixin: $.extend, + identity: function(x) { + return x; + }, + clone: function(obj) { + return $.extend(true, {}, obj); + }, + getIdGenerator: function() { + var counter = 0; + return function() { + return counter++; + }; + }, + templatify: function templatify(obj) { + return $.isFunction(obj) ? obj : template; + function template() { + return String(obj); + } + }, + defer: function(fn) { + setTimeout(fn, 0); + }, + debounce: function(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments, later, callNow; + later = function() { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; + }, + throttle: function(func, wait) { + var context, args, timeout, result, previous, later; + previous = 0; + later = function() { + previous = new Date(); + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date(), remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }, + stringify: function(val) { + return _.isString(val) ? val : JSON.stringify(val); + }, + guid: function() { + function _p8(s) { + var p = (Math.random().toString(16) + "000000000").substr(2, 8); + return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p; + } + return "tt-" + _p8() + _p8(true) + _p8(true) + _p8(); + }, + noop: function() {} }; }(); - var css = function() { + var WWW = function() { "use strict"; - var css = { - wrapper: { - position: "relative", - display: "inline-block" - }, - hint: { - position: "absolute", - top: "0", - left: "0", - borderColor: "transparent", - boxShadow: "none", - opacity: "1" - }, - input: { - position: "relative", - verticalAlign: "top", - backgroundColor: "transparent" - }, - inputWithNoHint: { - position: "relative", - verticalAlign: "top" - }, - dropdown: { - position: "absolute", - top: "100%", - left: "0", - zIndex: "100", - display: "none" - }, - suggestions: { - display: "block" - }, - suggestion: { - whiteSpace: "nowrap", - cursor: "pointer" - }, - suggestionChild: { - whiteSpace: "normal" - }, - ltr: { - left: "0", - right: "auto" - }, - rtl: { - left: "auto", - right: " 0" - } + var defaultClassNames = { + wrapper: "twitter-typeahead", + input: "tt-input", + hint: "tt-hint", + menu: "tt-menu", + dataset: "tt-dataset", + suggestion: "tt-suggestion", + selectable: "tt-selectable", + empty: "tt-empty", + open: "tt-open", + cursor: "tt-cursor", + highlight: "tt-highlight" }; - if (_.isMsie()) { - _.mixin(css.input, { - backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" - }); + return build; + function build(o) { + var www, classes; + classes = _.mixin({}, defaultClassNames, o); + www = { + css: buildCss(), + classes: classes, + html: buildHtml(classes), + selectors: buildSelectors(classes) + }; + return { + css: www.css, + html: www.html, + classes: www.classes, + selectors: www.selectors, + mixin: function(o) { + _.mixin(o, www); + } + }; } - if (_.isMsie() && _.isMsie() <= 7) { - _.mixin(css.input, { - marginTop: "-1px" - }); + function buildHtml(c) { + return { + wrapper: '', + menu: '
      ' + }; + } + function buildSelectors(classes) { + var selectors = {}; + _.each(classes, function(v, k) { + selectors[k] = "." + v; + }); + return selectors; + } + function buildCss() { + var css = { + wrapper: { + position: "relative", + display: "inline-block" + }, + hint: { + position: "absolute", + top: "0", + left: "0", + borderColor: "transparent", + boxShadow: "none", + opacity: "1" + }, + input: { + position: "relative", + verticalAlign: "top", + backgroundColor: "transparent" + }, + inputWithNoHint: { + position: "relative", + verticalAlign: "top" + }, + menu: { + position: "absolute", + top: "100%", + left: "0", + zIndex: "100", + display: "none" + }, + ltr: { + left: "0", + right: "auto" + }, + rtl: { + left: "auto", + right: " 0" + } + }; + if (_.isMsie()) { + _.mixin(css.input, { + backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" + }); + } + return css; } - return css; }(); var EventBus = function() { "use strict"; - var namespace = "typeahead:"; + var namespace, deprecationMap; + namespace = "typeahead:"; + deprecationMap = { + render: "rendered", + cursorchange: "cursorchanged", + select: "selected", + autocomplete: "autocompleted" + }; function EventBus(o) { if (!o || !o.el) { $.error("EventBus initialized without el"); @@ -805,9 +1218,23 @@ this.$el = $(o.el); } _.mixin(EventBus.prototype, { + _trigger: function(type, args) { + var $e = $.Event(namespace + type); + this.$el.trigger.call(this.$el, $e, args || []); + return $e; + }, + before: function(type) { + var args, $e; + args = [].slice.call(arguments, 1); + $e = this._trigger("before" + type, args); + return $e.isDefaultPrevented(); + }, trigger: function(type) { - var args = [].slice.call(arguments, 1); - this.$el.trigger(namespace + type, args); + var deprecatedType; + this._trigger(type, [].slice.call(arguments, 1)); + if (deprecatedType = deprecationMap[type]) { + this._trigger(deprecatedType, [].slice.call(arguments, 1)); + } } }); return EventBus; @@ -910,7 +1337,36 @@ tagName: "strong", className: null, wordsOnly: false, - caseSensitive: false + caseSensitive: false, + diacriticInsensitive: false + }; + var accented = { + A: "[AaªÀ-Åà-åĀ-ąǍǎȀ-ȃȦȧᴬᵃḀḁẚẠ-ảₐ℀℁℻⒜Ⓐⓐ㍱-㍴㎀-㎄㎈㎉㎩-㎯㏂㏊㏟㏿Aa]", + B: "[BbᴮᵇḂ-ḇℬ⒝Ⓑⓑ㍴㎅-㎇㏃㏈㏔㏝Bb]", + C: "[CcÇçĆ-čᶜ℀ℂ℃℅℆ℭⅭⅽ⒞Ⓒⓒ㍶㎈㎉㎝㎠㎤㏄-㏇Cc]", + D: "[DdĎďDŽ-džDZ-dzᴰᵈḊ-ḓⅅⅆⅮⅾ⒟Ⓓⓓ㋏㍲㍷-㍹㎗㎭-㎯㏅㏈Dd]", + E: "[EeÈ-Ëè-ëĒ-ěȄ-ȇȨȩᴱᵉḘ-ḛẸ-ẽₑ℡ℯℰⅇ⒠Ⓔⓔ㉐㋍㋎Ee]", + F: "[FfᶠḞḟ℉ℱ℻⒡Ⓕⓕ㎊-㎌㎙ff-fflFf]", + G: "[GgĜ-ģǦǧǴǵᴳᵍḠḡℊ⒢Ⓖⓖ㋌㋍㎇㎍-㎏㎓㎬㏆㏉㏒㏿Gg]", + H: "[HhĤĥȞȟʰᴴḢ-ḫẖℋ-ℎ⒣Ⓗⓗ㋌㍱㎐-㎔㏊㏋㏗Hh]", + I: "[IiÌ-Ïì-ïĨ-İIJijǏǐȈ-ȋᴵᵢḬḭỈ-ịⁱℐℑℹⅈⅠ-ⅣⅥ-ⅨⅪⅫⅰ-ⅳⅵ-ⅸⅺⅻ⒤Ⓘⓘ㍺㏌㏕fiffiIi]", + J: "[JjIJ-ĵLJ-njǰʲᴶⅉ⒥ⒿⓙⱼJj]", + K: "[KkĶķǨǩᴷᵏḰ-ḵK⒦Ⓚⓚ㎄㎅㎉㎏㎑㎘㎞㎢㎦㎪㎸㎾㏀㏆㏍-㏏Kk]", + L: "[LlĹ-ŀLJ-ljˡᴸḶḷḺ-ḽℒℓ℡Ⅼⅼ⒧Ⓛⓛ㋏㎈㎉㏐-㏓㏕㏖㏿flfflLl]", + M: "[MmᴹᵐḾ-ṃ℠™ℳⅯⅿ⒨Ⓜⓜ㍷-㍹㎃㎆㎎㎒㎖㎙-㎨㎫㎳㎷㎹㎽㎿㏁㏂㏎㏐㏔-㏖㏘㏙㏞㏟Mm]", + N: "[NnÑñŃ-ʼnNJ-njǸǹᴺṄ-ṋⁿℕ№⒩Ⓝⓝ㎁㎋㎚㎱㎵㎻㏌㏑Nn]", + O: "[OoºÒ-Öò-öŌ-őƠơǑǒǪǫȌ-ȏȮȯᴼᵒỌ-ỏₒ℅№ℴ⒪Ⓞⓞ㍵㏇㏒㏖Oo]", + P: "[PpᴾᵖṔ-ṗℙ⒫Ⓟⓟ㉐㍱㍶㎀㎊㎩-㎬㎰㎴㎺㏋㏗-㏚Pp]", + Q: "[Qqℚ⒬Ⓠⓠ㏃Qq]", + R: "[RrŔ-řȐ-ȓʳᴿᵣṘ-ṛṞṟ₨ℛ-ℝ⒭Ⓡⓡ㋍㍴㎭-㎯㏚㏛Rr]", + S: "[SsŚ-šſȘșˢṠ-ṣ₨℁℠⒮Ⓢⓢ㎧㎨㎮-㎳㏛㏜stSs]", + T: "[TtŢ-ťȚțᵀᵗṪ-ṱẗ℡™⒯Ⓣⓣ㉐㋏㎔㏏ſtstTt]", + U: "[UuÙ-Üù-üŨ-ųƯưǓǔȔ-ȗᵁᵘᵤṲ-ṷỤ-ủ℆⒰Ⓤⓤ㍳㍺Uu]", + V: "[VvᵛᵥṼ-ṿⅣ-Ⅷⅳ-ⅷ⒱Ⓥⓥⱽ㋎㍵㎴-㎹㏜㏞Vv]", + W: "[WwŴŵʷᵂẀ-ẉẘ⒲Ⓦⓦ㎺-㎿㏝Ww]", + X: "[XxˣẊ-ẍₓ℻Ⅸ-Ⅻⅸ-ⅻ⒳Ⓧⓧ㏓Xx]", + Y: "[YyÝýÿŶ-ŸȲȳʸẎẏẙỲ-ỹ⒴Ⓨⓨ㏉Yy]", + Z: "[ZzŹ-žDZ-dzᶻẐ-ẕℤℨ⒵Ⓩⓩ㎐-㎔Zz]" }; return function hightlight(o) { var regex; @@ -919,7 +1375,7 @@ return; } o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ]; - regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly); + regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly, o.diacriticInsensitive); traverse(o.node, hightlightTextNode); function hightlightTextNode(textNode) { var match, patternNode, wrapperNode; @@ -945,10 +1401,17 @@ } } }; - function getRegex(patterns, caseSensitive, wordsOnly) { + function accent_replacer(chr) { + return accented[chr.toUpperCase()] || chr; + } + function getRegex(patterns, caseSensitive, wordsOnly, diacriticInsensitive) { var escapedPatterns = [], regexStr; for (var i = 0, len = patterns.length; i < len; i++) { - escapedPatterns.push(_.escapeRegExChars(patterns[i])); + var escapedWord = _.escapeRegExChars(patterns[i]); + if (diacriticInsensitive) { + escapedWord = escapedWord.replace(/\S/g, accent_replacer); + } + escapedPatterns.push(escapedWord); } regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")"; return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i"); @@ -966,36 +1429,38 @@ 38: "up", 40: "down" }; - function Input(o) { - var that = this, onBlur, onFocus, onKeydown, onInput; + function Input(o, www) { + var id; o = o || {}; if (!o.input) { $.error("input is missing"); } - onBlur = _.bind(this._onBlur, this); - onFocus = _.bind(this._onFocus, this); - onKeydown = _.bind(this._onKeydown, this); - onInput = _.bind(this._onInput, this); + www.mixin(this); this.$hint = $(o.hint); - this.$input = $(o.input).on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); + this.$input = $(o.input); + this.$menu = $(o.menu); + id = this.$input.attr("id") || _.guid(); + this.$menu.attr("id", id + "_listbox"); + this.$hint.attr({ + "aria-hidden": true + }); + this.$input.attr({ + "aria-owns": id + "_listbox", + role: "combobox", + "aria-autocomplete": "list", + "aria-expanded": false + }); + this.query = this.$input.val(); + this.queryWhenFocused = this.hasFocus() ? this.query : null; + this.$overflowHelper = buildOverflowHelper(this.$input); + this._checkLanguageDirection(); if (this.$hint.length === 0) { this.setHint = this.getHint = this.clearHint = this.clearHintIfInvalid = _.noop; } - if (!_.isMsie()) { - this.$input.on("input.tt", onInput); - } else { - this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { - if (specialKeyCodeMap[$e.which || $e.keyCode]) { - return; - } - _.defer(_.bind(that._onInput, that, $e)); - }); - } - this.query = this.$input.val(); - this.$overflowHelper = buildOverflowHelper(this.$input); + this.onSync("cursorchange", this._updateDescendent); } Input.normalizeQuery = function(str) { - return (str || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " "); + return _.toStr(str).replace(/^\s*/g, "").replace(/\s{2,}/g, " "); }; _.mixin(Input.prototype, EventEmitter, { _onBlur: function onBlur() { @@ -1003,6 +1468,7 @@ this.trigger("blurred"); }, _onFocus: function onFocus() { + this.queryWhenFocused = this.query; this.trigger("focused"); }, _onKeydown: function onKeydown($e) { @@ -1013,17 +1479,13 @@ } }, _onInput: function onInput() { - this._checkInputValue(); + this._setQuery(this.getInputValue()); + this.clearHintIfInvalid(); + this._checkLanguageDirection(); }, _managePreventDefault: function managePreventDefault(keyName, $e) { - var preventDefault, hintValue, inputValue; + var preventDefault; switch (keyName) { - case "tab": - hintValue = this.getHint(); - inputValue = this.getInputValue(); - preventDefault = hintValue && hintValue !== inputValue && !withModifier($e); - break; - case "up": case "down": preventDefault = !withModifier($e); @@ -1046,39 +1508,76 @@ } return trigger; }, - _checkInputValue: function checkInputValue() { - var inputValue, areEquivalent, hasDifferentWhitespace; - inputValue = this.getInputValue(); - areEquivalent = areQueriesEquivalent(inputValue, this.query); - hasDifferentWhitespace = areEquivalent ? this.query.length !== inputValue.length : false; - this.query = inputValue; - if (!areEquivalent) { + _checkLanguageDirection: function checkLanguageDirection() { + var dir = (this.$input.css("direction") || "ltr").toLowerCase(); + if (this.dir !== dir) { + this.dir = dir; + this.$hint.attr("dir", dir); + this.trigger("langDirChanged", dir); + } + }, + _setQuery: function setQuery(val, silent) { + var areEquivalent, hasDifferentWhitespace; + areEquivalent = areQueriesEquivalent(val, this.query); + hasDifferentWhitespace = areEquivalent ? this.query.length !== val.length : false; + this.query = val; + if (!silent && !areEquivalent) { this.trigger("queryChanged", this.query); - } else if (hasDifferentWhitespace) { + } else if (!silent && hasDifferentWhitespace) { this.trigger("whitespaceChanged", this.query); } }, + _updateDescendent: function updateDescendent(event, id) { + this.$input.attr("aria-activedescendant", id); + }, + bind: function() { + var that = this, onBlur, onFocus, onKeydown, onInput; + onBlur = _.bind(this._onBlur, this); + onFocus = _.bind(this._onFocus, this); + onKeydown = _.bind(this._onKeydown, this); + onInput = _.bind(this._onInput, this); + this.$input.on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); + if (!_.isMsie() || _.isMsie() > 9) { + this.$input.on("input.tt", onInput); + } else { + this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { + if (specialKeyCodeMap[$e.which || $e.keyCode]) { + return; + } + _.defer(_.bind(that._onInput, that, $e)); + }); + } + return this; + }, focus: function focus() { this.$input.focus(); }, blur: function blur() { this.$input.blur(); }, - getQuery: function getQuery() { - return this.query; + getLangDir: function getLangDir() { + return this.dir; }, - setQuery: function setQuery(query) { - this.query = query; + getQuery: function getQuery() { + return this.query || ""; + }, + setQuery: function setQuery(val, silent) { + this.setInputValue(val); + this._setQuery(val, silent); + }, + hasQueryChangedSinceLastFocus: function hasQueryChangedSinceLastFocus() { + return this.query !== this.queryWhenFocused; }, getInputValue: function getInputValue() { return this.$input.val(); }, - setInputValue: function setInputValue(value, silent) { + setInputValue: function setInputValue(value) { this.$input.val(value); - silent ? this.clearHint() : this._checkInputValue(); + this.clearHintIfInvalid(); + this._checkLanguageDirection(); }, resetInputValue: function resetInputValue() { - this.setInputValue(this.query, true); + this.setInputValue(this.query); }, getHint: function getHint() { return this.$hint.val(); @@ -1097,8 +1596,8 @@ isValid = val !== "" && valIsPrefixOfHint && !this.hasOverflow(); !isValid && this.clearHint(); }, - getLanguageDirection: function getLanguageDirection() { - return (this.$input.css("direction") || "ltr").toLowerCase(); + hasFocus: function hasFocus() { + return this.$input.is(":focus"); }, hasOverflow: function hasOverflow() { var constraint = this.$input.width() - 2; @@ -1121,7 +1620,11 @@ destroy: function destroy() { this.$hint.off(".tt"); this.$input.off(".tt"); - this.$hint = this.$input = this.$overflowHelper = null; + this.$overflowHelper.remove(); + this.$hint = this.$input = this.$overflowHelper = $("
      "); + }, + setAriaExpanded: function setAriaExpanded(value) { + this.$input.attr("aria-expanded", value); } }); return Input; @@ -1151,118 +1654,193 @@ }(); var Dataset = function() { "use strict"; - var datasetKey = "ttDataset", valueKey = "ttValue", datumKey = "ttDatum"; - function Dataset(o) { + var keys, nameGenerator; + keys = { + dataset: "tt-selectable-dataset", + val: "tt-selectable-display", + obj: "tt-selectable-object" + }; + nameGenerator = _.getIdGenerator(); + function Dataset(o, www) { o = o || {}; o.templates = o.templates || {}; + o.templates.notFound = o.templates.notFound || o.templates.empty; if (!o.source) { $.error("missing source"); } + if (!o.node) { + $.error("missing node"); + } if (o.name && !isValidName(o.name)) { $.error("invalid dataset name: " + o.name); } - this.query = null; + www.mixin(this); this.highlight = !!o.highlight; - this.name = o.name || _.getUniqueId(); - this.source = o.source; + this.name = _.toStr(o.name || nameGenerator()); + this.limit = o.limit || 5; this.displayFn = getDisplayFn(o.display || o.displayKey); this.templates = getTemplates(o.templates, this.displayFn); - this.$el = $(html.dataset.replace("%CLASS%", this.name)); + this.source = o.source.__ttAdapter ? o.source.__ttAdapter() : o.source; + this.async = _.isUndefined(o.async) ? this.source.length > 2 : !!o.async; + this._resetLastSuggestion(); + this.$el = $(o.node).attr("role", "presentation").addClass(this.classes.dataset).addClass(this.classes.dataset + "-" + this.name); } - Dataset.extractDatasetName = function extractDatasetName(el) { - return $(el).data(datasetKey); - }; - Dataset.extractValue = function extractDatum(el) { - return $(el).data(valueKey); - }; - Dataset.extractDatum = function extractDatum(el) { - return $(el).data(datumKey); + Dataset.extractData = function extractData(el) { + var $el = $(el); + if ($el.data(keys.obj)) { + return { + dataset: $el.data(keys.dataset) || "", + val: $el.data(keys.val) || "", + obj: $el.data(keys.obj) || null + }; + } + return null; }; _.mixin(Dataset.prototype, EventEmitter, { - _render: function render(query, suggestions) { - if (!this.$el) { - return; - } - var that = this, hasSuggestions; - this.$el.empty(); - hasSuggestions = suggestions && suggestions.length; - if (!hasSuggestions && this.templates.empty) { - this.$el.html(getEmptyHtml()).prepend(that.templates.header ? getHeaderHtml() : null).append(that.templates.footer ? getFooterHtml() : null); - } else if (hasSuggestions) { - this.$el.html(getSuggestionsHtml()).prepend(that.templates.header ? getHeaderHtml() : null).append(that.templates.footer ? getFooterHtml() : null); - } - this.trigger("rendered"); - function getEmptyHtml() { - return that.templates.empty({ - query: query, - isEmpty: true - }); - } - function getSuggestionsHtml() { - var $suggestions, nodes; - $suggestions = $(html.suggestions).css(css.suggestions); - nodes = _.map(suggestions, getSuggestionNode); - $suggestions.append.apply($suggestions, nodes); - that.highlight && highlight({ - className: "tt-highlight", - node: $suggestions[0], - pattern: query - }); - return $suggestions; - function getSuggestionNode(suggestion) { - var $el; - $el = $(html.suggestion).append(that.templates.suggestion(suggestion)).data(datasetKey, that.name).data(valueKey, that.displayFn(suggestion)).data(datumKey, suggestion); - $el.children().each(function() { - $(this).css(css.suggestionChild); - }); - return $el; - } - } - function getHeaderHtml() { - return that.templates.header({ - query: query, - isEmpty: !hasSuggestions - }); - } - function getFooterHtml() { - return that.templates.footer({ - query: query, - isEmpty: !hasSuggestions - }); + _overwrite: function overwrite(query, suggestions) { + suggestions = suggestions || []; + if (suggestions.length) { + this._renderSuggestions(query, suggestions); + } else if (this.async && this.templates.pending) { + this._renderPending(query); + } else if (!this.async && this.templates.notFound) { + this._renderNotFound(query); + } else { + this._empty(); } + this.trigger("rendered", suggestions, false, this.name); }, - getRoot: function getRoot() { - return this.$el; + _append: function append(query, suggestions) { + suggestions = suggestions || []; + if (suggestions.length && this.$lastSuggestion.length) { + this._appendSuggestions(query, suggestions); + } else if (suggestions.length) { + this._renderSuggestions(query, suggestions); + } else if (!this.$lastSuggestion.length && this.templates.notFound) { + this._renderNotFound(query); + } + this.trigger("rendered", suggestions, true, this.name); + }, + _renderSuggestions: function renderSuggestions(query, suggestions) { + var $fragment; + $fragment = this._getSuggestionsFragment(query, suggestions); + this.$lastSuggestion = $fragment.children().last(); + this.$el.html($fragment).prepend(this._getHeader(query, suggestions)).append(this._getFooter(query, suggestions)); + }, + _appendSuggestions: function appendSuggestions(query, suggestions) { + var $fragment, $lastSuggestion; + $fragment = this._getSuggestionsFragment(query, suggestions); + $lastSuggestion = $fragment.children().last(); + this.$lastSuggestion.after($fragment); + this.$lastSuggestion = $lastSuggestion; + }, + _renderPending: function renderPending(query) { + var template = this.templates.pending; + this._resetLastSuggestion(); + template && this.$el.html(template({ + query: query, + dataset: this.name + })); + }, + _renderNotFound: function renderNotFound(query) { + var template = this.templates.notFound; + this._resetLastSuggestion(); + template && this.$el.html(template({ + query: query, + dataset: this.name + })); + }, + _empty: function empty() { + this.$el.empty(); + this._resetLastSuggestion(); + }, + _getSuggestionsFragment: function getSuggestionsFragment(query, suggestions) { + var that = this, fragment; + fragment = document.createDocumentFragment(); + _.each(suggestions, function getSuggestionNode(suggestion) { + var $el, context; + context = that._injectQuery(query, suggestion); + $el = $(that.templates.suggestion(context)).data(keys.dataset, that.name).data(keys.obj, suggestion).data(keys.val, that.displayFn(suggestion)).addClass(that.classes.suggestion + " " + that.classes.selectable); + fragment.appendChild($el[0]); + }); + this.highlight && highlight({ + className: this.classes.highlight, + node: fragment, + pattern: query + }); + return $(fragment); + }, + _getFooter: function getFooter(query, suggestions) { + return this.templates.footer ? this.templates.footer({ + query: query, + suggestions: suggestions, + dataset: this.name + }) : null; + }, + _getHeader: function getHeader(query, suggestions) { + return this.templates.header ? this.templates.header({ + query: query, + suggestions: suggestions, + dataset: this.name + }) : null; + }, + _resetLastSuggestion: function resetLastSuggestion() { + this.$lastSuggestion = $(); + }, + _injectQuery: function injectQuery(query, obj) { + return _.isObject(obj) ? _.mixin({ + _query: query + }, obj) : obj; }, update: function update(query) { - var that = this; - this.query = query; - this.canceled = false; - this.source(query, render); - function render(suggestions) { - if (!that.canceled && query === that.query) { - that._render(query, suggestions); + var that = this, canceled = false, syncCalled = false, rendered = 0; + this.cancel(); + this.cancel = function cancel() { + canceled = true; + that.cancel = $.noop; + that.async && that.trigger("asyncCanceled", query, that.name); + }; + this.source(query, sync, async); + !syncCalled && sync([]); + function sync(suggestions) { + if (syncCalled && !async) { + return; + } + syncCalled = true; + suggestions = (suggestions || []).slice(0, that.limit); + rendered = suggestions.length; + that._overwrite(query, suggestions); + if (rendered < that.limit && that.async) { + that.trigger("asyncRequested", query, that.name); + } + } + function async(suggestions) { + suggestions = suggestions || []; + if (!canceled && rendered < that.limit) { + that.cancel = $.noop; + var idx = Math.abs(rendered - that.limit); + rendered += idx; + that._append(query, suggestions.slice(0, idx)); + that.async && that.trigger("asyncReceived", query, that.name); } } }, - cancel: function cancel() { - this.canceled = true; - }, + cancel: $.noop, clear: function clear() { + this._empty(); this.cancel(); - this.$el.empty(); - this.trigger("rendered"); + this.trigger("cleared"); }, isEmpty: function isEmpty() { return this.$el.is(":empty"); }, destroy: function destroy() { - this.$el = null; + this.$el = $("
      "); } }); return Dataset; function getDisplayFn(display) { - display = display || "value"; + display = display || _.stringify; return _.isFunction(display) ? display : displayFn; function displayFn(obj) { return obj[display]; @@ -1270,404 +1848,763 @@ } function getTemplates(templates, displayFn) { return { - empty: templates.empty && _.templatify(templates.empty), + notFound: templates.notFound && _.templatify(templates.notFound), + pending: templates.pending && _.templatify(templates.pending), header: templates.header && _.templatify(templates.header), footer: templates.footer && _.templatify(templates.footer), - suggestion: templates.suggestion || suggestionTemplate + suggestion: templates.suggestion ? userSuggestionTemplate : suggestionTemplate }; + function userSuggestionTemplate(context) { + var template = templates.suggestion; + return $(template(context)).attr("id", _.guid()); + } function suggestionTemplate(context) { - return "

      " + displayFn(context) + "

      "; + return $('
      ').attr("id", _.guid()).text(displayFn(context)); } } function isValidName(str) { return /^[_a-zA-Z0-9-]+$/.test(str); } }(); - var Dropdown = function() { + var Menu = function() { "use strict"; - function Dropdown(o) { - var that = this, onSuggestionClick, onSuggestionMouseEnter, onSuggestionMouseLeave; + function Menu(o, www) { + var that = this; o = o || {}; - if (!o.menu) { - $.error("menu is required"); + if (!o.node) { + $.error("node is required"); } - this.isOpen = false; - this.isEmpty = true; + www.mixin(this); + this.$node = $(o.node); + this.query = null; this.datasets = _.map(o.datasets, initializeDataset); - onSuggestionClick = _.bind(this._onSuggestionClick, this); - onSuggestionMouseEnter = _.bind(this._onSuggestionMouseEnter, this); - onSuggestionMouseLeave = _.bind(this._onSuggestionMouseLeave, this); - this.$menu = $(o.menu).on("click.tt", ".tt-suggestion", onSuggestionClick).on("mouseenter.tt", ".tt-suggestion", onSuggestionMouseEnter).on("mouseleave.tt", ".tt-suggestion", onSuggestionMouseLeave); - _.each(this.datasets, function(dataset) { - that.$menu.append(dataset.getRoot()); - dataset.onSync("rendered", that._onRendered, that); - }); + function initializeDataset(oDataset) { + var node = that.$node.find(oDataset.node).first(); + oDataset.node = node.length ? node : $("
      ").appendTo(that.$node); + return new Dataset(oDataset, www); + } } - _.mixin(Dropdown.prototype, EventEmitter, { - _onSuggestionClick: function onSuggestionClick($e) { - this.trigger("suggestionClicked", $($e.currentTarget)); + _.mixin(Menu.prototype, EventEmitter, { + _onSelectableClick: function onSelectableClick($e) { + this.trigger("selectableClicked", $($e.currentTarget)); }, - _onSuggestionMouseEnter: function onSuggestionMouseEnter($e) { - this._removeCursor(); - this._setCursor($($e.currentTarget), true); + _onRendered: function onRendered(type, dataset, suggestions, async) { + this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); + this.trigger("datasetRendered", dataset, suggestions, async); }, - _onSuggestionMouseLeave: function onSuggestionMouseLeave() { - this._removeCursor(); + _onCleared: function onCleared() { + this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); + this.trigger("datasetCleared"); }, - _onRendered: function onRendered() { - this.isEmpty = _.every(this.datasets, isDatasetEmpty); - this.isEmpty ? this._hide() : this.isOpen && this._show(); - this.trigger("datasetRendered"); - function isDatasetEmpty(dataset) { - return dataset.isEmpty(); - } + _propagate: function propagate() { + this.trigger.apply(this, arguments); }, - _hide: function() { - this.$menu.hide(); + _allDatasetsEmpty: function allDatasetsEmpty() { + return _.every(this.datasets, _.bind(function isDatasetEmpty(dataset) { + var isEmpty = dataset.isEmpty(); + this.$node.attr("aria-expanded", !isEmpty); + return isEmpty; + }, this)); }, - _show: function() { - this.$menu.css("display", "block"); + _getSelectables: function getSelectables() { + return this.$node.find(this.selectors.selectable); }, - _getSuggestions: function getSuggestions() { - return this.$menu.find(".tt-suggestion"); - }, - _getCursor: function getCursor() { - return this.$menu.find(".tt-cursor").first(); - }, - _setCursor: function setCursor($el, silent) { - $el.first().addClass("tt-cursor"); - !silent && this.trigger("cursorMoved"); - }, - _removeCursor: function removeCursor() { - this._getCursor().removeClass("tt-cursor"); - }, - _moveCursor: function moveCursor(increment) { - var $suggestions, $oldCursor, newCursorIndex, $newCursor; - if (!this.isOpen) { - return; - } - $oldCursor = this._getCursor(); - $suggestions = this._getSuggestions(); - this._removeCursor(); - newCursorIndex = $suggestions.index($oldCursor) + increment; - newCursorIndex = (newCursorIndex + 1) % ($suggestions.length + 1) - 1; - if (newCursorIndex === -1) { - this.trigger("cursorRemoved"); - return; - } else if (newCursorIndex < -1) { - newCursorIndex = $suggestions.length - 1; - } - this._setCursor($newCursor = $suggestions.eq(newCursorIndex)); - this._ensureVisible($newCursor); + _removeCursor: function _removeCursor() { + var $selectable = this.getActiveSelectable(); + $selectable && $selectable.removeClass(this.classes.cursor); }, _ensureVisible: function ensureVisible($el) { - var elTop, elBottom, menuScrollTop, menuHeight; + var elTop, elBottom, nodeScrollTop, nodeHeight; elTop = $el.position().top; elBottom = elTop + $el.outerHeight(true); - menuScrollTop = this.$menu.scrollTop(); - menuHeight = this.$menu.height() + parseInt(this.$menu.css("paddingTop"), 10) + parseInt(this.$menu.css("paddingBottom"), 10); + nodeScrollTop = this.$node.scrollTop(); + nodeHeight = this.$node.height() + parseInt(this.$node.css("paddingTop"), 10) + parseInt(this.$node.css("paddingBottom"), 10); if (elTop < 0) { - this.$menu.scrollTop(menuScrollTop + elTop); - } else if (menuHeight < elBottom) { - this.$menu.scrollTop(menuScrollTop + (elBottom - menuHeight)); + this.$node.scrollTop(nodeScrollTop + elTop); + } else if (nodeHeight < elBottom) { + this.$node.scrollTop(nodeScrollTop + (elBottom - nodeHeight)); } }, - close: function close() { - if (this.isOpen) { - this.isOpen = false; - this._removeCursor(); - this._hide(); - this.trigger("closed"); - } + bind: function() { + var that = this, onSelectableClick; + onSelectableClick = _.bind(this._onSelectableClick, this); + this.$node.on("click.tt", this.selectors.selectable, onSelectableClick); + this.$node.on("mouseover", this.selectors.selectable, function() { + that.setCursor($(this)); + }); + this.$node.on("mouseleave", function() { + that._removeCursor(); + }); + _.each(this.datasets, function(dataset) { + dataset.onSync("asyncRequested", that._propagate, that).onSync("asyncCanceled", that._propagate, that).onSync("asyncReceived", that._propagate, that).onSync("rendered", that._onRendered, that).onSync("cleared", that._onCleared, that); + }); + return this; + }, + isOpen: function isOpen() { + return this.$node.hasClass(this.classes.open); }, open: function open() { - if (!this.isOpen) { - this.isOpen = true; - !this.isEmpty && this._show(); - this.trigger("opened"); - } + this.$node.scrollTop(0); + this.$node.addClass(this.classes.open); + }, + close: function close() { + this.$node.attr("aria-expanded", false); + this.$node.removeClass(this.classes.open); + this._removeCursor(); }, setLanguageDirection: function setLanguageDirection(dir) { - this.$menu.css(dir === "ltr" ? css.ltr : css.rtl); + this.$node.attr("dir", dir); }, - moveCursorUp: function moveCursorUp() { - this._moveCursor(-1); + selectableRelativeToCursor: function selectableRelativeToCursor(delta) { + var $selectables, $oldCursor, oldIndex, newIndex; + $oldCursor = this.getActiveSelectable(); + $selectables = this._getSelectables(); + oldIndex = $oldCursor ? $selectables.index($oldCursor) : -1; + newIndex = oldIndex + delta; + newIndex = (newIndex + 1) % ($selectables.length + 1) - 1; + newIndex = newIndex < -1 ? $selectables.length - 1 : newIndex; + return newIndex === -1 ? null : $selectables.eq(newIndex); }, - moveCursorDown: function moveCursorDown() { - this._moveCursor(+1); - }, - getDatumForSuggestion: function getDatumForSuggestion($el) { - var datum = null; - if ($el.length) { - datum = { - raw: Dataset.extractDatum($el), - value: Dataset.extractValue($el), - datasetName: Dataset.extractDatasetName($el) - }; + setCursor: function setCursor($selectable) { + this._removeCursor(); + if ($selectable = $selectable && $selectable.first()) { + $selectable.addClass(this.classes.cursor); + this._ensureVisible($selectable); } - return datum; }, - getDatumForCursor: function getDatumForCursor() { - return this.getDatumForSuggestion(this._getCursor().first()); + getSelectableData: function getSelectableData($el) { + return $el && $el.length ? Dataset.extractData($el) : null; }, - getDatumForTopSuggestion: function getDatumForTopSuggestion() { - return this.getDatumForSuggestion(this._getSuggestions().first()); + getActiveSelectable: function getActiveSelectable() { + var $selectable = this._getSelectables().filter(this.selectors.cursor).first(); + return $selectable.length ? $selectable : null; + }, + getTopSelectable: function getTopSelectable() { + var $selectable = this._getSelectables().first(); + return $selectable.length ? $selectable : null; }, update: function update(query) { - _.each(this.datasets, updateDataset); + var isValidUpdate = query !== this.query; + if (isValidUpdate) { + this.query = query; + _.each(this.datasets, updateDataset); + } + return isValidUpdate; function updateDataset(dataset) { dataset.update(query); } }, empty: function empty() { _.each(this.datasets, clearDataset); - this.isEmpty = true; + this.query = null; + this.$node.addClass(this.classes.empty); function clearDataset(dataset) { dataset.clear(); } }, - isVisible: function isVisible() { - return this.isOpen && !this.isEmpty; - }, destroy: function destroy() { - this.$menu.off(".tt"); - this.$menu = null; + this.$node.off(".tt"); + this.$node = $("
      "); _.each(this.datasets, destroyDataset); function destroyDataset(dataset) { dataset.destroy(); } } }); - return Dropdown; - function initializeDataset(oDataset) { - return new Dataset(oDataset); + return Menu; + }(); + var Status = function() { + "use strict"; + function Status(options) { + this.$el = $("", { + role: "status", + "aria-live": "polite" + }).css({ + position: "absolute", + padding: "0", + border: "0", + height: "1px", + width: "1px", + "margin-bottom": "-1px", + "margin-right": "-1px", + overflow: "hidden", + clip: "rect(0 0 0 0)", + "white-space": "nowrap" + }); + options.$input.after(this.$el); + _.each(options.menu.datasets, _.bind(function(dataset) { + if (dataset.onSync) { + dataset.onSync("rendered", _.bind(this.update, this)); + dataset.onSync("cleared", _.bind(this.cleared, this)); + } + }, this)); } + _.mixin(Status.prototype, { + update: function update(event, suggestions) { + var length = suggestions.length; + var words; + if (length === 1) { + words = { + result: "result", + is: "is" + }; + } else { + words = { + result: "results", + is: "are" + }; + } + this.$el.text(length + " " + words.result + " " + words.is + " available, use up and down arrow keys to navigate."); + }, + cleared: function() { + this.$el.text(""); + } + }); + return Status; + }(); + var DefaultMenu = function() { + "use strict"; + var s = Menu.prototype; + function DefaultMenu() { + Menu.apply(this, [].slice.call(arguments, 0)); + } + _.mixin(DefaultMenu.prototype, Menu.prototype, { + open: function open() { + !this._allDatasetsEmpty() && this._show(); + return s.open.apply(this, [].slice.call(arguments, 0)); + }, + close: function close() { + this._hide(); + return s.close.apply(this, [].slice.call(arguments, 0)); + }, + _onRendered: function onRendered() { + if (this._allDatasetsEmpty()) { + this._hide(); + } else { + this.isOpen() && this._show(); + } + return s._onRendered.apply(this, [].slice.call(arguments, 0)); + }, + _onCleared: function onCleared() { + if (this._allDatasetsEmpty()) { + this._hide(); + } else { + this.isOpen() && this._show(); + } + return s._onCleared.apply(this, [].slice.call(arguments, 0)); + }, + setLanguageDirection: function setLanguageDirection(dir) { + this.$node.css(dir === "ltr" ? this.css.ltr : this.css.rtl); + return s.setLanguageDirection.apply(this, [].slice.call(arguments, 0)); + }, + _hide: function hide() { + this.$node.hide(); + }, + _show: function show() { + this.$node.css("display", "block"); + } + }); + return DefaultMenu; }(); var Typeahead = function() { "use strict"; - var attrsKey = "ttAttrs"; - function Typeahead(o) { - var $menu, $input, $hint; + function Typeahead(o, www) { + var onFocused, onBlurred, onEnterKeyed, onTabKeyed, onEscKeyed, onUpKeyed, onDownKeyed, onLeftKeyed, onRightKeyed, onQueryChanged, onWhitespaceChanged; o = o || {}; if (!o.input) { $.error("missing input"); } - this.isActivated = false; - this.autoselect = !!o.autoselect; + if (!o.menu) { + $.error("missing menu"); + } + if (!o.eventBus) { + $.error("missing event bus"); + } + www.mixin(this); + this.eventBus = o.eventBus; this.minLength = _.isNumber(o.minLength) ? o.minLength : 1; - this.$node = buildDom(o.input, o.withHint); - $menu = this.$node.find(".tt-dropdown-menu"); - $input = this.$node.find(".tt-input"); - $hint = this.$node.find(".tt-hint"); - $input.on("blur.tt", function($e) { - var active, isActive, hasActive; - active = document.activeElement; - isActive = $menu.is(active); - hasActive = $menu.has(active).length > 0; - if (_.isMsie() && (isActive || hasActive)) { - $e.preventDefault(); - $e.stopImmediatePropagation(); - _.defer(function() { - $input.focus(); - }); - } - }); - $menu.on("mousedown.tt", function($e) { - $e.preventDefault(); - }); - this.eventBus = o.eventBus || new EventBus({ - el: $input - }); - this.dropdown = new Dropdown({ - menu: $menu, - datasets: o.datasets - }).onSync("suggestionClicked", this._onSuggestionClicked, this).onSync("cursorMoved", this._onCursorMoved, this).onSync("cursorRemoved", this._onCursorRemoved, this).onSync("opened", this._onOpened, this).onSync("closed", this._onClosed, this).onAsync("datasetRendered", this._onDatasetRendered, this); - this.input = new Input({ - input: $input, - hint: $hint - }).onSync("focused", this._onFocused, this).onSync("blurred", this._onBlurred, this).onSync("enterKeyed", this._onEnterKeyed, this).onSync("tabKeyed", this._onTabKeyed, this).onSync("escKeyed", this._onEscKeyed, this).onSync("upKeyed", this._onUpKeyed, this).onSync("downKeyed", this._onDownKeyed, this).onSync("leftKeyed", this._onLeftKeyed, this).onSync("rightKeyed", this._onRightKeyed, this).onSync("queryChanged", this._onQueryChanged, this).onSync("whitespaceChanged", this._onWhitespaceChanged, this); - this._setLanguageDirection(); + this.input = o.input; + this.menu = o.menu; + this.enabled = true; + this.autoselect = !!o.autoselect; + this.active = false; + this.input.hasFocus() && this.activate(); + this.dir = this.input.getLangDir(); + this._hacks(); + this.menu.bind().onSync("selectableClicked", this._onSelectableClicked, this).onSync("asyncRequested", this._onAsyncRequested, this).onSync("asyncCanceled", this._onAsyncCanceled, this).onSync("asyncReceived", this._onAsyncReceived, this).onSync("datasetRendered", this._onDatasetRendered, this).onSync("datasetCleared", this._onDatasetCleared, this); + onFocused = c(this, "activate", "open", "_onFocused"); + onBlurred = c(this, "deactivate", "_onBlurred"); + onEnterKeyed = c(this, "isActive", "isOpen", "_onEnterKeyed"); + onTabKeyed = c(this, "isActive", "isOpen", "_onTabKeyed"); + onEscKeyed = c(this, "isActive", "_onEscKeyed"); + onUpKeyed = c(this, "isActive", "open", "_onUpKeyed"); + onDownKeyed = c(this, "isActive", "open", "_onDownKeyed"); + onLeftKeyed = c(this, "isActive", "isOpen", "_onLeftKeyed"); + onRightKeyed = c(this, "isActive", "isOpen", "_onRightKeyed"); + onQueryChanged = c(this, "_openIfActive", "_onQueryChanged"); + onWhitespaceChanged = c(this, "_openIfActive", "_onWhitespaceChanged"); + this.input.bind().onSync("focused", onFocused, this).onSync("blurred", onBlurred, this).onSync("enterKeyed", onEnterKeyed, this).onSync("tabKeyed", onTabKeyed, this).onSync("escKeyed", onEscKeyed, this).onSync("upKeyed", onUpKeyed, this).onSync("downKeyed", onDownKeyed, this).onSync("leftKeyed", onLeftKeyed, this).onSync("rightKeyed", onRightKeyed, this).onSync("queryChanged", onQueryChanged, this).onSync("whitespaceChanged", onWhitespaceChanged, this).onSync("langDirChanged", this._onLangDirChanged, this); } _.mixin(Typeahead.prototype, { - _onSuggestionClicked: function onSuggestionClicked(type, $el) { - var datum; - if (datum = this.dropdown.getDatumForSuggestion($el)) { - this._select(datum); + _hacks: function hacks() { + var $input, $menu; + $input = this.input.$input || $("
      "); + $menu = this.menu.$node || $("
      "); + $input.on("blur.tt", function($e) { + var active, isActive, hasActive; + active = document.activeElement; + isActive = $menu.is(active); + hasActive = $menu.has(active).length > 0; + if (_.isMsie() && (isActive || hasActive)) { + $e.preventDefault(); + $e.stopImmediatePropagation(); + _.defer(function() { + $input.focus(); + }); + } + }); + $menu.on("mousedown.tt", function($e) { + $e.preventDefault(); + }); + }, + _onSelectableClicked: function onSelectableClicked(type, $el) { + this.select($el); + }, + _onDatasetCleared: function onDatasetCleared() { + this._updateHint(); + }, + _onDatasetRendered: function onDatasetRendered(type, suggestions, async, dataset) { + this._updateHint(); + if (this.autoselect) { + var cursorClass = this.selectors.cursor.substr(1); + this.menu.$node.find(this.selectors.suggestion).first().addClass(cursorClass); } + this.eventBus.trigger("render", suggestions, async, dataset); }, - _onCursorMoved: function onCursorMoved() { - var datum = this.dropdown.getDatumForCursor(); - this.input.setInputValue(datum.value, true); - this.eventBus.trigger("cursorchanged", datum.raw, datum.datasetName); + _onAsyncRequested: function onAsyncRequested(type, dataset, query) { + this.eventBus.trigger("asyncrequest", query, dataset); }, - _onCursorRemoved: function onCursorRemoved() { - this.input.resetInputValue(); - this._updateHint(); + _onAsyncCanceled: function onAsyncCanceled(type, dataset, query) { + this.eventBus.trigger("asynccancel", query, dataset); }, - _onDatasetRendered: function onDatasetRendered() { - this._updateHint(); - }, - _onOpened: function onOpened() { - this._updateHint(); - this.eventBus.trigger("opened"); - }, - _onClosed: function onClosed() { - this.input.clearHint(); - this.eventBus.trigger("closed"); + _onAsyncReceived: function onAsyncReceived(type, dataset, query) { + this.eventBus.trigger("asyncreceive", query, dataset); }, _onFocused: function onFocused() { - this.isActivated = true; - this.dropdown.open(); + this._minLengthMet() && this.menu.update(this.input.getQuery()); }, _onBlurred: function onBlurred() { - this.isActivated = false; - this.dropdown.empty(); - this.dropdown.close(); + if (this.input.hasQueryChangedSinceLastFocus()) { + this.eventBus.trigger("change", this.input.getQuery()); + } }, _onEnterKeyed: function onEnterKeyed(type, $e) { - var cursorDatum, topSuggestionDatum; - cursorDatum = this.dropdown.getDatumForCursor(); - topSuggestionDatum = this.dropdown.getDatumForTopSuggestion(); - if (cursorDatum) { - this._select(cursorDatum); - $e.preventDefault(); - } else if (this.autoselect && topSuggestionDatum) { - this._select(topSuggestionDatum); - $e.preventDefault(); + var $selectable; + if ($selectable = this.menu.getActiveSelectable()) { + if (this.select($selectable)) { + $e.preventDefault(); + $e.stopPropagation(); + } + } else if (this.autoselect) { + if (this.select(this.menu.getTopSelectable())) { + $e.preventDefault(); + $e.stopPropagation(); + } } }, _onTabKeyed: function onTabKeyed(type, $e) { - var datum; - if (datum = this.dropdown.getDatumForCursor()) { - this._select(datum); - $e.preventDefault(); - } else { - this._autocomplete(true); + var $selectable; + if ($selectable = this.menu.getActiveSelectable()) { + this.select($selectable) && $e.preventDefault(); + } else if (this.autoselect) { + if ($selectable = this.menu.getTopSelectable()) { + this.autocomplete($selectable) && $e.preventDefault(); + } } }, _onEscKeyed: function onEscKeyed() { - this.dropdown.close(); - this.input.resetInputValue(); + this.close(); }, _onUpKeyed: function onUpKeyed() { - var query = this.input.getQuery(); - this.dropdown.isEmpty && query.length >= this.minLength ? this.dropdown.update(query) : this.dropdown.moveCursorUp(); - this.dropdown.open(); + this.moveCursor(-1); }, _onDownKeyed: function onDownKeyed() { - var query = this.input.getQuery(); - this.dropdown.isEmpty && query.length >= this.minLength ? this.dropdown.update(query) : this.dropdown.moveCursorDown(); - this.dropdown.open(); + this.moveCursor(+1); }, _onLeftKeyed: function onLeftKeyed() { - this.dir === "rtl" && this._autocomplete(); + if (this.dir === "rtl" && this.input.isCursorAtEnd()) { + this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable()); + } }, _onRightKeyed: function onRightKeyed() { - this.dir === "ltr" && this._autocomplete(); + if (this.dir === "ltr" && this.input.isCursorAtEnd()) { + this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable()); + } }, _onQueryChanged: function onQueryChanged(e, query) { - this.input.clearHintIfInvalid(); - query.length >= this.minLength ? this.dropdown.update(query) : this.dropdown.empty(); - this.dropdown.open(); - this._setLanguageDirection(); + this._minLengthMet(query) ? this.menu.update(query) : this.menu.empty(); }, _onWhitespaceChanged: function onWhitespaceChanged() { this._updateHint(); - this.dropdown.open(); }, - _setLanguageDirection: function setLanguageDirection() { - var dir; - if (this.dir !== (dir = this.input.getLanguageDirection())) { + _onLangDirChanged: function onLangDirChanged(e, dir) { + if (this.dir !== dir) { this.dir = dir; - this.$node.css("direction", dir); - this.dropdown.setLanguageDirection(dir); + this.menu.setLanguageDirection(dir); } }, + _openIfActive: function openIfActive() { + this.isActive() && this.open(); + }, + _minLengthMet: function minLengthMet(query) { + query = _.isString(query) ? query : this.input.getQuery() || ""; + return query.length >= this.minLength; + }, _updateHint: function updateHint() { - var datum, val, query, escapedQuery, frontMatchRegEx, match; - datum = this.dropdown.getDatumForTopSuggestion(); - if (datum && this.dropdown.isVisible() && !this.input.hasOverflow()) { - val = this.input.getInputValue(); + var $selectable, data, val, query, escapedQuery, frontMatchRegEx, match; + $selectable = this.menu.getTopSelectable(); + data = this.menu.getSelectableData($selectable); + val = this.input.getInputValue(); + if (data && !_.isBlankString(val) && !this.input.hasOverflow()) { query = Input.normalizeQuery(val); escapedQuery = _.escapeRegExChars(query); frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.+$)", "i"); - match = frontMatchRegEx.exec(datum.value); - match ? this.input.setHint(val + match[1]) : this.input.clearHint(); + match = frontMatchRegEx.exec(data.val); + match && this.input.setHint(val + match[1]); } else { this.input.clearHint(); } }, - _autocomplete: function autocomplete(laxCursor) { - var hint, query, isCursorAtEnd, datum; - hint = this.input.getHint(); - query = this.input.getQuery(); - isCursorAtEnd = laxCursor || this.input.isCursorAtEnd(); - if (hint && query !== hint && isCursorAtEnd) { - datum = this.dropdown.getDatumForTopSuggestion(); - datum && this.input.setInputValue(datum.value); - this.eventBus.trigger("autocompleted", datum.raw, datum.datasetName); + isEnabled: function isEnabled() { + return this.enabled; + }, + enable: function enable() { + this.enabled = true; + }, + disable: function disable() { + this.enabled = false; + }, + isActive: function isActive() { + return this.active; + }, + activate: function activate() { + if (this.isActive()) { + return true; + } else if (!this.isEnabled() || this.eventBus.before("active")) { + return false; + } else { + this.active = true; + this.eventBus.trigger("active"); + return true; } }, - _select: function select(datum) { - this.input.setQuery(datum.value); - this.input.setInputValue(datum.value, true); - this._setLanguageDirection(); - this.eventBus.trigger("selected", datum.raw, datum.datasetName); - this.dropdown.close(); - _.defer(_.bind(this.dropdown.empty, this.dropdown)); + deactivate: function deactivate() { + if (!this.isActive()) { + return true; + } else if (this.eventBus.before("idle")) { + return false; + } else { + this.active = false; + this.close(); + this.eventBus.trigger("idle"); + return true; + } + }, + isOpen: function isOpen() { + return this.menu.isOpen(); }, open: function open() { - this.dropdown.open(); + if (!this.isOpen() && !this.eventBus.before("open")) { + this.input.setAriaExpanded(true); + this.menu.open(); + this._updateHint(); + this.eventBus.trigger("open"); + } + return this.isOpen(); }, close: function close() { - this.dropdown.close(); + if (this.isOpen() && !this.eventBus.before("close")) { + this.input.setAriaExpanded(false); + this.menu.close(); + this.input.clearHint(); + this.input.resetInputValue(); + this.eventBus.trigger("close"); + } + return !this.isOpen(); }, setVal: function setVal(val) { - val = _.toStr(val); - if (this.isActivated) { - this.input.setInputValue(val); - } else { - this.input.setQuery(val); - this.input.setInputValue(val, true); - } - this._setLanguageDirection(); + this.input.setQuery(_.toStr(val)); }, getVal: function getVal() { return this.input.getQuery(); }, + select: function select($selectable) { + var data = this.menu.getSelectableData($selectable); + if (data && !this.eventBus.before("select", data.obj, data.dataset)) { + this.input.setQuery(data.val, true); + this.eventBus.trigger("select", data.obj, data.dataset); + this.close(); + return true; + } + return false; + }, + autocomplete: function autocomplete($selectable) { + var query, data, isValid; + query = this.input.getQuery(); + data = this.menu.getSelectableData($selectable); + isValid = data && query !== data.val; + if (isValid && !this.eventBus.before("autocomplete", data.obj, data.dataset)) { + this.input.setQuery(data.val); + this.eventBus.trigger("autocomplete", data.obj, data.dataset); + return true; + } + return false; + }, + moveCursor: function moveCursor(delta) { + var query, $candidate, data, suggestion, datasetName, cancelMove, id; + query = this.input.getQuery(); + $candidate = this.menu.selectableRelativeToCursor(delta); + data = this.menu.getSelectableData($candidate); + suggestion = data ? data.obj : null; + datasetName = data ? data.dataset : null; + id = $candidate ? $candidate.attr("id") : null; + this.input.trigger("cursorchange", id); + cancelMove = this._minLengthMet() && this.menu.update(query); + if (!cancelMove && !this.eventBus.before("cursorchange", suggestion, datasetName)) { + this.menu.setCursor($candidate); + if (data) { + if (typeof data.val === "string") { + this.input.setInputValue(data.val); + } + } else { + this.input.resetInputValue(); + this._updateHint(); + } + this.eventBus.trigger("cursorchange", suggestion, datasetName); + return true; + } + return false; + }, destroy: function destroy() { this.input.destroy(); - this.dropdown.destroy(); - destroyDomStructure(this.$node); - this.$node = null; + this.menu.destroy(); } }); return Typeahead; - function buildDom(input, withHint) { - var $input, $wrapper, $dropdown, $hint; - $input = $(input); - $wrapper = $(html.wrapper).css(css.wrapper); - $dropdown = $(html.dropdown).css(css.dropdown); - $hint = $input.clone().css(css.hint).css(getBackgroundStyles($input)); - $hint.val("").removeData().addClass("tt-hint").removeAttr("id name placeholder required").prop("readonly", true).attr({ - autocomplete: "off", + function c(ctx) { + var methods = [].slice.call(arguments, 1); + return function() { + var args = [].slice.call(arguments); + _.each(methods, function(method) { + return ctx[method].apply(ctx, args); + }); + }; + } + }(); + (function() { + "use strict"; + var old, keys, methods; + old = $.fn.typeahead; + keys = { + www: "tt-www", + attrs: "tt-attrs", + typeahead: "tt-typeahead" + }; + methods = { + initialize: function initialize(o, datasets) { + var www; + datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1); + o = o || {}; + www = WWW(o.classNames); + return this.each(attach); + function attach() { + var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu, eventBus, input, menu, status, typeahead, MenuConstructor; + _.each(datasets, function(d) { + d.highlight = !!o.highlight; + }); + $input = $(this); + $wrapper = $(www.html.wrapper); + $hint = $elOrNull(o.hint); + $menu = $elOrNull(o.menu); + defaultHint = o.hint !== false && !$hint; + defaultMenu = o.menu !== false && !$menu; + defaultHint && ($hint = buildHintFromInput($input, www)); + defaultMenu && ($menu = $(www.html.menu).css(www.css.menu)); + $hint && $hint.val(""); + $input = prepInput($input, www); + if (defaultHint || defaultMenu) { + $wrapper.css(www.css.wrapper); + $input.css(defaultHint ? www.css.input : www.css.inputWithNoHint); + $input.wrap($wrapper).parent().prepend(defaultHint ? $hint : null).append(defaultMenu ? $menu : null); + } + MenuConstructor = defaultMenu ? DefaultMenu : Menu; + eventBus = new EventBus({ + el: $input + }); + input = new Input({ + hint: $hint, + input: $input, + menu: $menu + }, www); + menu = new MenuConstructor({ + node: $menu, + datasets: datasets + }, www); + status = new Status({ + $input: $input, + menu: menu + }); + typeahead = new Typeahead({ + input: input, + menu: menu, + eventBus: eventBus, + minLength: o.minLength, + autoselect: o.autoselect + }, www); + $input.data(keys.www, www); + $input.data(keys.typeahead, typeahead); + } + }, + isEnabled: function isEnabled() { + var enabled; + ttEach(this.first(), function(t) { + enabled = t.isEnabled(); + }); + return enabled; + }, + enable: function enable() { + ttEach(this, function(t) { + t.enable(); + }); + return this; + }, + disable: function disable() { + ttEach(this, function(t) { + t.disable(); + }); + return this; + }, + isActive: function isActive() { + var active; + ttEach(this.first(), function(t) { + active = t.isActive(); + }); + return active; + }, + activate: function activate() { + ttEach(this, function(t) { + t.activate(); + }); + return this; + }, + deactivate: function deactivate() { + ttEach(this, function(t) { + t.deactivate(); + }); + return this; + }, + isOpen: function isOpen() { + var open; + ttEach(this.first(), function(t) { + open = t.isOpen(); + }); + return open; + }, + open: function open() { + ttEach(this, function(t) { + t.open(); + }); + return this; + }, + close: function close() { + ttEach(this, function(t) { + t.close(); + }); + return this; + }, + select: function select(el) { + var success = false, $el = $(el); + ttEach(this.first(), function(t) { + success = t.select($el); + }); + return success; + }, + autocomplete: function autocomplete(el) { + var success = false, $el = $(el); + ttEach(this.first(), function(t) { + success = t.autocomplete($el); + }); + return success; + }, + moveCursor: function moveCursoe(delta) { + var success = false; + ttEach(this.first(), function(t) { + success = t.moveCursor(delta); + }); + return success; + }, + val: function val(newVal) { + var query; + if (!arguments.length) { + ttEach(this.first(), function(t) { + query = t.getVal(); + }); + return query; + } else { + ttEach(this, function(t) { + t.setVal(_.toStr(newVal)); + }); + return this; + } + }, + destroy: function destroy() { + ttEach(this, function(typeahead, $input) { + revert($input); + typeahead.destroy(); + }); + return this; + } + }; + $.fn.typeahead = function(method) { + if (methods[method]) { + return methods[method].apply(this, [].slice.call(arguments, 1)); + } else { + return methods.initialize.apply(this, arguments); + } + }; + $.fn.typeahead.noConflict = function noConflict() { + $.fn.typeahead = old; + return this; + }; + function ttEach($els, fn) { + $els.each(function() { + var $input = $(this), typeahead; + (typeahead = $input.data(keys.typeahead)) && fn(typeahead, $input); + }); + } + function buildHintFromInput($input, www) { + return $input.clone().addClass(www.classes.hint).removeData().css(www.css.hint).css(getBackgroundStyles($input)).prop({ + readonly: true, + required: false + }).removeAttr("id name placeholder").removeClass("required").attr({ spellcheck: "false", tabindex: -1 }); - $input.data(attrsKey, { + } + function prepInput($input, www) { + $input.data(keys.attrs, { dir: $input.attr("dir"), autocomplete: $input.attr("autocomplete"), spellcheck: $input.attr("spellcheck"), style: $input.attr("style") }); - $input.addClass("tt-input").attr({ - autocomplete: "off", + $input.addClass(www.classes.input).attr({ spellcheck: false - }).css(withHint ? css.input : css.inputWithNoHint); + }); try { !$input.attr("dir") && $input.attr("dir", "auto"); } catch (e) {} - return $input.wrap($wrapper).parent().prepend(withHint ? $hint : null).append($dropdown); + return $input; } function getBackgroundStyles($el) { return { @@ -1681,102 +2618,24 @@ backgroundSize: $el.css("background-size") }; } - function destroyDomStructure($node) { - var $input = $node.find(".tt-input"); - _.each($input.data(attrsKey), function(val, key) { + function revert($input) { + var www, $wrapper; + www = $input.data(keys.www); + $wrapper = $input.parent().filter(www.selectors.wrapper); + _.each($input.data(keys.attrs), function(val, key) { _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); }); - $input.detach().removeData(attrsKey).removeClass("tt-input").insertAfter($node); - $node.remove(); + $input.removeData(keys.typeahead).removeData(keys.www).removeData(keys.attr).removeClass(www.classes.input); + if ($wrapper.length) { + $input.detach().insertAfter($wrapper); + $wrapper.remove(); + } + } + function $elOrNull(obj) { + var isValid, $el; + isValid = _.isJQuery(obj) || _.isElement(obj); + $el = isValid ? $(obj).first() : []; + return $el.length ? $el : null; } - }(); - (function() { - "use strict"; - var old, typeaheadKey, methods; - old = $.fn.typeahead; - typeaheadKey = "ttTypeahead"; - methods = { - initialize: function initialize(o, datasets) { - datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1); - o = o || {}; - return this.each(attach); - function attach() { - var $input = $(this), eventBus, typeahead; - _.each(datasets, function(d) { - d.highlight = !!o.highlight; - }); - typeahead = new Typeahead({ - input: $input, - eventBus: eventBus = new EventBus({ - el: $input - }), - withHint: _.isUndefined(o.hint) ? true : !!o.hint, - minLength: o.minLength, - autoselect: o.autoselect, - datasets: datasets - }); - $input.data(typeaheadKey, typeahead); - } - }, - open: function open() { - return this.each(openTypeahead); - function openTypeahead() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.open(); - } - } - }, - close: function close() { - return this.each(closeTypeahead); - function closeTypeahead() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.close(); - } - } - }, - val: function val(newVal) { - return !arguments.length ? getVal(this.first()) : this.each(setVal); - function setVal() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.setVal(newVal); - } - } - function getVal($input) { - var typeahead, query; - if (typeahead = $input.data(typeaheadKey)) { - query = typeahead.getVal(); - } - return query; - } - }, - destroy: function destroy() { - return this.each(unattach); - function unattach() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.destroy(); - $input.removeData(typeaheadKey); - } - } - } - }; - $.fn.typeahead = function(method) { - var tts; - if (methods[method] && method !== "initialize") { - tts = this.filter(function() { - return !!$(this).data(typeaheadKey); - }); - return methods[method].apply(tts, [].slice.call(arguments, 1)); - } else { - return methods.initialize.apply(this, arguments); - } - }; - $.fn.typeahead.noConflict = function noConflict() { - $.fn.typeahead = old; - return this; - }; })(); -})(window.jQuery); \ No newline at end of file +}); diff --git a/cps/static/js/main.js b/cps/static/js/main.js index d8641c3e..48c66767 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -15,7 +15,6 @@ * along with this program. If not, see . */ - function getPath() { var jsFileLocation = $("script[src*=jquery]").attr("src"); // the js file path return jsFileLocation.substr(0, jsFileLocation.search("/static/js/libs/jquery.min.js")); // the js folder path @@ -113,6 +112,14 @@ $("#btn-upload").change(function() { $("#form-upload").submit(); }); +$("#form-upload").uploadprogress({ + redirect_url: getPath() + "/", //"{{ url_for('web.index')}}", + uploadedMsg: $("#form-upload").data("message"), //"{{_('Upload done, processing, please wait...')}}", + modalTitle: $("#form-upload").data("title"), //"{{_('Uploading...')}}", + modalFooter: $("#form-upload").data("footer"), //"{{_('Close')}}", + modalTitleFailed: $("#form-upload").data("failed") //"{{_('Error')}}" +}); + $(document).ready(function() { var inp = $('#query').first() if (inp.length) { @@ -181,7 +188,7 @@ $("#delete_confirm").click(function() { if (item.format != "") { $("button[data-delete-format='"+item.format+"']").addClass('hidden'); } - $( ".navbar" ).after( '
      ' + + $( ".navbar" ).after( '
      ' + '
      '+item.message+'
      ' + '
      '); @@ -224,6 +231,16 @@ $(function() { var preFilters = $.Callbacks(); $.ajaxPrefilter(preFilters.fire); + // equip all post requests with csrf_token + var csrftoken = $("input[name='csrf_token']").val(); + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken) + } + } + }); + function restartTimer() { $("#spinner").addClass("hidden"); $("#RestartDialog").modal("hide"); @@ -246,16 +263,15 @@ $(function() { function updateTimer() { $.ajax({ dataType: "json", - url: window.location.pathname + "/../../get_updater_status", + url: getPath() + "/get_updater_status", success: function success(data) { - // console.log(data.status); $("#DialogContent").html(updateText[data.status]); if (data.status > 6) { cleanUp(); } }, error: function error() { - $("#DialogContent").html(updateText[7]); + $("#DialogContent").html(updateText[11]); cleanUp(); }, timeout: 2000 @@ -446,12 +462,11 @@ $(function() { $.ajax({ type: "POST", dataType: "json", - data: { start: "True"}, - url: window.location.pathname + "/../../get_updater_status", + data: { start: "True" }, + url: getPath() + "/get_updater_status", success: function success(data) { updateText = data.text; $("#DialogContent").html(updateText[data.status]); - // console.log(data.status); updateTimerID = setInterval(updateTimer, 2000); } }); @@ -554,7 +569,7 @@ $(function() { function handle_response(data) { if (!jQuery.isEmptyObject(data)) { data.forEach(function (item) { - $(".navbar").after('
      ' + + $(".navbar").after('
      ' + '
      ' + item.message + '
      ' + '
      '); }); @@ -579,7 +594,7 @@ $(function() { method:"post", dataType: "json", url: window.location.pathname + "/../../ajax/simulatedbchange", - data: {config_calibre_dir: $("#config_calibre_dir").val()}, + data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()}, success: function success(data) { if ( data.change ) { if ( data.valid ) { @@ -652,10 +667,10 @@ $(function() { var folder = target.data("folderonly"); var filter = target.data("filefilter"); $("#element_selected").text(path); - $("#file_confirm")[0].attributes["data-link"].value = target.data("link"); - $("#file_confirm")[0].attributes["data-folderonly"].value = (typeof folder === 'undefined') ? false : true; - $("#file_confirm")[0].attributes["data-filefilter"].value = (typeof filter === 'undefined') ? "" : filter; - $("#file_confirm")[0].attributes["data-newfile"].value = target.data("newfile"); + $("#file_confirm").data("link", target.data("link")); + $("#file_confirm").data("folderonly", (typeof folder === 'undefined') ? false : true); + $("#file_confirm").data("filefilter", (typeof filter === 'undefined') ? "" : filter); + $("#file_confirm").data("newfile", target.data("newfile")); fillFileTable(path,"dir", folder, filter); }); @@ -669,7 +684,7 @@ $(function() { var folder = $(file_confirm).data("folderonly"); var filter = $(file_confirm).data("filefilter"); var newfile = $(file_confirm).data("newfile"); - if (newfile !== 'undefined') { + if (newfile !== "") { $("#element_selected").text(path + $("#new_file".text())); } else { $("#element_selected").text(path); @@ -715,7 +730,7 @@ $(function() { method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../ajax/view", + url: getPath() + "/ajax/view", data: "{\"series\": {\"series_view\": \""+ view +"\"}}", success: function success() { location.reload(); diff --git a/cps/static/js/reading/djvu_reader.js b/cps/static/js/reading/djvu_reader.js new file mode 100644 index 00000000..c93d705a --- /dev/null +++ b/cps/static/js/reading/djvu_reader.js @@ -0,0 +1,21 @@ +/* This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) + * Copyright (C) 2021 Ozzieisaacs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +var DJVU_CONTEXT = { + background: "#666", + uiHideDelay: 1500, +}; diff --git a/cps/static/js/reading/txt_reader.js b/cps/static/js/reading/txt_reader.js new file mode 100644 index 00000000..9cfb51f5 --- /dev/null +++ b/cps/static/js/reading/txt_reader.js @@ -0,0 +1,86 @@ +/* This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) + * Copyright (C) 2021 Ozzieisaacs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +$(document).ready(function() { + //to int + $("#area").width($("#area").width()); + $("#content").width($("#content").width()); + //bind text + $("#content").load($("#readmain").data('load'), function(textStr) { + $(this).height($(this).parent().height()*0.95); + $(this).text(textStr); + }); + //keybind + $(document).keydown(function(event){ + if(event.keyCode == 37){ + prevPage(); + } + if(event.keyCode == 39){ + nextPage(); + } + }); + //click + $( "#left" ).click(function() { + prevPage(); + }); + $( "#right" ).click(function() { + nextPage(); + }); + $("#readmain").swipe( { + swipeRight:function() { + prevPage(); + }, + swipeLeft:function() { + nextPage(); + }, + }); + + //bind mouse + $(window).bind('DOMMouseScroll mousewheel', function(event) { + var delta = 0; + if (event.originalEvent.wheelDelta) { + delta = event.originalEvent.wheelDelta; + } else if (event.originalEvent.detail) { + delta = event.originalEvent.detail*-1; + } + if (delta >= 0) { + prevPage(); + } else { + nextPage(); + } + }); + + //page animate + var origwidth = $("#content")[0].getBoundingClientRect().width; + var gap = 20; + function prevPage() { + if($("#content").offset().left > 0) { + return; + } + leftoff = $("#content").offset().left; + leftoff = leftoff+origwidth+gap; + $("#content").offset({left:leftoff}); + } + function nextPage() { + leftoff = $("#content").offset().left; + leftoff = leftoff-origwidth-gap; + if (leftoff + $("#content")[0].scrollWidth < 0) { + return; + } + $("#content").offset({left:leftoff}); + } +}); diff --git a/cps/static/js/remote_login.js b/cps/static/js/remote_login.js new file mode 100644 index 00000000..f0d364dc --- /dev/null +++ b/cps/static/js/remote_login.js @@ -0,0 +1,36 @@ +/* This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) + * Copyright (C) 2017-2021 jkrehm, OzzieIsaacs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +(function () { + // Poll the server to check if the user has authenticated + var t = setInterval(function () { + $.post(getPath() + "/ajax/verify_token", { token: $("#verify_url").data("token") }) + .done(function(response) { + if (response.status === 'success') { + // Wait a tick so cookies are updated + setTimeout(function () { + window.location.href = getPath() + '/'; + }, 0); + } + }) + .fail(function (xhr) { + clearInterval(t); + var response = JSON.parse(xhr.responseText); + alert(response.message); + }); + }, 5000); +})() diff --git a/cps/static/js/shelforder.js b/cps/static/js/shelforder.js index 62b7e9f3..5cbe7109 100644 --- a/cps/static/js/shelforder.js +++ b/cps/static/js/shelforder.js @@ -35,6 +35,7 @@ function sendData(path) { var form = document.createElement("form"); form.setAttribute("method", "post"); form.setAttribute("action", path); + // form.setAttribute("csrf_token", ); for (counter = 0;counter < maxElements;counter++) { tmp[counter] = elements[counter].getAttribute("id"); @@ -44,6 +45,10 @@ function sendData(path) { hiddenField.setAttribute("value", String(counter + 1)); form.appendChild(hiddenField); } + $("") + .attr("name", "csrf_token").val($("input[name='csrf_token']").val()) + .appendTo(form); + document.body.appendChild(form); form.submit(); } diff --git a/cps/static/js/table.js b/cps/static/js/table.js index 9fa28281..a55ec5d1 100644 --- a/cps/static/js/table.js +++ b/cps/static/js/table.js @@ -85,7 +85,7 @@ $(function() { method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/mergebooks", + url: window.location.pathname + "/../ajax/mergebooks", data: JSON.stringify({"Merge_books":selections}), success: function success() { $("#books-table").bootstrapTable("refresh"); @@ -104,7 +104,7 @@ $(function() { method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/simulatemerge", + url: window.location.pathname + "/../ajax/simulatemerge", data: JSON.stringify({"Merge_books":selections}), success: function success(booTitles) { $.each(booTitles.from, function(i, item) { @@ -121,7 +121,7 @@ $(function() { method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/xchange", + url: window.location.pathname + "/../ajax/xchange", data: JSON.stringify({"xchange":selections}), success: function success() { $("#books-table").bootstrapTable("refresh"); @@ -161,10 +161,11 @@ $(function() { $("#books-table").bootstrapTable({ sidePagination: "server", + pageList: "[10, 25, 50, 100]", queryParams: queryParams, pagination: true, paginationLoop: false, - paginationDetailHAlign: " hidden", + paginationDetailHAlign: "right", paginationHAlign: "left", idField: "id", uniqueId: "id", @@ -187,7 +188,7 @@ $(function() { $.ajax({ method:"get", dataType: "json", - url: window.location.pathname + "/../../ajax/sort_value/" + field + "/" + row.id, + url: window.location.pathname + "/../ajax/sort_value/" + field + "/" + row.id, success: function success(data) { var key = Object.keys(data)[0]; $("#books-table").bootstrapTable("updateCellByUniqueId", { @@ -215,7 +216,7 @@ $(function() { method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/table_settings", + url: window.location.pathname + "/../ajax/table_settings", data: "{" + st + "}", }); }, @@ -769,7 +770,7 @@ function handleListServerResponse (data) { $("#flash_danger").remove(); if (!jQuery.isEmptyObject(data)) { data.forEach(function(item) { - $(".navbar").after('
      ' + + $(".navbar").after('
      ' + '
      ' + item.message + '
      ' + '
      '); }); diff --git a/cps/subproc_wrapper.py b/cps/subproc_wrapper.py index 3cc4a070..187b2cb2 100644 --- a/cps/subproc_wrapper.py +++ b/cps/subproc_wrapper.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from __future__ import division, print_function, unicode_literals import sys import os import subprocess @@ -33,13 +32,8 @@ def process_open(command, quotes=(), env=None, sout=subprocess.PIPE, serr=subpro if key in quotes: command[key] = '"' + element + '"' exc_command = " ".join(command) - if sys.version_info < (3, 0): - exc_command = exc_command.encode(sys.getfilesystemencoding()) else: - if sys.version_info < (3, 0): - exc_command = [x.encode(sys.getfilesystemencoding()) for x in command] - else: - exc_command = [x for x in command] + exc_command = [x for x in command] return subprocess.Popen(exc_command, shell=False, stdout=sout, stderr=serr, universal_newlines=newlines, env=env) # nosec diff --git a/cps/tasks/convert.py b/cps/tasks/convert.py index 56cc7076..08fb1644 100644 --- a/cps/tasks/convert.py +++ b/cps/tasks/convert.py @@ -1,4 +1,21 @@ -from __future__ import division, print_function, unicode_literals +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 pwr +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + import sys import os import re @@ -161,8 +178,6 @@ class TaskConvert(CalibreTask): while True: nextline = p.stdout.readlines() nextline = [x.strip('\n') for x in nextline if x != '\n'] - if sys.version_info < (3, 0): - nextline = [x.decode('utf-8') for x in nextline] for line in nextline: log.debug(line) if p.poll() is not None: @@ -207,10 +222,6 @@ class TaskConvert(CalibreTask): while p.poll() is None: nextline = p.stdout.readline() - if os.name == 'nt' and sys.version_info < (3, 0): - nextline = nextline.decode('windows-1252') - elif os.name == 'posix' and sys.version_info < (3, 0): - nextline = nextline.decode('utf-8') log.debug(nextline.strip('\r\n')) # parse progress string from calibre-converter progress = re.search(r"(\d+)%\s.*", nextline) @@ -224,8 +235,6 @@ class TaskConvert(CalibreTask): calibre_traceback = p.stderr.readlines() error_message = "" for ele in calibre_traceback: - if sys.version_info < (3, 0): - ele = ele.decode('utf-8') log.debug(ele.strip('\n')) if not ele.startswith('Traceback') and not ele.startswith(' File'): error_message = _("Calibre failed with error: %(error)s", error=ele.strip('\n')) diff --git a/cps/tasks/mail.py b/cps/tasks/mail.py index 292114d5..2e95ee98 100644 --- a/cps/tasks/mail.py +++ b/cps/tasks/mail.py @@ -1,11 +1,27 @@ -from __future__ import division, print_function, unicode_literals +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 pwr +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + import sys import os import smtplib import threading import socket import mimetypes -import base64 try: from StringIO import StringIO @@ -162,17 +178,11 @@ class TaskEmail(CalibreTask): log.debug_or_exception(ex) self._handleError(u'Error sending e-mail: {}'.format(ex)) - def send_standard_email(self, msg): use_ssl = int(self.settings.get('mail_use_ssl', 0)) timeout = 600 # set timeout to 5mins - # redirect output to logfile on python2 on python3 debugoutput is caught with overwritten - # _print_debug function - if sys.version_info < (3, 0): - org_smtpstderr = smtplib.stderr - smtplib.stderr = logger.StderrLogger('worker.smtp') - + # on python3 debugoutput is caught with overwritten _print_debug function log.debug("Start sending e-mail") if use_ssl == 2: self.asyncSMTP = EmailSSL(self.settings["mail_server"], self.settings["mail_port"], @@ -198,9 +208,6 @@ class TaskEmail(CalibreTask): self._handleSuccess() log.debug("E-mail send successfully") - if sys.version_info < (3, 0): - smtplib.stderr = org_smtpstderr - def send_gmail_email(self, message): return gmail.send_messsage(self.settings.get('mail_gmail_token', None), message) @@ -218,7 +225,6 @@ class TaskEmail(CalibreTask): self.asyncSMTP = None self._progress = x - @classmethod def _get_attachment(cls, bookpath, filename): """Get file as MIMEBase message""" diff --git a/cps/tasks/upload.py b/cps/tasks/upload.py index d7ef34c2..6a341cdd 100644 --- a/cps/tasks/upload.py +++ b/cps/tasks/upload.py @@ -1,4 +1,20 @@ -from __future__ import division, print_function, unicode_literals +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 pwr +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . from datetime import datetime from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 81ef955b..9a941594 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -186,6 +186,7 @@ {% if feature_support['updater'] %} +
      {{_('Check for Update')}}
      diff --git a/cps/templates/author.html b/cps/templates/author.html index b011bae8..d82b2ebd 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -36,8 +36,8 @@
      - - + + {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 2da7b09b..21ae98e6 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -23,20 +23,21 @@ {% if source_formats|length > 0 and conversion_formats|length > 0 %}

      {{_('Convert book format:')}}

      +
      @@ -48,6 +49,7 @@
      +
      @@ -56,11 +58,11 @@
      - -
      +
      +
      @@ -111,8 +113,8 @@ {% endif %}
      - - + + @@ -156,11 +158,11 @@ {% if c.datatype == 'datetime' %}
      - 0 %} value="{% if book['custom_column_' ~ c.id][0].value %}{{ book['custom_column_' ~ c.id][0].value|formatdateinput}}{% endif %}" {% endif %}> - 0 %} value="{% if book['custom_column_' ~ c.id][0].value %}{{book['custom_column_' ~ c.id][0].value|formatdate}}{% endif %}" {% endif %}> @@ -232,7 +234,7 @@
      - + @@ -241,25 +243,12 @@
      {{_('Click the cover to load metadata to the form')}}