diff --git a/.gitattributes b/.gitattributes index a55b8b50..ff7c4955 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ -web.py ident export-subst +helper.py ident export-subst cps/static/css/libs/* linguist-vendored cps/static/js/libs/* linguist-vendored diff --git a/.gitignore b/.gitignore index 15411480..09bf3faa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ settings.yaml gdrive_credentials vendor +client_secrets.json diff --git a/cps.py b/cps.py index 9b911b23..055c0ffe 100755 --- a/cps.py +++ b/cps.py @@ -10,42 +10,12 @@ sys.path.append(base_path) sys.path.append(os.path.join(base_path, 'cps')) sys.path.append(os.path.join(base_path, 'vendor')) -from cps import web -try: - from gevent.wsgi import WSGIServer - gevent_present = True -except ImportError: - from tornado.wsgi import WSGIContainer - from tornado.httpserver import HTTPServer - from tornado.ioloop import IOLoop - gevent_present = False +from cps.server import Server if __name__ == '__main__': - if web.ub.DEVELOPMENT: - web.app.run(port=web.ub.config.config_port, debug=True) - else: - if gevent_present: - web.app.logger.info('Attempting to start gevent') - web.start_gevent() - else: - web.app.logger.info('Falling back to Tornado') - # Max Buffersize set to 200MB - if web.ub.config.get_config_certfile() and web.ub.config.get_config_keyfile(): - ssl={"certfile": web.ub.config.get_config_certfile(), - "keyfile": web.ub.config.get_config_keyfile()} - else: - ssl=None - http_server = HTTPServer(WSGIContainer(web.app), - max_buffer_size = 209700000, - ssl_options=ssl) - http_server.listen(web.ub.config.config_port) - IOLoop.instance().start() - IOLoop.instance().close(True) - - - if web.helper.global_task == 0: - web.app.logger.info("Performing restart of Calibre-web") - os.execl(sys.executable, sys.executable, *sys.argv) - else: - web.app.logger.info("Performing shutdown of Calibre-web") - sys.exit(0) + Server.startServer() + + + + + diff --git a/cps/book_formats.py b/cps/book_formats.py index ea381b2b..1e0a08bd 100644 --- a/cps/book_formats.py +++ b/cps/book_formats.py @@ -7,6 +7,11 @@ import os from flask_babel import gettext as _ import comic +try: + from lxml.etree import LXML_VERSION as lxmlversion +except ImportError: + lxmlversion = None + __author__ = 'lemmsh' logger = logging.getLogger("book_formats") @@ -120,9 +125,13 @@ def get_versions(): if not use_generic_pdf_cover: IVersion=ImageVersion.MAGICK_VERSION else: - IVersion=_(u'not installed') + IVersion = _(u'not installed') if use_pdf_meta: - PVersion=PyPdfVersion + PVersion='v'+PyPdfVersion else: PVersion=_(u'not installed') - return {'ImageVersion': IVersion, 'PyPdfVersion': PVersion} + if lxmlversion: + XVersion = 'v'+'.'.join(map(str, lxmlversion)) + else: + XVersion = _(u'not installed') + return {'Image Magick': IVersion, 'PyPdf': PVersion, 'lxml':XVersion} diff --git a/cps/converter.py b/cps/converter.py new file mode 100644 index 00000000..8967d3e5 --- /dev/null +++ b/cps/converter.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os +import subprocess +import ub +import re +from flask_babel import gettext as _ + + +def versionKindle(): + versions = _(u'not installed') + if os.path.exists(ub.config.config_converterpath): + try: + p = subprocess.Popen(ub.config.config_converterpath, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p.wait() + for lines in p.stdout.readlines(): + if isinstance(lines, bytes): + lines = lines.decode('utf-8') + if re.search('Amazon kindlegen\(', lines): + versions = lines + except Exception: + versions = _(u'Excecution permissions missing') + return {'kindlegen' : versions} + + +def versionCalibre(): + versions = _(u'not installed') + if os.path.exists(ub.config.config_converterpath): + try: + p = subprocess.Popen([ub.config.config_converterpath, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p.wait() + for lines in p.stdout.readlines(): + if isinstance(lines, bytes): + lines = lines.decode('utf-8') + if re.search('ebook-convert.*\(calibre', lines): + versions = lines + except Exception: + versions = _(u'Excecution permissions missing') + return {'Calibre converter' : versions} + + +def versioncheck(): + if ub.config.config_ebookconverter == 1: + return versionKindle() + elif ub.config.config_ebookconverter == 2: + return versionCalibre() + else: + return {'ebook_converter':''} + diff --git a/cps/db.py b/cps/db.py index 1c274baa..54e42d25 100755 --- a/cps/db.py +++ b/cps/db.py @@ -112,6 +112,8 @@ class Identifiers(Base): return u"https://books.google.com/books?id={0}".format(self.val) elif self.type == "kobo": return u"https://www.kobo.com/ebook/{0}".format(self.val) + elif self.type == "url": + return u"{0}".format(self.val) else: return u"" @@ -254,7 +256,7 @@ class Books(Base): uuid = Column(String) authors = relationship('Authors', secondary=books_authors_link, backref='books') - tags = relationship('Tags', secondary=books_tags_link, backref='books') + tags = relationship('Tags', secondary=books_tags_link, backref='books',order_by="Tags.name") comments = relationship('Comments', backref='books') data = relationship('Data', backref='books') series = relationship('Series', secondary=books_series_link, backref='books') @@ -280,6 +282,9 @@ class Books(Base): self.timestamp, self.pubdate, self.series_index, self.last_modified, self.path, self.has_cover) + @property + def atom_timestamp(self): + return (self.timestamp or '').replace(' ', 'T') class Custom_Columns(Base): __tablename__ = 'custom_columns' @@ -313,8 +318,10 @@ def setup_db(): return False dbpath = os.path.join(config.config_calibre_dir, "metadata.db") - engine = create_engine('sqlite:///' + dbpath, echo=False, isolation_level="SERIALIZABLE") try: + if not os.path.exists(dbpath): + raise + engine = create_engine('sqlite:///' + dbpath, echo=False, isolation_level="SERIALIZABLE", connect_args={'check_same_thread': False}) conn = engine.connect() except Exception: content = ub.session.query(ub.Settings).first() @@ -374,8 +381,9 @@ def setup_db(): secondary=books_custom_column_links[cc_id[0]], backref='books')) - # Base.metadata.create_all(engine) - Session = sessionmaker() - Session.configure(bind=engine) + + Session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) session = Session() return True diff --git a/cps/fb2.py b/cps/fb2.py index 262a084f..87295ab8 100644 --- a/cps/fb2.py +++ b/cps/fb2.py @@ -20,41 +20,43 @@ def get_fb2_info(tmp_file_path, original_file_extension): def get_author(element): last_name = element.xpath('fb:last-name/text()', namespaces=ns) if len(last_name): - last_name = last_name[0] + last_name = last_name[0].encode('utf-8') else: last_name = u'' middle_name = element.xpath('fb:middle-name/text()', namespaces=ns) if len(middle_name): - middle_name = middle_name[0] + middle_name = middle_name[0].encode('utf-8') else: middle_name = u'' first_name = element.xpath('fb:first-name/text()', namespaces=ns) if len(first_name): - first_name = first_name[0] + first_name = first_name[0].encode('utf-8') else: first_name = u'' - return first_name + ' ' + middle_name + ' ' + last_name + return (first_name.decode('utf-8') + u' ' + + middle_name.decode('utf-8') + u' ' + + last_name.decode('utf-8')).encode('utf-8') author = str(", ".join(map(get_author, authors))) title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns) if len(title): - title = str(title[0]) + title = str(title[0].encode('utf-8')) else: title = u'' description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns) if len(description): - description = str(description[0]) + description = str(description[0].encode('utf-8')) else: description = u'' return uploader.BookMeta( file_path=tmp_file_path, extension=original_file_extension, - title=title.encode('utf-8').decode('utf-8'), - author=author.encode('utf-8').decode('utf-8'), + title=title.decode('utf-8'), + author=author.decode('utf-8'), cover=None, - description=description, + description=description.decode('utf-8'), tags="", series="", series_id="", diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index f2665ba6..d8df9587 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -1,13 +1,17 @@ try: from pydrive.auth import GoogleAuth from pydrive.drive import GoogleDrive + from pydrive.auth import RefreshError from apiclient import errors + gdrive_support = True except ImportError: - pass -import os + gdrive_support = False +import os from ub import config import cli +import shutil +from flask import Response, stream_with_context from sqlalchemy import * from sqlalchemy.ext.declarative import declarative_base @@ -16,6 +20,57 @@ from sqlalchemy.orm import * import web +class Singleton: + """ + A non-thread-safe helper class to ease implementing singletons. + This should be used as a decorator -- not a metaclass -- to the + class that should be a singleton. + + The decorated class can define one `__init__` function that + takes only the `self` argument. Also, the decorated class cannot be + inherited from. Other than that, there are no restrictions that apply + to the decorated class. + + To get the singleton instance, use the `Instance` method. Trying + to use `__call__` will result in a `TypeError` being raised. + + """ + + def __init__(self, decorated): + self._decorated = decorated + + def Instance(self): + """ + Returns the singleton instance. Upon its first call, it creates a + new instance of the decorated class and calls its `__init__` method. + On all subsequent calls, the already created instance is returned. + + """ + try: + return self._instance + except AttributeError: + self._instance = self._decorated() + return self._instance + + def __call__(self): + raise TypeError('Singletons must be accessed through `Instance()`.') + + def __instancecheck__(self, inst): + return isinstance(inst, self._decorated) + + +@Singleton +class Gauth: + def __init__(self): + self.auth = GoogleAuth(settings_file=os.path.join(config.get_main_dir,'settings.yaml')) + + +@Singleton +class Gdrive: + def __init__(self): + self.drive = getDrive(gauth=Gauth.Instance().auth) + + engine = create_engine('sqlite:///{0}'.format(cli.gdpath), echo=False) Base = declarative_base() @@ -68,19 +123,23 @@ if not os.path.exists(cli.gdpath): Base.metadata.create_all(engine) except Exception: raise - migrate() def getDrive(drive=None, gauth=None): if not drive: if not gauth: - gauth = GoogleAuth(settings_file='settings.yaml') + gauth = GoogleAuth(settings_file=os.path.join(config.get_main_dir,'settings.yaml')) # Try to load saved client credentials - gauth.LoadCredentialsFile("gdrive_credentials") + gauth.LoadCredentialsFile(os.path.join(config.get_main_dir,'gdrive_credentials')) if gauth.access_token_expired: # Refresh them if expired - gauth.Refresh() + try: + gauth.Refresh() + except RefreshError as e: + web.app.logger.error("Google Drive error: " + e.message) + except Exception as e: + web.app.logger.exception(e) else: # Initialize the saved creds gauth.Authorize() @@ -90,41 +149,55 @@ def getDrive(drive=None, gauth=None): drive.auth.Refresh() return drive +def listRootFolders(drive=None): + drive = getDrive(drive) + folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" + fileList = drive.ListFile({'q': folder}).GetList() + return fileList + def getEbooksFolder(drive=None): - drive = getDrive(drive) - ebooksFolder = "title = '%s' and 'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" % config.config_google_drive_folder + return getFolderInFolder('root',config.config_google_drive_folder,drive) - fileList = drive.ListFile({'q': ebooksFolder}).GetList() - return fileList[0] +def getFolderInFolder(parentId, folderName,drive=None): + drive = getDrive(drive) + query="" + if folderName: + query = "title = '%s' and " % folderName.replace("'", "\\'") + folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" % parentId + fileList = drive.ListFile({'q': folder}).GetList() + if fileList.__len__() == 0: + return None + else: + return fileList[0] +# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database def getEbooksFolderId(drive=None): storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first() if storedPathName: return storedPathName.gdrive_id else: gDriveId = GdriveId() - gDriveId.gdrive_id = getEbooksFolder(drive)['id'] + try: + gDriveId.gdrive_id = getEbooksFolder(drive)['id'] + except Exception: + web.app.logger.error('Error gDrive, root ID not found') gDriveId.path = '/' session.merge(gDriveId) session.commit() return -def getFolderInFolder(parentId, folderName, drive=None): - drive = getDrive(drive) - folder = "title = '%s' and '%s' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" % (folderName.replace("'", "\\'"), parentId) - fileList = drive.ListFile({'q': folder}).GetList() - return fileList[0] - - -def getFile(pathId, fileName, drive=None): - drive = getDrive(drive) +def getFile(pathId, fileName, drive): + # drive = getDrive(Gdrive.Instance().drive) metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", "\\'")) fileList = drive.ListFile({'q': metaDataFile}).GetList() - return fileList[0] + if fileList.__len__() == 0: + return None + else: + return fileList[0] def getFolderId(path, drive=None): @@ -145,12 +218,17 @@ def getFolderId(path, drive=None): if storedPathName: currentFolderId = storedPathName.gdrive_id else: - currentFolderId = getFolderInFolder(currentFolderId, x, drive)['id'] - gDriveId = GdriveId() - gDriveId.gdrive_id = currentFolderId - gDriveId.path = currentPath - session.merge(gDriveId) - dbChange = True + currentFolder = getFolderInFolder(currentFolderId, x, drive) + if currentFolder: + gDriveId = GdriveId() + gDriveId.gdrive_id = currentFolder['id'] + gDriveId.path = currentPath + session.merge(gDriveId) + dbChange = True + currentFolderId = currentFolder['id'] + else: + currentFolderId= None + break if dbChange: session.commit() else: @@ -158,15 +236,17 @@ def getFolderId(path, drive=None): return currentFolderId -def getFileFromEbooksFolder(drive, path, fileName): - drive = getDrive(drive) +def getFileFromEbooksFolder(path, fileName): + drive = getDrive(Gdrive.Instance().drive) if path: # sqlCheckPath=path if path[-1] =='/' else path + '/' folderId = getFolderId(path, drive) else: folderId = getEbooksFolderId(drive) - - return getFile(folderId, fileName, drive) + if folderId: + return getFile(folderId, fileName, drive) + else: + return None def copyDriveFileRemote(drive, origin_file_id, copy_title): @@ -181,22 +261,34 @@ def copyDriveFileRemote(drive, origin_file_id, copy_title): return None -def downloadFile(drive, path, filename, output): - drive = getDrive(drive) - f = getFileFromEbooksFolder(drive, path, filename) +# Download metadata.db from gdrive +def downloadFile(path, filename, output): + f = getFileFromEbooksFolder(path, filename) f.GetContentFile(output) -def backupCalibreDbAndOptionalDownload(drive, f=None): - drive = getDrive(drive) - metaDataFile = "'%s' in parents and title = 'metadata.db' and trashed = false" % getEbooksFolderId() - - fileList = drive.ListFile({'q': metaDataFile}).GetList() +def moveGdriveFolderRemote(origin_file, target_folder): + drive = getDrive(Gdrive.Instance().drive) + previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')]) + gFileTargetDir = getFileFromEbooksFolder(None, target_folder) + if not gFileTargetDir: + # Folder is not exisiting, create, and move folder + gFileTargetDir = drive.CreateFile( + {'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}], + "mimeType": "application/vnd.google-apps.folder"}) + gFileTargetDir.Upload() + # Move the file to the new folder + drive.auth.service.files().update(fileId=origin_file['id'], + addParents=gFileTargetDir['id'], + removeParents=previous_parents, + fields='id, parents').execute() + # if previous_parents has no childs anymore, delete originfileparent + # is not working correctly, because of slow update on gdrive -> could cause trouble in gdrive.db + # (nonexisting folder has id) + # children = drive.auth.service.children().list(folderId=previous_parents).execute() + # if not len(children['items']): + # drive.auth.service.files().delete(fileId=previous_parents).execute() - databaseFile = fileList[0] - - if f: - databaseFile.GetContentFile(f) def copyToDrive(drive, uploadFile, createRoot, replaceFiles, @@ -230,8 +322,8 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles, driveFile.Upload() -def uploadFileToEbooksFolder(drive, destFile, f): - drive = getDrive(drive) +def uploadFileToEbooksFolder(destFile, f): + drive = getDrive(Gdrive.Instance().drive) parent = getEbooksFolder(drive) splitDir = destFile.split('/') for i, x in enumerate(splitDir): @@ -255,7 +347,7 @@ def uploadFileToEbooksFolder(drive, destFile, f): def watchChange(drive, channel_id, channel_type, channel_address, channel_token=None, expiration=None): - drive = getDrive(drive) + # drive = getDrive(drive) # Watch for all changes to a user's Drive. # Args: # service: Drive API service instance. @@ -298,7 +390,7 @@ def watchFile(drive, file_id, channel_id, channel_type, channel_address, Raises: apiclient.errors.HttpError: if http request to create channel fails. """ - drive = getDrive(drive) + # drive = getDrive(drive) body = { 'id': channel_id, @@ -321,7 +413,7 @@ def stopChannel(drive, channel_id, resource_id): Raises: apiclient.errors.HttpError: if http request to create channel fails. """ - drive = getDrive(drive) + # drive = getDrive(drive) # service=drive.auth.service body = { 'id': channel_id, @@ -331,7 +423,7 @@ def stopChannel(drive, channel_id, resource_id): def getChangeById (drive, change_id): - drive = getDrive(drive) + # drive = getDrive(drive) # Print a single Change resource information. # # Args: @@ -340,6 +432,73 @@ def getChangeById (drive, change_id): try: change = drive.auth.service.changes().get(changeId=change_id).execute() return change - except (errors.HttpError, error): - web.app.logger.exception(error) + except (errors.HttpError) as error: + web.app.logger.info(error.message) + return None + +# Deletes the local hashes database to force search for new folder names +def deleteDatabaseOnChange(): + session.query(GdriveId).delete() + session.commit() + +def updateGdriveCalibreFromLocal(): + copyToDrive(Gdrive.Instance().drive, config.config_calibre_dir, False, True) + for x in os.listdir(config.config_calibre_dir): + if os.path.isdir(os.path.join(config.config_calibre_dir, x)): + shutil.rmtree(os.path.join(config.config_calibre_dir, x)) + +# update gdrive.db on edit of books title +def updateDatabaseOnEdit(ID,newPath): + storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first() + if storedPathName: + storedPathName.path = newPath + session.commit() + +# Deletes the hashes in database of deleted book +def deleteDatabaseEntry(ID): + session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete() + session.commit() + +# Gets cover file from gdrive +def get_cover_via_gdrive(cover_path): + df = getFileFromEbooksFolder(cover_path, 'cover.jpg') + if df: + if not session.query(PermissionAdded).filter(PermissionAdded.gdrive_id == df['id']).first(): + df.GetPermissions() + df.InsertPermission({ + 'type': 'anyone', + 'value': 'anyone', + 'role': 'reader', + 'withLink': True}) + permissionAdded = PermissionAdded() + permissionAdded.gdrive_id = df['id'] + session.add(permissionAdded) + session.commit() + return df.metadata.get('webContentLink') + else: return None + +# Creates chunks for downloading big files +def partial(total_byte_len, part_size_limit): + s = [] + for p in range(0, total_byte_len, part_size_limit): + last = min(total_byte_len - 1, p + part_size_limit - 1) + s.append([p, last]) + return s + +# downloads files in chunks from gdrive +def do_gdrive_download(df, headers): + total_size = int(df.metadata.get('fileSize')) + download_url = df.metadata.get('downloadUrl') + s = partial(total_size, 1024 * 1024) # I'm downloading BIG files, so 100M chunk size is fine for me + + def stream(): + for byte in s: + headers = {"Range": 'bytes=%s-%s' % (byte[0], byte[1])} + resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers) + if resp.status == 206: + yield content + else: + web.app.logger.info('An error occurred: %s' % resp) + return + return Response(stream_with_context(stream()), headers=headers) diff --git a/cps/helper.py b/cps/helper.py index 237f8981..722a6245 100755 --- a/cps/helper.py +++ b/cps/helper.py @@ -5,43 +5,29 @@ import db import ub from flask import current_app as app import logging -import smtplib from tempfile import gettempdir -import socket import sys import os -import traceback import re import unicodedata from io import BytesIO +import worker +import time -try: - from StringIO import StringIO - from email.MIMEBase import MIMEBase - from email.MIMEMultipart import MIMEMultipart - from email.MIMEText import MIMEText -except ImportError as e: - from io import StringIO - from email.mime.base import MIMEBase - from email.mime.multipart import MIMEMultipart - from email.mime.text import MIMEText - -from email import encoders -from email.generator import Generator -from email.utils import formatdate -from email.utils import make_msgid +from flask import send_from_directory, make_response, redirect, abort from flask_babel import gettext as _ -import subprocess import threading import shutil import requests import zipfile -from tornado.ioloop import IOLoop try: import gdriveutils as gd except ImportError: pass import web +import server +import random +import subprocess try: import unidecode @@ -50,215 +36,114 @@ except ImportError: use_unidecode = False # Global variables -global_task = None updater_thread = None - -RET_SUCCESS = 1 -RET_FAIL = 0 +global_WorkerThread = worker.WorkerThread() +global_WorkerThread.start() def update_download(book_id, user_id): check = ub.session.query(ub.Downloads).filter(ub.Downloads.user_id == user_id).filter(ub.Downloads.book_id == book_id).first() - if not check: new_download = ub.Downloads(user_id=user_id, book_id=book_id) ub.session.add(new_download) ub.session.commit() - -def make_mobi(book_id, calibrepath): - error_message = None - vendorpath = os.path.join(os.path.normpath(os.path.dirname(os.path.realpath(__file__)) + - os.sep + "../vendor" + os.sep)) - if sys.platform == "win32": - kindlegen = (os.path.join(vendorpath, u"kindlegen.exe")).encode(sys.getfilesystemencoding()) - else: - kindlegen = (os.path.join(vendorpath, u"kindlegen")).encode(sys.getfilesystemencoding()) - if not os.path.exists(kindlegen): - error_message = _(u"kindlegen binary %(kindlepath)s not found", kindlepath=kindlegen) - app.logger.error("make_mobi: " + error_message) - return error_message, RET_FAIL +# Convert existing book entry to new format +def convert_book_format(book_id, calibrepath, old_book_format, new_book_format, user_id, kindle_mail=None): book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == 'EPUB').first() + data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == old_book_format).first() if not data: - error_message = _(u"epub format not found for book id: %(book)d", book=book_id) - app.logger.error("make_mobi: " + error_message) - return error_message, RET_FAIL - + error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id) + app.logger.error("convert_book_format: " + error_message) + return error_message + if ub.config.config_use_google_drive: + df = gd.getFileFromEbooksFolder(book.path, data.name + "." + old_book_format.lower()) + if df: + datafile = os.path.join(calibrepath, book.path, data.name + u"." + old_book_format.lower()) + if not os.path.exists(os.path.join(calibrepath, book.path)): + os.makedirs(os.path.join(calibrepath, book.path)) + df.GetContentFile(datafile) + else: + error_message = _(u"%(format)s not found on Google Drive: %(fn)s", + format=old_book_format, fn=data.name + "." + old_book_format.lower()) + return error_message file_path = os.path.join(calibrepath, book.path, data.name) - if os.path.exists(file_path + u".epub"): - try: - p = subprocess.Popen((kindlegen + " \"" + file_path + u".epub\"").encode(sys.getfilesystemencoding()), - stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) - except Exception: - error_message = _(u"kindlegen failed, no execution permissions") - app.logger.error("make_mobi: " + error_message) - return error_message, RET_FAIL - # Poll process for new output until finished - while True: - nextline = p.stdout.readline() - if nextline == '' and p.poll() is not None: - break - if nextline != "\r\n": - # Format of error message (kindlegen translates its output texts): - # Error(prcgen):E23006: Language not recognized in metadata.The dc:Language field is mandatory.Aborting. - conv_error = re.search(".*\(.*\):(E\d+):\s(.*)", nextline) - # If error occoures, log in every case - if conv_error: - error_message = _(u"Kindlegen failed with Error %(error)s. Message: %(message)s", - error=conv_error.group(1), message=conv_error.group(2).decode('utf-8')) - app.logger.info("make_mobi: " + error_message) - app.logger.info(nextline.strip('\r\n')) - app.logger.debug(nextline.strip('\r\n')) - - check = p.returncode - if not check or check < 2: - book.data.append(db.Data( - name=book.data[0].name, - book_format="MOBI", - book=book.id, - uncompressed_size=os.path.getsize(file_path + ".mobi") - )) - db.session.commit() - return file_path + ".mobi", RET_SUCCESS + if os.path.exists(file_path + "." + old_book_format.lower()): + # read settings and append converter task to queue + if kindle_mail: + settings = ub.get_mail_settings() + text = _(u"Convert: %(book)s" , book=book.title) else: - app.logger.info("make_mobi: kindlegen failed with error while converting book") - if not error_message: - error_message = 'kindlegen failed, no excecution permissions' - return error_message, RET_FAIL + settings = dict() + text = _(u"Convert to %(format)s: %(book)s", format=new_book_format, book=book.title) + settings['old_book_format'] = old_book_format + settings['new_book_format'] = new_book_format + global_WorkerThread.add_convert(file_path, book.id, user_id, text, settings, kindle_mail) + return None else: - error_message = "make_mobi: epub not found: %s.epub" % file_path - return error_message, RET_FAIL - - -class StderrLogger(object): - - buffer = '' - - def __init__(self): - self.logger = logging.getLogger('cps.web') - - def write(self, message): - if message == '\n': - self.logger.debug(self.buffer) - self.buffer = '' - else: - self.buffer += message - - -def send_raw_email(kindle_mail, msg): - settings = ub.get_mail_settings() - - msg['From'] = settings["mail_from"] - msg['To'] = kindle_mail - - use_ssl = int(settings.get('mail_use_ssl', 0)) - - # convert MIME message to string - fp = StringIO() - gen = Generator(fp, mangle_from_=False) - gen.flatten(msg) - msg = fp.getvalue() - - # send email - try: - timeout = 600 # set timeout to 5mins - - org_stderr = sys.stderr - sys.stderr = StderrLogger() - - if use_ssl == 2: - mailserver = smtplib.SMTP_SSL(settings["mail_server"], settings["mail_port"], timeout) - else: - mailserver = smtplib.SMTP(settings["mail_server"], settings["mail_port"], timeout) - mailserver.set_debuglevel(1) - - if use_ssl == 1: - mailserver.starttls() - - if settings["mail_password"]: - mailserver.login(str(settings["mail_login"]), str(settings["mail_password"])) - mailserver.sendmail(settings["mail_from"], kindle_mail, msg) - mailserver.quit() - - smtplib.stderr = org_stderr - - except (socket.error, smtplib.SMTPRecipientsRefused, smtplib.SMTPException) as ex: - app.logger.error(traceback.print_exc()) - return _("Failed to send mail: %s" % str(ex)) - - return None - - -def send_test_mail(kindle_mail): - msg = MIMEMultipart() - msg['Subject'] = _(u'Calibre-web test email') - text = _(u'This email has been sent via calibre web.') - msg.attach(MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8')) - return send_raw_email(kindle_mail, msg) - - -def send_mail(book_id, kindle_mail, calibrepath): + error_message = _(u"%(format)s not found: %(fn)s", + format=old_book_format, fn=data.name + "." + old_book_format.lower()) + return error_message + + +def send_test_mail(kindle_mail, user_name): + global_WorkerThread.add_email(_(u'Calibre-Web test e-mail'),None, None, ub.get_mail_settings(), + kindle_mail, user_name, _(u"Test e-mail")) + return + + +# Send registration email or password reset email, depending on parameter resend (False means welcome email) +def send_registration_mail(e_mail, user_name, default_password, resend=False): + text = "Hello %s!\r\n" % user_name + if not resend: + text += "Your new account at Calibre-Web has been created. Thanks for joining us!\r\n" + text += "Please log in to your account using the following informations:\r\n" + text += "User name: %s\n" % user_name + text += "Password: %s\r\n" % default_password + text += "Don't forget to change your password after first login.\r\n" + text += "Sincerely\r\n\r\n" + text += "Your Calibre-Web team" + global_WorkerThread.add_email(_(u'Get Started with Calibre-Web'),None, None, ub.get_mail_settings(), + e_mail, user_name, _(u"Registration e-mail for user: %(name)s", name=user_name),text) + return + + +# Files are processed in the following order/priority: +# 1: If Mobi file is exisiting, it's directly send to kindle email, +# 2: If Epub file is exisiting, it's converted and send to kindle email +# 3: If Pdf file is exisiting, it's directly send to kindle email, +def send_mail(book_id, kindle_mail, calibrepath, user_id): """Send email with attachments""" - # create MIME message - msg = MIMEMultipart() - msg['Subject'] = _(u'Send to Kindle') - msg['Message-Id'] = make_msgid('calibre-web') - msg['Date'] = formatdate(localtime=True) - text = _(u'This email has been sent via calibre web.') - msg.attach(MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8')) - book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - data = db.session.query(db.Data).filter(db.Data.book == book.id) + data = db.session.query(db.Data).filter(db.Data.book == book.id).all() formats = {} - for entry in data: if entry.format == "MOBI": - formats["mobi"] = os.path.join(calibrepath, book.path, entry.name + ".mobi") + formats["mobi"] = entry.name + ".mobi" if entry.format == "EPUB": - formats["epub"] = os.path.join(calibrepath, book.path, entry.name + ".epub") + formats["epub"] = entry.name + ".epub" if entry.format == "PDF": - formats["pdf"] = os.path.join(calibrepath, book.path, entry.name + ".pdf") + formats["pdf"] = entry.name + ".pdf" if len(formats) == 0: - return _("Could not find any formats suitable for sending by email") + return _(u"Could not find any formats suitable for sending by e-mail") if 'mobi' in formats: - msg.attach(get_attachment(formats['mobi'])) + result = formats['mobi'] elif 'epub' in formats: - data, resultCode = make_mobi(book.id, calibrepath) - if resultCode == RET_SUCCESS: - msg.attach(get_attachment(data)) - else: - app.logger.error = data - return data # _("Could not convert epub to mobi") + # returns None if sucess, otherwise errormessage + return convert_book_format(book_id, calibrepath, u'epub', u'mobi', user_id, kindle_mail) elif 'pdf' in formats: - msg.attach(get_attachment(formats['pdf'])) + result = formats['pdf'] # worker.get_attachment() else: - return _("Could not find any formats suitable for sending by email") - - return send_raw_email(kindle_mail, msg) - - -def get_attachment(file_path): - """Get file as MIMEBase message""" - - try: - file_ = open(file_path, 'rb') - attachment = MIMEBase('application', 'octet-stream') - attachment.set_payload(file_.read()) - file_.close() - encoders.encode_base64(attachment) - - attachment.add_header('Content-Disposition', 'attachment', - filename=os.path.basename(file_path)) - return attachment - except IOError: - traceback.print_exc() - app.logger.error = u'The requested file could not be read. Maybe wrong permissions?' - return None + return _(u"Could not find any formats suitable for sending by e-mail") + if result: + global_WorkerThread.add_email(_(u"Send to Kindle"), book.path, result, ub.get_mail_settings(), + kindle_mail, user_id, _(u"E-mail: %(book)s", book=book.title)) + else: + return _(u"The requested file could not be read. Maybe wrong permissions?") def get_valid_filename(value, replace_whitespace=True): @@ -288,7 +173,6 @@ def get_valid_filename(value, replace_whitespace=True): value = value[:128] if not value: raise ValueError("Filename cannot be empty") - return value @@ -302,24 +186,34 @@ def get_sorted_author(value): else: value2 = value[-1] + ", " + " ".join(value[:-1]) except Exception: - logging.getLogger('cps.web').error("Sorting author " + str(value) + "failed") + web.app.logger.error("Sorting author " + str(value) + "failed") value2 = value return value2 -def delete_book(book, calibrepath): - if "/" in book.path: +# Deletes a book fro the local filestorage, returns True if deleting is successfull, otherwise false +def delete_book_file(book, calibrepath, book_format=None): + # check that path is 2 elements deep, check that target path has no subfolders + if book.path.count('/') == 1: path = os.path.join(calibrepath, book.path) - shutil.rmtree(path, ignore_errors=True) - else: - logging.getLogger('cps.web').error("Deleting book " + str(book.id) + " failed, book path value: "+ book.path) - -# ToDo: Implement delete book on gdrive -def delete_book_gdrive(book): - pass + if book_format: + for file in os.listdir(path): + if file.upper().endswith("."+book_format): + os.remove(os.path.join(path, file)) + else: + if os.path.isdir(path): + if len(next(os.walk(path))[1]): + web.app.logger.error( + "Deleting book " + str(book.id) + " failed, path has subfolders: " + book.path) + return False + shutil.rmtree(path, ignore_errors=True) + return True + else: + web.app.logger.error("Deleting book " + str(book.id) + " failed, book path not valid: " + book.path) + return False -def update_dir_stucture(book_id, calibrepath): +def update_dir_structure_file(book_id, calibrepath): localbook = db.session.query(db.Books).filter(db.Books.id == book_id).first() path = os.path.join(calibrepath, localbook.path) @@ -332,22 +226,28 @@ def update_dir_stucture(book_id, calibrepath): if titledir != new_titledir: try: new_title_path = os.path.join(os.path.dirname(path), new_titledir) - os.renames(path, new_title_path) + if not os.path.exists(new_title_path): + os.renames(path, new_title_path) + else: + web.app.logger.info("Copying title: " + path + " into existing: " + new_title_path) + for dir_name, subdir_list, file_list in os.walk(path): + for file in file_list: + os.renames(os.path.join(dir_name, file), os.path.join(new_title_path + dir_name[len(path):], file)) path = new_title_path localbook.path = localbook.path.split('/')[0] + '/' + new_titledir except OSError as ex: - logging.getLogger('cps.web').error("Rename title from: " + path + " to " + new_title_path) - logging.getLogger('cps.web').error(ex, exc_info=True) - return _('Rename title from: "%s" to "%s" failed with error: %s' % (path, new_title_path, str(ex))) + web.app.logger.error("Rename title from: " + path + " to " + new_title_path) + web.app.logger.error(ex, exc_info=True) + return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s", src=path, dest=new_title_path, error=str(ex)) if authordir != new_authordir: try: new_author_path = os.path.join(os.path.join(calibrepath, new_authordir), os.path.basename(path)) os.renames(path, new_author_path) localbook.path = new_authordir + '/' + localbook.path.split('/')[1] except OSError as ex: - logging.getLogger('cps.web').error("Rename author from: " + path + " to " + new_author_path) - logging.getLogger('cps.web').error(ex, exc_info=True) - return _('Rename author from: "%s" to "%s" failed with error: %s' % (path, new_title_path, str(ex))) + web.app.logger.error("Rename author from: " + path + " to " + new_author_path) + web.app.logger.error(ex, exc_info=True) + return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s", src=path, dest=new_title_path, error=str(ex)) return False @@ -361,20 +261,119 @@ def update_dir_structure_gdrive(book_id): new_titledir = get_valid_filename(book.title) + " (" + str(book_id) + ")" if titledir != new_titledir: - print (titledir) - gFile = gd.getFileFromEbooksFolder(web.Gdrive.Instance().drive, os.path.dirname(book.path), titledir) - gFile['title'] = new_titledir - gFile.Upload() - book.path = book.path.split('/')[0] + '/' + new_titledir + gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir) + if gFile: + gFile['title'] = new_titledir + + gFile.Upload() + book.path = book.path.split('/')[0] + '/' + new_titledir + gd.updateDatabaseOnEdit(gFile['id'], book.path) # only child folder affected + else: + error = _(u'File %(file)s not found on Google Drive', file= book.path) # file not found if authordir != new_authordir: - gFile = gd.getFileFromEbooksFolder(web.Gdrive.Instance().drive, None, authordir) - gFile['title'] = new_authordir - gFile.Upload() - book.path = new_authordir + '/' + book.path.split('/')[1] + gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir) + if gFile: + gd.moveGdriveFolderRemote(gFile,new_authordir) + book.path = new_authordir + '/' + book.path.split('/')[1] + gd.updateDatabaseOnEdit(gFile['id'], book.path) + else: + error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found return error +def delete_book_gdrive(book, book_format): + error= False + if book_format: + name = '' + for entry in book.data: + if entry.format.upper() == book_format: + name = entry.name + '.' + book_format + gFile = gd.getFileFromEbooksFolder(book.path, name) + else: + gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path),book.path.split('/')[1]) + if gFile: + gd.deleteDatabaseEntry(gFile['id']) + gFile.Trash() + else: + error =_(u'Book path %(path)s not found on Google Drive', path=book.path) # file not found + return error + +def generate_random_password(): + s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?" + passlen = 8 + return "".join(random.sample(s,passlen )) + +################################## External interface + +def update_dir_stucture(book_id, calibrepath): + if ub.config.config_use_google_drive: + return update_dir_structure_gdrive(book_id) + else: + return update_dir_structure_file(book_id, calibrepath) + +def delete_book(book, calibrepath, book_format): + if ub.config.config_use_google_drive: + return delete_book_gdrive(book, book_format) + else: + return delete_book_file(book, calibrepath, book_format) + +def get_book_cover(cover_path): + if ub.config.config_use_google_drive: + try: + path=gd.get_cover_via_gdrive(cover_path) + if path: + return redirect(path) + else: + web.app.logger.error(cover_path + '/cover.jpg not found on Google Drive') + return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg") + except Exception as e: + web.app.logger.error("Error Message: "+e.message) + web.app.logger.exception(e) + # traceback.print_exc() + return send_from_directory(os.path.join(os.path.dirname(__file__), "static"),"generic_cover.jpg") + else: + return send_from_directory(os.path.join(ub.config.config_calibre_dir, cover_path), "cover.jpg") + +# saves book cover to gdrive or locally +def save_cover(url, book_path): + img = requests.get(url) + if img.headers.get('content-type') != 'image/jpeg': + web.app.logger.error("Cover is no jpg file, can't save") + return False + + if ub.config.config_use_google_drive: + tmpDir = gettempdir() + f = open(os.path.join(tmpDir, "uploaded_cover.jpg"), "wb") + f.write(img.content) + f.close() + uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'), os.path.join(tmpDir, f.name)) + web.app.logger.info("Cover is saved on Google Drive") + return True + + f = open(os.path.join(ub.config.config_calibre_dir, book_path, "cover.jpg"), "wb") + f.write(img.content) + f.close() + web.app.logger.info("Cover is saved") + return True + +def do_download_file(book, book_format, data, headers): + if ub.config.config_use_google_drive: + startTime = time.time() + df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format) + web.app.logger.debug(time.time() - startTime) + if df: + return gd.do_gdrive_download(df, headers) + else: + abort(404) + else: + response = make_response(send_from_directory(os.path.join(ub.config.config_calibre_dir, book.path), data.name + "." + book_format)) + response.headers = headers + return response + +################################## + + class Updater(threading.Thread): def __init__(self): @@ -382,7 +381,6 @@ class Updater(threading.Thread): self.status = 0 def run(self): - global global_task self.status = 1 r = requests.get('https://api.github.com/repos/janeczku/calibre-web/zipball/master', stream=True) fname = re.findall("filename=(.+)", r.headers['content-disposition'])[0] @@ -394,19 +392,13 @@ class Updater(threading.Thread): self.status = 4 self.update_source(os.path.join(tmp_dir, os.path.splitext(fname)[0]), ub.config.get_main_dir) self.status = 5 - global_task = 0 db.session.close() db.engine.dispose() ub.session.close() ub.engine.dispose() self.status = 6 - - if web.gevent_server: - web.gevent_server.stop() - else: - # stop tornado server - server = IOLoop.instance() - server.add_callback(server.stop) + server.Server.setRestartTyp(True) + server.Server.stopServer() self.status = 7 def get_update_status(self): @@ -530,3 +522,48 @@ class Updater(threading.Thread): except Exception: logging.getLogger('cps.web').debug("Could not remove:" + item_path) shutil.rmtree(source, ignore_errors=True) + + +def check_unrar(unrarLocation): + error = False + if os.path.exists(unrarLocation): + try: + if sys.version_info < (3, 0): + unrarLocation = unrarLocation.encode(sys.getfilesystemencoding()) + p = subprocess.Popen(unrarLocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p.wait() + for lines in p.stdout.readlines(): + if isinstance(lines, bytes): + lines = lines.decode('utf-8') + value=re.search('UNRAR (.*) freeware', lines) + if value: + version = value.group(1) + except OSError as e: + error = True + web.app.logger.exception(e) + version =_(u'Error excecuting UnRar') + else: + version = _(u'Unrar binary file not found') + error=True + return (error, version) + + +def is_sha1(sha1): + if len(sha1) != 40: + return False + try: + int(sha1, 16) + except ValueError: + return False + return True + + +def get_current_version_info(): + content = {} + content[0] = '$Format:%H$' + content[1] = '$Format:%cI$' + # content[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57' + # content[1] = '2018-09-09T10:13:08+02:00' + if is_sha1(content[0]) and len(content[1]) > 0: + return {'hash': content[0], 'datetime': content[1]} + return False diff --git a/cps/server.py b/cps/server.py new file mode 100644 index 00000000..37245b42 --- /dev/null +++ b/cps/server.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +from socket import error as SocketError +import sys +import os +try: + from gevent.pywsgi import WSGIServer + from gevent.pool import Pool + from gevent import __version__ as geventVersion + gevent_present = True +except ImportError: + from tornado.wsgi import WSGIContainer + from tornado.httpserver import HTTPServer + from tornado.ioloop import IOLoop + from tornado import version as tornadoVersion + gevent_present = False + +import web + + +class server: + + wsgiserver = None + restart= False + + def __init__(self): + pass + + def start_gevent(self): + try: + ssl_args = dict() + if web.ub.config.get_config_certfile() and web.ub.config.get_config_keyfile(): + ssl_args = {"certfile": web.ub.config.get_config_certfile(), + "keyfile": web.ub.config.get_config_keyfile()} + if os.name == 'nt': + self.wsgiserver= WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) + else: + self.wsgiserver = WSGIServer(('', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) + self.wsgiserver.serve_forever() + + except SocketError: + web.app.logger.info('Unable to listen on \'\', trying on IPv4 only...') + self.wsgiserver = WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) + self.wsgiserver.serve_forever() + except Exception: + web.app.logger.info("Unknown error while starting gevent") + + def startServer(self): + if gevent_present: + web.app.logger.info('Starting Gevent server') + # leave subprocess out to allow forking for fetchers and processors + self.start_gevent() + else: + web.app.logger.info('Starting Tornado server') + if web.ub.config.get_config_certfile() and web.ub.config.get_config_keyfile(): + ssl={"certfile": web.ub.config.get_config_certfile(), + "keyfile": web.ub.config.get_config_keyfile()} + else: + ssl=None + # Max Buffersize set to 200MB + http_server = HTTPServer(WSGIContainer(web.app), + max_buffer_size = 209700000, + ssl_options=ssl) + http_server.listen(web.ub.config.config_port) + self.wsgiserver=IOLoop.instance() + self.wsgiserver.start() # wait for stop signal + self.wsgiserver.close(True) + + if self.restart == True: + web.app.logger.info("Performing restart of Calibre-Web") + web.helper.global_WorkerThread.stop() + if os.name == 'nt': + arguments = ["\"" + sys.executable + "\""] + for e in sys.argv: + arguments.append("\"" + e + "\"") + os.execv(sys.executable, arguments) + else: + os.execl(sys.executable, sys.executable, *sys.argv) + else: + web.app.logger.info("Performing shutdown of Calibre-Web") + web.helper.global_WorkerThread.stop() + sys.exit(0) + + def setRestartTyp(self,starttyp): + self.restart=starttyp + + def stopServer(self): + if gevent_present: + self.wsgiserver.close() + else: + self.wsgiserver.add_callback(self.wsgiserver.stop) + + @staticmethod + def getNameVersion(): + if gevent_present: + return {'Gevent':'v'+geventVersion} + else: + return {'Tornado':'v'+tornadoVersion} + + +# Start Instance of Server +Server=server() diff --git a/cps/static/css/fonts/glyphicons-halflings-regular.eot b/cps/static/css/fonts/glyphicons-halflings-regular.eot index 87eaa434..b93a4953 100644 Binary files a/cps/static/css/fonts/glyphicons-halflings-regular.eot and b/cps/static/css/fonts/glyphicons-halflings-regular.eot differ diff --git a/cps/static/css/fonts/glyphicons-halflings-regular.svg b/cps/static/css/fonts/glyphicons-halflings-regular.svg index 5fee0685..94fb5490 100644 --- a/cps/static/css/fonts/glyphicons-halflings-regular.svg +++ b/cps/static/css/fonts/glyphicons-halflings-regular.svg @@ -6,223 +6,283 @@ - - + + - - + + - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cps/static/css/fonts/glyphicons-halflings-regular.ttf b/cps/static/css/fonts/glyphicons-halflings-regular.ttf index be784dc1..1413fc60 100644 Binary files a/cps/static/css/fonts/glyphicons-halflings-regular.ttf and b/cps/static/css/fonts/glyphicons-halflings-regular.ttf differ diff --git a/cps/static/css/fonts/glyphicons-halflings-regular.woff b/cps/static/css/fonts/glyphicons-halflings-regular.woff index 2cc3e485..9e612858 100644 Binary files a/cps/static/css/fonts/glyphicons-halflings-regular.woff and b/cps/static/css/fonts/glyphicons-halflings-regular.woff differ diff --git a/cps/static/css/kthoom.css b/cps/static/css/kthoom.css index a6b41a32..770b94a2 100644 --- a/cps/static/css/kthoom.css +++ b/cps/static/css/kthoom.css @@ -1,32 +1,133 @@ body { - background: #444; - overflow: hidden; - color: white; - font-family: sans-serif; - margin: 0px; + background: #444; + overflow-x: hidden; + overflow-y: auto; + color: white; + font-family: sans-serif; + margin: 0px; } -.main { - position: re; - left: 5px; - overflow: hidden; - right: 5px; - text-align: center; - top: 5px; +#main { + text-align: center; + z-index: 2; +} + +.view { + padding-top:0px; +} + +#sidebar a, +#sidebar ul, +#sidebar li, +#sidebar li img { + max-width: 100%; + text-align: center; +} + +#sidebar ul { + position: relative; +} + +#sidebar a { + display: inline-block; + position: relative; + cursor: pointer; + padding: 4px; + + transition: all .2s ease; +} + +#sidebar a:hover, +#sidebar a:focus { + outline: none; + box-shadow: 0px 2px 8px 1px black; +} + +#sidebar a.active, +#sidebar a.active img + span { + background-color: #45B29D; +} + +#sidebar li img { + display: block; + max-height: 200px; +} + +#sidebar li img + span { + position: absolute; + bottom: 0; + right: 0; + padding: 2px; + min-width: 25px; + line-height: 25px; + background: #6b6b6b; + border-top-left-radius: 5px; +} + +#sidebar #panels { + z-index: 1; } #progress { - position: absolute; - display: inline; - left: 90px; - right: 160px; - height: 20px; - margin-top: 1px; - text-align: right; + position: absolute; + display: inline; + top: 0; + left: 0; + right: 0; + min-height: 4px; + font-family: sans-serif; + font-size: 10px; + line-height: 10px; + text-align: right; + + transition: min-height 150ms ease-in-out; +} + +#progress .bar-load, +#progress .bar-read { + display: flex; + align-items: flex-end; + justify-content: flex-end; + position: absolute; + top: 0; + left: 0; + bottom: 0; + + transition: width 150ms ease-in-out; +} + +#progress .bar-load { + color: #000; + background-color: #CCC; +} + +#progress .bar-read { + color: #FFF; + background-color: #45B29D; +} + +#progress .text { + display: none; + padding: 0 5px; +} + +#progress.loading, +#titlebar:hover #progress { + min-height: 10px; +} + +#progress.loading .text, +#titlebar:hover #progress .text { + display: inline-block; } .hide { - display: none !important; + display: none !important; +} + +#mainContent { + overflow: auto; + outline: none; } #mainText { @@ -42,29 +143,13 @@ body { word-wrap: break-word; } -#mainImage{ - margin-top: 32px; +#titlebar { + min-height: 25px; + height: auto; } -#titlebar.main { - opacity: 0; - position: absolute; - top: 0; - height: 30px; - left: 0; - right: 0; - background-color: black; - padding-bottom: 70px; - -webkit-transition: opacity 0.2s ease; - -moz-transition: opacity 0.2s ease; - transition: opacity 0.2s ease; - background: -moz-linear-gradient(top, rgba(0,2,34,1) 0%, rgba(0,1,24,1) 30%, rgba(0,0,0,0) 100%); /* FF3.6+ */ - background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(0,2,34,1)), color-stop(30%,rgba(0,1,24,1)), color-stop(100%,rgba(0,0,0,0))); /* Chrome,Safari4+ */ - background: -webkit-linear-gradient(top, rgba(0,2,34,1) 0%,rgba(0,1,24,1) 30%,rgba(0,0,0,0) 100%); /* Chrome10+,Safari5.1+ */ - background: -o-linear-gradient(top, rgba(0,2,34,1) 0%,rgba(0,1,24,1) 30%,rgba(0,0,0,0) 100%); /* Opera11.10+ */ - background: -ms-linear-gradient(top, rgba(0,2,34,1) 0%,rgba(0,1,24,1) 30%,rgba(0,0,0,0) 100%); /* IE10+ */ - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#000222', endColorstr='#00000000',GradientType=0 ); /* IE6-9 */ - background: linear-gradient(top, rgba(0,2,34,1) 0%,rgba(0,1,24,1) 30%,rgba(0,0,0,0) 100%); /* W3C */ +#metainfo { + max-width: 70%; } #prev { @@ -100,6 +185,72 @@ body { color: #000; } +th, td { + padding: 5px; +} + +th { + text-align: right; + vertical-align: top; +} + +.modal { + /* Makes the modal responsive. Note sure if this should be moved to main.css */ + margin: 0; + max-width: 96%; + transform: translate(-50%, -50%); +} +.md-content { + min-height: 320px; + height: auto; +} +.md-content > div { + overflow: hidden; +} +.md-content > div p { + padding: 5px 0; +} + +.settings-column { + float: left; + min-width: 35%; + padding-bottom: 10px; +} + +.inputs { + margin: -5px; +} + +.inputs input { + vertical-align: middle; +} + +.inputs label { + display: inline-block; + margin: 5px; + white-space: nowrap; +} + +.dark-theme #main { + background-color: #000; +} + +.dark-theme #titlebar { + color: #DDD; +} + +.dark-theme #titlebar a:active { + color: #FFF; +} + +.dark-theme #progress .bar-read { + background-color: red; +} + + +.dark-theme .overlay { + background-color: rgba(0,0,0,0.8); +} diff --git a/cps/static/css/libs/bootstrap-editable.css b/cps/static/css/libs/bootstrap-editable.css new file mode 100644 index 00000000..ff7ea50f --- /dev/null +++ b/cps/static/css/libs/bootstrap-editable.css @@ -0,0 +1,663 @@ +/*! X-editable - v1.5.3 +* In-place editing with Twitter Bootstrap, jQuery UI or pure jQuery +* http://github.com/vitalets/x-editable +* Copyright (c) 2015 Vitaliy Potapov; Licensed MIT */ +.editableform { + margin-bottom: 0; /* overwrites bootstrap margin */ +} + +.editableform .control-group { + margin-bottom: 0; /* overwrites bootstrap margin */ + white-space: nowrap; /* prevent wrapping buttons on new line */ + line-height: 20px; /* overwriting bootstrap line-height. See #133 */ +} + +/* + BS3 width:1005 for inputs breaks editable form in popup + See: https://github.com/vitalets/x-editable/issues/393 +*/ +.editableform .form-control { + width: auto; +} + +.editable-buttons { + display: inline-block; /* should be inline to take effect of parent's white-space: nowrap */ + vertical-align: top; + margin-left: 7px; + /* inline-block emulation for IE7*/ + zoom: 1; + *display: inline; +} + +.editable-buttons.editable-buttons-bottom { + display: block; + margin-top: 7px; + margin-left: 0; +} + +.editable-input { + vertical-align: top; + display: inline-block; /* should be inline to take effect of parent's white-space: nowrap */ + width: auto; /* bootstrap-responsive has width: 100% that breakes layout */ + white-space: normal; /* reset white-space decalred in parent*/ + /* display-inline emulation for IE7*/ + zoom: 1; + *display: inline; +} + +.editable-buttons .editable-cancel { + margin-left: 7px; +} + +/*for jquery-ui buttons need set height to look more pretty*/ +.editable-buttons button.ui-button-icon-only { + height: 24px; + width: 30px; +} + +.editableform-loading { + background: url('../img/loading.gif') center center no-repeat; + height: 25px; + width: auto; + min-width: 25px; +} + +.editable-inline .editableform-loading { + background-position: left 5px; +} + + .editable-error-block { + max-width: 300px; + margin: 5px 0 0 0; + width: auto; + white-space: normal; +} + +/*add padding for jquery ui*/ +.editable-error-block.ui-state-error { + padding: 3px; +} + +.editable-error { + color: red; +} + +/* ---- For specific types ---- */ + +.editableform .editable-date { + padding: 0; + margin: 0; + float: left; +} + +/* move datepicker icon to center of add-on button. See https://github.com/vitalets/x-editable/issues/183 */ +.editable-inline .add-on .icon-th { + margin-top: 3px; + margin-left: 1px; +} + + +/* checklist vertical alignment */ +.editable-checklist label input[type="checkbox"], +.editable-checklist label span { + vertical-align: middle; + margin: 0; +} + +.editable-checklist label { + white-space: nowrap; +} + +/* set exact width of textarea to fit buttons toolbar */ +.editable-wysihtml5 { + width: 566px; + height: 250px; +} + +/* clear button shown as link in date inputs */ +.editable-clear { + clear: both; + font-size: 0.9em; + text-decoration: none; + text-align: right; +} + +/* IOS-style clear button for text inputs */ +.editable-clear-x { + background: url('../img/clear.png') center center no-repeat; + display: block; + width: 13px; + height: 13px; + position: absolute; + opacity: 0.6; + z-index: 100; + + top: 50%; + right: 6px; + margin-top: -6px; + +} + +.editable-clear-x:hover { + opacity: 1; +} + +.editable-pre-wrapped { + white-space: pre-wrap; +} +.editable-container.editable-popup { + max-width: none !important; /* without this rule poshytip/tooltip does not stretch */ +} + +.editable-container.popover { + width: auto; /* without this rule popover does not stretch */ +} + +.editable-container.editable-inline { + display: inline-block; + vertical-align: middle; + width: auto; + /* inline-block emulation for IE7*/ + zoom: 1; + *display: inline; +} + +.editable-container.ui-widget { + font-size: inherit; /* jqueryui widget font 1.1em too big, overwrite it */ + z-index: 9990; /* should be less than select2 dropdown z-index to close dropdown first when click */ +} +.editable-click, +a.editable-click, +a.editable-click:hover { + text-decoration: none; + border-bottom: dashed 1px #0088cc; +} + +.editable-click.editable-disabled, +a.editable-click.editable-disabled, +a.editable-click.editable-disabled:hover { + color: #585858; + cursor: default; + border-bottom: none; +} + +.editable-empty, .editable-empty:hover, .editable-empty:focus{ + font-style: italic; + color: #DD1144; + /* border-bottom: none; */ + text-decoration: none; +} + +.editable-unsaved { + font-weight: bold; +} + +.editable-unsaved:after { +/* content: '*'*/ +} + +.editable-bg-transition { + -webkit-transition: background-color 1400ms ease-out; + -moz-transition: background-color 1400ms ease-out; + -o-transition: background-color 1400ms ease-out; + -ms-transition: background-color 1400ms ease-out; + transition: background-color 1400ms ease-out; +} + +/*see https://github.com/vitalets/x-editable/issues/139 */ +.form-horizontal .editable +{ + padding-top: 5px; + display:inline-block; +} + + +/*! + * Datepicker for Bootstrap + * + * Copyright 2012 Stefan Petre + * Improvements by Andrew Rowls + * Licensed under the Apache License v2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + */ +.datepicker { + padding: 4px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + direction: ltr; + /*.dow { + border-top: 1px solid #ddd !important; + }*/ + +} +.datepicker-inline { + width: 220px; +} +.datepicker.datepicker-rtl { + direction: rtl; +} +.datepicker.datepicker-rtl table tr td span { + float: right; +} +.datepicker-dropdown { + top: 0; + left: 0; +} +.datepicker-dropdown:before { + content: ''; + display: inline-block; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid #ccc; + border-bottom-color: rgba(0, 0, 0, 0.2); + position: absolute; + top: -7px; + left: 6px; +} +.datepicker-dropdown:after { + content: ''; + display: inline-block; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid #ffffff; + position: absolute; + top: -6px; + left: 7px; +} +.datepicker > div { + display: none; +} +.datepicker.days div.datepicker-days { + display: block; +} +.datepicker.months div.datepicker-months { + display: block; +} +.datepicker.years div.datepicker-years { + display: block; +} +.datepicker table { + margin: 0; +} +.datepicker td, +.datepicker th { + text-align: center; + width: 20px; + height: 20px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + border: none; +} +.table-striped .datepicker table tr td, +.table-striped .datepicker table tr th { + background-color: transparent; +} +.datepicker table tr td.day:hover { + background: #eeeeee; + cursor: pointer; +} +.datepicker table tr td.old, +.datepicker table tr td.new { + color: #999999; +} +.datepicker table tr td.disabled, +.datepicker table tr td.disabled:hover { + background: none; + color: #999999; + cursor: default; +} +.datepicker table tr td.today, +.datepicker table tr td.today:hover, +.datepicker table tr td.today.disabled, +.datepicker table tr td.today.disabled:hover { + background-color: #fde19a; + background-image: -moz-linear-gradient(top, #fdd49a, #fdf59a); + background-image: -ms-linear-gradient(top, #fdd49a, #fdf59a); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#fdd49a), to(#fdf59a)); + background-image: -webkit-linear-gradient(top, #fdd49a, #fdf59a); + background-image: -o-linear-gradient(top, #fdd49a, #fdf59a); + background-image: linear-gradient(top, #fdd49a, #fdf59a); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fdd49a', endColorstr='#fdf59a', GradientType=0); + border-color: #fdf59a #fdf59a #fbed50; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); + color: #000; +} +.datepicker table tr td.today:hover, +.datepicker table tr td.today:hover:hover, +.datepicker table tr td.today.disabled:hover, +.datepicker table tr td.today.disabled:hover:hover, +.datepicker table tr td.today:active, +.datepicker table tr td.today:hover:active, +.datepicker table tr td.today.disabled:active, +.datepicker table tr td.today.disabled:hover:active, +.datepicker table tr td.today.active, +.datepicker table tr td.today:hover.active, +.datepicker table tr td.today.disabled.active, +.datepicker table tr td.today.disabled:hover.active, +.datepicker table tr td.today.disabled, +.datepicker table tr td.today:hover.disabled, +.datepicker table tr td.today.disabled.disabled, +.datepicker table tr td.today.disabled:hover.disabled, +.datepicker table tr td.today[disabled], +.datepicker table tr td.today:hover[disabled], +.datepicker table tr td.today.disabled[disabled], +.datepicker table tr td.today.disabled:hover[disabled] { + background-color: #fdf59a; +} +.datepicker table tr td.today:active, +.datepicker table tr td.today:hover:active, +.datepicker table tr td.today.disabled:active, +.datepicker table tr td.today.disabled:hover:active, +.datepicker table tr td.today.active, +.datepicker table tr td.today:hover.active, +.datepicker table tr td.today.disabled.active, +.datepicker table tr td.today.disabled:hover.active { + background-color: #fbf069 \9; +} +.datepicker table tr td.today:hover:hover { + color: #000; +} +.datepicker table tr td.today.active:hover { + color: #fff; +} +.datepicker table tr td.range, +.datepicker table tr td.range:hover, +.datepicker table tr td.range.disabled, +.datepicker table tr td.range.disabled:hover { + background: #eeeeee; + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; +} +.datepicker table tr td.range.today, +.datepicker table tr td.range.today:hover, +.datepicker table tr td.range.today.disabled, +.datepicker table tr td.range.today.disabled:hover { + background-color: #f3d17a; + background-image: -moz-linear-gradient(top, #f3c17a, #f3e97a); + background-image: -ms-linear-gradient(top, #f3c17a, #f3e97a); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f3c17a), to(#f3e97a)); + background-image: -webkit-linear-gradient(top, #f3c17a, #f3e97a); + background-image: -o-linear-gradient(top, #f3c17a, #f3e97a); + background-image: linear-gradient(top, #f3c17a, #f3e97a); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#f3c17a', endColorstr='#f3e97a', GradientType=0); + border-color: #f3e97a #f3e97a #edde34; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; +} +.datepicker table tr td.range.today:hover, +.datepicker table tr td.range.today:hover:hover, +.datepicker table tr td.range.today.disabled:hover, +.datepicker table tr td.range.today.disabled:hover:hover, +.datepicker table tr td.range.today:active, +.datepicker table tr td.range.today:hover:active, +.datepicker table tr td.range.today.disabled:active, +.datepicker table tr td.range.today.disabled:hover:active, +.datepicker table tr td.range.today.active, +.datepicker table tr td.range.today:hover.active, +.datepicker table tr td.range.today.disabled.active, +.datepicker table tr td.range.today.disabled:hover.active, +.datepicker table tr td.range.today.disabled, +.datepicker table tr td.range.today:hover.disabled, +.datepicker table tr td.range.today.disabled.disabled, +.datepicker table tr td.range.today.disabled:hover.disabled, +.datepicker table tr td.range.today[disabled], +.datepicker table tr td.range.today:hover[disabled], +.datepicker table tr td.range.today.disabled[disabled], +.datepicker table tr td.range.today.disabled:hover[disabled] { + background-color: #f3e97a; +} +.datepicker table tr td.range.today:active, +.datepicker table tr td.range.today:hover:active, +.datepicker table tr td.range.today.disabled:active, +.datepicker table tr td.range.today.disabled:hover:active, +.datepicker table tr td.range.today.active, +.datepicker table tr td.range.today:hover.active, +.datepicker table tr td.range.today.disabled.active, +.datepicker table tr td.range.today.disabled:hover.active { + background-color: #efe24b \9; +} +.datepicker table tr td.selected, +.datepicker table tr td.selected:hover, +.datepicker table tr td.selected.disabled, +.datepicker table tr td.selected.disabled:hover { + background-color: #9e9e9e; + background-image: -moz-linear-gradient(top, #b3b3b3, #808080); + background-image: -ms-linear-gradient(top, #b3b3b3, #808080); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#b3b3b3), to(#808080)); + background-image: -webkit-linear-gradient(top, #b3b3b3, #808080); + background-image: -o-linear-gradient(top, #b3b3b3, #808080); + background-image: linear-gradient(top, #b3b3b3, #808080); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#b3b3b3', endColorstr='#808080', GradientType=0); + border-color: #808080 #808080 #595959; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); + color: #fff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.datepicker table tr td.selected:hover, +.datepicker table tr td.selected:hover:hover, +.datepicker table tr td.selected.disabled:hover, +.datepicker table tr td.selected.disabled:hover:hover, +.datepicker table tr td.selected:active, +.datepicker table tr td.selected:hover:active, +.datepicker table tr td.selected.disabled:active, +.datepicker table tr td.selected.disabled:hover:active, +.datepicker table tr td.selected.active, +.datepicker table tr td.selected:hover.active, +.datepicker table tr td.selected.disabled.active, +.datepicker table tr td.selected.disabled:hover.active, +.datepicker table tr td.selected.disabled, +.datepicker table tr td.selected:hover.disabled, +.datepicker table tr td.selected.disabled.disabled, +.datepicker table tr td.selected.disabled:hover.disabled, +.datepicker table tr td.selected[disabled], +.datepicker table tr td.selected:hover[disabled], +.datepicker table tr td.selected.disabled[disabled], +.datepicker table tr td.selected.disabled:hover[disabled] { + background-color: #808080; +} +.datepicker table tr td.selected:active, +.datepicker table tr td.selected:hover:active, +.datepicker table tr td.selected.disabled:active, +.datepicker table tr td.selected.disabled:hover:active, +.datepicker table tr td.selected.active, +.datepicker table tr td.selected:hover.active, +.datepicker table tr td.selected.disabled.active, +.datepicker table tr td.selected.disabled:hover.active { + background-color: #666666 \9; +} +.datepicker table tr td.active, +.datepicker table tr td.active:hover, +.datepicker table tr td.active.disabled, +.datepicker table tr td.active.disabled:hover { + background-color: #006dcc; + background-image: -moz-linear-gradient(top, #0088cc, #0044cc); + background-image: -ms-linear-gradient(top, #0088cc, #0044cc); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); + background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); + background-image: -o-linear-gradient(top, #0088cc, #0044cc); + background-image: linear-gradient(top, #0088cc, #0044cc); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0); + border-color: #0044cc #0044cc #002a80; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); + color: #fff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.datepicker table tr td.active:hover, +.datepicker table tr td.active:hover:hover, +.datepicker table tr td.active.disabled:hover, +.datepicker table tr td.active.disabled:hover:hover, +.datepicker table tr td.active:active, +.datepicker table tr td.active:hover:active, +.datepicker table tr td.active.disabled:active, +.datepicker table tr td.active.disabled:hover:active, +.datepicker table tr td.active.active, +.datepicker table tr td.active:hover.active, +.datepicker table tr td.active.disabled.active, +.datepicker table tr td.active.disabled:hover.active, +.datepicker table tr td.active.disabled, +.datepicker table tr td.active:hover.disabled, +.datepicker table tr td.active.disabled.disabled, +.datepicker table tr td.active.disabled:hover.disabled, +.datepicker table tr td.active[disabled], +.datepicker table tr td.active:hover[disabled], +.datepicker table tr td.active.disabled[disabled], +.datepicker table tr td.active.disabled:hover[disabled] { + background-color: #0044cc; +} +.datepicker table tr td.active:active, +.datepicker table tr td.active:hover:active, +.datepicker table tr td.active.disabled:active, +.datepicker table tr td.active.disabled:hover:active, +.datepicker table tr td.active.active, +.datepicker table tr td.active:hover.active, +.datepicker table tr td.active.disabled.active, +.datepicker table tr td.active.disabled:hover.active { + background-color: #003399 \9; +} +.datepicker table tr td span { + display: block; + width: 23%; + height: 54px; + line-height: 54px; + float: left; + margin: 1%; + cursor: pointer; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.datepicker table tr td span:hover { + background: #eeeeee; +} +.datepicker table tr td span.disabled, +.datepicker table tr td span.disabled:hover { + background: none; + color: #999999; + cursor: default; +} +.datepicker table tr td span.active, +.datepicker table tr td span.active:hover, +.datepicker table tr td span.active.disabled, +.datepicker table tr td span.active.disabled:hover { + background-color: #006dcc; + background-image: -moz-linear-gradient(top, #0088cc, #0044cc); + background-image: -ms-linear-gradient(top, #0088cc, #0044cc); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); + background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); + background-image: -o-linear-gradient(top, #0088cc, #0044cc); + background-image: linear-gradient(top, #0088cc, #0044cc); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0); + border-color: #0044cc #0044cc #002a80; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); + color: #fff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.datepicker table tr td span.active:hover, +.datepicker table tr td span.active:hover:hover, +.datepicker table tr td span.active.disabled:hover, +.datepicker table tr td span.active.disabled:hover:hover, +.datepicker table tr td span.active:active, +.datepicker table tr td span.active:hover:active, +.datepicker table tr td span.active.disabled:active, +.datepicker table tr td span.active.disabled:hover:active, +.datepicker table tr td span.active.active, +.datepicker table tr td span.active:hover.active, +.datepicker table tr td span.active.disabled.active, +.datepicker table tr td span.active.disabled:hover.active, +.datepicker table tr td span.active.disabled, +.datepicker table tr td span.active:hover.disabled, +.datepicker table tr td span.active.disabled.disabled, +.datepicker table tr td span.active.disabled:hover.disabled, +.datepicker table tr td span.active[disabled], +.datepicker table tr td span.active:hover[disabled], +.datepicker table tr td span.active.disabled[disabled], +.datepicker table tr td span.active.disabled:hover[disabled] { + background-color: #0044cc; +} +.datepicker table tr td span.active:active, +.datepicker table tr td span.active:hover:active, +.datepicker table tr td span.active.disabled:active, +.datepicker table tr td span.active.disabled:hover:active, +.datepicker table tr td span.active.active, +.datepicker table tr td span.active:hover.active, +.datepicker table tr td span.active.disabled.active, +.datepicker table tr td span.active.disabled:hover.active { + background-color: #003399 \9; +} +.datepicker table tr td span.old, +.datepicker table tr td span.new { + color: #999999; +} +.datepicker th.datepicker-switch { + width: 145px; +} +.datepicker thead tr:first-child th, +.datepicker tfoot tr th { + cursor: pointer; +} +.datepicker thead tr:first-child th:hover, +.datepicker tfoot tr th:hover { + background: #eeeeee; +} +.datepicker .cw { + font-size: 10px; + width: 12px; + padding: 0 2px 0 5px; + vertical-align: middle; +} +.datepicker thead tr:first-child th.cw { + cursor: default; + background-color: transparent; +} +.input-append.date .add-on i, +.input-prepend.date .add-on i { + display: block; + cursor: pointer; + width: 16px; + height: 16px; +} +.input-daterange input { + text-align: center; +} +.input-daterange input:first-child { + -webkit-border-radius: 3px 0 0 3px; + -moz-border-radius: 3px 0 0 3px; + border-radius: 3px 0 0 3px; +} +.input-daterange input:last-child { + -webkit-border-radius: 0 3px 3px 0; + -moz-border-radius: 0 3px 3px 0; + border-radius: 0 3px 3px 0; +} +.input-daterange .add-on { + display: inline-block; + width: auto; + min-width: 16px; + height: 18px; + padding: 4px 5px; + font-weight: normal; + line-height: 18px; + text-align: center; + text-shadow: 0 1px 0 #ffffff; + vertical-align: middle; + background-color: #eeeeee; + border: 1px solid #ccc; + margin-left: -5px; + margin-right: -5px; +} diff --git a/cps/static/css/libs/bootstrap-table.min.css b/cps/static/css/libs/bootstrap-table.min.css new file mode 100644 index 00000000..770b6728 --- /dev/null +++ b/cps/static/css/libs/bootstrap-table.min.css @@ -0,0 +1 @@ +.fixed-table-container .bs-checkbox,.fixed-table-container .no-records-found{text-align:center}.fixed-table-body thead th .th-inner,.table td,.table th{box-sizing:border-box}.bootstrap-table .table{margin-bottom:0!important;border-bottom:1px solid #ddd;border-collapse:collapse!important;border-radius:1px}.bootstrap-table .table:not(.table-condensed),.bootstrap-table .table:not(.table-condensed)>tbody>tr>td,.bootstrap-table .table:not(.table-condensed)>tbody>tr>th,.bootstrap-table .table:not(.table-condensed)>tfoot>tr>td,.bootstrap-table .table:not(.table-condensed)>tfoot>tr>th,.bootstrap-table .table:not(.table-condensed)>thead>tr>td{padding:8px}.bootstrap-table .table.table-no-bordered>tbody>tr>td,.bootstrap-table .table.table-no-bordered>thead>tr>th{border-right:2px solid transparent}.bootstrap-table .table.table-no-bordered>tbody>tr>td:last-child{border-right:none}.fixed-table-container{position:relative;clear:both;border:1px solid #ddd;border-radius:4px;-webkit-border-radius:4px;-moz-border-radius:4px}.fixed-table-container.table-no-bordered{border:1px solid transparent}.fixed-table-footer,.fixed-table-header{overflow:hidden}.fixed-table-footer{border-top:1px solid #ddd}.fixed-table-body{overflow-x:auto;overflow-y:auto;height:100%}.fixed-table-container table{width:100%}.fixed-table-container thead th{height:0;padding:0;margin:0;border-left:1px solid #ddd}.fixed-table-container thead th:focus{outline:transparent solid 0}.fixed-table-container thead th:first-child:not([data-not-first-th]){border-left:none;border-top-left-radius:4px;-webkit-border-top-left-radius:4px;-moz-border-radius-topleft:4px}.fixed-table-container tbody td .th-inner,.fixed-table-container thead th .th-inner{padding:8px;line-height:24px;vertical-align:top;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fixed-table-container thead th .sortable{cursor:pointer;background-position:right;background-repeat:no-repeat;padding-right:30px}.fixed-table-container thead th .both{background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAQAAADYWf5HAAAAkElEQVQoz7X QMQ5AQBCF4dWQSJxC5wwax1Cq1e7BAdxD5SL+Tq/QCM1oNiJidwox0355mXnG/DrEtIQ6azioNZQxI0ykPhTQIwhCR+BmBYtlK7kLJYwWCcJA9M4qdrZrd8pPjZWPtOqdRQy320YSV17OatFC4euts6z39GYMKRPCTKY9UnPQ6P+GtMRfGtPnBCiqhAeJPmkqAAAAAElFTkSuQmCC')}.fixed-table-container thead th .asc{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZ0lEQVQ4y2NgGLKgquEuFxBPAGI2ahhWCsS/gDibUoO0gPgxEP8H4ttArEyuQYxAPBdqEAxPBImTY5gjEL9DM+wTENuQahAvEO9DMwiGdwAxOymGJQLxTyD+jgWDxCMZRsEoGAVoAADeemwtPcZI2wAAAABJRU5ErkJggg==)}.fixed-table-container thead th .desc{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZUlEQVQ4y2NgGAWjYBSggaqGu5FA/BOIv2PBIPFEUgxjB+IdQPwfC94HxLykus4GiD+hGfQOiB3J8SojEE9EM2wuSJzcsFMG4ttQgx4DsRalkZENxL+AuJQaMcsGxBOAmGvopk8AVz1sLZgg0bsAAAAASUVORK5CYII=)}.fixed-table-container th.detail{width:30px}.fixed-table-container tbody td{border-left:1px solid #ddd}.fixed-table-container tbody tr:first-child td{border-top:none}.fixed-table-container tbody td:first-child{border-left:none}.fixed-table-container tbody .selected td{background-color:#f5f5f5}.fixed-table-container input[type=radio],.fixed-table-container input[type=checkbox]{margin:0 auto!important}.fixed-table-pagination .pagination-detail,.fixed-table-pagination div.pagination{margin-top:10px;margin-bottom:10px}.fixed-table-pagination div.pagination .pagination{margin:0}.fixed-table-pagination .pagination a{padding:6px 12px;line-height:1.428571429}.fixed-table-pagination .pagination-info{line-height:34px;margin-right:5px}.fixed-table-pagination .btn-group{position:relative;display:inline-block;vertical-align:middle}.fixed-table-pagination .dropup .dropdown-menu{margin-bottom:0}.fixed-table-pagination .page-list{display:inline-block}.fixed-table-toolbar .columns-left{margin-right:5px}.fixed-table-toolbar .columns-right{margin-left:5px}.fixed-table-toolbar .columns label{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.428571429}.fixed-table-toolbar .bs-bars,.fixed-table-toolbar .columns,.fixed-table-toolbar .search{position:relative;margin-top:10px;margin-bottom:10px;line-height:34px}.fixed-table-pagination li.disabled a{pointer-events:none;cursor:default}.fixed-table-loading{display:none;position:absolute;top:42px;right:0;bottom:0;left:0;z-index:99;background-color:#fff;text-align:center}.fixed-table-body .card-view .title{font-weight:700;display:inline-block;min-width:30%;text-align:left!important}.table td,.table th{vertical-align:middle}.fixed-table-toolbar .dropdown-menu{text-align:left;max-height:300px;overflow:auto}.fixed-table-toolbar .btn-group>.btn-group{display:inline-block;margin-left:-1px!important}.fixed-table-toolbar .btn-group>.btn-group>.btn{border-radius:0}.fixed-table-toolbar .btn-group>.btn-group:first-child>.btn{border-top-left-radius:4px;border-bottom-left-radius:4px}.fixed-table-toolbar .btn-group>.btn-group:last-child>.btn{border-top-right-radius:4px;border-bottom-right-radius:4px}.bootstrap-table .table>thead>tr>th{vertical-align:bottom;border-bottom:1px solid #ddd}.bootstrap-table .table thead>tr>th{padding:0;margin:0}.bootstrap-table .fixed-table-footer tbody>tr>td{padding:0!important}.bootstrap-table .fixed-table-footer .table{border-bottom:none;border-radius:0;padding:0!important}.bootstrap-table .pull-right .dropdown-menu{right:0;left:auto}p.fixed-table-scroll-inner{width:100%;height:200px}div.fixed-table-scroll-outer{top:0;left:0;visibility:hidden;width:200px;height:150px;overflow:hidden}.fixed-table-pagination:after,.fixed-table-toolbar:after{content:"";display:block;clear:both}.fullscreen{position:fixed;top:0;left:0;z-index:1050;width:100%!important;background:#FFF} \ No newline at end of file diff --git a/cps/static/css/style.css b/cps/static/css/style.css index a89291e5..16c3cb36 100644 --- a/cps/static/css/style.css +++ b/cps/static/css/style.css @@ -48,7 +48,7 @@ span.glyphicon.glyphicon-tags {padding-right: 5px;color: #999;vertical-align: te .btn-primary:hover, .btn-primary:focus, .btn-primary:active, .btn-primary.active, .open .dropdown-toggle.btn-primary{ background-color: #1C5484; } .btn-primary.disabled, .btn-primary[disabled], fieldset[disabled] .btn-primary, .btn-primary.disabled:hover, .btn-primary[disabled]:hover, fieldset[disabled] .btn-primary:hover, .btn-primary.disabled:focus, .btn-primary[disabled]:focus, fieldset[disabled] .btn-primary:focus, .btn-primary.disabled:active, .btn-primary[disabled]:active, fieldset[disabled] .btn-primary:active, .btn-primary.disabled.active, .btn-primary[disabled].active, fieldset[disabled] .btn-primary.active { background-color: #89B9E2; } .btn-toolbar>.btn+.btn, .btn-toolbar>.btn-group+.btn, .btn-toolbar>.btn+.btn-group, .btn-toolbar>.btn-group+.btn-group { margin-left:0px; } - +.panel-body {background-color: #f5f5f5;} .spinner {margin:0 41%;} .spinner2 {margin:0 41%;} @@ -94,5 +94,13 @@ input.pill:not(:checked) + label .glyphicon { .upload-format-input-text {display: initial;} #btn-upload-format {display: none;} -.upload-format-input-text {display: initial;} -#btn-upload-format {display: none;} +.upload-cover-input-text {display: initial;} +#btn-upload-cover {display: none;} + +.panel-title > a { text-decoration: none;} + +.editable-buttons { display:inline-block; margin-left: 7px ;} +.editable-input { display:inline-block;} +.editable-cancel { margin-bottom: 0px !important; margin-left: 7px !important;} +.editable-submit { margin-bottom: 0px !important;} + diff --git a/cps/static/img/.gitignore b/cps/static/img/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/cps/static/js/archive.js b/cps/static/js/archive.js index 28aae182..b859513f 100644 --- a/cps/static/js/archive.js +++ b/cps/static/js/archive.js @@ -143,13 +143,12 @@ bitjs.archive = bitjs.archive || {}; * Progress event. */ bitjs.archive.UnarchiveProgressEvent = function( - currentFilename, - currentFileNumber, - currentBytesUnarchivedInFile, - currentBytesUnarchived, - totalUncompressedBytesInArchive, - totalFilesInArchive) - { + currentFilename, + currentFileNumber, + currentBytesUnarchivedInFile, + currentBytesUnarchived, + totalUncompressedBytesInArchive, + totalFilesInArchive) { bitjs.base(this, bitjs.archive.UnarchiveEvent.Type.PROGRESS); this.currentFilename = currentFilename; diff --git a/cps/static/js/bytestream.js b/cps/static/js/bytestream.js new file mode 100644 index 00000000..cb5df363 --- /dev/null +++ b/cps/static/js/bytestream.js @@ -0,0 +1,308 @@ +/* + * bytestream.js + * + * Provides readers for byte streams. + * + * Licensed under the MIT License + * + * Copyright(c) 2011 Google Inc. + * Copyright(c) 2011 antimatter15 + */ + +var bitjs = bitjs || {}; +bitjs.io = bitjs.io || {}; + + +/** + * This object allows you to peek and consume bytes as numbers and strings out + * of a stream. More bytes can be pushed into the back of the stream via the + * push() method. + */ +bitjs.io.ByteStream = class { + /** + * @param {ArrayBuffer} ab The ArrayBuffer object. + * @param {number=} opt_offset The offset into the ArrayBuffer + * @param {number=} opt_length The length of this BitStream + */ + constructor(ab, opt_offset, opt_length) { + if (!(ab instanceof ArrayBuffer)) { + throw 'Error! BitArray constructed with an invalid ArrayBuffer object'; + } + + const offset = opt_offset || 0; + const length = opt_length || ab.byteLength; + + /** + * The current page of bytes in the stream. + * @type {Uint8Array} + * @private + */ + this.bytes = new Uint8Array(ab, offset, length); + + /** + * The next pages of bytes in the stream. + * @type {Array} + * @private + */ + this.pages_ = []; + + /** + * The byte in the current page that we will read next. + * @type {Number} + * @private + */ + this.ptr = 0; + + /** + * An ever-increasing number. + * @type {Number} + * @private + */ + this.bytesRead_ = 0; + } + + /** + * Returns how many bytes have been read in the stream since the beginning of time. + */ + getNumBytesRead() { + return this.bytesRead_; + } + + /** + * Returns how many bytes are currently in the stream left to be read. + */ + getNumBytesLeft() { + const bytesInCurrentPage = (this.bytes.byteLength - this.ptr); + return this.pages_.reduce((acc, arr) => acc + arr.length, bytesInCurrentPage); + } + + /** + * Move the pointer ahead n bytes. If the pointer is at the end of the current array + * of bytes and we have another page of bytes, point at the new page. This is a private + * method, no validation is done. + * @param {number} n Number of bytes to increment. + * @private + */ + movePointer_(n) { + this.ptr += n; + this.bytesRead_ += n; + while (this.ptr >= this.bytes.length && this.pages_.length > 0) { + this.ptr -= this.bytes.length; + this.bytes = this.pages_.shift(); + } + } + + /** + * Peeks at the next n bytes as an unsigned number but does not advance the + * pointer. + * @param {number} n The number of bytes to peek at. Must be a positive integer. + * @return {number} The n bytes interpreted as an unsigned number. + */ + peekNumber(n) { + const num = parseInt(n, 10); + if (n !== num || num < 0) { + throw 'Error! Called peekNumber() with a non-positive integer'; + } else if (num === 0) { + return 0; + } + + if (n > 4) { + throw 'Error! Called peekNumber(' + n + + ') but this method can only reliably read numbers up to 4 bytes long'; + } + + if (this.getNumBytesLeft() < num) { + throw 'Error! Overflowed the byte stream while peekNumber()! n=' + num + + ', ptr=' + this.ptr + ', bytes.length=' + this.getNumBytesLeft(); + } + + let result = 0; + let curPage = this.bytes; + let pageIndex = 0; + let ptr = this.ptr; + for (let i = 0; i < num; ++i) { + result |= (curPage[ptr++] << (i * 8)); + + if (ptr >= curPage.length) { + curPage = this.pages_[pageIndex++]; + ptr = 0; + } + } + + return result; + } + + + /** + * Returns the next n bytes as an unsigned number (or -1 on error) + * and advances the stream pointer n bytes. + * @param {number} n The number of bytes to read. Must be a positive integer. + * @return {number} The n bytes interpreted as an unsigned number. + */ + readNumber(n) { + const num = this.peekNumber(n); + this.movePointer_(n); + return num; + } + + + /** + * Returns the next n bytes as a signed number but does not advance the + * pointer. + * @param {number} n The number of bytes to read. Must be a positive integer. + * @return {number} The bytes interpreted as a signed number. + */ + peekSignedNumber(n) { + let num = this.peekNumber(n); + const HALF = Math.pow(2, (n * 8) - 1); + const FULL = HALF * 2; + + if (num >= HALF) num -= FULL; + + return num; + } + + + /** + * Returns the next n bytes as a signed number and advances the stream pointer. + * @param {number} n The number of bytes to read. Must be a positive integer. + * @return {number} The bytes interpreted as a signed number. + */ + readSignedNumber(n) { + const num = this.peekSignedNumber(n); + this.movePointer_(n); + return num; + } + + + /** + * This returns n bytes as a sub-array, advancing the pointer if movePointers + * is true. + * @param {number} n The number of bytes to read. Must be a positive integer. + * @param {boolean} movePointers Whether to move the pointers. + * @return {Uint8Array} The subarray. + */ + peekBytes(n, movePointers) { + const num = parseInt(n, 10); + if (n !== num || num < 0) { + throw 'Error! Called peekBytes() with a non-positive integer'; + } else if (num === 0) { + return new Uint8Array(); + } + + const totalBytesLeft = this.getNumBytesLeft(); + if (num > totalBytesLeft) { + throw 'Error! Overflowed the byte stream during peekBytes! n=' + num + + ', ptr=' + this.ptr + ', bytes.length=' + this.getNumBytesLeft(); + } + + const result = new Uint8Array(num); + let curPage = this.bytes; + let ptr = this.ptr; + let bytesLeftToCopy = num; + let pageIndex = 0; + while (bytesLeftToCopy > 0) { + const bytesLeftInPage = curPage.length - ptr; + const sourceLength = Math.min(bytesLeftToCopy, bytesLeftInPage); + + result.set(curPage.subarray(ptr, ptr + sourceLength), num - bytesLeftToCopy); + + ptr += sourceLength; + if (ptr >= curPage.length) { + curPage = this.pages_[pageIndex++]; + ptr = 0; + } + + bytesLeftToCopy -= sourceLength; + } + + if (movePointers) { + this.movePointer_(num); + } + + return result; + } + + /** + * Reads the next n bytes as a sub-array. + * @param {number} n The number of bytes to read. Must be a positive integer. + * @return {Uint8Array} The subarray. + */ + readBytes(n) { + return this.peekBytes(n, true); + } + + /** + * Peeks at the next n bytes as an ASCII string but does not advance the pointer. + * @param {number} n The number of bytes to peek at. Must be a positive integer. + * @return {string} The next n bytes as a string. + */ + peekString(n) { + const num = parseInt(n, 10); + if (n !== num || num < 0) { + throw 'Error! Called peekString() with a non-positive integer'; + } else if (num === 0) { + return ''; + } + + const totalBytesLeft = this.getNumBytesLeft(); + if (num > totalBytesLeft) { + throw 'Error! Overflowed the byte stream while peekString()! n=' + num + + ', ptr=' + this.ptr + ', bytes.length=' + this.getNumBytesLeft(); + } + + let result = new Array(num); + let curPage = this.bytes; + let pageIndex = 0; + let ptr = this.ptr; + for (let i = 0; i < num; ++i) { + result[i] = String.fromCharCode(curPage[ptr++]); + if (ptr >= curPage.length) { + curPage = this.pages_[pageIndex++]; + ptr = 0; + } + } + + return result.join(''); + } + + /** + * Returns the next n bytes as an ASCII string and advances the stream pointer + * n bytes. + * @param {number} n The number of bytes to read. Must be a positive integer. + * @return {string} The next n bytes as a string. + */ + readString(n) { + const strToReturn = this.peekString(n); + this.movePointer_(n); + return strToReturn; + } + + /** + * Feeds more bytes into the back of the stream. + * @param {ArrayBuffer} ab + */ + push(ab) { + if (!(ab instanceof ArrayBuffer)) { + throw 'Error! ByteStream.push() called with an invalid ArrayBuffer object'; + } + + this.pages_.push(new Uint8Array(ab)); + // If the pointer is at the end of the current page of bytes, this will advance + // to the next page. + this.movePointer_(0); + } + + /** + * Creates a new ByteStream from this ByteStream that can be read / peeked. + * @return {bitjs.io.ByteStream} A clone of this ByteStream. + */ + tee() { + const clone = new bitjs.io.ByteStream(this.bytes.buffer); + clone.bytes = this.bytes; + clone.ptr = this.ptr; + clone.pages_ = this.pages_.slice(); + clone.bytesRead_ = this.bytesRead_; + return clone; + } +} diff --git a/cps/static/js/edit_books.js b/cps/static/js/edit_books.js index dd907c47..1d182887 100644 --- a/cps/static/js/edit_books.js +++ b/cps/static/js/edit_books.js @@ -80,7 +80,7 @@ function prefixedSource(prefix, query, cb, bhAdapter) { function getPath() { var jsFileLocation = $("script[src*=edit_books]").attr("src"); // the js file path - return jsFileLocation.substr(0,jsFileLocation.search("/static/js/edit_books.js")); // the js folder path + return jsFileLocation.substr(0, jsFileLocation.search("/static/js/edit_books.js")); // the js folder path } var authors = new Bloodhound({ @@ -246,3 +246,12 @@ $("#btn-upload-format").on("change", function () { } // Remove c:\fake at beginning from localhost chrome $("#upload-format").html(filename); }); + +$("#btn-upload-cover").on("change", function () { + var filename = $(this).val(); + if (filename.substring(3, 11) === "fakepath") { + filename = filename.substring(12); + } // Remove c:\fake at beginning from localhost chrome + $("#upload-cover").html(filename); +}); + diff --git a/cps/static/js/io.js b/cps/static/js/io.js index 6cc4d81c..292f5f95 100644 --- a/cps/static/js/io.js +++ b/cps/static/js/io.js @@ -121,7 +121,7 @@ bitjs.io = bitjs.io || {}; * @return {number} The peeked bits, as an unsigned number. */ bitjs.io.BitStream.prototype.peekBitsRtl = function(n, movePointers) { - if (n <= 0 || typeof n != typeof 1) { + if (n <= 0 || typeof n !== typeof 1) { return 0; } @@ -150,8 +150,7 @@ bitjs.io = bitjs.io || {}; bytePtr++; bitPtr = 0; n -= numBitsLeftInThisByte; - } - else { + } else { result <<= n; result |= ((bytes[bytePtr] & (BITMASK[n] << (8 - n - bitPtr))) >> (8 - n - bitPtr)); diff --git a/cps/static/js/kthoom.js b/cps/static/js/kthoom.js index 16c51cdd..6a107b9a 100644 --- a/cps/static/js/kthoom.js +++ b/cps/static/js/kthoom.js @@ -15,7 +15,7 @@ * Typed Arrays: http://www.khronos.org/registry/typedarray/specs/latest/#6 */ -/* global bitjs */ +/* global screenfull, bitjs */ if (window.opera) { window.console.log = function(str) { @@ -35,62 +35,69 @@ function getElem(id) { return document.getElementById(id); } -if (window.kthoom === undefined) { +if (typeof window.kthoom === "undefined" ) { kthoom = {}; } // key codes kthoom.Key = { ESCAPE: 27, + SPACE: 32, LEFT: 37, UP: 38, RIGHT: 39, - DOWN: 40, - A: 65, B: 66, C: 67, D: 68, E: 69, F: 70, G: 71, H: 72, I: 73, J: 74, K: 75, L: 76, M: 77, + DOWN: 40, + A: 65, B: 66, C: 67, D: 68, E: 69, F: 70, G: 71, H: 72, I: 73, J: 74, K: 75, L: 76, M: 77, N: 78, O: 79, P: 80, Q: 81, R: 82, S: 83, T: 84, U: 85, V: 86, W: 87, X: 88, Y: 89, Z: 90, QUESTION_MARK: 191, LEFT_SQUARE_BRACKET: 219, RIGHT_SQUARE_BRACKET: 221 }; -// The rotation orientation of the comic. -kthoom.rotateTimes = 0; - // global variables var unarchiver = null; var currentImage = 0; var imageFiles = []; var imageFilenames = []; var totalImages = 0; -var lastCompletion = 0; -var hflip = false, vflip = false, fitMode = kthoom.Key.B; -var canKeyNext = true, canKeyPrev = true; +var settings = { + hflip: false, + vflip: false, + rotateTimes: 0, + fitMode: kthoom.Key.B, + theme: "light" +}; kthoom.saveSettings = function() { - localStorage.kthoomSettings = JSON.stringify({ - rotateTimes: kthoom.rotateTimes, - hflip: hflip, - vflip: vflip, - fitMode: fitMode - }); + localStorage.kthoomSettings = JSON.stringify(settings); }; kthoom.loadSettings = function() { try { - if (localStorage.kthoomSettings.length < 10){ + if (!localStorage.kthoomSettings) { return; } - var s = JSON.parse(localStorage.kthoomSettings); - kthoom.rotateTimes = s.rotateTimes; - hflip = s.hflip; - vflip = s.vflip; - fitMode = s.fitMode; + + $.extend(settings, JSON.parse(localStorage.kthoomSettings)); + + kthoom.setSettings(); } catch (err) { alert("Error load settings"); } }; +kthoom.setSettings = function() { + // Set settings control values + $.each(settings, function(key, value) { + if (typeof value === "boolean") { + $("input[name=" + key + "]").prop("checked", value); + } else { + $("input[name=" + key + "]").val([value]); + } + }); +}; + var createURLFromArray = function(array, mimeType) { var offset = array.byteOffset, len = array.byteLength; var url; @@ -127,9 +134,6 @@ var createURLFromArray = function(array, mimeType) { // Stores an image filename and its data: URI. -// TODO: investigate if we really need to store as base64 (leave off ;base64 and just -// non-safe URL characters are encoded as %xx ?) -// This would save 25% on memory since base64-encoded strings are 4/3 the size of the binary kthoom.ImageFile = function(file) { this.filename = file.filename; var fileExtension = file.filename.split(".").pop().toLowerCase(); @@ -141,138 +145,13 @@ kthoom.ImageFile = function(file) { }; -kthoom.initProgressMeter = function() { - var svgns = "http://www.w3.org/2000/svg"; - var pdiv = $("#progress")[0]; - var svg = document.createElementNS(svgns, "svg"); - svg.style.width = "100%"; - svg.style.height = "100%"; - - var defs = document.createElementNS(svgns, "defs"); - - var patt = document.createElementNS(svgns, "pattern"); - patt.id = "progress_pattern"; - patt.setAttribute("width", "30"); - patt.setAttribute("height", "20"); - patt.setAttribute("patternUnits", "userSpaceOnUse"); - - var rect = document.createElementNS(svgns, "rect"); - rect.setAttribute("width", "100%"); - rect.setAttribute("height", "100%"); - rect.setAttribute("fill", "#cc2929"); - - var poly = document.createElementNS(svgns, "polygon"); - poly.setAttribute("fill", "yellow"); - poly.setAttribute("points", "15,0 30,0 15,20 0,20"); - - patt.appendChild(rect); - patt.appendChild(poly); - defs.appendChild(patt); - - svg.appendChild(defs); - - var g = document.createElementNS(svgns, "g"); - - var outline = document.createElementNS(svgns, "rect"); - outline.setAttribute("y", "1"); - outline.setAttribute("width", "100%"); - outline.setAttribute("height", "15"); - outline.setAttribute("fill", "#777"); - outline.setAttribute("stroke", "white"); - outline.setAttribute("rx", "5"); - outline.setAttribute("ry", "5"); - g.appendChild(outline); - - var title = document.createElementNS(svgns, "text"); - title.id = "progress_title"; - title.appendChild(document.createTextNode("0%")); - title.setAttribute("y", "13"); - title.setAttribute("x", "99.5%"); - title.setAttribute("fill", "white"); - title.setAttribute("font-size", "12px"); - title.setAttribute("text-anchor", "end"); - g.appendChild(title); - - var meter = document.createElementNS(svgns, "rect"); - meter.id = "meter"; - meter.setAttribute("width", "0%"); - meter.setAttribute("height", "17"); - meter.setAttribute("fill", "url(#progress_pattern)"); - meter.setAttribute("rx", "5"); - meter.setAttribute("ry", "5"); - - var meter2 = document.createElementNS(svgns, "rect"); - meter2.id = "meter2"; - meter2.setAttribute("width", "0%"); - meter2.setAttribute("height", "17"); - meter2.setAttribute("opacity", "0.8"); - meter2.setAttribute("fill", "#007fff"); - meter2.setAttribute("rx", "5"); - meter2.setAttribute("ry", "5"); - - g.appendChild(meter); - g.appendChild(meter2); - - var page = document.createElementNS(svgns, "text"); - page.id = "page"; - page.appendChild(document.createTextNode("0/0")); - page.setAttribute("y", "13"); - page.setAttribute("x", "0.5%"); - page.setAttribute("fill", "white"); - page.setAttribute("font-size", "12px"); - g.appendChild(page); - - - svg.appendChild(g); - pdiv.appendChild(svg); - var l; - svg.onclick = function(e) { - for (var x = pdiv, l = 0; x !== document.documentElement; x = x.parentNode) l += x.offsetLeft; - var page = Math.max(1, Math.ceil(((e.clientX - l) / pdiv.offsetWidth) * totalImages)) - 1; +function initProgressClick() { + $("#progress").click(function(e) { + var page = Math.max(1, Math.ceil((e.offsetX / $(this).width()) * totalImages)) - 1; currentImage = page; updatePage(); - }; -} - -kthoom.setProgressMeter = function(pct, optLabel) { - pct = (pct * 100); - var part = 1 / totalImages; - var remain = ((pct - lastCompletion) / 100) / part; - var fract = Math.min(1, remain); - var smartpct = ((imageFiles.length / totalImages) + (fract * part)) * 100; - if (totalImages === 0) smartpct = pct; - - // + Math.min((pct - lastCompletion), 100/totalImages * 0.9 + (pct - lastCompletion - 100/totalImages)/2, 100/totalImages); - var oldval = parseFloat(getElem("meter").getAttribute("width")); - if (isNaN(oldval)) oldval = 0; - var weight = 0.5; - smartpct = ((weight * smartpct) + ((1 - weight) * oldval)); - if (pct === 100) smartpct = 100; - - if (!isNaN(smartpct)) { - getElem("meter").setAttribute("width", smartpct + "%"); - } - var title = getElem("progress_title"); - while (title.firstChild) title.removeChild(title.firstChild); - - var labelText = pct.toFixed(2) + "% " + imageFiles.length + "/" + totalImages + ""; - if (optLabel) { - labelText = optLabel + " " + labelText; - } - title.appendChild(document.createTextNode(labelText)); - - getElem("meter2").setAttribute("width", - 100 * (totalImages === 0 ? 0 : ((currentImage + 1) / totalImages)) + "%"); - - var titlePage = getElem("page"); - while (titlePage.firstChild) titlePage.removeChild(titlePage.firstChild); - titlePage.appendChild(document.createTextNode( (currentImage + 1) + "/" + totalImages )); - - if (pct > 0) { - //getElem('nav').className = ''; - getElem("progress").className = ""; - } -} + }); +}; function loadFromArrayBuffer(ab) { var start = (new Date).getTime(); @@ -291,8 +170,7 @@ function loadFromArrayBuffer(ab) { function(e) { var percentage = e.currentBytesUnarchived / e.totalUncompressedBytesInArchive; totalImages = e.totalFilesInArchive; - kthoom.setProgressMeter(percentage, "Unzipping"); - // display nav + updateProgress(percentage *100); lastCompletion = percentage * 100; }); unarchiver.addEventListener(bitjs.archive.UnarchiveEvent.Type.EXTRACT, @@ -304,11 +182,20 @@ function loadFromArrayBuffer(ab) { if (imageFilenames.indexOf(f.filename) === -1) { imageFilenames.push(f.filename); imageFiles.push(new kthoom.ImageFile(f)); + // add thumbnails to the TOC list + $("#thumbnails").append( + "
  • " + + "" + + "" + + "" + imageFiles.length + "" + + "" + + "
  • " + ); } } // display first page if we haven't yet if (imageFiles.length === currentImage + 1) { - updatePage(); + updatePage(lastCompletion); } }); unarchiver.addEventListener(bitjs.archive.UnarchiveEvent.Type.FINISH, @@ -322,19 +209,54 @@ function loadFromArrayBuffer(ab) { } } +function scrollTocToActive() { + // Scroll to the thumbnail in the TOC on page change + $('#tocView').stop().animate({ + scrollTop: $('#tocView a.active').position().top + }, 200); +} function updatePage() { - var title = getElem("page"); - while (title.firstChild) title.removeChild(title.firstChild); - title.appendChild(document.createTextNode( (currentImage + 1 ) + "/" + totalImages )); + $('.page').text((currentImage + 1 ) + "/" + totalImages); + + // Mark the current page in the TOC + $('#tocView a[data-page]') + // Remove the currently active thumbnail + .removeClass('active') + // Find the new one + .filter('[data-page='+ (currentImage + 1) +']') + // Set it to active + .addClass('active'); + + scrollTocToActive(); + updateProgress(); - getElem("meter2").setAttribute("width", - 100 * (totalImages === 0 ? 0 : ((currentImage + 1 ) / totalImages)) + "%"); if (imageFiles[currentImage]) { setImage(imageFiles[currentImage].dataURI); } else { setImage("loading"); } + + $("body").toggleClass("dark-theme", settings.theme === "dark"); + + kthoom.setSettings(); + kthoom.saveSettings(); +} + +function updateProgress(loadPercentage) { + // Set the load/unzip progress if it's passed in + if (loadPercentage) { + $("#progress .bar-load").css({ width: loadPercentage + "%" }); + + if (loadPercentage === 100) { + $("#progress") + .removeClass('loading') + .find(".load").text(''); + } + } + + // Set page progress bar + $("#progress .bar-read").css({ width: totalImages === 0 ? 0 : Math.round((currentImage + 1) / totalImages * 100) + "%"}); } function setImage(url) { @@ -345,81 +267,92 @@ function setImage(url) { updateScale(true); canvas.width = innerWidth - 100; canvas.height = 200; - x.fillStyle = "red"; - x.font = "50px sans-serif"; + x.fillStyle = "black"; + x.textAlign = "center"; + x.font = "24px sans-serif"; x.strokeStyle = "black"; - x.fillText("Loading Page #" + (currentImage + 1), 100, 100); + x.fillText("Loading Page #" + (currentImage + 1), innerWidth / 2, 100); } else { - if ($("body").css("scrollHeight") / innerHeight > 1) { - $("body").css("overflowY", "scroll"); - } - - var img = new Image(); - img.onerror = function() { - canvas.width = innerWidth - 100; - canvas.height = 300; + if (url === "error") { updateScale(true); - x.fillStyle = "orange"; - x.font = "50px sans-serif"; + canvas.width = innerWidth - 100; + canvas.height = 200; + x.fillStyle = "black"; + x.textAlign = "center"; + x.font = "24px sans-serif"; x.strokeStyle = "black"; - x.fillText("Page #" + (currentImage + 1) + " (" + - imageFiles[currentImage].filename + ")", 100, 100); - x.fillStyle = "red"; - x.fillText("Is corrupt or not an image", 100, 200); - - var xhr = new XMLHttpRequest(); - if (/(html|htm)$/.test(imageFiles[currentImage].filename)) { - xhr.open("GET", url, true); - xhr.onload = function() { - //document.getElementById('mainText').style.display = ''; - $("#mainText").css("display", ""); - $("#mainText").innerHTML(""); - } - xhr.send(null); - } else if (!/(jpg|jpeg|png|gif)$/.test(imageFiles[currentImage].filename) && imageFiles[currentImage].data.uncompressedSize < 10 * 1024) { - xhr.open("GET", url, true); - xhr.onload = function() { - $("#mainText").css("display", ""); - $("#mainText").innerText(xhr.responseText); - }; - xhr.send(null); + x.fillText("Unable to decompress image #" + (currentImage + 1), innerWidth / 2, 100); + } else { + if ($("body").css("scrollHeight") / innerHeight > 1) { + $("body").css("overflowY", "scroll"); } - }; - img.onload = function() { - var h = img.height, - w = img.width, - sw = w, - sh = h; - kthoom.rotateTimes = (4 + kthoom.rotateTimes) % 4; - x.save(); - if (kthoom.rotateTimes % 2 === 1) { - sh = w; - sw = h; - } - canvas.height = sh; - canvas.width = sw; - x.translate(sw / 2, sh / 2); - x.rotate(Math.PI / 2 * kthoom.rotateTimes); - x.translate(-w / 2, -h / 2); - if (vflip) { - x.scale(1, -1); - x.translate(0, -h); - } - if (hflip) { - x.scale(-1, 1); - x.translate(-w, 0); - } - canvas.style.display = "none"; - scrollTo(0, 0); - x.drawImage(img, 0, 0); - updateScale(); + var img = new Image(); + img.onerror = function() { + canvas.width = innerWidth - 100; + canvas.height = 300; + updateScale(true); + x.fillStyle = "black"; + x.font = "50px sans-serif"; + x.strokeStyle = "black"; + x.fillText("Page #" + (currentImage + 1) + " (" + + imageFiles[currentImage].filename + ")", innerWidth / 2, 100); + x.fillStyle = "black"; + x.fillText("Is corrupt or not an image", innerWidth / 2, 200); + + var xhr = new XMLHttpRequest(); + if (/(html|htm)$/.test(imageFiles[currentImage].filename)) { + xhr.open("GET", url, true); + xhr.onload = function() { + $("#mainText").css("display", ""); + $("#mainText").innerHTML(""); + } + xhr.send(null); + } else if (!/(jpg|jpeg|png|gif)$/.test(imageFiles[currentImage].filename) && imageFiles[currentImage].data.uncompressedSize < 10 * 1024) { + xhr.open("GET", url, true); + xhr.onload = function() { + $("#mainText").css("display", ""); + $("#mainText").innerText(xhr.responseText); + }; + xhr.send(null); + } + }; + img.onload = function() { + var h = img.height, + w = img.width, + sw = w, + sh = h; + settings.rotateTimes = (4 + settings.rotateTimes) % 4; + x.save(); + if (settings.rotateTimes % 2 === 1) { + sh = w; + sw = h; + } + canvas.height = sh; + canvas.width = sw; + x.translate(sw / 2, sh / 2); + x.rotate(Math.PI / 2 * settings.rotateTimes); + x.translate(-w / 2, -h / 2); + if (settings.vflip) { + x.scale(1, -1); + x.translate(0, -h); + } + if (settings.hflip) { + x.scale(-1, 1); + x.translate(-w, 0); + } + canvas.style.display = "none"; + scrollTo(0, 0); + x.drawImage(img, 0, 0); + + updateScale(false); - canvas.style.display = ""; - $("body").css("overflowY", ""); - x.restore(); - }; - img.src = url; + canvas.style.display = ""; + $("body").css("overflowY", ""); + x.restore(); + }; + img.src = url; + } } } @@ -449,149 +382,254 @@ function updateScale(clear) { mainImageStyle.height = ""; mainImageStyle.maxWidth = ""; mainImageStyle.maxHeight = ""; - var maxheight = innerHeight - 15; - if (!/main/.test(getElem("titlebar").className)) { - maxheight -= 25; - } - if (clear || fitMode === kthoom.Key.N) { - } else if (fitMode === kthoom.Key.B) { - mainImageStyle.maxWidth = "100%"; - mainImageStyle.maxHeight = maxheight + "px"; - } else if (fitMode === kthoom.Key.H) { - mainImageStyle.height = maxheight + "px"; - } else if (fitMode === kthoom.Key.W) { - mainImageStyle.width = "100%"; + var maxheight = innerHeight - 50; + + if (!clear) { + switch (settings.fitMode) { + case kthoom.Key.B: + mainImageStyle.maxWidth = "100%"; + mainImageStyle.maxHeight = maxheight + "px"; + break; + case kthoom.Key.H: + mainImageStyle.height = maxheight + "px"; + break; + case kthoom.Key.W: + mainImageStyle.width = "100%"; + break; + default: + break; + } } + $("#mainContent").css({maxHeight: maxheight + 5}); + kthoom.setSettings(); kthoom.saveSettings(); } function keyHandler(evt) { - var code = evt.keyCode; - - if ($("#progress").css("display") === "none"){ - return; - } - canKeyNext = (($("body").css("offsetWidth") + $("body").css("scrollLeft")) / $("body").css("scrollWidth")) >= 1; - canKeyPrev = (scrollX <= 0); - - if (evt.ctrlKey || evt.shiftKey || evt.metaKey) return; - switch (code) { + var hasModifier = evt.ctrlKey || evt.shiftKey || evt.metaKey; + switch (evt.keyCode) { case kthoom.Key.LEFT: - if (canKeyPrev) showPrevPage(); + if (hasModifier) break; + showPrevPage(); break; case kthoom.Key.RIGHT: - if (canKeyNext) showNextPage(); + if (hasModifier) break; + showNextPage(); break; case kthoom.Key.L: - kthoom.rotateTimes--; - if (kthoom.rotateTimes < 0) { - kthoom.rotateTimes = 3; + if (hasModifier) break; + settings.rotateTimes--; + if (settings.rotateTimes < 0) { + settings.rotateTimes = 3; } updatePage(); break; case kthoom.Key.R: - kthoom.rotateTimes++; - if (kthoom.rotateTimes > 3) { - kthoom.rotateTimes = 0; + if (hasModifier) break; + settings.rotateTimes++; + if (settings.rotateTimes > 3) { + settings.rotateTimes = 0; } updatePage(); break; case kthoom.Key.F: - if (!hflip && !vflip) { - hflip = true; - } else if (hflip === true) { - vflip = true; - hflip = false; - } else if (vflip === true) { - vflip = false; + if (hasModifier) break; + if (!settings.hflip && !settings.vflip) { + settings.hflip = true; + } else if (settings.hflip === true && settings.vflip === true) { + settings.vflip = false; + settings.hflip = false; + } else if (settings.hflip === true) { + settings.vflip = true; + settings.hflip = false; + } else if (settings.vflip === true) { + settings.hflip = true; } updatePage(); break; case kthoom.Key.W: - fitMode = kthoom.Key.W; - updateScale(); + if (hasModifier) break; + settings.fitMode = kthoom.Key.W; + updateScale(false); break; case kthoom.Key.H: - fitMode = kthoom.Key.H; - updateScale(); + if (hasModifier) break; + settings.fitMode = kthoom.Key.H; + updateScale(false); break; case kthoom.Key.B: - fitMode = kthoom.Key.B; - updateScale(); + if (hasModifier) break; + settings.fitMode = kthoom.Key.B; + updateScale(false); break; case kthoom.Key.N: - fitMode = kthoom.Key.N; - updateScale(); + if (hasModifier) break; + settings.fitMode = kthoom.Key.N; + updateScale(false); + break; + case kthoom.Key.SPACE: + var container = $('#mainContent'); + var atTop = container.scrollTop() === 0; + var atBottom = container.scrollTop() >= container[0].scrollHeight - container.height(); + + if (evt.shiftKey && atTop) { + evt.preventDefault(); + // If it's Shift + Space and the container is at the top of the page + showPrevPage(); + } else if (!evt.shiftKey && atBottom) { + evt.preventDefault(); + // If you're at the bottom of the page and you only pressed space + showNextPage(); + container.scrollTop(0); + } break; default: - //console.log('KeyCode = ' + code); + //console.log('KeyCode', evt.keyCode); break; } } -function init(filename) { - if (!window.FileReader) { - alert("Sorry, kthoom will not work with your browser because it does not support the File API. Please try kthoom with Chrome 12+ or Firefox 7+"); +/*function ImageLoadCallback() { + var jso = this.response; + // Unable to decompress file, or no response from server + if (jso === null) { + setImage("error"); } else { - var request = new XMLHttpRequest(); - request.open("GET", filename); - request.responseType = "arraybuffer"; - request.setRequestHeader("X-Test", "test1"); - request.setRequestHeader("X-Test", "test2"); - request.addEventListener("load", function(event) { - if (request.status >= 200 && request.status < 300) { - loadFromArrayBuffer(request.response); - } else { - console.warn(request.statusText, request.responseText); - } - }); - request.send(); - kthoom.initProgressMeter(); - document.body.className += /AppleWebKit/.test(navigator.userAgent) ? " webkit" : ""; - updateScale(true); - kthoom.loadSettings(); - $(document).keydown(keyHandler); + // IE 11 sometimes sees the response as a string + if (typeof jso !== "object") { + jso = JSON.parse(jso); + } + + if (jso.page !== jso.last) { + this.open("GET", this.fileid + "/" + (jso.page + 1)); + this.addEventListener("load", ImageLoadCallback); + this.send(); + } + + loadFromArrayBuffer(jso); + } +}*/ +function init(filename) { + var request = new XMLHttpRequest(); + request.open("GET", filename); + request.responseType = "arraybuffer"; + request.setRequestHeader("X-Test", "test1"); + request.setRequestHeader("X-Test", "test2"); + request.addEventListener("load", function(event) { + if (request.status >= 200 && request.status < 300) { + loadFromArrayBuffer(request.response); + } else { + console.warn(request.statusText, request.responseText); + } + }); + request.send(); + initProgressClick(); + document.body.className += /AppleWebKit/.test(navigator.userAgent) ? " webkit" : ""; + kthoom.loadSettings(); + updateScale(true); + + $(document).keydown(keyHandler); + + $(window).resize(function() { + updateScale(false); + }); + + // Open TOC menu + $("#slider").click(function() { + $("#sidebar").toggleClass("open"); + $("#main").toggleClass("closed"); + $(this).toggleClass("icon-menu icon-right"); + + // We need this in a timeout because if we call it during the CSS transition, IE11 shakes the page ¯\_(ツ)_/¯ + setTimeout(function(){ + // Focus on the TOC or the main content area, depending on which is open + $('#main:not(.closed) #mainContent, #sidebar.open #tocView').focus(); + scrollTocToActive(); + }, 500); + }); + + // Open Settings modal + $("#setting").click(function() { + $("#settings-modal").toggleClass("md-show"); + }); + + // On Settings input change + $("#settings input").on("change", function() { + // Get either the checked boolean or the assigned value + var value = this.type === "checkbox" ? this.checked : this.value; - $(window).resize(function() { - var f = (screen.width - innerWidth < 4 && screen.height - innerHeight < 4); - getElem("titlebar").className = f ? "main" : ""; - updateScale(); + // If it's purely numeric, parse it to an integer + value = /^\d+$/.test(value) ? parseInt(value) : value; + + settings[this.name] = value; + updatePage(); + updateScale(false); + }); + + // Close modal + $(".closer, .overlay").click(function() { + $(".md-show").removeClass("md-show"); + }); + + // TOC thumbnail pagination + $("#thumbnails").on("click", "a", function() { + currentImage = $(this).data("page") - 1; + updatePage(); + }); + + // Fullscreen mode + if (typeof screenfull !== "undefined") { + $("#fullscreen").click(function() { + screenfull.toggle($("#container")[0]); }); - $("#mainImage").click(function(evt) { - // Firefox does not support offsetX/Y so we have to manually calculate - // where the user clicked in the image. - var mainContentWidth = $("#mainContent").width(); - var mainContentHeight = $("#mainContent").height(); - var comicWidth = evt.target.clientWidth; - var comicHeight = evt.target.clientHeight; - var offsetX = (mainContentWidth - comicWidth) / 2; - var offsetY = (mainContentHeight - comicHeight) / 2; - var clickX = !!evt.offsetX ? evt.offsetX : (evt.clientX - offsetX); - var clickY = !!evt.offsetY ? evt.offsetY : (evt.clientY - offsetY); - - // Determine if the user clicked/tapped the left side or the - // right side of the page. - var clickedPrev = false; - switch (kthoom.rotateTimes) { + if (screenfull.raw) { + var $button = $("#fullscreen"); + document.addEventListener(screenfull.raw.fullscreenchange, function() { + screenfull.isFullscreen + ? $button.addClass("icon-resize-small").removeClass("icon-resize-full") + : $button.addClass("icon-resize-full").removeClass("icon-resize-small"); + }); + } + } + + // Focus the scrollable area so that keyboard scrolling work as expected + $('#mainContent').focus(); + + $("#mainImage").click(function(evt) { + // Firefox does not support offsetX/Y so we have to manually calculate + // where the user clicked in the image. + var mainContentWidth = $("#mainContent").width(); + var mainContentHeight = $("#mainContent").height(); + var comicWidth = evt.target.clientWidth; + var comicHeight = evt.target.clientHeight; + var offsetX = (mainContentWidth - comicWidth) / 2; + var offsetY = (mainContentHeight - comicHeight) / 2; + var clickX = evt.offsetX ? evt.offsetX : (evt.clientX - offsetX); + var clickY = evt.offsetY ? evt.offsetY : (evt.clientY - offsetY); + + // Determine if the user clicked/tapped the left side or the + // right side of the page. + var clickedPrev = false; + switch (settings.rotateTimes) { case 0: - clickedPrev = clickX < (comicWidth / 2); - break; + clickedPrev = clickX < (comicWidth / 2); + break; case 1: - clickedPrev = clickY < (comicHeight / 2); - break; + clickedPrev = clickY < (comicHeight / 2); + break; case 2: - clickedPrev = clickX > (comicWidth / 2); - break; + clickedPrev = clickX > (comicWidth / 2); + break; case 3: - clickedPrev = clickY > (comicHeight / 2); - break; - } - if (clickedPrev) { - showPrevPage(); - } else { - showNextPage(); - } - }); - } + clickedPrev = clickY > (comicHeight / 2); + break; + } + if (clickedPrev) { + showPrevPage(); + } else { + showNextPage(); + } + }); } + diff --git a/cps/static/js/libs/bootstrap-table/bootstrap-editable.min.js b/cps/static/js/libs/bootstrap-table/bootstrap-editable.min.js new file mode 100644 index 00000000..0511799c --- /dev/null +++ b/cps/static/js/libs/bootstrap-table/bootstrap-editable.min.js @@ -0,0 +1,7 @@ +/*! X-editable - v1.5.3 +* In-place editing with Twitter Bootstrap, jQuery UI or pure jQuery +* http://github.com/vitalets/x-editable +* Copyright (c) 2015 Vitaliy Potapov; Licensed MIT */ +!function(a){"use strict";var b=function(b,c){this.options=a.extend({},a.fn.editableform.defaults,c),this.$div=a(b),this.options.scope||(this.options.scope=this)};b.prototype={constructor:b,initInput:function(){this.input=this.options.input,this.value=this.input.str2value(this.options.value),this.input.prerender()},initTemplate:function(){this.$form=a(a.fn.editableform.template)},initButtons:function(){var b=this.$form.find(".editable-buttons");b.append(a.fn.editableform.buttons),"bottom"===this.options.showbuttons&&b.addClass("editable-buttons-bottom")},render:function(){this.$loading=a(a.fn.editableform.loading),this.$div.empty().append(this.$loading),this.initTemplate(),this.options.showbuttons?this.initButtons():this.$form.find(".editable-buttons").remove(),this.showLoading(),this.isSaving=!1,this.$div.triggerHandler("rendering"),this.initInput(),this.$form.find("div.editable-input").append(this.input.$tpl),this.$div.append(this.$form),a.when(this.input.render()).then(a.proxy(function(){if(this.options.showbuttons||this.input.autosubmit(),this.$form.find(".editable-cancel").click(a.proxy(this.cancel,this)),this.input.error)this.error(this.input.error),this.$form.find(".editable-submit").attr("disabled",!0),this.input.$input.attr("disabled",!0),this.$form.submit(function(a){a.preventDefault()});else{this.error(!1),this.input.$input.removeAttr("disabled"),this.$form.find(".editable-submit").removeAttr("disabled");var b=null===this.value||void 0===this.value||""===this.value?this.options.defaultValue:this.value;this.input.value2input(b),this.$form.submit(a.proxy(this.submit,this))}this.$div.triggerHandler("rendered"),this.showForm(),this.input.postrender&&this.input.postrender()},this))},cancel:function(){this.$div.triggerHandler("cancel")},showLoading:function(){var a,b;this.$form?(a=this.$form.outerWidth(),b=this.$form.outerHeight(),a&&this.$loading.width(a),b&&this.$loading.height(b),this.$form.hide()):(a=this.$loading.parent().width(),a&&this.$loading.width(a)),this.$loading.show()},showForm:function(a){this.$loading.hide(),this.$form.show(),a!==!1&&this.input.activate(),this.$div.triggerHandler("show")},error:function(b){var c,d=this.$form.find(".control-group"),e=this.$form.find(".editable-error-block");if(b===!1)d.removeClass(a.fn.editableform.errorGroupClass),e.removeClass(a.fn.editableform.errorBlockClass).empty().hide();else{if(b){c=(""+b).split("\n");for(var f=0;f").text(c[f]).html();b=c.join("
    ")}d.addClass(a.fn.editableform.errorGroupClass),e.addClass(a.fn.editableform.errorBlockClass).html(b).show()}},submit:function(b){b.stopPropagation(),b.preventDefault();var c=this.input.input2value(),d=this.validate(c);if("object"===a.type(d)&&void 0!==d.newValue){if(c=d.newValue,this.input.value2input(c),"string"==typeof d.msg)return this.error(d.msg),void this.showForm()}else if(d)return this.error(d),void this.showForm();if(!this.options.savenochange&&this.input.value2str(c)===this.input.value2str(this.value))return void this.$div.triggerHandler("nochange");var e=this.input.value2submit(c);this.isSaving=!0,a.when(this.save(e)).done(a.proxy(function(a){this.isSaving=!1;var b="function"==typeof this.options.success?this.options.success.call(this.options.scope,a,c):null;return b===!1?(this.error(!1),void this.showForm(!1)):"string"==typeof b?(this.error(b),void this.showForm()):(b&&"object"==typeof b&&b.hasOwnProperty("newValue")&&(c=b.newValue),this.error(!1),this.value=c,void this.$div.triggerHandler("save",{newValue:c,submitValue:e,response:a}))},this)).fail(a.proxy(function(a){this.isSaving=!1;var b;b="function"==typeof this.options.error?this.options.error.call(this.options.scope,a,c):"string"==typeof a?a:a.responseText||a.statusText||"Unknown error!",this.error(b),this.showForm()},this))},save:function(b){this.options.pk=a.fn.editableutils.tryParseJson(this.options.pk,!0);var c,d="function"==typeof this.options.pk?this.options.pk.call(this.options.scope):this.options.pk,e=!!("function"==typeof this.options.url||this.options.url&&("always"===this.options.send||"auto"===this.options.send&&null!==d&&void 0!==d));return e?(this.showLoading(),c={name:this.options.name||"",value:b,pk:d},"function"==typeof this.options.params?c=this.options.params.call(this.options.scope,c):(this.options.params=a.fn.editableutils.tryParseJson(this.options.params,!0),a.extend(c,this.options.params)),"function"==typeof this.options.url?this.options.url.call(this.options.scope,c):a.ajax(a.extend({url:this.options.url,data:c,type:"POST"},this.options.ajaxOptions))):void 0},validate:function(a){return void 0===a&&(a=this.value),"function"==typeof this.options.validate?this.options.validate.call(this.options.scope,a):void 0},option:function(a,b){a in this.options&&(this.options[a]=b),"value"===a&&this.setValue(b)},setValue:function(a,b){b?this.value=this.input.str2value(a):this.value=a,this.$form&&this.$form.is(":visible")&&this.input.value2input(this.value)}},a.fn.editableform=function(c){var d=arguments;return this.each(function(){var e=a(this),f=e.data("editableform"),g="object"==typeof c&&c;f||e.data("editableform",f=new b(this,g)),"string"==typeof c&&f[c].apply(f,Array.prototype.slice.call(d,1))})},a.fn.editableform.Constructor=b,a.fn.editableform.defaults={type:"text",url:null,params:null,name:null,pk:null,value:null,defaultValue:null,send:"auto",validate:null,success:null,error:null,ajaxOptions:null,showbuttons:!0,scope:null,savenochange:!1},a.fn.editableform.template='
    ',a.fn.editableform.loading='
    ',a.fn.editableform.buttons='',a.fn.editableform.errorGroupClass=null,a.fn.editableform.errorBlockClass="editable-error",a.fn.editableform.engine="jquery"}(window.jQuery),function(a){"use strict";a.fn.editableutils={inherit:function(a,b){var c=function(){};c.prototype=b.prototype,a.prototype=new c,a.prototype.constructor=a,a.superclass=b.prototype},setCursorPosition:function(a,b){if(a.setSelectionRange)a.setSelectionRange(b,b);else if(a.createTextRange){var c=a.createTextRange();c.collapse(!0),c.moveEnd("character",b),c.moveStart("character",b),c.select()}},tryParseJson:function(a,b){if("string"==typeof a&&a.length&&a.match(/^[\{\[].*[\}\]]$/))if(b)try{a=new Function("return "+a)()}catch(c){}finally{return a}else a=new Function("return "+a)();return a},sliceObj:function(b,c,d){var e,f,g={};if(!a.isArray(c)||!c.length)return g;for(var h=0;h").text(b).html()},itemsByValue:function(b,c,d){if(!c||null===b)return[];if("function"!=typeof d){var e=d||"value";d=function(a){return a[e]}}var f=a.isArray(b),g=[],h=this;return a.each(c,function(c,e){if(e.children)g=g.concat(h.itemsByValue(b,e.children,d));else if(f)a.grep(b,function(a){return a==(e&&"object"==typeof e?d(e):e)}).length&&g.push(e);else{var i=e&&"object"==typeof e?d(e):e;b==i&&g.push(e)}}),g},createInput:function(b){var c,d,e,f=b.type;return"date"===f&&("inline"===b.mode?a.fn.editabletypes.datefield?f="datefield":a.fn.editabletypes.dateuifield&&(f="dateuifield"):a.fn.editabletypes.date?f="date":a.fn.editabletypes.dateui&&(f="dateui"),"date"!==f||a.fn.editabletypes.date||(f="combodate")),"datetime"===f&&"inline"===b.mode&&(f="datetimefield"),"wysihtml5"!==f||a.fn.editabletypes[f]||(f="textarea"),"function"==typeof a.fn.editabletypes[f]?(c=a.fn.editabletypes[f],d=this.sliceObj(b,this.objectKeys(c.defaults)),e=new c(d)):(a.error("Unknown type: "+f),!1)},supportsTransitions:function(){var a=document.body||document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e"),this.tip().is(this.innerCss)?this.tip().append(this.$form):this.tip().find(this.innerCss).append(this.$form),this.renderForm()},hide:function(a){if(this.tip()&&this.tip().is(":visible")&&this.$element.hasClass("editable-open")){if(this.$form.data("editableform").isSaving)return void(this.delayedHide={reason:a});this.delayedHide=!1,this.$element.removeClass("editable-open"),this.innerHide(),this.$element.triggerHandler("hidden",a||"manual")}},innerShow:function(){},innerHide:function(){},toggle:function(a){this.container()&&this.tip()&&this.tip().is(":visible")?this.hide():this.show(a)},setPosition:function(){},save:function(a,b){this.$element.triggerHandler("save",b),this.hide("save")},option:function(a,b){this.options[a]=b,a in this.containerOptions?(this.containerOptions[a]=b,this.setContainerOption(a,b)):(this.formOptions[a]=b,this.$form&&this.$form.editableform("option",a,b))},setContainerOption:function(a,b){this.call("option",a,b)},destroy:function(){this.hide(),this.innerDestroy(),this.$element.off("destroyed"),this.$element.removeData("editableContainer")},innerDestroy:function(){},closeOthers:function(b){a(".editable-open").each(function(c,d){if(d!==b&&!a(d).find(b).length){var e=a(d),f=e.data("editableContainer");f&&("cancel"===f.options.onblur?e.data("editableContainer").hide("onblur"):"submit"===f.options.onblur&&e.data("editableContainer").tip().find("form").submit())}})},activate:function(){this.tip&&this.tip().is(":visible")&&this.$form&&this.$form.data("editableform").input.activate()}},a.fn.editableContainer=function(d){var e=arguments;return this.each(function(){var f=a(this),g="editableContainer",h=f.data(g),i="object"==typeof d&&d,j="inline"===i.mode?c:b;h||f.data(g,h=new j(this,i)),"string"==typeof d&&h[d].apply(h,Array.prototype.slice.call(e,1))})},a.fn.editableContainer.Popup=b,a.fn.editableContainer.Inline=c,a.fn.editableContainer.defaults={value:null,placement:"top",autohide:!0,onblur:"cancel",anim:!1,mode:"popup"},jQuery.event.special.destroyed={remove:function(a){a.handler&&a.handler()}}}(window.jQuery),function(a){"use strict";a.extend(a.fn.editableContainer.Inline.prototype,a.fn.editableContainer.Popup.prototype,{containerName:"editableform",innerCss:".editable-inline",containerClass:"editable-container editable-inline",initContainer:function(){this.$tip=a(""),this.options.anim||(this.options.anim=0)},splitOptions:function(){this.containerOptions={},this.formOptions=this.options},tip:function(){return this.$tip},innerShow:function(){this.$element.hide(),this.tip().insertAfter(this.$element).show()},innerHide:function(){this.$tip.hide(this.options.anim,a.proxy(function(){this.$element.show(),this.innerDestroy()},this))},innerDestroy:function(){this.tip()&&this.tip().empty().remove()}})}(window.jQuery),function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.editable.defaults,c,a.fn.editableutils.getConfigData(this.$element)),this.options.selector?this.initLive():this.init(),this.options.highlight&&!a.fn.editableutils.supportsTransitions()&&(this.options.highlight=!1)};b.prototype={constructor:b,init:function(){var b,c=!1;if(this.options.name=this.options.name||this.$element.attr("id"),this.options.scope=this.$element[0],this.input=a.fn.editableutils.createInput(this.options),this.input){switch(void 0===this.options.value||null===this.options.value?(this.value=this.input.html2value(a.trim(this.$element.html())),c=!0):(this.options.value=a.fn.editableutils.tryParseJson(this.options.value,!0),"string"==typeof this.options.value?this.value=this.input.str2value(this.options.value):this.value=this.options.value),this.$element.addClass("editable"),"textarea"===this.input.type&&this.$element.addClass("editable-pre-wrapped"),"manual"!==this.options.toggle?(this.$element.addClass("editable-click"),this.$element.on(this.options.toggle+".editable",a.proxy(function(a){if(this.options.disabled||a.preventDefault(),"mouseenter"===this.options.toggle)this.show();else{var b="click"!==this.options.toggle;this.toggle(b)}},this))):this.$element.attr("tabindex",-1),"function"==typeof this.options.display&&(this.options.autotext="always"),this.options.autotext){case"always":b=!0;break;case"auto":b=!a.trim(this.$element.text()).length&&null!==this.value&&void 0!==this.value&&!c;break;default:b=!1}a.when(b?this.render():!0).then(a.proxy(function(){this.options.disabled?this.disable():this.enable(),this.$element.triggerHandler("init",this)},this))}},initLive:function(){var b=this.options.selector;this.options.selector=!1,this.options.autotext="never",this.$element.on(this.options.toggle+".editable",b,a.proxy(function(b){var c=a(b.target);c.data("editable")||(c.hasClass(this.options.emptyclass)&&c.empty(),c.editable(this.options).trigger(b))},this))},render:function(a){return this.options.display!==!1?this.input.value2htmlFinal?this.input.value2html(this.value,this.$element[0],this.options.display,a):"function"==typeof this.options.display?this.options.display.call(this.$element[0],this.value,a):this.input.value2html(this.value,this.$element[0]):void 0},enable:function(){this.options.disabled=!1,this.$element.removeClass("editable-disabled"),this.handleEmpty(this.isEmpty),"manual"!==this.options.toggle&&"-1"===this.$element.attr("tabindex")&&this.$element.removeAttr("tabindex")},disable:function(){this.options.disabled=!0,this.hide(),this.$element.addClass("editable-disabled"),this.handleEmpty(this.isEmpty),this.$element.attr("tabindex",-1)},toggleDisabled:function(){this.options.disabled?this.enable():this.disable()},option:function(b,c){return b&&"object"==typeof b?void a.each(b,a.proxy(function(b,c){this.option(a.trim(b),c)},this)):(this.options[b]=c,"disabled"===b?c?this.disable():this.enable():("value"===b&&this.setValue(c),this.container&&this.container.option(b,c),void(this.input.option&&this.input.option(b,c))))},handleEmpty:function(b){this.options.display!==!1&&(void 0!==b?this.isEmpty=b:"function"==typeof this.input.isEmpty?this.isEmpty=this.input.isEmpty(this.$element):this.isEmpty=""===a.trim(this.$element.html()),this.options.disabled?this.isEmpty&&(this.$element.empty(),this.options.emptyclass&&this.$element.removeClass(this.options.emptyclass)):this.isEmpty?(this.$element.html(this.options.emptytext),this.options.emptyclass&&this.$element.addClass(this.options.emptyclass)):this.options.emptyclass&&this.$element.removeClass(this.options.emptyclass))},show:function(b){if(!this.options.disabled){if(this.container){if(this.container.tip().is(":visible"))return}else{var c=a.extend({},this.options,{value:this.value,input:this.input});this.$element.editableContainer(c),this.$element.on("save.internal",a.proxy(this.save,this)),this.container=this.$element.data("editableContainer")}this.container.show(b)}},hide:function(){this.container&&this.container.hide()},toggle:function(a){this.container&&this.container.tip().is(":visible")?this.hide():this.show(a)},save:function(a,b){if(this.options.unsavedclass){var c=!1;c=c||"function"==typeof this.options.url,c=c||this.options.display===!1,c=c||void 0!==b.response,c=c||this.options.savenochange&&this.input.value2str(this.value)!==this.input.value2str(b.newValue),c?this.$element.removeClass(this.options.unsavedclass):this.$element.addClass(this.options.unsavedclass)}if(this.options.highlight){var d=this.$element,e=d.css("background-color");d.css("background-color",this.options.highlight),setTimeout(function(){"transparent"===e&&(e=""),d.css("background-color",e),d.addClass("editable-bg-transition"),setTimeout(function(){d.removeClass("editable-bg-transition")},1700)},10)}this.setValue(b.newValue,!1,b.response)},validate:function(){return"function"==typeof this.options.validate?this.options.validate.call(this,this.value):void 0},setValue:function(b,c,d){c?this.value=this.input.str2value(b):this.value=b,this.container&&this.container.option("value",this.value),a.when(this.render(d)).then(a.proxy(function(){this.handleEmpty()},this))},activate:function(){this.container&&this.container.activate()},destroy:function(){this.disable(),this.container&&this.container.destroy(),this.input.destroy(),"manual"!==this.options.toggle&&(this.$element.removeClass("editable-click"),this.$element.off(this.options.toggle+".editable")),this.$element.off("save.internal"),this.$element.removeClass("editable editable-open editable-disabled"),this.$element.removeData("editable")}},a.fn.editable=function(c){var d={},e=arguments,f="editable";switch(c){case"validate":return this.each(function(){var b,c=a(this),e=c.data(f);e&&(b=e.validate())&&(d[e.options.name]=b)}),d;case"getValue":return 2===arguments.length&&arguments[1]===!0?d=this.eq(0).data(f).value:this.each(function(){var b=a(this),c=b.data(f);c&&void 0!==c.value&&null!==c.value&&(d[c.options.name]=c.input.value2submit(c.value))}),d;case"submit":var g=arguments[1]||{},h=this,i=this.editable("validate");if(a.isEmptyObject(i)){var j={};if(1===h.length){var k=h.data("editable"),l={name:k.options.name||"",value:k.input.value2submit(k.value),pk:"function"==typeof k.options.pk?k.options.pk.call(k.options.scope):k.options.pk};"function"==typeof k.options.params?l=k.options.params.call(k.options.scope,l):(k.options.params=a.fn.editableutils.tryParseJson(k.options.params,!0),a.extend(l,k.options.params)),j={url:k.options.url,data:l,type:"POST"},g.success=g.success||k.options.success,g.error=g.error||k.options.error}else{var m=this.editable("getValue");j={url:g.url,data:m,type:"POST"}}j.success="function"==typeof g.success?function(a){g.success.call(h,a,g)}:a.noop,j.error="function"==typeof g.error?function(){g.error.apply(h,arguments)}:a.noop,g.ajaxOptions&&a.extend(j,g.ajaxOptions),g.data&&a.extend(j.data,g.data),a.ajax(j)}else"function"==typeof g.error&&g.error.call(h,i);return this}return this.each(function(){var d=a(this),g=d.data(f),h="object"==typeof c&&c;return h&&h.selector?void(g=new b(this,h)):(g||d.data(f,g=new b(this,h)),void("string"==typeof c&&g[c].apply(g,Array.prototype.slice.call(e,1))))})},a.fn.editable.defaults={type:"text",disabled:!1,toggle:"click",emptytext:"Empty",autotext:"auto",value:null,display:null,emptyclass:"editable-empty",unsavedclass:"editable-unsaved",selector:null,highlight:"#FFFF80"}}(window.jQuery),function(a){"use strict";a.fn.editabletypes={};var b=function(){};b.prototype={init:function(b,c,d){this.type=b,this.options=a.extend({},d,c)},prerender:function(){this.$tpl=a(this.options.tpl),this.$input=this.$tpl,this.$clear=null,this.error=null},render:function(){},value2html:function(b,c){a(c)[this.options.escape?"text":"html"](a.trim(b))},html2value:function(b){return a("
    ").html(b).text()},value2str:function(a){return a},str2value:function(a){return a},value2submit:function(a){return a},value2input:function(a){this.$input.val(a)},input2value:function(){return this.$input.val()},activate:function(){this.$input.is(":visible")&&this.$input.focus()},clear:function(){this.$input.val(null)},escape:function(b){return a("
    ").text(b).html()},autosubmit:function(){},destroy:function(){},setClass:function(){this.options.inputclass&&this.$input.addClass(this.options.inputclass)},setAttr:function(a){void 0!==this.options[a]&&null!==this.options[a]&&this.$input.attr(a,this.options[a])},option:function(a,b){this.options[a]=b}},b.defaults={tpl:"",inputclass:null,escape:!0,scope:null,showbuttons:!0},a.extend(a.fn.editabletypes,{abstractinput:b})}(window.jQuery),function(a){"use strict";var b=function(a){};a.fn.editableutils.inherit(b,a.fn.editabletypes.abstractinput),a.extend(b.prototype,{render:function(){var b=a.Deferred();return this.error=null,this.onSourceReady(function(){this.renderList(),b.resolve()},function(){this.error=this.options.sourceError,b.resolve()}),b.promise()},html2value:function(a){return null},value2html:function(b,c,d,e){var f=a.Deferred(),g=function(){"function"==typeof d?d.call(c,b,this.sourceData,e):this.value2htmlFinal(b,c),f.resolve()};return null===b?g.call(this):this.onSourceReady(g,function(){f.resolve()}),f.promise()},onSourceReady:function(b,c){var d;if(a.isFunction(this.options.source)?(d=this.options.source.call(this.options.scope),this.sourceData=null):d=this.options.source,this.options.sourceCache&&a.isArray(this.sourceData))return void b.call(this);try{d=a.fn.editableutils.tryParseJson(d,!1)}catch(e){return void c.call(this)}if("string"==typeof d){if(this.options.sourceCache){var f,g=d;if(a(document).data(g)||a(document).data(g,{}),f=a(document).data(g),f.loading===!1&&f.sourceData)return this.sourceData=f.sourceData,this.doPrepend(),void b.call(this);if(f.loading===!0)return f.callbacks.push(a.proxy(function(){this.sourceData=f.sourceData,this.doPrepend(),b.call(this)},this)),void f.err_callbacks.push(a.proxy(c,this));f.loading=!0,f.callbacks=[],f.err_callbacks=[]}var h=a.extend({url:d,type:"get",cache:!1,dataType:"json",success:a.proxy(function(d){f&&(f.loading=!1),this.sourceData=this.makeArray(d),a.isArray(this.sourceData)?(f&&(f.sourceData=this.sourceData,a.each(f.callbacks,function(){this.call()})),this.doPrepend(),b.call(this)):(c.call(this),f&&a.each(f.err_callbacks,function(){this.call()}))},this),error:a.proxy(function(){c.call(this),f&&(f.loading=!1,a.each(f.err_callbacks,function(){this.call()}))},this)},this.options.sourceOptions);a.ajax(h)}else this.sourceData=this.makeArray(d),a.isArray(this.sourceData)?(this.doPrepend(),b.call(this)):c.call(this)},doPrepend:function(){null!==this.options.prepend&&void 0!==this.options.prepend&&(a.isArray(this.prependData)||(a.isFunction(this.options.prepend)&&(this.options.prepend=this.options.prepend.call(this.options.scope)),this.options.prepend=a.fn.editableutils.tryParseJson(this.options.prepend,!0),"string"==typeof this.options.prepend&&(this.options.prepend={"":this.options.prepend}),this.prependData=this.makeArray(this.options.prepend)),a.isArray(this.prependData)&&a.isArray(this.sourceData)&&(this.sourceData=this.prependData.concat(this.sourceData)))},renderList:function(){},value2htmlFinal:function(a,b){},makeArray:function(b){var c,d,e,f,g=[];if(!b||"string"==typeof b)return null;if(a.isArray(b)){f=function(a,b){return d={value:a,text:b},c++>=2?!1:void 0};for(var h=0;h1&&(e.children&&(e.children=this.makeArray(e.children)),g.push(e))):g.push({value:e,text:e})}else a.each(b,function(a,b){g.push({value:a,text:b})});return g},option:function(a,b){this.options[a]=b,"source"===a&&(this.sourceData=null),"prepend"===a&&(this.prependData=null)}}),b.defaults=a.extend({},a.fn.editabletypes.abstractinput.defaults,{source:null,prepend:!1,sourceError:"Error when loading list",sourceCache:!0,sourceOptions:null}),a.fn.editabletypes.list=b}(window.jQuery),function(a){"use strict";var b=function(a){this.init("text",a,b.defaults)};a.fn.editableutils.inherit(b,a.fn.editabletypes.abstractinput),a.extend(b.prototype,{render:function(){this.renderClear(),this.setClass(),this.setAttr("placeholder")},activate:function(){this.$input.is(":visible")&&(this.$input.focus(),this.$input.is("input,textarea")&&!this.$input.is('[type="checkbox"],[type="range"]')&&a.fn.editableutils.setCursorPosition(this.$input.get(0),this.$input.val().length),this.toggleClear&&this.toggleClear())},renderClear:function(){this.options.clear&&(this.$clear=a(''),this.$input.after(this.$clear).css("padding-right",24).keyup(a.proxy(function(b){if(!~a.inArray(b.keyCode,[40,38,9,13,27])){clearTimeout(this.t);var c=this;this.t=setTimeout(function(){c.toggleClear(b)},100)}},this)).parent().css("position","relative"),this.$clear.click(a.proxy(this.clear,this)))},postrender:function(){},toggleClear:function(a){if(this.$clear){var b=this.$input.val().length,c=this.$clear.is(":visible");b&&!c&&this.$clear.show(),!b&&c&&this.$clear.hide()}},clear:function(){this.$clear.hide(),this.$input.val("").focus()}}),b.defaults=a.extend({},a.fn.editabletypes.abstractinput.defaults,{tpl:'',placeholder:null,clear:!0}),a.fn.editabletypes.text=b}(window.jQuery),function(a){"use strict";var b=function(a){this.init("textarea",a,b.defaults)};a.fn.editableutils.inherit(b,a.fn.editabletypes.abstractinput),a.extend(b.prototype,{render:function(){this.setClass(),this.setAttr("placeholder"),this.setAttr("rows"),this.$input.keydown(function(b){b.ctrlKey&&13===b.which&&a(this).closest("form").submit()})},activate:function(){a.fn.editabletypes.text.prototype.activate.call(this)}}),b.defaults=a.extend({},a.fn.editabletypes.abstractinput.defaults,{tpl:"",inputclass:"input-large",placeholder:null,rows:7}),a.fn.editabletypes.textarea=b}(window.jQuery),function(a){"use strict";var b=function(a){this.init("select",a,b.defaults)};a.fn.editableutils.inherit(b,a.fn.editabletypes.list),a.extend(b.prototype,{renderList:function(){this.$input.empty();var b=this.options.escape,c=function(d,e){var f;if(a.isArray(e))for(var g=0;g",f),e[g].children));else{f.value=e[g].value,e[g].disabled&&(f.disabled=!0);var h=a("