diff options
106 files changed, 7220 insertions, 603 deletions
diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e6a7464c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "pdf.js"] + path = pdf.js + url = git://github.com/mozilla/pdf.js.git +[submodule "extlib/pdf.js"] + path = extlib/pdf.js + url = git://github.com/mozilla/pdf.js.git @@ -30,6 +30,7 @@ Thank you! * Greg Grossmeier * Jakob Kramer * Jef van Schendel +* Jessica Tallon * Jim Campbell * Joar Wandborg * Jorge Araya Navarro diff --git a/docs/source/devel/codebase.rst b/docs/source/devel/codebase.rst index 9718a097..122a3297 100644 --- a/docs/source/devel/codebase.rst +++ b/docs/source/devel/codebase.rst @@ -119,7 +119,7 @@ Software Stack * `Python <http://python.org/>`_: the language we're using to write this - * `Nose <http://somethingaboutorange.com/mrl/projects/nose/>`_: + * `Py.Test <http://pytest.org/>`_: for unit tests * `virtualenv <http://www.virtualenv.org/>`_: for setting up an diff --git a/docs/source/devel/storage.rst b/docs/source/devel/storage.rst index 52406c4e..215f9579 100644 --- a/docs/source/devel/storage.rst +++ b/docs/source/devel/storage.rst @@ -2,18 +2,28 @@ Storage ========= - -See for now: http://wiki.mediagoblin.org/Storage - -Things get moved here. - - The storage systems attached to your app ---------------------------------------- Dynamic content: queue_store and public_store ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Two instances of the StorageInterface come attached to your app. These +are: + ++ **queue_store:** When a user submits a fresh piece of media for + their gallery, before the Processing stage, that piece of media sits + here in the queue_store. (It's possible that we'll rename this to + "private_store" and start storing more non-publicly-stored stuff in + the future...). This is a StorageInterface implementation + instance. Visitors to your site probably cannot see it... it isn't + designed to be seen, anyway. + ++ **public_store:** After your media goes through processing it gets + moved to the public store. This is also a StorageInterface + implelementation, and is for stuff that's intended to be seen by + site visitors. + The workbench ~~~~~~~~~~~~~ @@ -32,6 +42,28 @@ See the workbench module documentation for more. Static assets / staticdirect ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +On top of all that, there is some static media that comes bundled with your +application. This stuff is kept in: + + mediagoblin/static/ + +These files are for mediagoblin base assets. Things like the CSS files, +logos, etc. You can mount these at whatever location is appropriate to you +(see the direct_remote_path option in the config file) so if your users +are keeping their static assets at http://static.mgoblin.example.org/ but +their actual site is at http://mgoblin.example.org/, you need to be able +to get your static files in a where-it's-mounted agnostic way. There's a +"staticdirector" attached to the request object. It's pretty easy to use; +just look at this bit taken from the +mediagoblin/templates/mediagoblin/base.html main template: + + <link rel="stylesheet" type="text/css" + href="Template:Request.staticdirect('/css/extlib/text.css')"/> + +see? Not too hard. As expected, if you configured direct_remote_path to be +http://static.mgoblin.example.org/ you'll get back +http://static.mgoblin.example.org/css/extlib/text.css just as you'd +probably expect. StorageInterface and implementations ------------------------------------ @@ -39,5 +71,55 @@ StorageInterface and implementations The guts of StorageInterface and friends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +So, the StorageInterface! + +So, the public and queue stores both use StorageInterface implementations +... but what does that mean? It's not too hard. + +Open up: + + mediagoblin/storage.py + +In here you'll see a couple of things. First of all, there's the +StorageInterface class. What you'll see is that this is just a very simple +python class. A few of the methods actually implement things, but for the +most part, they don't. What really matters about this class is the +docstrings. Each expected method is documented as to how it should be +constructed. Want to make a new StorageInterface? Simply subclass it. Want +to know how to use the methods of your storage system? Read these docs, +they span all implementations. + +There are a couple of implementations of these classes bundled in +storage.py as well. The most simple of these is BasicFileStorage, which is +also the default storage system used. As expected, this stores files +locally on your machine. + +There's also a CloudFileStorage system. This provides a mapping to +[OpenStack's swift http://swift.openstack.org/] storage system (used by +RackSpace Cloud files and etc). + +Between these two examples you should be able to get a pretty good idea of +how to write your own storage systems, for storing data across your +beowulf cluster of radioactive monkey brains, whatever. + Writing code to store stuff ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +So what does coding for StorageInterface implementations actually look +like? It's pretty simple, really. For one thing, the design is fairly +inspired by [Django's file storage API +https://docs.djangoproject.com/en/dev/ref/files/storage/]... with some +differences. + +Basically, you access files on "file paths", which aren't exactly like +unix file paths, but are close. If you wanted to store a file on a path +like dir1/dir2/filename.jpg you'd actually write that file path like: + +['dir1', 'dir2', 'filename.jpg'] + +This way we can be *sure* that each component is actually a component of +the path that's expected... we do some filename cleaning on each component. + +Your StorageInterface should pass in and out "file like objects". In other +words, they should provide .read() and .write() at minimum, and probably +also .seek() and .close(). diff --git a/docs/source/pluginwriter/api.rst b/docs/source/pluginwriter/api.rst index 44ffd6e8..df933511 100644 --- a/docs/source/pluginwriter/api.rst +++ b/docs/source/pluginwriter/api.rst @@ -16,10 +16,35 @@ Plugin API ========== +This documents the general plugin API. + +Please note, at this point OUR PLUGIN HOOKS MAY AND WILL CHANGE. +Authors are encouraged to develop plugins and work with the +MediaGoblin community to keep them up to date, but this API will be a +moving target for a few releases. + +Please check the release notes for updates! + :mod:`pluginapi` Module ----------------------- .. automodule:: mediagoblin.tools.pluginapi :members: get_config, register_routes, register_template_path, register_template_hooks, get_hook_templates, - callable_runone, callable_runall + hook_handle, hook_runall, hook_transform + +Configuration +------------- + +Your plugin may define its own configuration defaults. + +Simply add to the directory of your plugin a config_spec.ini file. An +example might look like:: + + [plugin_spec] + some_string = string(default="blork") + some_int = integer(default=50) + +This means that when people enable your plugin in their config you'll +be able to provide defaults as well as type validation. + diff --git a/docs/source/siteadmin/deploying.rst b/docs/source/siteadmin/deploying.rst index 77e60037..f2f71e01 100644 --- a/docs/source/siteadmin/deploying.rst +++ b/docs/source/siteadmin/deploying.rst @@ -345,3 +345,17 @@ Visit the site you've set up in your browser by visiting smaller deployments. However, for larger production deployments with larger processing requirements, see the ":doc:`production-deployments`" documentation. + + +Security Considerations +~~~~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + + The directory ``user_dev/crypto/`` contains some very + sensitive files. + Especially the ``itsdangeroussecret.bin`` is very important + for session security. Make sure not to leak its contents anywhere. + If the contents gets leaked nevertheless, delete your file + and restart the server, so that it creates a new secret key. + All previous sessions will be invalifated then. diff --git a/docs/source/siteadmin/media-types.rst b/docs/source/siteadmin/media-types.rst index 23d3f3b9..210094b9 100644 --- a/docs/source/siteadmin/media-types.rst +++ b/docs/source/siteadmin/media-types.rst @@ -195,3 +195,40 @@ Run You should now be able to upload .obj and .stl files and MediaGoblin will be able to present them to your wide audience of admirers! + +PDF and Document +================ + +To enable the "PDF and Document" support plugin, you need pdftocairo, pdfinfo, +unoconv with headless support. All executables must be on your execution path. + +To install this on Fedora: + +.. code-block:: bash + + sudo yum install -y poppler-utils unoconv libreoffice-headless + +pdf.js relies on git submodules, so be sure you have fetched them: + +.. code-block:: bash + + git submodule init + git submodule update + +This feature has been tested on Fedora with: + poppler-utils-0.20.2-9.fc18.x86_64 + unoconv-0.5-2.fc18.noarch + libreoffice-headless-3.6.5.2-8.fc18.x86_64 + +It may work on some earlier versions, but that is not guaranteed. + +Add ``mediagoblin.media_types.pdf`` to the ``media_types`` list in your +``mediagoblin_local.ini`` and restart MediaGoblin. + +Run + +.. code-block:: bash + + ./bin/gmg dbupdate + + diff --git a/docs/source/siteadmin/relnotes.rst b/docs/source/siteadmin/relnotes.rst index 6962dc5a..04863ec6 100644 --- a/docs/source/siteadmin/relnotes.rst +++ b/docs/source/siteadmin/relnotes.rst @@ -100,7 +100,19 @@ MongoDB-based MediaGoblin instance to the newer SQL-based system. **Do this to upgrade** -1. Make sure to run ``bin/gmg dbupdate`` after upgrading. + # directory of your mediagoblin install + cd /srv/mediagoblin.example.org + + # copy source for this release + git fetch + git checkout tags/v0.3.2 + + # perform any needed database updates + bin/gmg dbupdate + + # restart your servers however you do that, e.g., + sudo service mediagoblin-paster restart + sudo service mediagoblin-celeryd restart **New features** diff --git a/extlib/README b/extlib/README index a2cc6ec0..45ee5b46 100644 --- a/extlib/README +++ b/extlib/README @@ -63,6 +63,19 @@ FAQ This is a last resort; consult with the rest of the dev group before taking this radical step. +:Q: What about submodules? + +:A: pdf.js is supplied as a submodule, and other software may use that too, + to add a new submodule: + git submodule add <git-repo-of-fun-project> extlib/fun-project + + Use it just like a snapshotted extlib directory. When a new clone of mediagoblin + is made you need to run + + git submodule init + git submodule update + + As noted in HackingHowto Thanks ====== diff --git a/extlib/freesound/audioprocessing.py b/extlib/freesound/audioprocessing.py index c1dfe2eb..2c2b35b5 100644 --- a/extlib/freesound/audioprocessing.py +++ b/extlib/freesound/audioprocessing.py @@ -20,7 +20,7 @@ # Bram de Jong <bram.dejong at domain.com where domain in gmail> # 2012, Joar Wandborg <first name at last name dot se> -import Image, ImageDraw, ImageColor #@UnresolvedImport +from PIL import Image, ImageDraw, ImageColor #@UnresolvedImport from functools import partial import math import numpy diff --git a/extlib/pdf.js b/extlib/pdf.js new file mode 160000 +Subproject 369b81b63f560b5d729da26752ca541503d8151 diff --git a/mediagoblin.ini b/mediagoblin.ini index bed69737..43621107 100644 --- a/mediagoblin.ini +++ b/mediagoblin.ini @@ -20,6 +20,8 @@ email_debug_mode = true allow_registration = true ## Uncomment this to turn on video or enable other media types +## You may have to install dependencies, and will have to run ./bin/dbupdate +## See http://docs.mediagoblin.org/siteadmin/media-types.html for details. # media_types = mediagoblin.media_types.image, mediagoblin.media_types.video ## Uncomment this to put some user-overriding templates here diff --git a/mediagoblin/app.py b/mediagoblin/app.py index 1137c0d7..bf0e0f13 100644 --- a/mediagoblin/app.py +++ b/mediagoblin/app.py @@ -35,7 +35,7 @@ from mediagoblin.init.plugins import setup_plugins from mediagoblin.init import (get_jinja_loader, get_staticdirector, setup_global_and_app_config, setup_locales, setup_workbench, setup_database, setup_storage) -from mediagoblin.tools.pluginapi import PluginManager +from mediagoblin.tools.pluginapi import PluginManager, hook_transform from mediagoblin.tools.crypto import setup_crypto @@ -227,7 +227,7 @@ class MediaGoblinApp(object): for m in self.meddleware[::-1]: m.process_response(request, response) except HTTPException as e: - response = render_http_exeption( + response = render_http_exception( request, e, e.get_description(environ)) session_manager.save_session_to_cookie(request.session, @@ -259,8 +259,6 @@ def paste_app_factory(global_config, **app_config): raise IOError("Usable mediagoblin config not found.") mgoblin_app = MediaGoblinApp(mediagoblin_config) - - for callable_hook in PluginManager().get_hook_callables('wrap_wsgi'): - mgoblin_app = callable_hook(mgoblin_app) + mgoblin_app = hook_transform('wrap_wsgi', mgoblin_app) return mgoblin_app diff --git a/mediagoblin/auth/forms.py b/mediagoblin/auth/forms.py index 8f091d21..33e1f45c 100644 --- a/mediagoblin/auth/forms.py +++ b/mediagoblin/auth/forms.py @@ -17,7 +17,7 @@ import wtforms from mediagoblin.tools.mail import normalize_email -from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ def normalize_user_or_email_field(allow_email=True, allow_user=True): """Check if we were passed a field that matches a username and/or email pattern @@ -64,9 +64,9 @@ class RegistrationForm(wtforms.Form): class LoginForm(wtforms.Form): username = wtforms.TextField( - _('Username'), + _('Username or Email'), [wtforms.validators.Required(), - normalize_user_or_email_field(allow_email=False)]) + normalize_user_or_email_field()]) password = wtforms.PasswordField( _('Password'), [wtforms.validators.Required(), diff --git a/mediagoblin/auth/views.py b/mediagoblin/auth/views.py index 354b48c1..dc408911 100644 --- a/mediagoblin/auth/views.py +++ b/mediagoblin/auth/views.py @@ -25,7 +25,7 @@ from mediagoblin.auth import lib as auth_lib from mediagoblin.auth import forms as auth_forms from mediagoblin.auth.lib import send_verification_email, \ send_fp_verification_email - +from sqlalchemy import or_ def email_debug_message(request): """ @@ -113,8 +113,16 @@ def login(request): login_failed = False if request.method == 'POST': + + username = login_form.data['username'] + if login_form.validate(): - user = User.query.filter_by(username=login_form.data['username']).first() + user = User.query.filter( + or_( + User.username == username, + User.email == username, + + )).first() if user and user.check_login(login_form.password.data): # set up login in session diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index 545d02e2..6c00aa58 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -32,7 +32,7 @@ email_smtp_pass = string(default=None) allow_registration = boolean(default=True) # tag parsing -tags_max_length = integer(default=50) +tags_max_length = integer(default=255) # Enable/disable comments allow_comments = boolean(default=True) @@ -94,6 +94,8 @@ max_height = integer(default=180) [media_type:mediagoblin.media_types.image] # One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS resize_filter = string(default="ANTIALIAS") +#level of compression used when resizing images +quality = integer(default=90) [media_type:mediagoblin.media_types.video] # Should we keep the original file? @@ -116,7 +118,6 @@ video_codecs = string_list(default=list("VP8 video")) audio_codecs = string_list(default=list("Vorbis")) dimensions_match = boolean(default=True) - [media_type:mediagoblin.media_types.audio] keep_original = boolean(default=True) # vorbisenc quality @@ -124,10 +125,12 @@ quality = float(default=0.3) create_spectrogram = boolean(default=True) spectrogram_fft_size = integer(default=4096) - [media_type:mediagoblin.media_types.ascii] thumbnail_font = string(default=None) +[media_type:mediagoblin.media_types.pdf] +pdf_js = boolean(default=False) + [celery] # default result stuff diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 167c4f87..2c553396 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import datetime +import uuid from sqlalchemy import (MetaData, Table, Column, Boolean, SmallInteger, Integer, Unicode, UnicodeText, DateTime, @@ -212,7 +213,6 @@ def mediaentry_new_slug_era(db): - slugs with = (or also : which is now also not allowed) to have those stripped out (small possibility of breakage here sadly) """ - import uuid def slug_and_user_combo_exists(slug, uploader): return db.execute( @@ -251,3 +251,39 @@ def mediaentry_new_slug_era(db): row, row.slug.replace(u"=", u"-").replace(u":", u"-")) db.commit() + + +@RegisterMigration(10, MIGRATIONS) +def unique_collections_slug(db): + """Add unique constraint to collection slug""" + metadata = MetaData(bind=db.bind) + collection_table = inspect_table(metadata, "core__collections") + existing_slugs = {} + slugs_to_change = [] + + for row in db.execute(collection_table.select()): + # if duplicate slug, generate a unique slug + if row.creator in existing_slugs and row.slug in \ + existing_slugs[row.creator]: + slugs_to_change.append(row.id) + else: + if not row.creator in existing_slugs: + existing_slugs[row.creator] = [row.slug] + else: + existing_slugs[row.creator].append(row.slug) + + for row_id in slugs_to_change: + new_slug = unicode(uuid.uuid4()) + db.execute(collection_table.update(). + where(collection_table.c.id == row_id). + values(slug=new_slug)) + # sqlite does not like to change the schema when a transaction(update) is + # not yet completed + db.commit() + + constraint = UniqueConstraint('creator', 'slug', + name='core__collection_creator_slug_key', + table=collection_table) + constraint.create() + + db.commit() diff --git a/mediagoblin/db/mixin.py b/mediagoblin/db/mixin.py index 0dc3bc85..388bac89 100644 --- a/mediagoblin/db/mixin.py +++ b/mediagoblin/db/mixin.py @@ -149,7 +149,7 @@ class MediaEntryMixin(GenerateSlugMixin): or, if not found, None. """ - fetch_order = self.media_manager.get("media_fetch_order") + fetch_order = self.media_manager.media_fetch_order # No fetching order found? well, give up! if not fetch_order: @@ -212,7 +212,7 @@ class MediaEntryMixin(GenerateSlugMixin): # than iterating through all media managers. for media_type, manager in get_media_managers(): if media_type == self.media_type: - return manager + return manager(self) # Not found? Then raise an error raise FileTypeNotSupported( "MediaManager not in enabled types. Check media_types in config?") diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index fcfd0f61..2412706e 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -410,7 +410,7 @@ class Collection(Base, CollectionMixin): title = Column(Unicode, nullable=False) slug = Column(Unicode) created = Column(DateTime, nullable=False, default=datetime.datetime.now, - index=True) + index=True) description = Column(UnicodeText) creator = Column(Integer, ForeignKey(User.id), nullable=False) # TODO: No of items in Collection. Badly named, can we migrate to num_items? @@ -421,6 +421,10 @@ class Collection(Base, CollectionMixin): backref=backref("collections", cascade="all, delete-orphan")) + __table_args__ = ( + UniqueConstraint('creator', 'slug'), + {}) + def get_collection_items(self, ascending=False): #TODO, is this still needed with self.collection_items being available? order_col = CollectionItem.position diff --git a/mediagoblin/db/open.py b/mediagoblin/db/open.py index 5fd5ed03..0b1679fb 100644 --- a/mediagoblin/db/open.py +++ b/mediagoblin/db/open.py @@ -71,12 +71,24 @@ def _sqlite_fk_pragma_on_connect(dbapi_con, con_record): dbapi_con.execute('pragma foreign_keys=on') -def setup_connection_and_db_from_config(app_config): +def _sqlite_disable_fk_pragma_on_connect(dbapi_con, con_record): + """ + Disable foreign key checking on each new sqlite connection + (Good for migrations!) + """ + dbapi_con.execute('pragma foreign_keys=off') + + +def setup_connection_and_db_from_config(app_config, migrations=False): engine = create_engine(app_config['sql_engine']) # Enable foreign key checking for sqlite if app_config['sql_engine'].startswith('sqlite://'): - event.listen(engine, 'connect', _sqlite_fk_pragma_on_connect) + if migrations: + event.listen(engine, 'connect', + _sqlite_disable_fk_pragma_on_connect) + else: + event.listen(engine, 'connect', _sqlite_fk_pragma_on_connect) # logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) diff --git a/mediagoblin/edit/forms.py b/mediagoblin/edit/forms.py index 2673967b..ef270237 100644 --- a/mediagoblin/edit/forms.py +++ b/mediagoblin/edit/forms.py @@ -17,7 +17,7 @@ import wtforms from mediagoblin.tools.text import tag_length_validator, TOO_LONG_TAG_WARNING -from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ from mediagoblin.tools.licenses import licenses_as_choices class EditForm(wtforms.Form): diff --git a/mediagoblin/edit/views.py b/mediagoblin/edit/views.py index 34b7aaca..bfcf65b5 100644 --- a/mediagoblin/edit/views.py +++ b/mediagoblin/edit/views.py @@ -28,7 +28,8 @@ from mediagoblin.edit.lib import may_edit_media from mediagoblin.decorators import (require_active_login, active_user_from_url, get_media_entry_by_id, user_may_alter_collection, get_user_collection) -from mediagoblin.tools.response import render_to_response, redirect +from mediagoblin.tools.response import render_to_response, \ + redirect, redirect_obj from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin.tools.text import ( convert_to_tag_list_of_dicts, media_tags_as_string) @@ -74,8 +75,7 @@ def edit_media(request, media): media.slug = slug media.save() - return redirect(request, - location=media.url_for_self(request.urlgen)) + return redirect_obj(request, media) if request.user.is_admin \ and media.uploader != request.user.id \ @@ -331,9 +331,7 @@ def edit_collection(request, collection): collection.save() - return redirect(request, "mediagoblin.user_pages.user_collection", - user=collection.get_creator.username, - collection=collection.slug) + return redirect_obj(request, collection) if request.user.is_admin \ and collection.creator != request.user.id \ diff --git a/mediagoblin/gmg_commands/dbupdate.py b/mediagoblin/gmg_commands/dbupdate.py index 65b3f922..32700c40 100644 --- a/mediagoblin/gmg_commands/dbupdate.py +++ b/mediagoblin/gmg_commands/dbupdate.py @@ -114,7 +114,7 @@ def run_dbupdate(app_config, global_config): global_config.get('plugins', {}).keys()) # Set up the database - db = setup_connection_and_db_from_config(app_config) + db = setup_connection_and_db_from_config(app_config, migrations=True) Session = sessionmaker(bind=db.engine) diff --git a/mediagoblin/init/celery/__init__.py b/mediagoblin/init/celery/__init__.py index bb0d5989..169cc935 100644 --- a/mediagoblin/init/celery/__init__.py +++ b/mediagoblin/init/celery/__init__.py @@ -18,7 +18,7 @@ import os import sys from celery import Celery -from mediagoblin.tools.pluginapi import callable_runall +from mediagoblin.tools.pluginapi import hook_runall MANDATORY_CELERY_IMPORTS = ['mediagoblin.processing.task'] @@ -66,7 +66,7 @@ def setup_celery_app(app_config, global_config, celery_app = Celery() celery_app.config_from_object(celery_settings) - callable_runall('celery_setup', celery_app) + hook_runall('celery_setup', celery_app) def setup_celery_from_config(app_config, global_config, diff --git a/mediagoblin/init/celery/from_celery.py b/mediagoblin/init/celery/from_celery.py index e2899c0b..b395a826 100644 --- a/mediagoblin/init/celery/from_celery.py +++ b/mediagoblin/init/celery/from_celery.py @@ -22,7 +22,7 @@ from celery.signals import setup_logging from mediagoblin import app, mg_globals from mediagoblin.init.celery import setup_celery_from_config -from mediagoblin.tools.pluginapi import callable_runall +from mediagoblin.tools.pluginapi import hook_runall OUR_MODULENAME = __name__ @@ -47,7 +47,7 @@ def setup_logging_from_paste_ini(loglevel, **kw): logging.config.fileConfig(logging_conf_file) - callable_runall('celery_logging_setup') + hook_runall('celery_logging_setup') setup_logging.connect(setup_logging_from_paste_ini) diff --git a/mediagoblin/init/config.py b/mediagoblin/init/config.py index ac4ab9bf..11a91cff 100644 --- a/mediagoblin/init/config.py +++ b/mediagoblin/init/config.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import logging import os import pkg_resources @@ -21,6 +22,9 @@ from configobj import ConfigObj, flatten_errors from validate import Validator +_log = logging.getLogger(__name__) + + CONFIG_SPEC_PATH = pkg_resources.resource_filename( 'mediagoblin', 'config_spec.ini') @@ -42,6 +46,9 @@ def read_mediagoblin_config(config_path, config_spec=CONFIG_SPEC_PATH): Also provides %(__file__)s and %(here)s values of this file and its directory respectively similar to paste deploy. + Also reads for [plugins] section, appends all config_spec.ini + files from said plugins into the general config_spec specification. + This function doesn't itself raise any exceptions if validation fails, you'll have to do something @@ -57,10 +64,45 @@ def read_mediagoblin_config(config_path, config_spec=CONFIG_SPEC_PATH): """ config_path = os.path.abspath(config_path) + # PRE-READ of config file. This allows us to fetch the plugins so + # we can add their plugin specs to the general config_spec. + config = ConfigObj( + config_path, + interpolation='ConfigParser') + + plugins = config.get("plugins", {}).keys() + plugin_configs = {} + + for plugin in plugins: + try: + plugin_config_spec_path = pkg_resources.resource_filename( + plugin, "config_spec.ini") + if not os.path.exists(plugin_config_spec_path): + continue + + plugin_config_spec = ConfigObj( + plugin_config_spec_path, + encoding='UTF8', list_values=False, _inspec=True) + _setup_defaults(plugin_config_spec, config_path) + + if not "plugin_spec" in plugin_config_spec: + continue + + plugin_configs[plugin] = plugin_config_spec["plugin_spec"] + + except ImportError: + _log.warning( + "When setting up config section, could not import '%s'" % + plugin) + + # Now load the main config spec config_spec = ConfigObj( config_spec, encoding='UTF8', list_values=False, _inspec=True) + # append the plugin specific sections of the config spec + config_spec['plugins'] = plugin_configs + _setup_defaults(config_spec, config_path) config = ConfigObj( diff --git a/mediagoblin/init/plugins/__init__.py b/mediagoblin/init/plugins/__init__.py index 72bd5c7d..0df4f381 100644 --- a/mediagoblin/init/plugins/__init__.py +++ b/mediagoblin/init/plugins/__init__.py @@ -59,4 +59,4 @@ def setup_plugins(): pman.register_hooks(plugin.hooks) # Execute anything registered to the setup hook. - pluginapi.callable_runall('setup') + pluginapi.hook_runall('setup') diff --git a/mediagoblin/media_types/__init__.py b/mediagoblin/media_types/__init__.py index 0abb38d3..20e1918e 100644 --- a/mediagoblin/media_types/__init__.py +++ b/mediagoblin/media_types/__init__.py @@ -20,6 +20,7 @@ import logging import tempfile from mediagoblin import mg_globals +from mediagoblin.tools.common import import_component from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ _log = logging.getLogger(__name__) @@ -31,6 +32,56 @@ class InvalidFileType(Exception): pass +class MediaManagerBase(object): + "Base class for all media managers" + + # Please override in actual media managers + media_fetch_order = None + + @staticmethod + def sniff_handler(*args, **kwargs): + return False + + def __init__(self, entry): + self.entry = entry + + def __getitem__(self, i): + return getattr(self, i) + + def __contains__(self, i): + return hasattr(self, i) + + +class CompatMediaManager(object): + def __init__(self, mm_dict, entry=None): + self.mm_dict = mm_dict + self.entry = entry + + def __call__(self, entry): + "So this object can look like a class too, somehow" + assert self.entry is None + return self.__class__(self.mm_dict, entry) + + def __getitem__(self, i): + return self.mm_dict[i] + + def __contains__(self, i): + return (i in self.mm_dict) + + @property + def media_fetch_order(self): + return self.mm_dict.get('media_fetch_order') + + def sniff_handler(self, *args, **kwargs): + func = self.mm_dict.get("sniff_handler", None) + if func is not None: + return func(*args, **kwargs) + return False + + def __getattr__(self, i): + return self.mm_dict[i] + + def sniff_media(media): ''' Iterate through the enabled media types and find those suited @@ -49,8 +100,7 @@ def sniff_media(media): for media_type, manager in get_media_managers(): _log.info('Sniffing {0}'.format(media_type)) - if 'sniff_handler' in manager and \ - manager['sniff_handler'](media_file, media=media): + if manager.sniff_handler(media_file, media=media): _log.info('{0} accepts the file'.format(media_type)) return media_type, manager else: @@ -74,9 +124,12 @@ def get_media_managers(): Generator, yields all enabled media managers ''' for media_type in get_media_types(): - __import__(media_type) + mm = import_component(media_type + ":MEDIA_MANAGER") + + if isinstance(mm, dict): + mm = CompatMediaManager(mm) - yield media_type, sys.modules[media_type].MEDIA_MANAGER + yield media_type, mm def get_media_type_and_manager(filename): @@ -92,7 +145,7 @@ def get_media_type_and_manager(filename): for media_type, manager in get_media_managers(): # Omit the dot from the extension and match it against # the media manager - if ext[1:] in manager['accepted_extensions']: + if ext[1:] in manager.accepted_extensions: return media_type, manager else: _log.info('File {0} has no file extension, let\'s hope the sniffers get it.'.format( diff --git a/mediagoblin/media_types/ascii/__init__.py b/mediagoblin/media_types/ascii/__init__.py index 856d1d7b..0931e83a 100644 --- a/mediagoblin/media_types/ascii/__init__.py +++ b/mediagoblin/media_types/ascii/__init__.py @@ -14,16 +14,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +from mediagoblin.media_types import MediaManagerBase from mediagoblin.media_types.ascii.processing import process_ascii, \ sniff_handler -MEDIA_MANAGER = { - "human_readable": "ASCII", - "processor": process_ascii, # alternately a string, - # 'mediagoblin.media_types.image.processing'? - "sniff_handler": sniff_handler, - "display_template": "mediagoblin/media_displays/ascii.html", - "default_thumb": "images/media_thumbs/ascii.jpg", - "accepted_extensions": [ - "txt", "asc", "nfo"]} +class ASCIIMediaManager(MediaManagerBase): + human_readable = "ASCII" + processor = staticmethod(process_ascii) + sniff_handler = staticmethod(sniff_handler) + display_template = "mediagoblin/media_displays/ascii.html" + default_thumb = "images/media_thumbs/ascii.jpg" + accepted_extensions = ["txt", "asc", "nfo"] + + +MEDIA_MANAGER = ASCIIMediaManager diff --git a/mediagoblin/media_types/ascii/asciitoimage.py b/mediagoblin/media_types/ascii/asciitoimage.py index 108de023..786941f6 100644 --- a/mediagoblin/media_types/ascii/asciitoimage.py +++ b/mediagoblin/media_types/ascii/asciitoimage.py @@ -14,9 +14,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import Image -import ImageFont -import ImageDraw +try: + from PIL import Image + from PIL import ImageFont + from PIL import ImageDraw +except ImportError: + import Image + import ImageFont + import ImageDraw import logging import pkg_resources import os diff --git a/mediagoblin/media_types/ascii/processing.py b/mediagoblin/media_types/ascii/processing.py index 309aab0a..2f6079be 100644 --- a/mediagoblin/media_types/ascii/processing.py +++ b/mediagoblin/media_types/ascii/processing.py @@ -15,7 +15,10 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import chardet import os -import Image +try: + from PIL import Image +except ImportError: + import Image import logging from mediagoblin import mg_globals as mgg @@ -42,7 +45,7 @@ def process_ascii(proc_state): """Code to process a txt file. Will be run by celery. A Workbench() represents a local tempory dir. It is automatically - cleaned up when this function exits. + cleaned up when this function exits. """ entry = proc_state.entry workbench = proc_state.workbench diff --git a/mediagoblin/media_types/audio/__init__.py b/mediagoblin/media_types/audio/__init__.py index 4f3ead60..2eb7300e 100644 --- a/mediagoblin/media_types/audio/__init__.py +++ b/mediagoblin/media_types/audio/__init__.py @@ -14,12 +14,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +from mediagoblin.media_types import MediaManagerBase from mediagoblin.media_types.audio.processing import process_audio, \ sniff_handler -MEDIA_MANAGER = { - 'human_readable': 'Audio', - 'processor': process_audio, - 'sniff_handler': sniff_handler, - 'display_template': 'mediagoblin/media_displays/audio.html', - 'accepted_extensions': ['mp3', 'flac', 'wav', 'm4a']} + +class AudioMediaManager(MediaManagerBase): + human_readable = "Audio" + processor = staticmethod(process_audio) + sniff_handler = staticmethod(sniff_handler) + display_template = "mediagoblin/media_displays/audio.html" + accepted_extensions = ["mp3", "flac", "wav", "m4a"] + + +MEDIA_MANAGER = AudioMediaManager diff --git a/mediagoblin/media_types/audio/spectrogram.py b/mediagoblin/media_types/audio/spectrogram.py index 458855c1..dd4d0299 100644 --- a/mediagoblin/media_types/audio/spectrogram.py +++ b/mediagoblin/media_types/audio/spectrogram.py @@ -19,7 +19,10 @@ # Bram de Jong <bram.dejong at domain.com where domain in gmail> # 2012, Joar Wandborg <first name at last name dot se> -from PIL import Image +try: + from PIL import Image +except ImportError: + import Image import math import numpy diff --git a/mediagoblin/media_types/audio/transcoders.py b/mediagoblin/media_types/audio/transcoders.py index 3a9a2125..84e6af7e 100644 --- a/mediagoblin/media_types/audio/transcoders.py +++ b/mediagoblin/media_types/audio/transcoders.py @@ -15,7 +15,10 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging -import Image +try: + from PIL import Image +except ImportError: + import Image from mediagoblin.processing import BadMediaFail from mediagoblin.media_types.audio import audioprocessing diff --git a/mediagoblin/media_types/image/__init__.py b/mediagoblin/media_types/image/__init__.py index 3e167db1..15cc8dda 100644 --- a/mediagoblin/media_types/image/__init__.py +++ b/mediagoblin/media_types/image/__init__.py @@ -14,19 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +from mediagoblin.media_types import MediaManagerBase from mediagoblin.media_types.image.processing import process_image, \ sniff_handler -MEDIA_MANAGER = { - "human_readable": "Image", - "processor": process_image, # alternately a string, - # 'mediagoblin.media_types.image.processing'? - "sniff_handler": sniff_handler, - "display_template": "mediagoblin/media_displays/image.html", - "default_thumb": "images/media_thumbs/image.png", - "accepted_extensions": ["jpg", "jpeg", "png", "gif", "tiff"], +class ImageMediaManager(MediaManagerBase): + human_readable = "Image" + processor = staticmethod(process_image) + sniff_handler = staticmethod(sniff_handler) + display_template = "mediagoblin/media_displays/image.html" + default_thumb = "images/media_thumbs/image.png" + accepted_extensions = ["jpg", "jpeg", "png", "gif", "tiff"] + media_fetch_order = [u'medium', u'original', u'thumb'] + - # Used by the media_entry.get_display_media method - "media_fetch_order": [u'medium', u'original', u'thumb'], -} +MEDIA_MANAGER = ImageMediaManager diff --git a/mediagoblin/media_types/image/processing.py b/mediagoblin/media_types/image/processing.py index e951ef29..bc0ce3f8 100644 --- a/mediagoblin/media_types/image/processing.py +++ b/mediagoblin/media_types/image/processing.py @@ -14,13 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import Image +try: + from PIL import Image +except ImportError: + import Image import os import logging from mediagoblin import mg_globals as mgg -from mediagoblin.processing import BadMediaFail, \ - create_pub_filepath, FilenameBuilder +from mediagoblin.processing import BadMediaFail, FilenameBuilder from mediagoblin.tools.exif import exif_fix_image_orientation, \ extract_exif, clean_exif, get_gps_data, get_useful, \ exif_image_needs_rotation @@ -34,29 +36,25 @@ PIL_FILTERS = { 'ANTIALIAS': Image.ANTIALIAS} -def resize_image(entry, filename, new_path, exif_tags, workdir, new_size, - size_limits=(0, 0)): +def resize_image(proc_state, resized, keyname, target_name, new_size, + exif_tags, workdir): """ Store a resized version of an image and return its pathname. Arguments: - entry -- the entry for the image to resize - filename -- the filename of the original image being resized - new_path -- public file path for the new resized image + proc_state -- the processing state for the image to resize + resized -- an image from Image.open() of the original image being resized + keyname -- Under what key to save in the db. + target_name -- public file path for the new resized image exif_tags -- EXIF data for the original image workdir -- directory path for storing converted image files new_size -- 2-tuple size for the resized image """ - try: - resized = Image.open(filename) - except IOError: - raise BadMediaFail() - resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation + config = mgg.global_config['media_type:mediagoblin.media_types.image'] - filter_config = \ - mgg.global_config['media_type:mediagoblin.media_types.image']\ - ['resize_filter'] + resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation + filter_config = config['resize_filter'] try: resize_filter = PIL_FILTERS[filter_config.upper()] except KeyError: @@ -67,10 +65,34 @@ def resize_image(entry, filename, new_path, exif_tags, workdir, new_size, resized.thumbnail(new_size, resize_filter) # Copy the new file to the conversion subdir, then remotely. - tmp_resized_filename = os.path.join(workdir, new_path[-1]) + tmp_resized_filename = os.path.join(workdir, target_name) with file(tmp_resized_filename, 'w') as resized_file: - resized.save(resized_file) - mgg.public_store.copy_local_to_storage(tmp_resized_filename, new_path) + resized.save(resized_file, quality=config['quality']) + proc_state.store_public(keyname, tmp_resized_filename, target_name) + + +def resize_tool(proc_state, force, keyname, target_name, + conversions_subdir, exif_tags): + # filename -- the filename of the original image being resized + filename = proc_state.get_queued_filename() + max_width = mgg.global_config['media:' + keyname]['max_width'] + max_height = mgg.global_config['media:' + keyname]['max_height'] + # If the size of the original file exceeds the specified size for the desized + # file, a target_name file is created and later associated with the media + # entry. + # Also created if the file needs rotation, or if forced. + try: + im = Image.open(filename) + except IOError: + raise BadMediaFail() + if force \ + or im.size[0] > max_width \ + or im.size[1] > max_height \ + or exif_image_needs_rotation(exif_tags): + resize_image( + proc_state, im, unicode(keyname), target_name, + (max_width, max_height), + exif_tags, conversions_subdir) SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg'] @@ -116,29 +138,14 @@ def process_image(proc_state): gps_data = get_gps_data(exif_tags) # Always create a small thumbnail - thumb_filepath = create_pub_filepath( - entry, name_builder.fill('{basename}.thumbnail{ext}')) - resize_image(entry, queued_filename, thumb_filepath, - exif_tags, conversions_subdir, - (mgg.global_config['media:thumb']['max_width'], - mgg.global_config['media:thumb']['max_height'])) - entry.media_files[u'thumb'] = thumb_filepath - - # If the size of the original file exceeds the specified size of a `medium` - # file, a `.medium.jpg` files is created and later associated with the media - # entry. - medium = Image.open(queued_filename) - if medium.size[0] > mgg.global_config['media:medium']['max_width'] \ - or medium.size[1] > mgg.global_config['media:medium']['max_height'] \ - or exif_image_needs_rotation(exif_tags): - medium_filepath = create_pub_filepath( - entry, name_builder.fill('{basename}.medium{ext}')) - resize_image( - entry, queued_filename, medium_filepath, - exif_tags, conversions_subdir, - (mgg.global_config['media:medium']['max_width'], - mgg.global_config['media:medium']['max_height'])) - entry.media_files[u'medium'] = medium_filepath + resize_tool(proc_state, True, 'thumb', + name_builder.fill('{basename}.thumbnail{ext}'), + conversions_subdir, exif_tags) + + # Possibly create a medium + resize_tool(proc_state, False, 'medium', + name_builder.fill('{basename}.medium{ext}'), + conversions_subdir, exif_tags) # Copy our queued local workbench to its final destination proc_state.copy_original(name_builder.fill('{basename}{ext}')) diff --git a/mediagoblin/media_types/pdf/__init__.py b/mediagoblin/media_types/pdf/__init__.py new file mode 100644 index 00000000..f0ba7867 --- /dev/null +++ b/mediagoblin/media_types/pdf/__init__.py @@ -0,0 +1,31 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from mediagoblin.media_types import MediaManagerBase +from mediagoblin.media_types.pdf.processing import process_pdf, \ + sniff_handler + + +class PDFMediaManager(MediaManagerBase): + human_readable = "PDF" + processor = staticmethod(process_pdf) + sniff_handler = staticmethod(sniff_handler) + display_template = "mediagoblin/media_displays/pdf.html" + default_thumb = "images/media_thumbs/pdf.jpg" + accepted_extensions = ["pdf"] + + +MEDIA_MANAGER = PDFMediaManager diff --git a/mediagoblin/media_types/pdf/migrations.py b/mediagoblin/media_types/pdf/migrations.py new file mode 100644 index 00000000..f54c23ea --- /dev/null +++ b/mediagoblin/media_types/pdf/migrations.py @@ -0,0 +1,17 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +MIGRATIONS = {} diff --git a/mediagoblin/media_types/pdf/models.py b/mediagoblin/media_types/pdf/models.py new file mode 100644 index 00000000..c39262d1 --- /dev/null +++ b/mediagoblin/media_types/pdf/models.py @@ -0,0 +1,58 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from mediagoblin.db.base import Base + +from sqlalchemy import ( + Column, Float, Integer, String, DateTime, ForeignKey) +from sqlalchemy.orm import relationship, backref + + +BACKREF_NAME = "pdf__media_data" + + +class PdfData(Base): + __tablename__ = "pdf__mediadata" + + # The primary key *and* reference to the main media_entry + media_entry = Column(Integer, ForeignKey('core__media_entries.id'), + primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref(BACKREF_NAME, uselist=False, + cascade="all, delete-orphan")) + pages = Column(Integer) + + # These are taken from what pdfinfo can do, perhaps others make sense too + pdf_author = Column(String) + pdf_title = Column(String) + # note on keywords: this is the pdf parsed string, it should be considered a cached + # value like the rest of these values, since they can be deduced at query time / client + # side too. + pdf_keywords = Column(String) + pdf_creator = Column(String) + pdf_producer = Column(String) + pdf_creation_date = Column(DateTime) + pdf_modified_date = Column(DateTime) + pdf_version_major = Column(Integer) + pdf_version_minor = Column(Integer) + pdf_page_size_width = Column(Float) # unit: pts + pdf_page_size_height = Column(Float) + pdf_pages = Column(Integer) + + +DATA_MODEL = PdfData +MODELS = [PdfData] diff --git a/mediagoblin/media_types/pdf/processing.py b/mediagoblin/media_types/pdf/processing.py new file mode 100644 index 00000000..49742fd7 --- /dev/null +++ b/mediagoblin/media_types/pdf/processing.py @@ -0,0 +1,277 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import os +import logging +import dateutil.parser +from subprocess import PIPE, Popen + +from mediagoblin import mg_globals as mgg +from mediagoblin.processing import (create_pub_filepath, + FilenameBuilder, BadMediaFail) +from mediagoblin.tools.translate import fake_ugettext_passthrough as _ + +_log = logging.getLogger(__name__) + +# TODO - cache (memoize) util + +# This is a list created via uniconv --show and hand removing some types that +# we already support via other media types better. +unoconv_supported = [ + 'bib', # - BibTeX [.bib] + #bmp - Windows Bitmap [.bmp] + 'csv', # - Text CSV [.csv] + 'dbf', # - dBASE [.dbf] + 'dif', # - Data Interchange Format [.dif] + 'doc6', # - Microsoft Word 6.0 [.doc] + 'doc95', # - Microsoft Word 95 [.doc] + 'docbook', # - DocBook [.xml] + 'doc', # - Microsoft Word 97/2000/XP [.doc] + 'docx7', # - Microsoft Office Open XML [.docx] + 'docx', # - Microsoft Office Open XML [.docx] + #emf - Enhanced Metafile [.emf] + 'eps', # - Encapsulated PostScript [.eps] + 'fodp', # - OpenDocument Presentation (Flat XML) [.fodp] + 'fods', # - OpenDocument Spreadsheet (Flat XML) [.fods] + 'fodt', # - OpenDocument Text (Flat XML) [.fodt] + #gif - Graphics Interchange Format [.gif] + 'html', # - HTML Document (OpenOffice.org Writer) [.html] + #jpg - Joint Photographic Experts Group [.jpg] + 'latex', # - LaTeX 2e [.ltx] + 'mediawiki', # - MediaWiki [.txt] + 'met', # - OS/2 Metafile [.met] + 'odd', # - OpenDocument Drawing [.odd] + 'odg', # - ODF Drawing (Impress) [.odg] + 'odp', # - ODF Presentation [.odp] + 'ods', # - ODF Spreadsheet [.ods] + 'odt', # - ODF Text Document [.odt] + 'ooxml', # - Microsoft Office Open XML [.xml] + 'otg', # - OpenDocument Drawing Template [.otg] + 'otp', # - ODF Presentation Template [.otp] + 'ots', # - ODF Spreadsheet Template [.ots] + 'ott', # - Open Document Text [.ott] + #pbm - Portable Bitmap [.pbm] + #pct - Mac Pict [.pct] + 'pdb', # - AportisDoc (Palm) [.pdb] + #pdf - Portable Document Format [.pdf] + #pgm - Portable Graymap [.pgm] + #png - Portable Network Graphic [.png] + 'pot', # - Microsoft PowerPoint 97/2000/XP Template [.pot] + 'potm', # - Microsoft PowerPoint 2007/2010 XML Template [.potm] + #ppm - Portable Pixelmap [.ppm] + 'pps', # - Microsoft PowerPoint 97/2000/XP (Autoplay) [.pps] + 'ppt', # - Microsoft PowerPoint 97/2000/XP [.ppt] + 'pptx', # - Microsoft PowerPoint 2007/2010 XML [.pptx] + 'psw', # - Pocket Word [.psw] + 'pwp', # - PlaceWare [.pwp] + 'pxl', # - Pocket Excel [.pxl] + #ras - Sun Raster Image [.ras] + 'rtf', # - Rich Text Format [.rtf] + 'sda', # - StarDraw 5.0 (OpenOffice.org Impress) [.sda] + 'sdc3', # - StarCalc 3.0 [.sdc] + 'sdc4', # - StarCalc 4.0 [.sdc] + 'sdc', # - StarCalc 5.0 [.sdc] + 'sdd3', # - StarDraw 3.0 (OpenOffice.org Impress) [.sdd] + 'sdd4', # - StarImpress 4.0 [.sdd] + 'sdd', # - StarImpress 5.0 [.sdd] + 'sdw3', # - StarWriter 3.0 [.sdw] + 'sdw4', # - StarWriter 4.0 [.sdw] + 'sdw', # - StarWriter 5.0 [.sdw] + 'slk', # - SYLK [.slk] + 'stc', # - OpenOffice.org 1.0 Spreadsheet Template [.stc] + 'std', # - OpenOffice.org 1.0 Drawing Template [.std] + 'sti', # - OpenOffice.org 1.0 Presentation Template [.sti] + 'stw', # - Open Office.org 1.0 Text Document Template [.stw] + #svg - Scalable Vector Graphics [.svg] + 'svm', # - StarView Metafile [.svm] + 'swf', # - Macromedia Flash (SWF) [.swf] + 'sxc', # - OpenOffice.org 1.0 Spreadsheet [.sxc] + 'sxd3', # - StarDraw 3.0 [.sxd] + 'sxd5', # - StarDraw 5.0 [.sxd] + 'sxd', # - OpenOffice.org 1.0 Drawing (OpenOffice.org Impress) [.sxd] + 'sxi', # - OpenOffice.org 1.0 Presentation [.sxi] + 'sxw', # - Open Office.org 1.0 Text Document [.sxw] + #text - Text Encoded [.txt] + #tiff - Tagged Image File Format [.tiff] + #txt - Text [.txt] + 'uop', # - Unified Office Format presentation [.uop] + 'uos', # - Unified Office Format spreadsheet [.uos] + 'uot', # - Unified Office Format text [.uot] + 'vor3', # - StarDraw 3.0 Template (OpenOffice.org Impress) [.vor] + 'vor4', # - StarWriter 4.0 Template [.vor] + 'vor5', # - StarDraw 5.0 Template (OpenOffice.org Impress) [.vor] + 'vor', # - StarCalc 5.0 Template [.vor] + #wmf - Windows Metafile [.wmf] + 'xhtml', # - XHTML Document [.html] + 'xls5', # - Microsoft Excel 5.0 [.xls] + 'xls95', # - Microsoft Excel 95 [.xls] + 'xls', # - Microsoft Excel 97/2000/XP [.xls] + 'xlt5', # - Microsoft Excel 5.0 Template [.xlt] + 'xlt95', # - Microsoft Excel 95 Template [.xlt] + 'xlt', # - Microsoft Excel 97/2000/XP Template [.xlt] + #xpm - X PixMap [.xpm] +] + +def is_unoconv_working(): + # TODO: must have libreoffice-headless installed too, need to check for it + unoconv = where('unoconv') + if not unoconv: + return False + try: + proc = Popen([unoconv, '--show'], stderr=PIPE) + output = proc.stderr.read() + except OSError, e: + _log.warn(_('unoconv failing to run, check log file')) + return False + if 'ERROR' in output: + return False + return True + +def supported_extensions(cache=[None]): + if cache[0] == None: + cache[0] = 'pdf' + if is_unoconv_working(): + cache.extend(unoconv_supported) + return cache + +def where(name): + for p in os.environ['PATH'].split(os.pathsep): + fullpath = os.path.join(p, name) + if os.path.exists(fullpath): + return fullpath + return None + +def check_prerequisites(): + if not where('pdfinfo'): + _log.warn('missing pdfinfo') + return False + if not where('pdftocairo'): + _log.warn('missing pdfcairo') + return False + return True + +def sniff_handler(media_file, **kw): + if not check_prerequisites(): + return False + if kw.get('media') is not None: + name, ext = os.path.splitext(kw['media'].filename) + clean_ext = ext[1:].lower() + + if clean_ext in supported_extensions(): + return True + + return False + +def create_pdf_thumb(original, thumb_filename, width, height): + # Note: pdftocairo adds '.png', remove it + thumb_filename = thumb_filename[:-4] + executable = where('pdftocairo') + args = [executable, '-scale-to', str(min(width, height)), + '-singlefile', '-png', original, thumb_filename] + _log.debug('calling {0}'.format(repr(' '.join(args)))) + Popen(executable=executable, args=args).wait() + +def pdf_info(original): + """ + Extract dictionary of pdf information. This could use a library instead + of a process. + + Note: I'm assuming pdfinfo output is sanitized (integers where integers are + expected, etc.) - if this is wrong then an exception will be raised and caught + leading to the dreaded error page. It seems a safe assumption. + """ + ret_dict = {} + pdfinfo = where('pdfinfo') + try: + proc = Popen(executable=pdfinfo, + args=[pdfinfo, original], stdout=PIPE) + lines = proc.stdout.readlines() + except OSError: + _log.debug('pdfinfo could not read the pdf file.') + raise BadMediaFail() + + info_dict = dict([[part.strip() for part in l.strip().split(':', 1)] + for l in lines if ':' in l]) + + for date_key in [('pdf_mod_date', 'ModDate'), + ('pdf_creation_date', 'CreationDate')]: + if date_key in info_dict: + ret_dict[date_key] = dateutil.parser.parse(info_dict[date_key]) + for db_key, int_key in [('pdf_pages', 'Pages')]: + if int_key in info_dict: + ret_dict[db_key] = int(info_dict[int_key]) + + # parse 'PageSize' field: 595 x 842 pts (A4) + page_size_parts = info_dict['Page size'].split() + ret_dict['pdf_page_size_width'] = float(page_size_parts[0]) + ret_dict['pdf_page_size_height'] = float(page_size_parts[2]) + + for db_key, str_key in [('pdf_keywords', 'Keywords'), + ('pdf_creator', 'Creator'), ('pdf_producer', 'Producer'), + ('pdf_author', 'Author'), ('pdf_title', 'Title')]: + ret_dict[db_key] = info_dict.get(str_key, None) + ret_dict['pdf_version_major'], ret_dict['pdf_version_minor'] = \ + map(int, info_dict['PDF version'].split('.')) + + return ret_dict + +def process_pdf(proc_state): + """Code to process a pdf file. Will be run by celery. + + A Workbench() represents a local tempory dir. It is automatically + cleaned up when this function exits. + """ + entry = proc_state.entry + workbench = proc_state.workbench + + queued_filename = proc_state.get_queued_filename() + name_builder = FilenameBuilder(queued_filename) + + # Copy our queued local workbench to its final destination + original_dest = name_builder.fill('{basename}{ext}') + proc_state.copy_original(original_dest) + + # Create a pdf if this is a different doc, store pdf for viewer + ext = queued_filename.rsplit('.', 1)[-1].lower() + if ext == 'pdf': + pdf_filename = queued_filename + else: + pdf_filename = queued_filename.rsplit('.', 1)[0] + '.pdf' + unoconv = where('unoconv') + call(executable=unoconv, + args=[unoconv, '-v', '-f', 'pdf', queued_filename]) + if not os.path.exists(pdf_filename): + _log.debug('unoconv failed to convert file to pdf') + raise BadMediaFail() + proc_state.store_public(keyname=u'pdf', local_file=pdf_filename) + + pdf_info_dict = pdf_info(pdf_filename) + + for name, width, height in [ + (u'thumb', mgg.global_config['media:thumb']['max_width'], + mgg.global_config['media:thumb']['max_height']), + (u'medium', mgg.global_config['media:medium']['max_width'], + mgg.global_config['media:medium']['max_height']), + ]: + filename = name_builder.fill('{basename}.%s.png' % name) + path = workbench.joinpath(filename) + create_pdf_thumb(pdf_filename, path, width, height) + assert(os.path.exists(path)) + proc_state.store_public(keyname=name, local_file=path) + + proc_state.delete_queue_file() + + entry.media_data_init(**pdf_info_dict) + entry.save() diff --git a/mediagoblin/media_types/stl/__init__.py b/mediagoblin/media_types/stl/__init__.py index edffc633..6ae8a8b9 100644 --- a/mediagoblin/media_types/stl/__init__.py +++ b/mediagoblin/media_types/stl/__init__.py @@ -14,14 +14,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +from mediagoblin.media_types import MediaManagerBase from mediagoblin.media_types.stl.processing import process_stl, \ sniff_handler -MEDIA_MANAGER = { - "human_readable": "stereo lithographics", - "processor": process_stl, - "sniff_handler": sniff_handler, - "display_template": "mediagoblin/media_displays/stl.html", - "default_thumb": "images/media_thumbs/video.jpg", - "accepted_extensions": ["obj", "stl"]} +class STLMediaManager(MediaManagerBase): + human_readable = "stereo lithographics" + processor = staticmethod(process_stl) + sniff_handler = staticmethod(sniff_handler) + display_template = "mediagoblin/media_displays/stl.html" + default_thumb = "images/media_thumbs/video.jpg" + accepted_extensions = ["obj", "stl"] + + +MEDIA_MANAGER = STLMediaManager diff --git a/mediagoblin/media_types/stl/processing.py b/mediagoblin/media_types/stl/processing.py index e41df395..49382495 100644 --- a/mediagoblin/media_types/stl/processing.py +++ b/mediagoblin/media_types/stl/processing.py @@ -64,8 +64,6 @@ def blender_render(config): """ Called to prerender a model. """ - arg_string = "blender -b blender_render.blend -F " - arg_string +="JPEG -P blender_render.py" env = {"RENDER_SETUP" : json.dumps(config), "DISPLAY":":0"} subprocess.call( ["blender", diff --git a/mediagoblin/media_types/video/__init__.py b/mediagoblin/media_types/video/__init__.py index fab601f6..569cf11a 100644 --- a/mediagoblin/media_types/video/__init__.py +++ b/mediagoblin/media_types/video/__init__.py @@ -14,21 +14,23 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +from mediagoblin.media_types import MediaManagerBase from mediagoblin.media_types.video.processing import process_video, \ sniff_handler -MEDIA_MANAGER = { - "human_readable": "Video", - "processor": process_video, # alternately a string, - # 'mediagoblin.media_types.image.processing'? - "sniff_handler": sniff_handler, - "display_template": "mediagoblin/media_displays/video.html", - "default_thumb": "images/media_thumbs/video.jpg", - "accepted_extensions": [ - "mp4", "mov", "webm", "avi", "3gp", "3gpp", "mkv", "ogv", "m4v"], - +class VideoMediaManager(MediaManagerBase): + human_readable = "Video" + processor = staticmethod(process_video) + sniff_handler = staticmethod(sniff_handler) + display_template = "mediagoblin/media_displays/video.html" + default_thumb = "images/media_thumbs/video.jpg" + accepted_extensions = [ + "mp4", "mov", "webm", "avi", "3gp", "3gpp", "mkv", "ogv", "m4v"] + # Used by the media_entry.get_display_media method - "media_fetch_order": [u'webm_640', u'original'], - "default_webm_type": 'video/webm; codecs="vp8, vorbis"', -} + media_fetch_order = [u'webm_640', u'original'] + default_webm_type = 'video/webm; codecs="vp8, vorbis"' + + +MEDIA_MANAGER = VideoMediaManager diff --git a/mediagoblin/media_types/video/models.py b/mediagoblin/media_types/video/models.py index f696a892..0b52c53f 100644 --- a/mediagoblin/media_types/video/models.py +++ b/mediagoblin/media_types/video/models.py @@ -90,7 +90,7 @@ class VideoData(Base): return '%s; codecs="%s, %s"' % ( mimetype, video_codec, audio_codec) else: - return video.MEDIA_MANAGER["default_webm_type"] + return video.VideoMediaManager.default_webm_type DATA_MODEL = VideoData diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py index 58b2c0d4..90a767dd 100644 --- a/mediagoblin/media_types/video/transcoders.py +++ b/mediagoblin/media_types/video/transcoders.py @@ -26,7 +26,10 @@ import pygst pygst.require('0.10') import gst import struct -import Image +try: + from PIL import Image +except ImportError: + import Image from gst.extend import discoverer diff --git a/mediagoblin/plugins/api/__init__.py b/mediagoblin/plugins/api/__init__.py index d3fdf2ef..1eddd9e0 100644 --- a/mediagoblin/plugins/api/__init__.py +++ b/mediagoblin/plugins/api/__init__.py @@ -23,11 +23,11 @@ _log = logging.getLogger(__name__) PLUGIN_DIR = os.path.dirname(__file__) -config = pluginapi.get_config(__name__) - def setup_plugin(): _log.info('Setting up API...') + config = pluginapi.get_config(__name__) + _log.debug('API config: {0}'.format(config)) routes = [ diff --git a/mediagoblin/plugins/httpapiauth/__init__.py b/mediagoblin/plugins/httpapiauth/__init__.py index 081b590e..99b6a4b0 100644 --- a/mediagoblin/plugins/httpapiauth/__init__.py +++ b/mediagoblin/plugins/httpapiauth/__init__.py @@ -15,9 +15,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging -import base64 -from werkzeug.exceptions import BadRequest, Unauthorized +from werkzeug.exceptions import Unauthorized from mediagoblin.plugins.api.tools import Auth diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py index 4714d95d..5762379d 100644 --- a/mediagoblin/plugins/oauth/__init__.py +++ b/mediagoblin/plugins/oauth/__init__.py @@ -34,7 +34,7 @@ def setup_plugin(): _log.debug('OAuth config: {0}'.format(config)) routes = [ - ('mediagoblin.plugins.oauth.authorize', + ('mediagoblin.plugins.oauth.authorize', '/oauth/authorize', 'mediagoblin.plugins.oauth.views:authorize'), ('mediagoblin.plugins.oauth.authorize_client', diff --git a/mediagoblin/plugins/oauth/forms.py b/mediagoblin/plugins/oauth/forms.py index d0a4e9b8..5edd992a 100644 --- a/mediagoblin/plugins/oauth/forms.py +++ b/mediagoblin/plugins/oauth/forms.py @@ -19,7 +19,7 @@ import wtforms from urlparse import urlparse from mediagoblin.tools.extlib.wtf_html5 import URLField -from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ class AuthorizationForm(wtforms.Form): diff --git a/mediagoblin/plugins/oauth/migrations.py b/mediagoblin/plugins/oauth/migrations.py index 6aa0d7cb..d7b89da3 100644 --- a/mediagoblin/plugins/oauth/migrations.py +++ b/mediagoblin/plugins/oauth/migrations.py @@ -102,6 +102,21 @@ class OAuthCode_v0(declarative_base()): client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False) +class OAuthRefreshToken_v0(declarative_base()): + __tablename__ = 'oauth__refresh_tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + token = Column(Unicode, index=True) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + + # XXX: Is it OK to use OAuthClient_v0.id in this way? + client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False) + + @RegisterMigration(1, MIGRATIONS) def remove_and_replace_token_and_code(db): metadata = MetaData(bind=db.bind) @@ -122,3 +137,22 @@ def remove_and_replace_token_and_code(db): OAuthCode_v0.__table__.create(db.bind) db.commit() + + +@RegisterMigration(2, MIGRATIONS) +def remove_refresh_token_field(db): + metadata = MetaData(bind=db.bind) + + token_table = Table('oauth__tokens', metadata, autoload=True, + autoload_with=db.bind) + + refresh_token = token_table.columns['refresh_token'] + + refresh_token.drop() + db.commit() + +@RegisterMigration(3, MIGRATIONS) +def create_refresh_token_table(db): + OAuthRefreshToken_v0.__table__.create(db.bind) + + db.commit() diff --git a/mediagoblin/plugins/oauth/models.py b/mediagoblin/plugins/oauth/models.py index 695dad31..439424d3 100644 --- a/mediagoblin/plugins/oauth/models.py +++ b/mediagoblin/plugins/oauth/models.py @@ -14,17 +14,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import uuid -import bcrypt from datetime import datetime, timedelta -from mediagoblin.db.base import Base -from mediagoblin.db.models import User from sqlalchemy import ( Column, Unicode, Integer, DateTime, ForeignKey, Enum) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, backref +from mediagoblin.db.base import Base +from mediagoblin.db.models import User +from mediagoblin.plugins.oauth.tools import generate_identifier, \ + generate_secret, generate_token, generate_code, generate_refresh_token # Don't remove this, I *think* it applies sqlalchemy-migrate functionality onto # the models. @@ -41,11 +41,14 @@ class OAuthClient(Base): name = Column(Unicode) description = Column(Unicode) - identifier = Column(Unicode, unique=True, index=True) - secret = Column(Unicode, index=True) + identifier = Column(Unicode, unique=True, index=True, + default=generate_identifier) + secret = Column(Unicode, index=True, default=generate_secret) owner_id = Column(Integer, ForeignKey(User.id)) - owner = relationship(User, backref='registered_clients') + owner = relationship( + User, + backref=backref('registered_clients', cascade='all, delete-orphan')) redirect_uri = Column(Unicode) @@ -54,14 +57,8 @@ class OAuthClient(Base): u'public', name=u'oauth__client_type')) - def generate_identifier(self): - self.identifier = unicode(uuid.uuid4()) - - def generate_secret(self): - self.secret = unicode( - bcrypt.hashpw( - unicode(uuid.uuid4()), - bcrypt.gensalt())) + def update_secret(self): + self.secret = generate_secret() def __repr__(self): return '<{0} {1}:{2} ({3})>'.format( @@ -76,10 +73,15 @@ class OAuthUserClient(Base): id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey(User.id)) - user = relationship(User, backref='oauth_clients') + user = relationship( + User, + backref=backref('oauth_client_relations', + cascade='all, delete-orphan')) client_id = Column(Integer, ForeignKey(OAuthClient.id)) - client = relationship(OAuthClient, backref='users') + client = relationship( + OAuthClient, + backref=backref('oauth_user_relations', cascade='all, delete-orphan')) state = Column(Enum( u'approved', @@ -103,15 +105,18 @@ class OAuthToken(Base): default=datetime.now) expires = Column(DateTime, nullable=False, default=lambda: datetime.now() + timedelta(days=30)) - token = Column(Unicode, index=True) - refresh_token = Column(Unicode, index=True) + token = Column(Unicode, index=True, default=generate_token) user_id = Column(Integer, ForeignKey(User.id), nullable=False, index=True) - user = relationship(User) + user = relationship( + User, + backref=backref('oauth_tokens', cascade='all, delete-orphan')) client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) - client = relationship(OAuthClient) + client = relationship( + OAuthClient, + backref=backref('oauth_tokens', cascade='all, delete-orphan')) def __repr__(self): return '<{0} #{1} expires {2} [{3}, {4}]>'.format( @@ -121,6 +126,34 @@ class OAuthToken(Base): self.user, self.client) +class OAuthRefreshToken(Base): + __tablename__ = 'oauth__refresh_tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + token = Column(Unicode, index=True, + default=generate_refresh_token) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + + user = relationship(User, backref=backref('oauth_refresh_tokens', + cascade='all, delete-orphan')) + + client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) + client = relationship(OAuthClient, + backref=backref( + 'oauth_refresh_tokens', + cascade='all, delete-orphan')) + + def __repr__(self): + return '<{0} #{1} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.user, + self.client) + class OAuthCode(Base): __tablename__ = 'oauth__codes' @@ -130,14 +163,17 @@ class OAuthCode(Base): default=datetime.now) expires = Column(DateTime, nullable=False, default=lambda: datetime.now() + timedelta(minutes=5)) - code = Column(Unicode, index=True) + code = Column(Unicode, index=True, default=generate_code) user_id = Column(Integer, ForeignKey(User.id), nullable=False, index=True) - user = relationship(User) + user = relationship(User, backref=backref('oauth_codes', + cascade='all, delete-orphan')) client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) - client = relationship(OAuthClient) + client = relationship(OAuthClient, backref=backref( + 'oauth_codes', + cascade='all, delete-orphan')) def __repr__(self): return '<{0} #{1} expires {2} [{3}, {4}]>'.format( @@ -150,6 +186,7 @@ class OAuthCode(Base): MODELS = [ OAuthToken, + OAuthRefreshToken, OAuthCode, OAuthClient, OAuthUserClient] diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py index d21c8a5b..27ff32b4 100644 --- a/mediagoblin/plugins/oauth/tools.py +++ b/mediagoblin/plugins/oauth/tools.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # GNU MediaGoblin -- federated, autonomous media hosting # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. # @@ -14,13 +15,26 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import uuid + +from random import getrandbits + +from datetime import datetime + from functools import wraps -from mediagoblin.plugins.oauth.models import OAuthClient from mediagoblin.plugins.api.tools import json_response def require_client_auth(controller): + ''' + View decorator + + - Requires the presence of ``?client_id`` + ''' + # Avoid circular import + from mediagoblin.plugins.oauth.models import OAuthClient + @wraps(controller) def wrapper(request, *args, **kw): if not request.GET.get('client_id'): @@ -41,3 +55,60 @@ def require_client_auth(controller): return controller(request, client) return wrapper + + +def create_token(client, user): + ''' + Create an OAuthToken and an OAuthRefreshToken entry in the database + + Returns the data structure expected by the OAuth clients. + ''' + from mediagoblin.plugins.oauth.models import OAuthToken, OAuthRefreshToken + + token = OAuthToken() + token.user = user + token.client = client + token.save() + + refresh_token = OAuthRefreshToken() + refresh_token.user = user + refresh_token.client = client + refresh_token.save() + + # expire time of token in full seconds + # timedelta.total_seconds is python >= 2.7 or we would use that + td = token.expires - datetime.now() + exp_in = 86400*td.days + td.seconds # just ignore µsec + + return {'access_token': token.token, 'token_type': 'bearer', + 'refresh_token': refresh_token.token, 'expires_in': exp_in} + + +def generate_identifier(): + ''' Generates a ``uuid.uuid4()`` ''' + return unicode(uuid.uuid4()) + + +def generate_token(): + ''' Uses generate_identifier ''' + return generate_identifier() + + +def generate_refresh_token(): + ''' Uses generate_identifier ''' + return generate_identifier() + + +def generate_code(): + ''' Uses generate_identifier ''' + return generate_identifier() + + +def generate_secret(): + ''' + Generate a long string of pseudo-random characters + ''' + # XXX: We might not want it to use bcrypt, since bcrypt takes its time to + # generate the result. + return unicode(getrandbits(192)) + diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py index ea45c209..d6fd314f 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -16,21 +16,21 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging -import json from urllib import urlencode -from uuid import uuid4 -from datetime import datetime + +from werkzeug.exceptions import BadRequest from mediagoblin.tools.response import render_to_response, redirect from mediagoblin.decorators import require_active_login -from mediagoblin.messages import add_message, SUCCESS, ERROR +from mediagoblin.messages import add_message, SUCCESS from mediagoblin.tools.translate import pass_to_ugettext as _ -from mediagoblin.plugins.oauth.models import OAuthCode, OAuthToken, \ - OAuthClient, OAuthUserClient +from mediagoblin.plugins.oauth.models import OAuthCode, OAuthClient, \ + OAuthUserClient, OAuthRefreshToken from mediagoblin.plugins.oauth.forms import ClientRegistrationForm, \ AuthorizationForm -from mediagoblin.plugins.oauth.tools import require_client_auth +from mediagoblin.plugins.oauth.tools import require_client_auth, \ + create_token from mediagoblin.plugins.api.tools import json_response _log = logging.getLogger(__name__) @@ -51,9 +51,6 @@ def register_client(request): client.owner_id = request.user.id client.redirect_uri = unicode(form.redirect_uri.data) - client.generate_identifier() - client.generate_secret() - client.save() add_message(request, SUCCESS, _('The client {0} has been registered!')\ @@ -92,9 +89,9 @@ def authorize_client(request): form.client_id.data).first() if not client: - _log.error('''No such client id as received from client authorization - form.''') - return BadRequest() + _log.error('No such client id as received from client authorization \ +form.') + raise BadRequest() if form.validate(): relation = OAuthUserClient() @@ -105,7 +102,7 @@ def authorize_client(request): elif form.deny.data: relation.state = u'rejected' else: - return BadRequest + raise BadRequest() relation.save() @@ -136,7 +133,7 @@ def authorize(request, client): return json_response({ 'status': 400, 'errors': - [u'Public clients MUST have a redirect_uri pre-set']}, + [u'Public clients should have a redirect_uri pre-set.']}, _disable_cors=True) redirect_uri = client.redirect_uri @@ -146,11 +143,10 @@ def authorize(request, client): if not redirect_uri: return json_response({ 'status': 400, - 'errors': [u'Can not find a redirect_uri for client: {0}'\ - .format(client.name)]}, _disable_cors=True) + 'errors': [u'No redirect_uri supplied!']}, + _disable_cors=True) code = OAuthCode() - code.code = unicode(uuid4()) code.user = request.user code.client = client code.save() @@ -180,59 +176,79 @@ def authorize(request, client): def access_token(request): + ''' + Access token endpoint provides access tokens to any clients that have the + right grants/credentials + ''' + + client = None + user = None + if request.GET.get('code'): + # Validate the code arg, then get the client object from the db. code = OAuthCode.query.filter(OAuthCode.code == request.GET.get('code')).first() - if code: - if code.client.type == u'confidential': - client_identifier = request.GET.get('client_id') - - if not client_identifier: - return json_response({ - 'error': 'invalid_request', - 'error_description': - 'Missing client_id in request'}) - - client_secret = request.GET.get('client_secret') - - if not client_secret: - return json_response({ - 'error': 'invalid_request', - 'error_description': - 'Missing client_secret in request'}) - - if not client_secret == code.client.secret or \ - not client_identifier == code.client.identifier: - return json_response({ - 'error': 'invalid_client', - 'error_description': - 'The client_id or client_secret does not match the' - ' code'}) - - token = OAuthToken() - token.token = unicode(uuid4()) - token.user = code.user - token.client = code.client - token.save() - - # expire time of token in full seconds - # timedelta.total_seconds is python >= 2.7 or we would use that - td = token.expires - datetime.now() - exp_in = 86400*td.days + td.seconds # just ignore µsec - - access_token_data = { - 'access_token': token.token, - 'token_type': 'bearer', - 'expires_in': exp_in} - return json_response(access_token_data, _disable_cors=True) - else: + if not code: return json_response({ 'error': 'invalid_request', 'error_description': - 'Invalid code'}) - else: - return json_response({ - 'error': 'invalid_request', - 'error_descriptin': - 'Missing `code` parameter in request'}) + 'Invalid code.'}) + + client = code.client + user = code.user + + elif request.args.get('refresh_token'): + # Validate a refresh token, then get the client object from the db. + refresh_token = OAuthRefreshToken.query.filter( + OAuthRefreshToken.token == + request.args.get('refresh_token')).first() + + if not refresh_token: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Invalid refresh token.'}) + + client = refresh_token.client + user = refresh_token.user + + if client: + client_identifier = request.GET.get('client_id') + + if not client_identifier: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing client_id in request.'}) + + if not client_identifier == client.identifier: + return json_response({ + 'error': 'invalid_client', + 'error_description': + 'Mismatching client credentials.'}) + + if client.type == u'confidential': + client_secret = request.GET.get('client_secret') + + if not client_secret: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing client_secret in request.'}) + + if not client_secret == client.secret: + return json_response({ + 'error': 'invalid_client', + 'error_description': + 'Mismatching client credentials.'}) + + + access_token_data = create_token(client, user) + + return json_response(access_token_data, _disable_cors=True) + + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing `code` or `refresh_token` parameter in request.'}) diff --git a/mediagoblin/plugins/piwigo/__init__.py b/mediagoblin/plugins/piwigo/__init__.py index 73326e9e..c4da708a 100644 --- a/mediagoblin/plugins/piwigo/__init__.py +++ b/mediagoblin/plugins/piwigo/__init__.py @@ -17,6 +17,8 @@ import logging from mediagoblin.tools import pluginapi +from mediagoblin.tools.session import SessionManager +from .tools import PWGSession _log = logging.getLogger(__name__) @@ -32,6 +34,9 @@ def setup_plugin(): pluginapi.register_routes(routes) + PWGSession.session_manager = SessionManager("pwg_id", "plugins.piwigo") + + hooks = { 'setup': setup_plugin } diff --git a/mediagoblin/plugins/piwigo/forms.py b/mediagoblin/plugins/piwigo/forms.py index 5bb12e62..18cbd5c5 100644 --- a/mediagoblin/plugins/piwigo/forms.py +++ b/mediagoblin/plugins/piwigo/forms.py @@ -26,3 +26,19 @@ class AddSimpleForm(wtforms.Form): # tags = wtforms.FieldList(wtforms.TextField()) category = wtforms.IntegerField() level = wtforms.IntegerField() + + +_md5_validator = wtforms.validators.Regexp(r"^[0-9a-fA-F]{32}$") + + +class AddForm(wtforms.Form): + original_sum = wtforms.TextField(None, + [_md5_validator, + wtforms.validators.Required()]) + thumbnail_sum = wtforms.TextField(None, + [wtforms.validators.Optional(False), + _md5_validator]) + file_sum = wtforms.TextField(None, [_md5_validator]) + name = wtforms.TextField() + date_creation = wtforms.TextField() + categories = wtforms.TextField() diff --git a/mediagoblin/plugins/piwigo/tools.py b/mediagoblin/plugins/piwigo/tools.py index 85d77310..400be615 100644 --- a/mediagoblin/plugins/piwigo/tools.py +++ b/mediagoblin/plugins/piwigo/tools.py @@ -16,9 +16,11 @@ import logging +import six import lxml.etree as ET -from werkzeug.exceptions import MethodNotAllowed +from werkzeug.exceptions import MethodNotAllowed, BadRequest +from mediagoblin.tools.request import setup_user_in_request from mediagoblin.tools.response import Response @@ -43,7 +45,7 @@ class PwgNamedArray(list): def _fill_element_dict(el, data, as_attr=()): for k, v in data.iteritems(): if k in as_attr: - if not isinstance(v, basestring): + if not isinstance(v, six.string_types): v = str(v) el.set(k, v) else: @@ -57,7 +59,7 @@ def _fill_element(el, data): el.text = "1" else: el.text = "0" - elif isinstance(data, basestring): + elif isinstance(data, six.string_types): el.text = data elif isinstance(data, int): el.text = str(data) @@ -105,3 +107,46 @@ class CmdTable(object): _log.warn("Method %s only allowed for POST", cmd_name) raise MethodNotAllowed() return func + + +def check_form(form): + if not form.validate(): + _log.error("form validation failed for form %r", form) + for f in form: + if len(f.error): + _log.error("Errors for %s: %r", f.name, f.errors) + raise BadRequest() + dump = [] + for f in form: + dump.append("%s=%r" % (f.name, f.data)) + _log.debug("form: %s", " ".join(dump)) + + +class PWGSession(object): + session_manager = None + + def __init__(self, request): + self.request = request + self.in_pwg_session = False + + def __enter__(self): + # Backup old state + self.old_session = self.request.session + self.old_user = self.request.user + # Load piwigo session into state + self.request.session = self.session_manager.load_session_from_cookie( + self.request) + setup_user_in_request(self.request) + self.in_pwg_session = True + return self + + def __exit__(self, *args): + # Restore state + self.request.session = self.old_session + self.request.user = self.old_user + self.in_pwg_session = False + + def save_to_cookie(self, response): + assert self.in_pwg_session + self.session_manager.save_session_to_cookie(self.request.session, + self.request, response) diff --git a/mediagoblin/plugins/piwigo/views.py b/mediagoblin/plugins/piwigo/views.py index 3dee09cd..b59247ad 100644 --- a/mediagoblin/plugins/piwigo/views.py +++ b/mediagoblin/plugins/piwigo/views.py @@ -17,14 +17,15 @@ import logging import re -from werkzeug.exceptions import MethodNotAllowed, BadRequest +from werkzeug.exceptions import MethodNotAllowed, BadRequest, NotImplemented from werkzeug.wrappers import BaseResponse -from mediagoblin import mg_globals from mediagoblin.meddleware.csrf import csrf_exempt -from mediagoblin.tools.response import render_404 -from .tools import CmdTable, PwgNamedArray, response_xml -from .forms import AddSimpleForm +from mediagoblin.submit.lib import check_file_field +from mediagoblin.auth.lib import fake_login_attempt +from .tools import CmdTable, PwgNamedArray, response_xml, check_form, \ + PWGSession +from .forms import AddSimpleForm, AddForm _log = logging.getLogger(__name__) @@ -34,13 +35,25 @@ _log = logging.getLogger(__name__) def pwg_login(request): username = request.form.get("username") password = request.form.get("password") - _log.info("Login for %r/%r...", username, password) + _log.debug("Login for %r/%r...", username, password) + user = request.db.User.query.filter_by(username=username).first() + if not user: + _log.info("User %r not found", username) + fake_login_attempt() + return False + if not user.check_login(password): + _log.warn("Wrong password for %r", username) + return False + _log.info("Logging %r in", username) + request.session["user_id"] = user.id + request.session.save() return True @CmdTable("pwg.session.logout") def pwg_logout(request): _log.info("Logout") + request.session.delete() return True @@ -51,7 +64,11 @@ def pwg_getversion(request): @CmdTable("pwg.session.getStatus") def pwg_session_getStatus(request): - return {'username': "fake_user"} + if request.user: + username = request.user.username + else: + username = "guest" + return {'username': username} @CmdTable("pwg.categories.getList") @@ -92,6 +109,9 @@ def pwg_images_addSimple(request): dump.append("%s=%r" % (f.name, f.data)) _log.info("addimple: %r %s %r", request.form, " ".join(dump), request.files) + if not check_file_field(request, 'image'): + raise BadRequest() + return {'image_id': 123456, 'url': ''} @@ -130,17 +150,13 @@ def pwg_images_addChunk(request): return True -def possibly_add_cookie(request, response): - # TODO: We should only add a *real* cookie, if - # authenticated. And if there is no cookie already. - if True: - response.set_cookie( - 'pwg_id', - "some_fake_for_now", - path=request.environ['SCRIPT_NAME'], - domain=mg_globals.app_config.get('csrf_cookie_domain'), - secure=(request.scheme.lower() == 'https'), - httponly=True) +@CmdTable("pwg.images.add", True) +def pwg_images_add(request): + _log.info("add: %r", request.form) + form = AddForm(request.form) + check_form(form) + + return {'image_id': 123456, 'url': ''} @csrf_exempt @@ -153,15 +169,15 @@ def ws_php(request): if not func: _log.warn("wsphp: Unhandled %s %r %r", request.method, request.args, request.form) - return render_404(request) - - result = func(request) + raise NotImplemented() - if isinstance(result, BaseResponse): - return result + with PWGSession(request) as session: + result = func(request) - response = response_xml(result) + if isinstance(result, BaseResponse): + return result - possibly_add_cookie(request, response) + response = response_xml(result) + session.save_to_cookie(response) - return response + return response diff --git a/mediagoblin/processing/__init__.py b/mediagoblin/processing/__init__.py index a1fd3fb7..f3a85940 100644 --- a/mediagoblin/processing/__init__.py +++ b/mediagoblin/processing/__init__.py @@ -75,6 +75,14 @@ class FilenameBuilder(object): class ProcessingState(object): + """ + The first and only argument to the "processor" of a media type + + This could be thought of as a "request" to the processor + function. It has the main info for the request (media entry) + and a bunch of tools for the request on it. + It can get more fancy without impacting old media types. + """ def __init__(self, entry): self.entry = entry self.workbench = None diff --git a/mediagoblin/processing/task.py b/mediagoblin/processing/task.py index aec50aab..9af192ed 100644 --- a/mediagoblin/processing/task.py +++ b/mediagoblin/processing/task.py @@ -89,7 +89,7 @@ class ProcessMedia(task.Task): with mgg.workbench_manager.create() as workbench: proc_state.set_workbench(workbench) # run the processing code - entry.media_manager['processor'](proc_state) + entry.media_manager.processor(proc_state) # We set the state to processed and save the entry here so there's # no need to save at the end of the processing stage, probably ;) diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 8df9f2e1..0cb36753 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -360,6 +360,25 @@ textarea#description, textarea#bio { font-size: 0.9em; } +a.comment_authorlink { + text-decoration: none; + padding-right: 5px; + font-weight: bold; + padding-left: 2px; +} + +a.comment_authorlink:hover { + text-decoration: underline; +} + +a.comment_whenlink { + text-decoration: none; +} + +a.comment_whenlink:hover { + text-decoration: underline; +} + .comment_content { margin-left: 8px; margin-top: 8px; @@ -540,6 +559,7 @@ table.media_panel { table.media_panel th { font-weight: bold; padding-bottom: 4px; + text-align: left; } diff --git a/mediagoblin/static/css/pdf_viewer.css b/mediagoblin/static/css/pdf_viewer.css new file mode 100644 index 00000000..c04c8981 --- /dev/null +++ b/mediagoblin/static/css/pdf_viewer.css @@ -0,0 +1,1448 @@ +/* Copyright 2012 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +* { + padding: 0; + margin: 0; +} + +html { + height: 100%; +} + +body { + height: 100%; + background-color: #404040; + background-image: url(../extlib/pdf.js/web/images/texture.png); +} + +body, +input, +button, +select { + font: message-box; +} + +.hidden { + display: none; +} +[hidden] { + display: none !important; +} + +#viewerContainer:-webkit-full-screen { + top: 0px; + border-top: 2px solid transparent; + background-color: #404040; + background-image: url(../extlib/pdf.js/web/images/texture.png); + width: 100%; + height: 100%; + overflow: hidden; + cursor: none; +} + +#viewerContainer:-moz-full-screen { + top: 0px; + border-top: 2px solid transparent; + background-color: #404040; + background-image: url(../extlib/pdf.js/web/images/texture.png); + width: 100%; + height: 100%; + overflow: hidden; + cursor: none; +} + +#viewerContainer:fullscreen { + top: 0px; + border-top: 2px solid transparent; + background-color: #404040; + background-image: url(../extlib/pdf.js/web/images/texture.png); + width: 100%; + height: 100%; + overflow: hidden; + cursor: none; +} + + +:-webkit-full-screen .page { + margin-bottom: 100%; +} + +:-moz-full-screen .page { + margin-bottom: 100%; +} + +:fullscreen .page { + margin-bottom: 100%; +} + +#viewerContainer.presentationControls { + cursor: default; +} + +/* outer/inner center provides horizontal center */ +html[dir='ltr'] .outerCenter { + float: right; + position: relative; + right: 50%; +} +html[dir='rtl'] .outerCenter { + float: left; + position: relative; + left: 50%; +} +html[dir='ltr'] .innerCenter { + float: right; + position: relative; + right: -50%; +} +html[dir='rtl'] .innerCenter { + float: left; + position: relative; + left: -50%; +} + +#outerContainer { + width: 100%; + height: 100%; +} + +#sidebarContainer { + left: 0; + right: 0; + height: 200px; + visibility: hidden; + -webkit-transition-duration: 200ms; + -webkit-transition-timing-function: ease; + -moz-transition-duration: 200ms; + -moz-transition-timing-function: ease; + -ms-transition-duration: 200ms; + -ms-transition-timing-function: ease; + -o-transition-duration: 200ms; + -o-transition-timing-function: ease; + transition-duration: 200ms; + transition-timing-function: ease; + +} +html[dir='ltr'] #sidebarContainer { + -webkit-transition-property: top; + -moz-transition-property: top; + -ms-transition-property: top; + -o-transition-property: top; + transition-property: top; + top: -200px; +} +html[dir='rtl'] #sidebarContainer { + -webkit-transition-property: top; + -ms-transition-property: top; + -o-transition-property: top; + transition-property: top; + top: -200px; +} + +#outerContainer.sidebarMoving > #sidebarContainer, +#outerContainer.sidebarOpen > #sidebarContainer { + visibility: visible; +} +html[dir='ltr'] #outerContainer.sidebarOpen > #sidebarContainer { + left: 0px; +} +html[dir='rtl'] #outerContainer.sidebarOpen > #sidebarContainer { + right: 0px; +} + +#mainContainer { + top: 0; + right: 0; + bottom: 0; + left: 0; + min-width: 320px; + -webkit-transition-duration: 200ms; + -webkit-transition-timing-function: ease; + -moz-transition-duration: 200ms; + -moz-transition-timing-function: ease; + -ms-transition-duration: 200ms; + -ms-transition-timing-function: ease; + -o-transition-duration: 200ms; + -o-transition-timing-function: ease; + transition-duration: 200ms; + transition-timing-function: ease; +} +html[dir='ltr'] #outerContainer.sidebarOpen > #mainContainer { + -webkit-transition-property: left; + -moz-transition-property: left; + -ms-transition-property: left; + -o-transition-property: left; + transition-property: left; + left: 200px; +} +html[dir='rtl'] #outerContainer.sidebarOpen > #mainContainer { + -webkit-transition-property: right; + -moz-transition-property: right; + -ms-transition-property: right; + -o-transition-property: right; + transition-property: right; + right: 200px; +} + +#sidebarContent { + top: 32px; + bottom: 0; + overflow: auto; + height: 200px; + + background-color: hsla(0,0%,0%,.1); + box-shadow: inset -1px 0 0 hsla(0,0%,0%,.25); +} +html[dir='ltr'] #sidebarContent { + left: 0; +} +html[dir='rtl'] #sidebarContent { + right: 0; +} + +#viewerContainer { + overflow: auto; + box-shadow: inset 1px 0 0 hsla(0,0%,100%,.05); + top: 32px; + right: 0; + bottom: 0; + left: 0; + height: 480px; + width: 640px; +} + +.toolbar { + left: 0; + right: 0; + height: 32px; + z-index: 9999; + cursor: default; +} + +#toolbarContainer { + width: 100%; +} + +#toolbarSidebar { + width: 200px; + height: 32px; + background-image: url(../extlib/pdf.js/web/images/texture.png), + -webkit-linear-gradient(hsla(0,0%,30%,.99), hsla(0,0%,25%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + -moz-linear-gradient(hsla(0,0%,30%,.99), hsla(0,0%,25%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + -ms-linear-gradient(hsla(0,0%,30%,.99), hsla(0,0%,25%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + -o-linear-gradient(hsla(0,0%,30%,.99), hsla(0,0%,25%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + linear-gradient(hsla(0,0%,30%,.99), hsla(0,0%,25%,.95)); + box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.25), + + inset 0 -1px 0 hsla(0,0%,100%,.05), + 0 1px 0 hsla(0,0%,0%,.15), + 0 0 1px hsla(0,0%,0%,.1); +} + +#toolbarViewer, .findbar { + position: relative; + height: 32px; + background-color: #474747; /* IE9 */ + background-image: url(../extlib/pdf.js/web/images/texture.png), + -webkit-linear-gradient(hsla(0,0%,32%,.99), hsla(0,0%,27%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + -moz-linear-gradient(hsla(0,0%,32%,.99), hsla(0,0%,27%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + -ms-linear-gradient(hsla(0,0%,32%,.99), hsla(0,0%,27%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + -o-linear-gradient(hsla(0,0%,32%,.99), hsla(0,0%,27%,.95)); + background-image: url(../extlib/pdf.js/web/images/texture.png), + linear-gradient(hsla(0,0%,32%,.99), hsla(0,0%,27%,.95)); + box-shadow: inset 1px 0 0 hsla(0,0%,100%,.08), + inset 0 1px 1px hsla(0,0%,0%,.15), + inset 0 -1px 0 hsla(0,0%,100%,.05), + 0 1px 0 hsla(0,0%,0%,.15), + 0 1px 1px hsla(0,0%,0%,.1); +} + +.findbar { + top: 64px; + z-index: 10000; + height: 32px; + + min-width: 16px; + padding: 0px 6px 0px 6px; + margin: 4px 2px 4px 2px; + color: hsl(0,0%,85%); + font-size: 12px; + line-height: 14px; + text-align: left; + cursor: default; +} + +html[dir='ltr'] .findbar { + left: 68px; +} + +html[dir='rtl'] .findbar { + right: 68px; +} + +.findbar label { + -webkit-user-select: none; + -moz-user-select: none; +} + +#findInput[data-status="pending"] { + background-image: url(../extlib/pdf.js/web/images/loading-small.png); + background-repeat: no-repeat; + background-position: right; +} + +.doorHanger { + border: 1px solid hsla(0,0%,0%,.5); + border-radius: 2px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} +.doorHanger:after, .doorHanger:before { + bottom: 100%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + pointer-events: none; +} +.doorHanger:after { + border-bottom-color: hsla(0,0%,32%,.99); + border-width: 8px; +} +.doorHanger:before { + border-bottom-color: hsla(0,0%,0%,.5); + border-width: 9px; +} + +html[dir='ltr'] .doorHanger:after { + left: 13px; + margin-left: -8px; +} + +html[dir='ltr'] .doorHanger:before { + left: 13px; + margin-left: -9px; +} + +html[dir='rtl'] .doorHanger:after { + right: 13px; + margin-right: -8px; +} + +html[dir='rtl'] .doorHanger:before { + right: 13px; + margin-right: -9px; +} + +#findMsg { + font-style: italic; + color: #A6B7D0; +} + +.notFound { + background-color: rgb(255, 137, 153); +} + +html[dir='ltr'] #toolbarViewerLeft { + margin-left: -1px; +} +html[dir='rtl'] #toolbarViewerRight { + margin-left: -1px; +} + + +html[dir='ltr'] #toolbarViewerLeft, +html[dir='rtl'] #toolbarViewerRight { + position: absolute; + top: 0; + left: 0; +} +html[dir='ltr'] #toolbarViewerRight, +html[dir='rtl'] #toolbarViewerLeft { + position: absolute; + top: 0; + right: 0; +} +html[dir='ltr'] #toolbarViewerLeft > *, +html[dir='ltr'] #toolbarViewerMiddle > *, +html[dir='ltr'] #toolbarViewerRight > *, +html[dir='ltr'] .findbar > * { + float: left; +} +html[dir='rtl'] #toolbarViewerLeft > *, +html[dir='rtl'] #toolbarViewerMiddle > *, +html[dir='rtl'] #toolbarViewerRight > *, +html[dir='rtl'] .findbar > * { + float: right; +} + +html[dir='ltr'] .splitToolbarButton { + margin: 3px 2px 4px 0; + display: inline-block; +} +html[dir='rtl'] .splitToolbarButton { + margin: 3px 0 4px 2px; + display: inline-block; +} +html[dir='ltr'] .splitToolbarButton > .toolbarButton { + border-radius: 0; + float: left; +} +html[dir='rtl'] .splitToolbarButton > .toolbarButton { + border-radius: 0; + float: right; +} + +.toolbarButton { + border: 0 none; + background-color: rgba(0, 0, 0, 0); + width: 32px; + height: 25px; +} + +.toolbarButton > span { + display: inline-block; + width: 0; + height: 0; + overflow: hidden; +} + +.toolbarButton[disabled] { + opacity: .5; +} + +.toolbarButton.group { + margin-right: 0; +} + +.splitToolbarButton.toggled .toolbarButton { + margin: 0; +} + +.splitToolbarButton:hover > .toolbarButton, +.splitToolbarButton:focus > .toolbarButton, +.splitToolbarButton.toggled > .toolbarButton, +.toolbarButton.textButton { + background-color: hsla(0,0%,0%,.12); + background-image: -webkit-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -ms-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -o-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + border: 1px solid hsla(0,0%,0%,.35); + border-color: hsla(0,0%,0%,.32) hsla(0,0%,0%,.38) hsla(0,0%,0%,.42); + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.15) inset, + 0 1px 0 hsla(0,0%,100%,.05); + -webkit-transition-property: background-color, border-color, box-shadow; + -webkit-transition-duration: 150ms; + -webkit-transition-timing-function: ease; + -moz-transition-property: background-color, border-color, box-shadow; + -moz-transition-duration: 150ms; + -moz-transition-timing-function: ease; + -ms-transition-property: background-color, border-color, box-shadow; + -ms-transition-duration: 150ms; + -ms-transition-timing-function: ease; + -o-transition-property: background-color, border-color, box-shadow; + -o-transition-duration: 150ms; + -o-transition-timing-function: ease; + transition-property: background-color, border-color, box-shadow; + transition-duration: 150ms; + transition-timing-function: ease; + +} +.splitToolbarButton > .toolbarButton:hover, +.splitToolbarButton > .toolbarButton:focus, +.dropdownToolbarButton:hover, +.toolbarButton.textButton:hover, +.toolbarButton.textButton:focus { + background-color: hsla(0,0%,0%,.2); + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.15) inset, + 0 0 1px hsla(0,0%,0%,.05); + z-index: 199; +} +html[dir='ltr'] .splitToolbarButton > .toolbarButton:first-child, +html[dir='rtl'] .splitToolbarButton > .toolbarButton:last-child { + position: relative; + margin: 0; + margin-right: -1px; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + border-right-color: transparent; +} +html[dir='ltr'] .splitToolbarButton > .toolbarButton:last-child, +html[dir='rtl'] .splitToolbarButton > .toolbarButton:first-child { + position: relative; + margin: 0; + margin-left: -1px; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + border-left-color: transparent; +} +.splitToolbarButtonSeparator { + padding: 8px 0; + width: 1px; + background-color: hsla(0,0%,00%,.5); + z-index: 99; + box-shadow: 0 0 0 1px hsla(0,0%,100%,.08); + display: inline-block; + margin: 5px 0; +} +html[dir='ltr'] .splitToolbarButtonSeparator { + float: left; +} +html[dir='rtl'] .splitToolbarButtonSeparator { + float: right; +} +.splitToolbarButton:hover > .splitToolbarButtonSeparator, +.splitToolbarButton.toggled > .splitToolbarButtonSeparator { + padding: 12px 0; + margin: 1px 0; + box-shadow: 0 0 0 1px hsla(0,0%,100%,.03); + -webkit-transition-property: padding; + -webkit-transition-duration: 10ms; + -webkit-transition-timing-function: ease; + -moz-transition-property: padding; + -moz-transition-duration: 10ms; + -moz-transition-timing-function: ease; + -ms-transition-property: padding; + -ms-transition-duration: 10ms; + -ms-transition-timing-function: ease; + -o-transition-property: padding; + -o-transition-duration: 10ms; + -o-transition-timing-function: ease; + transition-property: padding; + transition-duration: 10ms; + transition-timing-function: ease; +} + +.toolbarButton, +.dropdownToolbarButton { + min-width: 16px; + padding: 2px 6px 0; + border: 1px solid transparent; + border-radius: 2px; + color: hsl(0,0%,95%); + font-size: 12px; + line-height: 14px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + /* Opera does not support user-select, use <... unselectable="on"> instead */ + cursor: default; + -webkit-transition-property: background-color, border-color, box-shadow; + -webkit-transition-duration: 150ms; + -webkit-transition-timing-function: ease; + -moz-transition-property: background-color, border-color, box-shadow; + -moz-transition-duration: 150ms; + -moz-transition-timing-function: ease; + -ms-transition-property: background-color, border-color, box-shadow; + -ms-transition-duration: 150ms; + -ms-transition-timing-function: ease; + -o-transition-property: background-color, border-color, box-shadow; + -o-transition-duration: 150ms; + -o-transition-timing-function: ease; + transition-property: background-color, border-color, box-shadow; + transition-duration: 150ms; + transition-timing-function: ease; +} + +html[dir='ltr'] .toolbarButton, +html[dir='ltr'] .dropdownToolbarButton { + margin: 3px 2px 4px 0; +} +html[dir='rtl'] .toolbarButton, +html[dir='rtl'] .dropdownToolbarButton { + margin: 3px 0 4px 2px; +} + +.toolbarButton:hover, +.toolbarButton:focus, +.dropdownToolbarButton { + background-color: hsla(0,0%,0%,.12); + background-image: -webkit-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -ms-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -o-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + border: 1px solid hsla(0,0%,0%,.35); + border-color: hsla(0,0%,0%,.32) hsla(0,0%,0%,.38) hsla(0,0%,0%,.42); + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.15) inset, + 0 1px 0 hsla(0,0%,100%,.05); +} + +.toolbarButton:hover:active, +.dropdownToolbarButton:hover:active { + background-color: hsla(0,0%,0%,.2); + background-image: -webkit-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -ms-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -o-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + border-color: hsla(0,0%,0%,.35) hsla(0,0%,0%,.4) hsla(0,0%,0%,.45); + box-shadow: 0 1px 1px hsla(0,0%,0%,.1) inset, + 0 0 1px hsla(0,0%,0%,.2) inset, + 0 1px 0 hsla(0,0%,100%,.05); + -webkit-transition-property: background-color, border-color, box-shadow; + -webkit-transition-duration: 10ms; + -webkit-transition-timing-function: linear; + -moz-transition-property: background-color, border-color, box-shadow; + -moz-transition-duration: 10ms; + -moz-transition-timing-function: linear; + -ms-transition-property: background-color, border-color, box-shadow; + -ms-transition-duration: 10ms; + -ms-transition-timing-function: linear; + -o-transition-property: background-color, border-color, box-shadow; + -o-transition-duration: 10ms; + -o-transition-timing-function: linear; + transition-property: background-color, border-color, box-shadow; + transition-duration: 10ms; + transition-timing-function: linear; +} + +.toolbarButton.toggled, +.splitToolbarButton.toggled > .toolbarButton.toggled { + background-color: hsla(0,0%,0%,.3); + background-image: -webkit-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -ms-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -o-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + border-color: hsla(0,0%,0%,.4) hsla(0,0%,0%,.45) hsla(0,0%,0%,.5); + box-shadow: 0 1px 1px hsla(0,0%,0%,.1) inset, + 0 0 1px hsla(0,0%,0%,.2) inset, + 0 1px 0 hsla(0,0%,100%,.05); + -webkit-transition-property: background-color, border-color, box-shadow; + -webkit-transition-duration: 10ms; + -webkit-transition-timing-function: linear; + -moz-transition-property: background-color, border-color, box-shadow; + -moz-transition-duration: 10ms; + -moz-transition-timing-function: linear; + -ms-transition-property: background-color, border-color, box-shadow; + -ms-transition-duration: 10ms; + -ms-transition-timing-function: linear; + -o-transition-property: background-color, border-color, box-shadow; + -o-transition-duration: 10ms; + -o-transition-timing-function: linear; + transition-property: background-color, border-color, box-shadow; + transition-duration: 10ms; + transition-timing-function: linear; +} + +.toolbarButton.toggled:hover:active, +.splitToolbarButton.toggled > .toolbarButton.toggled:hover:active { + background-color: hsla(0,0%,0%,.4); + border-color: hsla(0,0%,0%,.4) hsla(0,0%,0%,.5) hsla(0,0%,0%,.55); + box-shadow: 0 1px 1px hsla(0,0%,0%,.2) inset, + 0 0 1px hsla(0,0%,0%,.3) inset, + 0 1px 0 hsla(0,0%,100%,.05); +} + +.dropdownToolbarButton { + width: 120px; + max-width: 120px; + padding: 3px 2px 2px; + overflow: hidden; + background: url(../extlib/pdf.js/web/images/toolbarButton-menuArrows.png) no-repeat; +} +html[dir='ltr'] .dropdownToolbarButton { + background-position: 95%; +} +html[dir='rtl'] .dropdownToolbarButton { + background-position: 5%; +} + +.dropdownToolbarButton > select { + -webkit-appearance: none; + -moz-appearance: none; /* in the future this might matter, see bugzilla bug #649849 */ + min-width: 140px; + font-size: 12px; + color: hsl(0,0%,95%); + margin: 0; + padding: 0; + border: none; + background: rgba(0,0,0,0); /* Opera does not support 'transparent' <select> background */ +} + +.dropdownToolbarButton > select > option { + background: hsl(0,0%,24%); +} + +#customScaleOption { + display: none; +} + +#pageWidthOption { + border-bottom: 1px rgba(255, 255, 255, .5) solid; +} + +html[dir='ltr'] .splitToolbarButton:first-child, +html[dir='ltr'] .toolbarButton:first-child, +html[dir='rtl'] .splitToolbarButton:last-child, +html[dir='rtl'] .toolbarButton:last-child { + margin-left: 4px; +} +html[dir='ltr'] .splitToolbarButton:last-child, +html[dir='ltr'] .toolbarButton:last-child, +html[dir='rtl'] .splitToolbarButton:first-child, +html[dir='rtl'] .toolbarButton:first-child { + margin-right: 4px; +} + +.toolbarButtonSpacer { + width: 30px; + display: inline-block; + height: 1px; +} + +.toolbarButtonFlexibleSpacer { + -webkit-box-flex: 1; + -moz-box-flex: 1; + min-width: 30px; +} + +.toolbarButton#sidebarToggle::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-sidebarToggle.png); +} + +html[dir='ltr'] #findPrevious { + margin-left: 3px; +} +html[dir='ltr'] #findNext { + margin-right: 3px; +} + +html[dir='rtl'] #findPrevious { + margin-right: 3px; +} +html[dir='rtl'] #findNext { + margin-left: 3px; +} + +html[dir='ltr'] .toolbarButton.findPrevious::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/findbarButton-previous.png); +} + +html[dir='rtl'] .toolbarButton.findPrevious::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/findbarButton-previous-rtl.png); +} + +html[dir='ltr'] .toolbarButton.findNext::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/findbarButton-next.png); +} + +html[dir='rtl'] .toolbarButton.findNext::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/findbarButton-next-rtl.png); +} + +html[dir='ltr'] .toolbarButton.pageUp::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-pageUp.png); +} + +html[dir='rtl'] .toolbarButton.pageUp::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-pageUp-rtl.png); +} + +html[dir='ltr'] .toolbarButton.pageDown::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-pageDown.png); +} + +html[dir='rtl'] .toolbarButton.pageDown::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-pageDown-rtl.png); +} + +.toolbarButton.zoomOut::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-zoomOut.png); +} + +.toolbarButton.zoomIn::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-zoomIn.png); +} + +.toolbarButton.fullscreen::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-fullscreen.png); +} + +.toolbarButton.print::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-print.png); +} + +.toolbarButton.openFile::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-openFile.png); +} + +.toolbarButton.download::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-download.png); +} + +.toolbarButton.bookmark { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + margin-top: 3px; + padding-top: 4px; +} + +#viewBookmark[href='#'] { + opacity: .5; + pointer-events: none; +} + +.toolbarButton.bookmark::before { + content: url(../extlib/pdf.js/web/images/toolbarButton-bookmark.png); +} + +#viewThumbnail.toolbarButton::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-viewThumbnail.png); +} + +#viewOutline.toolbarButton::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-viewOutline.png); +} + +#viewFind.toolbarButton::before { + display: inline-block; + content: url(../extlib/pdf.js/web/images/toolbarButton-search.png); +} + + +.toolbarField { + padding: 3px 6px; + margin: 4px 0 4px 0; + border: 1px solid transparent; + border-radius: 2px; + background-color: hsla(0,0%,100%,.09); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + border: 1px solid hsla(0,0%,0%,.35); + border-color: hsla(0,0%,0%,.32) hsla(0,0%,0%,.38) hsla(0,0%,0%,.42); + box-shadow: 0 1px 0 hsla(0,0%,0%,.05) inset, + 0 1px 0 hsla(0,0%,100%,.05); + color: hsl(0,0%,95%); + font-size: 12px; + line-height: 14px; + outline-style: none; + -moz-transition-property: background-color, border-color, box-shadow; + -moz-transition-duration: 150ms; + -moz-transition-timing-function: ease; +} + +.toolbarField[type=checkbox] { + display: inline-block; + margin: 8px 0px; +} + +.toolbarField.pageNumber { + min-width: 16px; + text-align: right; + width: 40px; +} + +.toolbarField.pageNumber::-webkit-inner-spin-button, +.toolbarField.pageNumber::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.toolbarField:hover { + background-color: hsla(0,0%,100%,.11); + border-color: hsla(0,0%,0%,.4) hsla(0,0%,0%,.43) hsla(0,0%,0%,.45); +} + +.toolbarField:focus { + background-color: hsla(0,0%,100%,.15); + border-color: hsla(204,100%,65%,.8) hsla(204,100%,65%,.85) hsla(204,100%,65%,.9); +} + +.toolbarLabel { + min-width: 16px; + padding: 3px 6px 3px 2px; + margin: 4px 2px 4px 0; + border: 1px solid transparent; + border-radius: 2px; + color: hsl(0,0%,85%); + font-size: 12px; + line-height: 14px; + text-align: left; + -webkit-user-select: none; + -moz-user-select: none; + cursor: default; +} + +#thumbnailView { + top: 0; + bottom: 0; + padding: 10px 10px 0; + overflow: auto; +} + +.thumbnail { + float: left; +} + +.thumbnail:not([data-loaded]) { + border: 1px dashed rgba(255, 255, 255, 0.5); + margin-bottom: 10px; +} + +.thumbnailImage { + -moz-transition-duration: 150ms; + border: 1px solid transparent; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); + opacity: 0.8; + z-index: 99; +} + +.thumbnailSelectionRing { + border-radius: 2px; + padding: 7px; + -moz-transition-duration: 150ms; +} + +a:focus > .thumbnail > .thumbnailSelectionRing > .thumbnailImage, +.thumbnail:hover > .thumbnailSelectionRing > .thumbnailImage { + opacity: .9; +} + +a:focus > .thumbnail > .thumbnailSelectionRing, +.thumbnail:hover > .thumbnailSelectionRing { + background-color: hsla(0,0%,100%,.15); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.2) inset, + 0 0 1px hsla(0,0%,0%,.2); + color: hsla(0,0%,100%,.9); +} + +.thumbnail.selected > .thumbnailSelectionRing > .thumbnailImage { + box-shadow: 0 0 0 1px hsla(0,0%,0%,.5); + opacity: 1; +} + +.thumbnail.selected > .thumbnailSelectionRing { + background-color: hsla(0,0%,100%,.3); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.1) inset, + 0 0 1px hsla(0,0%,0%,.2); + color: hsla(0,0%,100%,1); +} + +#outlineView { + width: 192px; + top: 0; + bottom: 0; + padding: 4px 4px 0; + overflow: auto; + -webkit-user-select: none; + -moz-user-select: none; +} + +html[dir='ltr'] .outlineItem > .outlineItems { + margin-left: 20px; +} + +html[dir='rtl'] .outlineItem > .outlineItems { + margin-right: 20px; +} + +.outlineItem > a { + text-decoration: none; + display: inline-block; + min-width: 95%; + height: auto; + margin-bottom: 1px; + border-radius: 2px; + color: hsla(0,0%,100%,.8); + font-size: 13px; + line-height: 15px; + -moz-user-select: none; + white-space: normal; +} + +html[dir='ltr'] .outlineItem > a { + padding: 2px 0 5px 10px; +} + +html[dir='rtl'] .outlineItem > a { + padding: 2px 10px 5px 0; +} + +.outlineItem > a:hover { + background-color: hsla(0,0%,100%,.02); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.2) inset, + 0 0 1px hsla(0,0%,0%,.2); + color: hsla(0,0%,100%,.9); +} + +.outlineItem.selected { + background-color: hsla(0,0%,100%,.08); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-clip: padding-box; + box-shadow: 0 1px 0 hsla(0,0%,100%,.05) inset, + 0 0 1px hsla(0,0%,100%,.1) inset, + 0 0 1px hsla(0,0%,0%,.2); + color: hsla(0,0%,100%,1); +} + +.noOutline, +.noResults { + font-size: 12px; + color: hsla(0,0%,100%,.8); + font-style: italic; + cursor: default; +} + +#findScrollView { + position: absolute; + top: 10px; + bottom: 10px; + left: 10px; + width: 280px; +} + +#sidebarControls { + position:absolute; + width: 180px; + height: 32px; + left: 15px; + bottom: 35px; +} + +canvas { + margin: auto; + display: block; +} + +.page { + direction: ltr; + width: 816px; + height: 1056px; + margin: 1px auto -8px auto; + position: relative; + overflow: visible; + border: 9px solid transparent; + background-clip: content-box; + border-image: url(../extlib/pdf.js/web/images/shadow.png) 9 9 repeat; + background-color: white; +} + +.page > a { + display: block; + position: absolute; +} + +.page > a:hover { + opacity: 0.2; + background: #ff0; + box-shadow: 0px 2px 10px #ff0; +} + +.loadingIcon { + position: absolute; + display: block; + left: 0; + top: 0; + right: 0; + bottom: 0; + background: url('../extlib/pdf.js/web/images/loading-icon.gif') center no-repeat; +} + +#loadingBox { + position: absolute; + top: 50%; + margin-top: -25px; + left: 0; + right: 0; + text-align: center; + color: #ddd; + font-size: 14px; +} + +#loadingBar { + display: inline-block; + clear: both; + margin: 0px; + margin-top: 5px; + line-height: 0; + border-radius: 2px; + width: 200px; + height: 25px; + + background-color: hsla(0,0%,0%,.3); + background-image: -moz-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + background-image: -webkit-linear-gradient(hsla(0,0%,100%,.05), hsla(0,0%,100%,0)); + border: 1px solid #000; + box-shadow: 0 1px 1px hsla(0,0%,0%,.1) inset, + 0 0 1px hsla(0,0%,0%,.2) inset, + 0 0 1px 1px rgba(255, 255, 255, 0.1); +} + +#loadingBar .progress { + display: inline-block; + float: left; + + background: #666; + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#b2b2b2), color-stop(100%,#898989)); + background: -webkit-linear-gradient(top, #b2b2b2 0%,#898989 100%); + background: -moz-linear-gradient(top, #b2b2b2 0%,#898989 100%); + background: -ms-linear-gradient(top, #b2b2b2 0%,#898989 100%); + background: -o-linear-gradient(top, #b2b2b2 0%,#898989 100%); + background: linear-gradient(top, #b2b2b2 0%,#898989 100%); + + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + + width: 0%; + height: 100%; +} + +#loadingBar .progress.full { + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; +} + +#loadingBar .progress.indeterminate { + width: 100%; + height: 25px; + background-image: -moz-linear-gradient( 30deg, #404040, #404040 15%, #898989, #404040 85%, #404040); + background-image: -webkit-linear-gradient( 30deg, #404040, #404040 15%, #898989, #404040 85%, #404040); + background-image: -ms-linear-gradient( 30deg, #404040, #404040 15%, #898989, #404040 85%, #404040); + background-image: -o-linear-gradient( 30deg, #404040, #404040 15%, #898989, #404040 85%, #404040); + background-size: 75px 25px; + -moz-animation: progressIndeterminate 1s linear infinite; + -webkit-animation: progressIndeterminate 1s linear infinite; +} + +@-moz-keyframes progressIndeterminate { + from { background-position: 0px 0px; } + to { background-position: 75px 0px; } +} + +@-webkit-keyframes progressIndeterminate { + from { background-position: 0px 0px; } + to { background-position: 75px 0px; } +} + +.textLayer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + color: #000; + font-family: sans-serif; + overflow: hidden; +} + +.textLayer > div { + color: transparent; + position: absolute; + line-height: 1; + white-space: pre; + cursor: text; +} + +.textLayer .highlight { + margin: -1px; + padding: 1px; + + background-color: rgba(180, 0, 170, 0.2); + border-radius: 4px; +} + +.textLayer .highlight.begin { + border-radius: 4px 0px 0px 4px; +} + +.textLayer .highlight.end { + border-radius: 0px 4px 4px 0px; +} + +.textLayer .highlight.middle { + border-radius: 0px; +} + +.textLayer .highlight.selected { + background-color: rgba(0, 100, 0, 0.2); +} + +/* TODO: file FF bug to support ::-moz-selection:window-inactive + so we can override the opaque grey background when the window is inactive; + see https://bugzilla.mozilla.org/show_bug.cgi?id=706209 */ +::selection { background:rgba(0,0,255,0.3); } +::-moz-selection { background:rgba(0,0,255,0.3); } + +.annotText > div { + z-index: 200; + position: absolute; + padding: 0.6em; + max-width: 20em; + background-color: #FFFF99; + box-shadow: 0px 2px 10px #333; + border-radius: 7px; +} + +.annotText > img { + position: absolute; + opacity: 0.6; +} + +.annotText > img:hover { + cursor: pointer; + opacity: 1; +} + +.annotText > div > h1 { + font-size: 1.2em; + border-bottom: 1px solid #000000; + margin: 0px; +} + +#errorWrapper { + background: none repeat scroll 0 0 #FF5555; + color: white; + left: 0; + position: absolute; + right: 0; + top: 32px; + z-index: 1000; + padding: 3px; + font-size: 0.8em; +} + +#errorMessageLeft { + float: left; +} + +#errorMessageRight { + float: right; +} + +#errorMoreInfo { + background-color: #FFFFFF; + color: black; + padding: 3px; + margin: 3px; + width: 98%; +} + +.clearBoth { + clear: both; +} + +.fileInput { + background: white; + color: black; + margin-top: 5px; +} + +#PDFBug { + background: none repeat scroll 0 0 white; + border: 1px solid #666666; + position: fixed; + top: 32px; + right: 0; + bottom: 0; + font-size: 10px; + padding: 0; + width: 300px; +} +#PDFBug .controls { + background:#EEEEEE; + border-bottom: 1px solid #666666; + padding: 3px; +} +#PDFBug .panels { + bottom: 0; + left: 0; + overflow: auto; + position: absolute; + right: 0; + top: 27px; +} +#PDFBug button.active { + font-weight: bold; +} +.debuggerShowText { + background: none repeat scroll 0 0 yellow; + color: blue; + opacity: 0.3; +} +.debuggerHideText:hover { + background: none repeat scroll 0 0 yellow; + opacity: 0.3; +} +#PDFBug .stats { + font-family: courier; + font-size: 10px; + white-space: pre; +} +#PDFBug .stats .title { + font-weight: bold; +} +#PDFBug table { + font-size: 10px; +} + +#viewer.textLayer-visible .textLayer > div, +#viewer.textLayer-hover .textLayer > div:hover { + background-color: white; + color: black; +} + +#viewer.textLayer-shadow .textLayer > div { + background-color: rgba(255,255,255, .6); + color: black; +} + +@page { + margin: 0; +} + +#printContainer { + display: none; +} + +@media print { + /* Rules for browsers that don't support mozPrintCallback. */ + #sidebarContainer, .toolbar, #loadingBox, #errorWrapper, .textLayer { + display: none; + } + + #mainContainer, #viewerContainer, .page, .page canvas { + position: static; + padding: 0; + margin: 0; + } + + .page { + float: left; + display: none; + box-shadow: none; + } + + .page[data-loaded] { + display: block; + } + + /* Rules for browsers that support mozPrintCallback */ + body[data-mozPrintCallback] #outerContainer { + display: none; + } + body[data-mozPrintCallback] #printContainer { + display: block; + } + #printContainer canvas { + position: relative; + top: 0; + left: 0; + } +} + +@media all and (max-width: 950px) { + html[dir='ltr'] #outerContainer.sidebarMoving .outerCenter, + html[dir='ltr'] #outerContainer.sidebarOpen .outerCenter { + float: left; + left: 180px; + } + html[dir='rtl'] #outerContainer.sidebarMoving .outerCenter, + html[dir='rtl'] #outerContainer.sidebarOpen .outerCenter { + float: right; + right: 180px; + } +} + +@media all and (max-width: 770px) { + #sidebarContainer { + top: 33px; + z-index: 100; + } + #sidebarContent { + top: 32px; + background-color: hsla(0,0%,0%,.7); + } + + html[dir='ltr'] #outerContainer.sidebarOpen > #mainContainer { + left: 0px; + } + html[dir='rtl'] #outerContainer.sidebarOpen > #mainContainer { + right: 0px; + } + + html[dir='ltr'] .outerCenter { + float: left; + left: 180px; + } + html[dir='rtl'] .outerCenter { + float: right; + right: 180px; + } +} + +@media all and (max-width: 600px) { + .hiddenSmallView { + display: none; + } + html[dir='ltr'] .outerCenter { + left: 156px; + } + html[dir='rtr'] .outerCenter { + right: 156px; + } + .toolbarButtonSpacer { + width: 0; + } +} + +@media all and (max-width: 500px) { + #scaleSelectContainer, #pageNumberLabel { + display: none; + } +} + diff --git a/mediagoblin/static/extlib/pdf.js b/mediagoblin/static/extlib/pdf.js new file mode 120000 index 00000000..f829660a --- /dev/null +++ b/mediagoblin/static/extlib/pdf.js @@ -0,0 +1 @@ +../../../extlib/pdf.js
\ No newline at end of file diff --git a/mediagoblin/static/js/pdf_viewer.js b/mediagoblin/static/js/pdf_viewer.js new file mode 100644 index 00000000..79c1e708 --- /dev/null +++ b/mediagoblin/static/js/pdf_viewer.js @@ -0,0 +1,3615 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ +/* Copyright 2012 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* globals PDFJS, PDFBug, FirefoxCom, Stats */ + +'use strict'; + +var DEFAULT_SCALE = 'auto'; +var DEFAULT_SCALE_DELTA = 1.1; +var UNKNOWN_SCALE = 0; +var CACHE_SIZE = 20; +var CSS_UNITS = 96.0 / 72.0; +var SCROLLBAR_PADDING = 40; +var VERTICAL_PADDING = 5; +var MIN_SCALE = 0.25; +var MAX_SCALE = 4.0; +var IMAGE_DIR = './images/'; +var SETTINGS_MEMORY = 20; +var ANNOT_MIN_SIZE = 10; +var RenderingStates = { + INITIAL: 0, + RUNNING: 1, + PAUSED: 2, + FINISHED: 3 +}; +var FindStates = { + FIND_FOUND: 0, + FIND_NOTFOUND: 1, + FIND_WRAPPED: 2, + FIND_PENDING: 3 +}; + +//#if (FIREFOX || MOZCENTRAL || B2G || GENERIC || CHROME) +//PDFJS.workerSrc = '../build/pdf.js'; +//#endif + +var mozL10n = document.mozL10n || document.webL10n; + +function getFileName(url) { + var anchor = url.indexOf('#'); + var query = url.indexOf('?'); + var end = Math.min( + anchor > 0 ? anchor : url.length, + query > 0 ? query : url.length); + return url.substring(url.lastIndexOf('/', end) + 1, end); +} + +function scrollIntoView(element, spot) { + // Assuming offsetParent is available (it's not available when viewer is in + // hidden iframe or object). We have to scroll: if the offsetParent is not set + // producing the error. See also animationStartedClosure. + var parent = element.offsetParent; + var offsetY = element.offsetTop + element.clientTop; + if (!parent) { + console.error('offsetParent is not set -- cannot scroll'); + return; + } + while (parent.clientHeight == parent.scrollHeight) { + offsetY += parent.offsetTop; + parent = parent.offsetParent; + if (!parent) + return; // no need to scroll + } + if (spot) + offsetY += spot.top; + parent.scrollTop = offsetY; +} + +var Cache = function cacheCache(size) { + var data = []; + this.push = function cachePush(view) { + var i = data.indexOf(view); + if (i >= 0) + data.splice(i); + data.push(view); + if (data.length > size) + data.shift().destroy(); + }; +}; + +var ProgressBar = (function ProgressBarClosure() { + + function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); + } + + function ProgressBar(id, opts) { + + // Fetch the sub-elements for later + this.div = document.querySelector(id + ' .progress'); + + // Get options, with sensible defaults + this.height = opts.height || 100; + this.width = opts.width || 100; + this.units = opts.units || '%'; + + // Initialize heights + this.div.style.height = this.height + this.units; + } + + ProgressBar.prototype = { + + updateBar: function ProgressBar_updateBar() { + if (this._indeterminate) { + this.div.classList.add('indeterminate'); + return; + } + + var progressSize = this.width * this._percent / 100; + + if (this._percent > 95) + this.div.classList.add('full'); + else + this.div.classList.remove('full'); + this.div.classList.remove('indeterminate'); + + this.div.style.width = progressSize + this.units; + }, + + get percent() { + return this._percent; + }, + + set percent(val) { + this._indeterminate = isNaN(val); + this._percent = clamp(val, 0, 100); + this.updateBar(); + } + }; + + return ProgressBar; +})(); + +//#if FIREFOX || MOZCENTRAL +//#include firefoxcom.js +//#endif + +// Settings Manager - This is a utility for saving settings +// First we see if localStorage is available +// If not, we use FUEL in FF +// Use asyncStorage for B2G +var Settings = (function SettingsClosure() { +//#if !(FIREFOX || MOZCENTRAL || B2G) + var isLocalStorageEnabled = (function localStorageEnabledTest() { + // Feature test as per http://diveintohtml5.info/storage.html + // The additional localStorage call is to get around a FF quirk, see + // bug #495747 in bugzilla + try { + return 'localStorage' in window && window['localStorage'] !== null && + localStorage; + } catch (e) { + return false; + } + })(); +//#endif + + function Settings(fingerprint) { + this.fingerprint = fingerprint; + this.initializedPromise = new PDFJS.Promise(); + + var resolvePromise = (function settingsResolvePromise(db) { + this.initialize(db || '{}'); + this.initializedPromise.resolve(); + }).bind(this); + +//#if B2G +// asyncStorage.getItem('database', resolvePromise); +//#endif + +//#if FIREFOX || MOZCENTRAL +// resolvePromise(FirefoxCom.requestSync('getDatabase', null)); +//#endif + +//#if !(FIREFOX || MOZCENTRAL || B2G) + if (isLocalStorageEnabled) + resolvePromise(localStorage.getItem('database')); +//#endif + } + + Settings.prototype = { + initialize: function settingsInitialize(database) { + database = JSON.parse(database); + if (!('files' in database)) + database.files = []; + if (database.files.length >= SETTINGS_MEMORY) + database.files.shift(); + var index; + for (var i = 0, length = database.files.length; i < length; i++) { + var branch = database.files[i]; + if (branch.fingerprint == this.fingerprint) { + index = i; + break; + } + } + if (typeof index != 'number') + index = database.files.push({fingerprint: this.fingerprint}) - 1; + this.file = database.files[index]; + this.database = database; + }, + + set: function settingsSet(name, val) { + if (!this.initializedPromise.isResolved) + return; + + var file = this.file; + file[name] = val; + var database = JSON.stringify(this.database); + +//#if B2G +// asyncStorage.setItem('database', database); +//#endif + +//#if FIREFOX || MOZCENTRAL +// FirefoxCom.requestSync('setDatabase', database); +//#endif + +//#if !(FIREFOX || MOZCENTRAL || B2G) + if (isLocalStorageEnabled) + localStorage.setItem('database', database); +//#endif + }, + + get: function settingsGet(name, defaultValue) { + if (!this.initializedPromise.isResolved) + return defaultValue; + + return this.file[name] || defaultValue; + } + }; + + return Settings; +})(); + +var cache = new Cache(CACHE_SIZE); +var currentPageNumber = 1; + +var PDFFindController = { + startedTextExtraction: false, + + extractTextPromises: [], + + // If active, find results will be highlighted. + active: false, + + // Stores the text for each page. + pageContents: [], + + pageMatches: [], + + // Currently selected match. + selected: { + pageIdx: -1, + matchIdx: -1 + }, + + // Where find algorithm currently is in the document. + offset: { + pageIdx: null, + matchIdx: null + }, + + resumePageIdx: null, + + resumeCallback: null, + + state: null, + + dirtyMatch: false, + + findTimeout: null, + + initialize: function() { + var events = [ + 'find', + 'findagain', + 'findhighlightallchange', + 'findcasesensitivitychange' + ]; + + this.handleEvent = this.handleEvent.bind(this); + + for (var i = 0; i < events.length; i++) { + window.addEventListener(events[i], this.handleEvent); + } + }, + + calcFindMatch: function(pageIndex) { + var pageContent = this.pageContents[pageIndex]; + var query = this.state.query; + var caseSensitive = this.state.caseSensitive; + var queryLen = query.length; + + if (queryLen === 0) { + // Do nothing the matches should be wiped out already. + return; + } + + if (!caseSensitive) { + pageContent = pageContent.toLowerCase(); + query = query.toLowerCase(); + } + + var matches = []; + + var matchIdx = -queryLen; + while (true) { + matchIdx = pageContent.indexOf(query, matchIdx + queryLen); + if (matchIdx === -1) { + break; + } + + matches.push(matchIdx); + } + this.pageMatches[pageIndex] = matches; + this.updatePage(pageIndex); + if (this.resumePageIdx === pageIndex) { + var callback = this.resumeCallback; + this.resumePageIdx = null; + this.resumeCallback = null; + callback(); + } + }, + + extractText: function() { + if (this.startedTextExtraction) { + return; + } + this.startedTextExtraction = true; + + this.pageContents = []; + for (var i = 0, ii = PDFView.pdfDocument.numPages; i < ii; i++) { + this.extractTextPromises.push(new PDFJS.Promise()); + } + + var self = this; + function extractPageText(pageIndex) { + PDFView.pages[pageIndex].getTextContent().then( + function textContentResolved(data) { + // Build the find string. + var bidiTexts = data.bidiTexts; + var str = ''; + + for (var i = 0; i < bidiTexts.length; i++) { + str += bidiTexts[i].str; + } + + // Store the pageContent as a string. + self.pageContents.push(str); + + self.extractTextPromises[pageIndex].resolve(pageIndex); + if ((pageIndex + 1) < PDFView.pages.length) + extractPageText(pageIndex + 1); + } + ); + } + extractPageText(0); + return this.extractTextPromise; + }, + + handleEvent: function(e) { + if (this.state === null || e.type !== 'findagain') { + this.dirtyMatch = true; + } + this.state = e.detail; + this.updateUIState(FindStates.FIND_PENDING); + + this.extractText(); + + clearTimeout(this.findTimeout); + if (e.type === 'find') { + // Only trigger the find action after 250ms of silence. + this.findTimeout = setTimeout(this.nextMatch.bind(this), 250); + } else { + this.nextMatch(); + } + }, + + updatePage: function(idx) { + var page = PDFView.pages[idx]; + + if (this.selected.pageIdx === idx) { + // If the page is selected, scroll the page into view, which triggers + // rendering the page, which adds the textLayer. Once the textLayer is + // build, it will scroll onto the selected match. + page.scrollIntoView(); + } + + if (page.textLayer) { + page.textLayer.updateMatches(); + } + }, + + nextMatch: function() { + var pages = PDFView.pages; + var previous = this.state.findPrevious; + var numPages = PDFView.pages.length; + + this.active = true; + + if (this.dirtyMatch) { + // Need to recalculate the matches, reset everything. + this.dirtyMatch = false; + this.selected.pageIdx = this.selected.matchIdx = -1; + this.offset.pageIdx = previous ? numPages - 1 : 0; + this.offset.matchIdx = null; + this.hadMatch = false; + this.resumeCallback = null; + this.resumePageIdx = null; + this.pageMatches = []; + var self = this; + + for (var i = 0; i < numPages; i++) { + // Wipe out any previous highlighted matches. + this.updatePage(i); + + // As soon as the text is extracted start finding the matches. + this.extractTextPromises[i].onData(function(pageIdx) { + // Use a timeout since all the pages may already be extracted and we + // want to start highlighting before finding all the matches. + setTimeout(function() { + self.calcFindMatch(pageIdx); + }); + }); + } + } + + // If there's no query there's no point in searching. + if (this.state.query === '') { + this.updateUIState(FindStates.FIND_FOUND); + return; + } + + // If we're waiting on a page, we return since we can't do anything else. + if (this.resumeCallback) { + return; + } + + var offset = this.offset; + // If there's already a matchIdx that means we are iterating through a + // page's matches. + if (offset.matchIdx !== null) { + var numPageMatches = this.pageMatches[offset.pageIdx].length; + if ((!previous && offset.matchIdx + 1 < numPageMatches) || + (previous && offset.matchIdx > 0)) { + // The simple case, we just have advance the matchIdx to select the next + // match on the page. + this.hadMatch = true; + offset.matchIdx = previous ? offset.matchIdx - 1 : offset.matchIdx + 1; + this.updateMatch(true); + return; + } + // We went beyond the current page's matches, so we advance to the next + // page. + this.advanceOffsetPage(previous); + } + // Start searching through the page. + this.nextPageMatch(); + }, + + nextPageMatch: function() { + if (this.resumePageIdx !== null) + console.error('There can only be one pending page.'); + + var matchesReady = function(matches) { + var offset = this.offset; + var numMatches = matches.length; + var previous = this.state.findPrevious; + if (numMatches) { + // There were matches for the page, so initialize the matchIdx. + this.hadMatch = true; + offset.matchIdx = previous ? numMatches - 1 : 0; + this.updateMatch(true); + } else { + // No matches attempt to search the next page. + this.advanceOffsetPage(previous); + if (offset.wrapped) { + offset.matchIdx = null; + if (!this.hadMatch) { + // No point in wrapping there were no matches. + this.updateMatch(false); + return; + } + } + // Search the next page. + this.nextPageMatch(); + } + }.bind(this); + + var pageIdx = this.offset.pageIdx; + var pageMatches = this.pageMatches; + if (!pageMatches[pageIdx]) { + // The matches aren't ready setup a callback so we can be notified, + // when they are ready. + this.resumeCallback = function() { + matchesReady(pageMatches[pageIdx]); + }; + this.resumePageIdx = pageIdx; + return; + } + // The matches are finished already. + matchesReady(pageMatches[pageIdx]); + }, + + advanceOffsetPage: function(previous) { + var offset = this.offset; + var numPages = this.extractTextPromises.length; + offset.pageIdx = previous ? offset.pageIdx - 1 : offset.pageIdx + 1; + offset.matchIdx = null; + if (offset.pageIdx >= numPages || offset.pageIdx < 0) { + offset.pageIdx = previous ? numPages - 1 : 0; + offset.wrapped = true; + return; + } + }, + + updateMatch: function(found) { + var state = FindStates.FIND_NOTFOUND; + var wrapped = this.offset.wrapped; + this.offset.wrapped = false; + if (found) { + var previousPage = this.selected.pageIdx; + this.selected.pageIdx = this.offset.pageIdx; + this.selected.matchIdx = this.offset.matchIdx; + state = wrapped ? FindStates.FIND_WRAPPED : FindStates.FIND_FOUND; + // Update the currently selected page to wipe out any selected matches. + if (previousPage !== -1 && previousPage !== this.selected.pageIdx) { + this.updatePage(previousPage); + } + } + this.updateUIState(state, this.state.findPrevious); + if (this.selected.pageIdx !== -1) { + this.updatePage(this.selected.pageIdx, true); + } + }, + + updateUIState: function(state, previous) { + if (PDFView.supportsIntegratedFind) { + FirefoxCom.request('updateFindControlState', + {result: state, findPrevious: previous}); + return; + } + PDFFindBar.updateUIState(state, previous); + } +}; + +var PDFFindBar = { + // TODO: Enable the FindBar *AFTER* the pagesPromise in the load function + // got resolved + + opened: false, + + initialize: function() { + this.bar = document.getElementById('findbar'); + this.toggleButton = document.getElementById('viewFind'); + this.findField = document.getElementById('findInput'); + this.highlightAll = document.getElementById('findHighlightAll'); + this.caseSensitive = document.getElementById('findMatchCase'); + this.findMsg = document.getElementById('findMsg'); + this.findStatusIcon = document.getElementById('findStatusIcon'); + + var self = this; + this.toggleButton.addEventListener('click', function() { + self.toggle(); + }); + + this.findField.addEventListener('input', function() { + self.dispatchEvent(''); + }); + + this.bar.addEventListener('keydown', function(evt) { + switch (evt.keyCode) { + case 13: // Enter + if (evt.target === self.findField) { + self.dispatchEvent('again', evt.shiftKey); + } + break; + case 27: // Escape + self.close(); + break; + } + }); + + document.getElementById('findPrevious').addEventListener('click', + function() { self.dispatchEvent('again', true); } + ); + + document.getElementById('findNext').addEventListener('click', function() { + self.dispatchEvent('again', false); + }); + + this.highlightAll.addEventListener('click', function() { + self.dispatchEvent('highlightallchange'); + }); + + this.caseSensitive.addEventListener('click', function() { + self.dispatchEvent('casesensitivitychange'); + }); + }, + + dispatchEvent: function(aType, aFindPrevious) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('find' + aType, true, true, { + query: this.findField.value, + caseSensitive: this.caseSensitive.checked, + highlightAll: this.highlightAll.checked, + findPrevious: aFindPrevious + }); + return window.dispatchEvent(event); + }, + + updateUIState: function(state, previous) { + var notFound = false; + var findMsg = ''; + var status = ''; + + switch (state) { + case FindStates.FIND_FOUND: + break; + + case FindStates.FIND_PENDING: + status = 'pending'; + break; + + case FindStates.FIND_NOTFOUND: + findMsg = mozL10n.get('find_not_found', null, 'Phrase not found'); + notFound = true; + break; + + case FindStates.FIND_WRAPPED: + if (previous) { + findMsg = mozL10n.get('find_reached_top', null, + 'Reached top of document, continued from bottom'); + } else { + findMsg = mozL10n.get('find_reached_bottom', null, + 'Reached end of document, continued from top'); + } + break; + } + + if (notFound) { + this.findField.classList.add('notFound'); + } else { + this.findField.classList.remove('notFound'); + } + + this.findField.setAttribute('data-status', status); + this.findMsg.textContent = findMsg; + }, + + open: function() { + if (this.opened) return; + + this.opened = true; + this.toggleButton.classList.add('toggled'); + this.bar.classList.remove('hidden'); + this.findField.select(); + this.findField.focus(); + }, + + close: function() { + if (!this.opened) return; + + this.opened = false; + this.toggleButton.classList.remove('toggled'); + this.bar.classList.add('hidden'); + + PDFFindController.active = false; + }, + + toggle: function() { + if (this.opened) { + this.close(); + } else { + this.open(); + } + } +}; + +var PDFView = { + pages: [], + thumbnails: [], + currentScale: UNKNOWN_SCALE, + currentScaleValue: null, + initialBookmark: document.location.hash.substring(1), + startedTextExtraction: false, + pageText: [], + container: null, + thumbnailContainer: null, + initialized: false, + fellback: false, + pdfDocument: null, + sidebarOpen: false, + pageViewScroll: null, + thumbnailViewScroll: null, + isFullscreen: false, + previousScale: null, + pageRotation: 0, + mouseScrollTimeStamp: 0, + mouseScrollDelta: 0, + lastScroll: 0, + previousPageNumber: 1, + + // called once when the document is loaded + initialize: function pdfViewInitialize() { + var self = this; + var container = this.container = document.getElementById('viewerContainer'); + this.pageViewScroll = {}; + this.watchScroll(container, this.pageViewScroll, updateViewarea); + + var thumbnailContainer = this.thumbnailContainer = + document.getElementById('thumbnailView'); + this.thumbnailViewScroll = {}; + this.watchScroll(thumbnailContainer, this.thumbnailViewScroll, + this.renderHighestPriority.bind(this)); + + PDFFindBar.initialize(); + PDFFindController.initialize(); + + this.initialized = true; + container.addEventListener('scroll', function() { + self.lastScroll = Date.now(); + }, false); + }, + + getPage: function pdfViewGetPage(n) { + return this.pdfDocument.getPage(n); + }, + + // Helper function to keep track whether a div was scrolled up or down and + // then call a callback. + watchScroll: function pdfViewWatchScroll(viewAreaElement, state, callback) { + state.down = true; + state.lastY = viewAreaElement.scrollTop; + viewAreaElement.addEventListener('scroll', function webViewerScroll(evt) { + var currentY = viewAreaElement.scrollTop; + var lastY = state.lastY; + if (currentY > lastY) + state.down = true; + else if (currentY < lastY) + state.down = false; + // else do nothing and use previous value + state.lastY = currentY; + callback(); + }, true); + }, + + setScale: function pdfViewSetScale(val, resetAutoSettings, noScroll) { + if (val == this.currentScale) + return; + + var pages = this.pages; + for (var i = 0; i < pages.length; i++) + pages[i].update(val * CSS_UNITS); + + if (!noScroll && this.currentScale != val) + this.pages[this.page - 1].scrollIntoView(); + this.currentScale = val; + + var event = document.createEvent('UIEvents'); + event.initUIEvent('scalechange', false, false, window, 0); + event.scale = val; + event.resetAutoSettings = resetAutoSettings; + window.dispatchEvent(event); + }, + + parseScale: function pdfViewParseScale(value, resetAutoSettings, noScroll) { + if ('custom' == value) + return; + + var scale = parseFloat(value); + this.currentScaleValue = value; + if (scale) { + this.setScale(scale, true, noScroll); + return; + } + + var container = this.container; + var currentPage = this.pages[this.page - 1]; + if (!currentPage) { + return; + } + + var pageWidthScale = (container.clientWidth - SCROLLBAR_PADDING) / + currentPage.width * currentPage.scale / CSS_UNITS; + var pageHeightScale = (container.clientHeight - VERTICAL_PADDING) / + currentPage.height * currentPage.scale / CSS_UNITS; + switch (value) { + case 'page-actual': + scale = 1; + break; + case 'page-width': + scale = pageWidthScale; + break; + case 'page-height': + scale = pageHeightScale; + break; + case 'page-fit': + scale = Math.min(pageWidthScale, pageHeightScale); + break; + case 'auto': + scale = Math.min(1.0, pageWidthScale); + break; + } + this.setScale(scale, resetAutoSettings, noScroll); + + selectScaleOption(value); + }, + + zoomIn: function pdfViewZoomIn() { + var newScale = (this.currentScale * DEFAULT_SCALE_DELTA).toFixed(2); + newScale = Math.ceil(newScale * 10) / 10; + newScale = Math.min(MAX_SCALE, newScale); + this.parseScale(newScale, true); + }, + + zoomOut: function pdfViewZoomOut() { + var newScale = (this.currentScale / DEFAULT_SCALE_DELTA).toFixed(2); + newScale = Math.floor(newScale * 10) / 10; + newScale = Math.max(MIN_SCALE, newScale); + this.parseScale(newScale, true); + }, + + set page(val) { + var pages = this.pages; + var input = document.getElementById('pageNumber'); + var event = document.createEvent('UIEvents'); + event.initUIEvent('pagechange', false, false, window, 0); + + if (!(0 < val && val <= pages.length)) { + this.previousPageNumber = val; + event.pageNumber = this.page; + window.dispatchEvent(event); + return; + } + + pages[val - 1].updateStats(); + this.previousPageNumber = currentPageNumber; + currentPageNumber = val; + event.pageNumber = val; + window.dispatchEvent(event); + + // checking if the this.page was called from the updateViewarea function: + // avoiding the creation of two "set page" method (internal and public) + if (updateViewarea.inProgress) + return; + + // Avoid scrolling the first page during loading + if (this.loading && val == 1) + return; + + pages[val - 1].scrollIntoView(); + }, + + get page() { + return currentPageNumber; + }, + + get supportsPrinting() { + var canvas = document.createElement('canvas'); + var value = 'mozPrintCallback' in canvas; + // shadow + Object.defineProperty(this, 'supportsPrinting', { value: value, + enumerable: true, + configurable: true, + writable: false }); + return value; + }, + + get supportsFullscreen() { + var doc = document.documentElement; + var support = doc.requestFullscreen || doc.mozRequestFullScreen || + doc.webkitRequestFullScreen; + + // Disable fullscreen button if we're in an iframe + if (!!window.frameElement) + support = false; + + Object.defineProperty(this, 'supportsFullScreen', { value: support, + enumerable: true, + configurable: true, + writable: false }); + return support; + }, + + get supportsIntegratedFind() { + var support = false; +//#if !(FIREFOX || MOZCENTRAL) +//#else +// support = FirefoxCom.requestSync('supportsIntegratedFind'); +//#endif + Object.defineProperty(this, 'supportsIntegratedFind', { value: support, + enumerable: true, + configurable: true, + writable: false }); + return support; + }, + + get supportsDocumentFonts() { + var support = true; +//#if !(FIREFOX || MOZCENTRAL) +//#else +// support = FirefoxCom.requestSync('supportsDocumentFonts'); +//#endif + Object.defineProperty(this, 'supportsDocumentFonts', { value: support, + enumerable: true, + configurable: true, + writable: false }); + return support; + }, + + get isHorizontalScrollbarEnabled() { + var div = document.getElementById('viewerContainer'); + return div.scrollWidth > div.clientWidth; + }, + + initPassiveLoading: function pdfViewInitPassiveLoading() { + if (!PDFView.loadingBar) { + PDFView.loadingBar = new ProgressBar('#loadingBar', {}); + } + + window.addEventListener('message', function window_message(e) { + var args = e.data; + + if (typeof args !== 'object' || !('pdfjsLoadAction' in args)) + return; + switch (args.pdfjsLoadAction) { + case 'progress': + PDFView.progress(args.loaded / args.total); + break; + case 'complete': + if (!args.data) { + PDFView.error(mozL10n.get('loading_error', null, + 'An error occurred while loading the PDF.'), e); + break; + } + PDFView.open(args.data, 0); + break; + } + }); + FirefoxCom.requestSync('initPassiveLoading', null); + }, + + setTitleUsingUrl: function pdfViewSetTitleUsingUrl(url) { + this.url = url; + try { + this.setTitle(decodeURIComponent(getFileName(url)) || url); + } catch (e) { + // decodeURIComponent may throw URIError, + // fall back to using the unprocessed url in that case + this.setTitle(url); + } + }, + + setTitle: function pdfViewSetTitle(title) { + document.title = title; +//#if B2G +// document.getElementById('activityTitle').textContent = title; +//#endif + }, + + open: function pdfViewOpen(url, scale, password) { + var parameters = {password: password}; + if (typeof url === 'string') { // URL + this.setTitleUsingUrl(url); + parameters.url = url; + } else if (url && 'byteLength' in url) { // ArrayBuffer + parameters.data = url; + } + + if (!PDFView.loadingBar) { + PDFView.loadingBar = new ProgressBar('#loadingBar', {}); + } + + this.pdfDocument = null; + var self = this; + self.loading = true; + PDFJS.getDocument(parameters).then( + function getDocumentCallback(pdfDocument) { + self.load(pdfDocument, scale); + self.loading = false; + }, + function getDocumentError(message, exception) { + if (exception && exception.name === 'PasswordException') { + if (exception.code === 'needpassword') { + var promptString = mozL10n.get('request_password', null, + 'PDF is protected by a password:'); + password = prompt(promptString); + if (password && password.length > 0) { + return PDFView.open(url, scale, password); + } + } + } + + var loadingErrorMessage = mozL10n.get('loading_error', null, + 'An error occurred while loading the PDF.'); + + if (exception && exception.name === 'InvalidPDFException') { + // change error message also for other builds + var loadingErrorMessage = mozL10n.get('invalid_file_error', null, + 'Invalid or corrupted PDF file.'); +//#if B2G +// window.alert(loadingErrorMessage); +// return window.close(); +//#endif + } + + if (exception && exception.name === 'MissingPDFException') { + // special message for missing PDF's + var loadingErrorMessage = mozL10n.get('missing_file_error', null, + 'Missing PDF file.'); + +//#if B2G +// window.alert(loadingErrorMessage); +// return window.close(); +//#endif + } + + var loadingIndicator = document.getElementById('loading'); + loadingIndicator.textContent = mozL10n.get('loading_error_indicator', + null, 'Error'); + var moreInfo = { + message: message + }; + self.error(loadingErrorMessage, moreInfo); + self.loading = false; + }, + function getDocumentProgress(progressData) { + self.progress(progressData.loaded / progressData.total); + } + ); + }, + + download: function pdfViewDownload() { + function noData() { + FirefoxCom.request('download', { originalUrl: url }); + } + var url = this.url.split('#')[0]; +//#if !(FIREFOX || MOZCENTRAL) + url += '#pdfjs.action=download'; + window.open(url, '_parent'); +//#else +// // Document isn't ready just try to download with the url. +// if (!this.pdfDocument) { +// noData(); +// return; +// } +// this.pdfDocument.getData().then( +// function getDataSuccess(data) { +// var blob = PDFJS.createBlob(data.buffer, 'application/pdf'); +// var blobUrl = window.URL.createObjectURL(blob); +// +// FirefoxCom.request('download', { blobUrl: blobUrl, originalUrl: url }, +// function response(err) { +// if (err) { +// // This error won't really be helpful because it's likely the +// // fallback won't work either (or is already open). +// PDFView.error('PDF failed to download.'); +// } +// window.URL.revokeObjectURL(blobUrl); +// } +// ); +// }, +// noData // Error occurred try downloading with just the url. +// ); +//#endif + }, + + fallback: function pdfViewFallback() { +//#if !(FIREFOX || MOZCENTRAL) +// return; +//#else +// // Only trigger the fallback once so we don't spam the user with messages +// // for one PDF. +// if (this.fellback) +// return; +// this.fellback = true; +// var url = this.url.split('#')[0]; +// FirefoxCom.request('fallback', url, function response(download) { +// if (!download) +// return; +// PDFView.download(); +// }); +//#endif + }, + + navigateTo: function pdfViewNavigateTo(dest) { + if (typeof dest === 'string') + dest = this.destinations[dest]; + if (!(dest instanceof Array)) + return; // invalid destination + // dest array looks like that: <page-ref> </XYZ|FitXXX> <args..> + var destRef = dest[0]; + var pageNumber = destRef instanceof Object ? + this.pagesRefMap[destRef.num + ' ' + destRef.gen + ' R'] : (destRef + 1); + if (pageNumber > this.pages.length) + pageNumber = this.pages.length; + if (pageNumber) { + this.page = pageNumber; + var currentPage = this.pages[pageNumber - 1]; + if (!this.isFullscreen) { // Avoid breaking fullscreen mode. + currentPage.scrollIntoView(dest); + } + } + }, + + getDestinationHash: function pdfViewGetDestinationHash(dest) { + if (typeof dest === 'string') + return PDFView.getAnchorUrl('#' + escape(dest)); + if (dest instanceof Array) { + var destRef = dest[0]; // see navigateTo method for dest format + var pageNumber = destRef instanceof Object ? + this.pagesRefMap[destRef.num + ' ' + destRef.gen + ' R'] : + (destRef + 1); + if (pageNumber) { + var pdfOpenParams = PDFView.getAnchorUrl('#page=' + pageNumber); + var destKind = dest[1]; + if (typeof destKind === 'object' && 'name' in destKind && + destKind.name == 'XYZ') { + var scale = (dest[4] || this.currentScale); + pdfOpenParams += '&zoom=' + (scale * 100); + if (dest[2] || dest[3]) { + pdfOpenParams += ',' + (dest[2] || 0) + ',' + (dest[3] || 0); + } + } + return pdfOpenParams; + } + } + return ''; + }, + + /** + * For the firefox extension we prefix the full url on anchor links so they + * don't come up as resource:// urls and so open in new tab/window works. + * @param {String} anchor The anchor hash include the #. + */ + getAnchorUrl: function getAnchorUrl(anchor) { +//#if !(FIREFOX || MOZCENTRAL) + return anchor; +//#else +// return this.url.split('#')[0] + anchor; +//#endif + }, + + /** + * Returns scale factor for the canvas. It makes sense for the HiDPI displays. + * @return {Object} The object with horizontal (sx) and vertical (sy) + scales. The scaled property is set to false if scaling is + not required, true otherwise. + */ + getOutputScale: function pdfViewGetOutputDPI() { + var pixelRatio = 'devicePixelRatio' in window ? window.devicePixelRatio : 1; + return { + sx: pixelRatio, + sy: pixelRatio, + scaled: pixelRatio != 1 + }; + }, + + /** + * Show the error box. + * @param {String} message A message that is human readable. + * @param {Object} moreInfo (optional) Further information about the error + * that is more technical. Should have a 'message' + * and optionally a 'stack' property. + */ + error: function pdfViewError(message, moreInfo) { + var moreInfoText = mozL10n.get('error_version_info', + {version: PDFJS.version || '?', build: PDFJS.build || '?'}, + 'PDF.js v{{version}} (build: {{build}})') + '\n'; + if (moreInfo) { + moreInfoText += + mozL10n.get('error_message', {message: moreInfo.message}, + 'Message: {{message}}'); + if (moreInfo.stack) { + moreInfoText += '\n' + + mozL10n.get('error_stack', {stack: moreInfo.stack}, + 'Stack: {{stack}}'); + } else { + if (moreInfo.filename) { + moreInfoText += '\n' + + mozL10n.get('error_file', {file: moreInfo.filename}, + 'File: {{file}}'); + } + if (moreInfo.lineNumber) { + moreInfoText += '\n' + + mozL10n.get('error_line', {line: moreInfo.lineNumber}, + 'Line: {{line}}'); + } + } + } + + var loadingBox = document.getElementById('loadingBox'); + loadingBox.setAttribute('hidden', 'true'); + +//#if !(FIREFOX || MOZCENTRAL) + var errorWrapper = document.getElementById('errorWrapper'); + errorWrapper.removeAttribute('hidden'); + + var errorMessage = document.getElementById('errorMessage'); + errorMessage.textContent = message; + + var closeButton = document.getElementById('errorClose'); + closeButton.onclick = function() { + errorWrapper.setAttribute('hidden', 'true'); + }; + + var errorMoreInfo = document.getElementById('errorMoreInfo'); + var moreInfoButton = document.getElementById('errorShowMore'); + var lessInfoButton = document.getElementById('errorShowLess'); + moreInfoButton.onclick = function() { + errorMoreInfo.removeAttribute('hidden'); + moreInfoButton.setAttribute('hidden', 'true'); + lessInfoButton.removeAttribute('hidden'); + }; + lessInfoButton.onclick = function() { + errorMoreInfo.setAttribute('hidden', 'true'); + moreInfoButton.removeAttribute('hidden'); + lessInfoButton.setAttribute('hidden', 'true'); + }; + moreInfoButton.removeAttribute('hidden'); + lessInfoButton.setAttribute('hidden', 'true'); + errorMoreInfo.value = moreInfoText; + + errorMoreInfo.rows = moreInfoText.split('\n').length - 1; +//#else +// console.error(message + '\n' + moreInfoText); +// this.fallback(); +//#endif + }, + + progress: function pdfViewProgress(level) { + var percent = Math.round(level * 100); + PDFView.loadingBar.percent = percent; + }, + + load: function pdfViewLoad(pdfDocument, scale) { + function bindOnAfterDraw(pageView, thumbnailView) { + // when page is painted, using the image as thumbnail base + pageView.onAfterDraw = function pdfViewLoadOnAfterDraw() { + thumbnailView.setImage(pageView.canvas); + }; + } + + this.pdfDocument = pdfDocument; + + var errorWrapper = document.getElementById('errorWrapper'); + errorWrapper.setAttribute('hidden', 'true'); + + var loadingBox = document.getElementById('loadingBox'); + loadingBox.setAttribute('hidden', 'true'); + var loadingIndicator = document.getElementById('loading'); + loadingIndicator.textContent = ''; + + var thumbsView = document.getElementById('thumbnailView'); + thumbsView.parentNode.scrollTop = 0; + + while (thumbsView.hasChildNodes()) + thumbsView.removeChild(thumbsView.lastChild); + + if ('_loadingInterval' in thumbsView) + clearInterval(thumbsView._loadingInterval); + + var container = document.getElementById('viewer'); + while (container.hasChildNodes()) + container.removeChild(container.lastChild); + + var pagesCount = pdfDocument.numPages; + var id = pdfDocument.fingerprint; + document.getElementById('numPages').textContent = + mozL10n.get('page_of', {pageCount: pagesCount}, 'of {{pageCount}}'); + document.getElementById('pageNumber').max = pagesCount; + + PDFView.documentFingerprint = id; + var store = PDFView.store = new Settings(id); + + this.pageRotation = 0; + + var pages = this.pages = []; + this.pageText = []; + this.startedTextExtraction = false; + var pagesRefMap = this.pagesRefMap = {}; + var thumbnails = this.thumbnails = []; + + var pagesPromise = new PDFJS.Promise(); + var self = this; + + var firstPagePromise = pdfDocument.getPage(1); + + // Fetch a single page so we can get a viewport that will be the default + // viewport for all pages + firstPagePromise.then(function(pdfPage) { + var viewport = pdfPage.getViewport(scale || 1.0); + var pagePromises = []; + for (var pageNum = 1; pageNum <= pagesCount; ++pageNum) { + var viewportClone = viewport.clone(); + var pageView = new PageView(container, pageNum, scale, + self.navigateTo.bind(self), + viewportClone); + var thumbnailView = new ThumbnailView(thumbsView, pageNum, + viewportClone); + bindOnAfterDraw(pageView, thumbnailView); + pages.push(pageView); + thumbnails.push(thumbnailView); + } + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('documentload', true, true, {}); + window.dispatchEvent(event); + + for (var pageNum = 1; pageNum <= pagesCount; ++pageNum) { + var pagePromise = pdfDocument.getPage(pageNum); + pagePromise.then(function(pdfPage) { + var pageNum = pdfPage.pageNumber; + var pageView = pages[pageNum - 1]; + if (!pageView.pdfPage) { + // The pdfPage might already be set if we've already entered + // pageView.draw() + pageView.setPdfPage(pdfPage); + } + var thumbnailView = thumbnails[pageNum - 1]; + if (!thumbnailView.pdfPage) { + thumbnailView.setPdfPage(pdfPage); + } + + var pageRef = pdfPage.ref; + var refStr = pageRef.num + ' ' + pageRef.gen + ' R'; + pagesRefMap[refStr] = pdfPage.pageNumber; + }); + pagePromises.push(pagePromise); + } + + PDFJS.Promise.all(pagePromises).then(function(pages) { + pagesPromise.resolve(pages); + }); + }); + + var storePromise = store.initializedPromise; + PDFJS.Promise.all([firstPagePromise, storePromise]).then(function() { + var storedHash = null; + if (store.get('exists', false)) { + var pageNum = store.get('page', '1'); + var zoom = store.get('zoom', PDFView.currentScale); + var left = store.get('scrollLeft', '0'); + var top = store.get('scrollTop', '0'); + + storedHash = 'page=' + pageNum + '&zoom=' + zoom + ',' + + left + ',' + top; + } + self.setInitialView(storedHash, scale); + }); + + pagesPromise.then(function() { + if (PDFView.supportsPrinting) { + pdfDocument.getJavaScript().then(function(javaScript) { + if (javaScript.length) { + console.warn('Warning: JavaScript is not supported'); + PDFView.fallback(); + } + // Hack to support auto printing. + var regex = /\bprint\s*\(/g; + for (var i = 0, ii = javaScript.length; i < ii; i++) { + var js = javaScript[i]; + if (js && regex.test(js)) { + setTimeout(function() { + window.print(); + }); + return; + } + } + }); + } + }); + + var destinationsPromise = pdfDocument.getDestinations(); + destinationsPromise.then(function(destinations) { + self.destinations = destinations; + }); + + // outline depends on destinations and pagesRefMap + var promises = [pagesPromise, destinationsPromise, + PDFView.animationStartedPromise]; + PDFJS.Promise.all(promises).then(function() { + pdfDocument.getOutline().then(function(outline) { + self.outline = new DocumentOutlineView(outline); + }); + + // Make all navigation keys work on document load, + // unless the viewer is embedded in another page. + if (window.parent.location === window.location) { + PDFView.container.focus(); + } + }); + + pdfDocument.getMetadata().then(function(data) { + var info = data.info, metadata = data.metadata; + self.documentInfo = info; + self.metadata = metadata; + + // Provides some basic debug information + console.log('PDF ' + pdfDocument.fingerprint + ' [' + + info.PDFFormatVersion + ' ' + (info.Producer || '-') + + ' / ' + (info.Creator || '-') + ']' + + (PDFJS.version ? ' (PDF.js: ' + PDFJS.version + ')' : '')); + + var pdfTitle; + if (metadata) { + if (metadata.has('dc:title')) + pdfTitle = metadata.get('dc:title'); + } + + if (!pdfTitle && info && info['Title']) + pdfTitle = info['Title']; + + if (pdfTitle) + self.setTitle(pdfTitle + ' - ' + document.title); + + if (info.IsAcroFormPresent) { + console.warn('Warning: AcroForm/XFA is not supported'); + PDFView.fallback(); + } + }); + }, + + setInitialView: function pdfViewSetInitialView(storedHash, scale) { + // Reset the current scale, as otherwise the page's scale might not get + // updated if the zoom level stayed the same. + this.currentScale = 0; + this.currentScaleValue = null; + if (this.initialBookmark) { + this.setHash(this.initialBookmark); + this.initialBookmark = null; + } + else if (storedHash) + this.setHash(storedHash); + else if (scale) { + this.parseScale(scale, true); + this.page = 1; + } + + if (PDFView.currentScale === UNKNOWN_SCALE) { + // Scale was not initialized: invalid bookmark or scale was not specified. + // Setting the default one. + this.parseScale(DEFAULT_SCALE, true); + } + }, + + renderHighestPriority: function pdfViewRenderHighestPriority() { + // Pages have a higher priority than thumbnails, so check them first. + var visiblePages = this.getVisiblePages(); + var pageView = this.getHighestPriority(visiblePages, this.pages, + this.pageViewScroll.down); + if (pageView) { + this.renderView(pageView, 'page'); + return; + } + // No pages needed rendering so check thumbnails. + if (this.sidebarOpen) { + var visibleThumbs = this.getVisibleThumbs(); + var thumbView = this.getHighestPriority(visibleThumbs, + this.thumbnails, + this.thumbnailViewScroll.down); + if (thumbView) + this.renderView(thumbView, 'thumbnail'); + } + }, + + getHighestPriority: function pdfViewGetHighestPriority(visible, views, + scrolledDown) { + // The state has changed figure out which page has the highest priority to + // render next (if any). + // Priority: + // 1 visible pages + // 2 if last scrolled down page after the visible pages + // 2 if last scrolled up page before the visible pages + var visibleViews = visible.views; + + var numVisible = visibleViews.length; + if (numVisible === 0) { + return false; + } + for (var i = 0; i < numVisible; ++i) { + var view = visibleViews[i].view; + if (!this.isViewFinished(view)) + return view; + } + + // All the visible views have rendered, try to render next/previous pages. + if (scrolledDown) { + var nextPageIndex = visible.last.id; + // ID's start at 1 so no need to add 1. + if (views[nextPageIndex] && !this.isViewFinished(views[nextPageIndex])) + return views[nextPageIndex]; + } else { + var previousPageIndex = visible.first.id - 2; + if (views[previousPageIndex] && + !this.isViewFinished(views[previousPageIndex])) + return views[previousPageIndex]; + } + // Everything that needs to be rendered has been. + return false; + }, + + isViewFinished: function pdfViewNeedsRendering(view) { + return view.renderingState === RenderingStates.FINISHED; + }, + + // Render a page or thumbnail view. This calls the appropriate function based + // on the views state. If the view is already rendered it will return false. + renderView: function pdfViewRender(view, type) { + var state = view.renderingState; + switch (state) { + case RenderingStates.FINISHED: + return false; + case RenderingStates.PAUSED: + PDFView.highestPriorityPage = type + view.id; + view.resume(); + break; + case RenderingStates.RUNNING: + PDFView.highestPriorityPage = type + view.id; + break; + case RenderingStates.INITIAL: + PDFView.highestPriorityPage = type + view.id; + view.draw(this.renderHighestPriority.bind(this)); + break; + } + return true; + }, + + setHash: function pdfViewSetHash(hash) { + if (!hash) + return; + + if (hash.indexOf('=') >= 0) { + var params = PDFView.parseQueryString(hash); + // borrowing syntax from "Parameters for Opening PDF Files" + if ('nameddest' in params) { + PDFView.navigateTo(params.nameddest); + return; + } + if ('page' in params) { + var pageNumber = (params.page | 0) || 1; + if ('zoom' in params) { + var zoomArgs = params.zoom.split(','); // scale,left,top + // building destination array + + // If the zoom value, it has to get divided by 100. If it is a string, + // it should stay as it is. + var zoomArg = zoomArgs[0]; + var zoomArgNumber = parseFloat(zoomArg); + if (zoomArgNumber) + zoomArg = zoomArgNumber / 100; + + var dest = [null, {name: 'XYZ'}, + zoomArgs.length > 1 ? (zoomArgs[1] | 0) : null, + zoomArgs.length > 2 ? (zoomArgs[2] | 0) : null, + zoomArg]; + var currentPage = this.pages[pageNumber - 1]; + currentPage.scrollIntoView(dest); + } else { + this.page = pageNumber; // simple page + } + } + if ('pagemode' in params) { + var toggle = document.getElementById('sidebarToggle'); + if (params.pagemode === 'thumbs' || params.pagemode === 'bookmarks') { + if (!this.sidebarOpen) { + toggle.click(); + } + this.switchSidebarView(params.pagemode === 'thumbs' ? + 'thumbs' : 'outline'); + } else if (params.pagemode === 'none' && this.sidebarOpen) { + toggle.click(); + } + } + } else if (/^\d+$/.test(hash)) // page number + this.page = hash; + else // named destination + PDFView.navigateTo(unescape(hash)); + }, + + switchSidebarView: function pdfViewSwitchSidebarView(view) { + var thumbsView = document.getElementById('thumbnailView'); + var outlineView = document.getElementById('outlineView'); + + var thumbsButton = document.getElementById('viewThumbnail'); + var outlineButton = document.getElementById('viewOutline'); + + switch (view) { + case 'thumbs': + var wasOutlineViewVisible = thumbsView.classList.contains('hidden'); + + thumbsButton.classList.add('toggled'); + outlineButton.classList.remove('toggled'); + thumbsView.classList.remove('hidden'); + outlineView.classList.add('hidden'); + + PDFView.renderHighestPriority(); + + if (wasOutlineViewVisible) { + // Ensure that the thumbnail of the current page is visible + // when switching from the outline view. + scrollIntoView(document.getElementById('thumbnailContainer' + + this.page)); + } + break; + + case 'outline': + thumbsButton.classList.remove('toggled'); + outlineButton.classList.add('toggled'); + thumbsView.classList.add('hidden'); + outlineView.classList.remove('hidden'); + + if (outlineButton.getAttribute('disabled')) + return; + break; + } + }, + + getVisiblePages: function pdfViewGetVisiblePages() { + if (!this.isFullscreen) { + return this.getVisibleElements(this.container, this.pages, true); + } else { + // The algorithm in getVisibleElements is broken in fullscreen mode. + var visible = [], page = this.page; + var currentPage = this.pages[page - 1]; + visible.push({ id: currentPage.id, view: currentPage }); + + return { first: currentPage, last: currentPage, views: visible}; + } + }, + + getVisibleThumbs: function pdfViewGetVisibleThumbs() { + return this.getVisibleElements(this.thumbnailContainer, this.thumbnails); + }, + + // Generic helper to find out what elements are visible within a scroll pane. + getVisibleElements: function pdfViewGetVisibleElements( + scrollEl, views, sortByVisibility) { + var top = scrollEl.scrollTop, bottom = top + scrollEl.clientHeight; + var left = scrollEl.scrollLeft, right = left + scrollEl.clientWidth; + + var visible = [], view; + var currentHeight, viewHeight, hiddenHeight, percentHeight; + var currentWidth, viewWidth; + for (var i = 0, ii = views.length; i < ii; ++i) { + view = views[i]; + currentHeight = view.el.offsetTop + view.el.clientTop; + viewHeight = view.el.clientHeight; + if ((currentHeight + viewHeight) < top) { + continue; + } + if (currentHeight > bottom) { + break; + } + currentWidth = view.el.offsetLeft + view.el.clientLeft; + viewWidth = view.el.clientWidth; + if ((currentWidth + viewWidth) < left || currentWidth > right) { + continue; + } + hiddenHeight = Math.max(0, top - currentHeight) + + Math.max(0, currentHeight + viewHeight - bottom); + percentHeight = ((viewHeight - hiddenHeight) * 100 / viewHeight) | 0; + + visible.push({ id: view.id, y: currentHeight, + view: view, percent: percentHeight }); + } + + var first = visible[0]; + var last = visible[visible.length - 1]; + + if (sortByVisibility) { + visible.sort(function(a, b) { + var pc = a.percent - b.percent; + if (Math.abs(pc) > 0.001) { + return -pc; + } + return a.id - b.id; // ensure stability + }); + } + return {first: first, last: last, views: visible}; + }, + + // Helper function to parse query string (e.g. ?param1=value&parm2=...). + parseQueryString: function pdfViewParseQueryString(query) { + var parts = query.split('&'); + var params = {}; + for (var i = 0, ii = parts.length; i < parts.length; ++i) { + var param = parts[i].split('='); + var key = param[0]; + var value = param.length > 1 ? param[1] : null; + params[unescape(key)] = unescape(value); + } + return params; + }, + + beforePrint: function pdfViewSetupBeforePrint() { + if (!this.supportsPrinting) { + var printMessage = mozL10n.get('printing_not_supported', null, + 'Warning: Printing is not fully supported by this browser.'); + this.error(printMessage); + return; + } + + var alertNotReady = false; + if (!this.pages.length) { + alertNotReady = true; + } else { + for (var i = 0, ii = this.pages.length; i < ii; ++i) { + if (!this.pages[i].pdfPage) { + alertNotReady = true; + break; + } + } + } + if (alertNotReady) { + var notReadyMessage = mozL10n.get('printing_not_ready', null, + 'Warning: The PDF is not fully loaded for printing.'); + window.alert(notReadyMessage); + return; + } + + var body = document.querySelector('body'); + body.setAttribute('data-mozPrintCallback', true); + for (var i = 0, ii = this.pages.length; i < ii; ++i) { + this.pages[i].beforePrint(); + } + }, + + afterPrint: function pdfViewSetupAfterPrint() { + var div = document.getElementById('printContainer'); + while (div.hasChildNodes()) + div.removeChild(div.lastChild); + }, + + fullscreen: function pdfViewFullscreen() { + var isFullscreen = document.fullscreenElement || document.mozFullScreen || + document.webkitIsFullScreen; + + if (isFullscreen) { + return false; + } + + var wrapper = document.getElementById('viewerContainer'); + if (document.documentElement.requestFullscreen) { + wrapper.requestFullscreen(); + } else if (document.documentElement.mozRequestFullScreen) { + wrapper.mozRequestFullScreen(); + } else if (document.documentElement.webkitRequestFullScreen) { + wrapper.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT); + } else { + return false; + } + + this.isFullscreen = true; + var currentPage = this.pages[this.page - 1]; + this.previousScale = this.currentScaleValue; + this.parseScale('page-fit', true); + + // Wait for fullscreen to take effect + setTimeout(function() { + currentPage.scrollIntoView(); + }, 0); + + this.showPresentationControls(); + return true; + }, + + exitFullscreen: function pdfViewExitFullscreen() { + this.isFullscreen = false; + this.parseScale(this.previousScale); + this.page = this.page; + this.clearMouseScrollState(); + this.hidePresentationControls(); + + // Ensure that the thumbnail of the current page is visible + // when exiting fullscreen mode. + scrollIntoView(document.getElementById('thumbnailContainer' + this.page)); + }, + + showPresentationControls: function pdfViewShowPresentationControls() { + var DELAY_BEFORE_HIDING_CONTROLS = 3000; + var wrapper = document.getElementById('viewerContainer'); + if (this.presentationControlsTimeout) { + clearTimeout(this.presentationControlsTimeout); + } else { + wrapper.classList.add('presentationControls'); + } + this.presentationControlsTimeout = setTimeout(function hideControls() { + wrapper.classList.remove('presentationControls'); + delete PDFView.presentationControlsTimeout; + }, DELAY_BEFORE_HIDING_CONTROLS); + }, + + hidePresentationControls: function pdfViewShowPresentationControls() { + if (!this.presentationControlsTimeout) { + return; + } + clearTimeout(this.presentationControlsTimeout); + delete this.presentationControlsTimeout; + + var wrapper = document.getElementById('viewerContainer'); + wrapper.classList.remove('presentationControls'); + }, + + rotatePages: function pdfViewPageRotation(delta) { + + this.pageRotation = (this.pageRotation + 360 + delta) % 360; + + for (var i = 0, l = this.pages.length; i < l; i++) { + var page = this.pages[i]; + page.update(page.scale, this.pageRotation); + } + + for (var i = 0, l = this.thumbnails.length; i < l; i++) { + var thumb = this.thumbnails[i]; + thumb.update(this.pageRotation); + } + + this.parseScale(this.currentScaleValue, true); + + this.renderHighestPriority(); + + var currentPage = this.pages[this.page - 1]; + if (!currentPage) { + return; + } + + // Wait for fullscreen to take effect + setTimeout(function() { + currentPage.scrollIntoView(); + }, 0); + }, + + /** + * This function flips the page in presentation mode if the user scrolls up + * or down with large enough motion and prevents page flipping too often. + * + * @this {PDFView} + * @param {number} mouseScrollDelta The delta value from the mouse event. + */ + mouseScroll: function pdfViewMouseScroll(mouseScrollDelta) { + var MOUSE_SCROLL_COOLDOWN_TIME = 50; + + var currentTime = (new Date()).getTime(); + var storedTime = this.mouseScrollTimeStamp; + + // In case one page has already been flipped there is a cooldown time + // which has to expire before next page can be scrolled on to. + if (currentTime > storedTime && + currentTime - storedTime < MOUSE_SCROLL_COOLDOWN_TIME) + return; + + // In case the user decides to scroll to the opposite direction than before + // clear the accumulated delta. + if ((this.mouseScrollDelta > 0 && mouseScrollDelta < 0) || + (this.mouseScrollDelta < 0 && mouseScrollDelta > 0)) + this.clearMouseScrollState(); + + this.mouseScrollDelta += mouseScrollDelta; + + var PAGE_FLIP_THRESHOLD = 120; + if (Math.abs(this.mouseScrollDelta) >= PAGE_FLIP_THRESHOLD) { + + var PageFlipDirection = { + UP: -1, + DOWN: 1 + }; + + // In fullscreen mode scroll one page at a time. + var pageFlipDirection = (this.mouseScrollDelta > 0) ? + PageFlipDirection.UP : + PageFlipDirection.DOWN; + this.clearMouseScrollState(); + var currentPage = this.page; + + // In case we are already on the first or the last page there is no need + // to do anything. + if ((currentPage == 1 && pageFlipDirection == PageFlipDirection.UP) || + (currentPage == this.pages.length && + pageFlipDirection == PageFlipDirection.DOWN)) + return; + + this.page += pageFlipDirection; + this.mouseScrollTimeStamp = currentTime; + } + }, + + /** + * This function clears the member attributes used with mouse scrolling in + * presentation mode. + * + * @this {PDFView} + */ + clearMouseScrollState: function pdfViewClearMouseScrollState() { + this.mouseScrollTimeStamp = 0; + this.mouseScrollDelta = 0; + } +}; + +var PageView = function pageView(container, id, scale, + navigateTo, defaultViewport) { + this.id = id; + + this.rotation = 0; + this.scale = scale || 1.0; + this.viewport = defaultViewport; + this.pdfPageRotate = defaultViewport.rotate; + + this.renderingState = RenderingStates.INITIAL; + this.resume = null; + + this.textContent = null; + this.textLayer = null; + + var anchor = document.createElement('a'); + anchor.name = '' + this.id; + + var div = this.el = document.createElement('div'); + div.id = 'pageContainer' + this.id; + div.className = 'page'; + div.style.width = Math.floor(this.viewport.width) + 'px'; + div.style.height = Math.floor(this.viewport.height) + 'px'; + + container.appendChild(anchor); + container.appendChild(div); + + this.setPdfPage = function pageViewSetPdfPage(pdfPage) { + this.pdfPage = pdfPage; + this.pdfPageRotate = pdfPage.rotate; + this.viewport = pdfPage.getViewport(this.scale); + this.stats = pdfPage.stats; + this.update(); + }; + + this.destroy = function pageViewDestroy() { + this.update(); + if (this.pdfPage) { + this.pdfPage.destroy(); + } + }; + + this.update = function pageViewUpdate(scale, rotation) { + this.renderingState = RenderingStates.INITIAL; + this.resume = null; + + if (typeof rotation !== 'undefined') { + this.rotation = rotation; + } + + this.scale = scale || this.scale; + + var totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = this.viewport.clone({ + scale: this.scale, + rotation: totalRotation + }); + + div.style.width = Math.floor(this.viewport.width) + 'px'; + div.style.height = Math.floor(this.viewport.height) + 'px'; + + while (div.hasChildNodes()) + div.removeChild(div.lastChild); + div.removeAttribute('data-loaded'); + + delete this.canvas; + + this.loadingIconDiv = document.createElement('div'); + this.loadingIconDiv.className = 'loadingIcon'; + div.appendChild(this.loadingIconDiv); + }; + + Object.defineProperty(this, 'width', { + get: function PageView_getWidth() { + return this.viewport.width; + }, + enumerable: true + }); + + Object.defineProperty(this, 'height', { + get: function PageView_getHeight() { + return this.viewport.height; + }, + enumerable: true + }); + + function setupAnnotations(pdfPage, viewport) { + function bindLink(link, dest) { + link.href = PDFView.getDestinationHash(dest); + link.onclick = function pageViewSetupLinksOnclick() { + if (dest) + PDFView.navigateTo(dest); + return false; + }; + } + function createElementWithStyle(tagName, item, rect) { + if (!rect) { + rect = viewport.convertToViewportRectangle(item.rect); + rect = PDFJS.Util.normalizeRect(rect); + } + var element = document.createElement(tagName); + element.style.left = Math.floor(rect[0]) + 'px'; + element.style.top = Math.floor(rect[1]) + 'px'; + element.style.width = Math.ceil(rect[2] - rect[0]) + 'px'; + element.style.height = Math.ceil(rect[3] - rect[1]) + 'px'; + return element; + } + function createTextAnnotation(item) { + var container = document.createElement('section'); + container.className = 'annotText'; + + var rect = viewport.convertToViewportRectangle(item.rect); + rect = PDFJS.Util.normalizeRect(rect); + // sanity check because of OOo-generated PDFs + if ((rect[3] - rect[1]) < ANNOT_MIN_SIZE) { + rect[3] = rect[1] + ANNOT_MIN_SIZE; + } + if ((rect[2] - rect[0]) < ANNOT_MIN_SIZE) { + rect[2] = rect[0] + (rect[3] - rect[1]); // make it square + } + var image = createElementWithStyle('img', item, rect); + var iconName = item.name; + image.src = IMAGE_DIR + 'annotation-' + + iconName.toLowerCase() + '.svg'; + image.alt = mozL10n.get('text_annotation_type', {type: iconName}, + '[{{type}} Annotation]'); + var content = document.createElement('div'); + content.setAttribute('hidden', true); + var title = document.createElement('h1'); + var text = document.createElement('p'); + content.style.left = Math.floor(rect[2]) + 'px'; + content.style.top = Math.floor(rect[1]) + 'px'; + title.textContent = item.title; + + if (!item.content && !item.title) { + content.setAttribute('hidden', true); + } else { + var e = document.createElement('span'); + var lines = item.content.split(/(?:\r\n?|\n)/); + for (var i = 0, ii = lines.length; i < ii; ++i) { + var line = lines[i]; + e.appendChild(document.createTextNode(line)); + if (i < (ii - 1)) + e.appendChild(document.createElement('br')); + } + text.appendChild(e); + image.addEventListener('mouseover', function annotationImageOver() { + content.removeAttribute('hidden'); + }, false); + + image.addEventListener('mouseout', function annotationImageOut() { + content.setAttribute('hidden', true); + }, false); + } + + content.appendChild(title); + content.appendChild(text); + container.appendChild(image); + container.appendChild(content); + + return container; + } + + pdfPage.getAnnotations().then(function(items) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + switch (item.type) { + case 'Link': + var link = createElementWithStyle('a', item); + link.href = item.url || ''; + if (!item.url) + bindLink(link, ('dest' in item) ? item.dest : null); + div.appendChild(link); + break; + case 'Text': + var textAnnotation = createTextAnnotation(item); + if (textAnnotation) + div.appendChild(textAnnotation); + break; + } + } + }); + } + + this.getPagePoint = function pageViewGetPagePoint(x, y) { + return this.viewport.convertToPdfPoint(x, y); + }; + + this.scrollIntoView = function pageViewScrollIntoView(dest) { + if (!dest) { + scrollIntoView(div); + return; + } + + var x = 0, y = 0; + var width = 0, height = 0, widthScale, heightScale; + var scale = 0; + switch (dest[1].name) { + case 'XYZ': + x = dest[2]; + y = dest[3]; + scale = dest[4]; + // If x and/or y coordinates are not supplied, default to + // _top_ left of the page (not the obvious bottom left, + // since aligning the bottom of the intended page with the + // top of the window is rarely helpful). + x = x !== null ? x : 0; + y = y !== null ? y : this.height / this.scale; + break; + case 'Fit': + case 'FitB': + scale = 'page-fit'; + break; + case 'FitH': + case 'FitBH': + y = dest[2]; + scale = 'page-width'; + break; + case 'FitV': + case 'FitBV': + x = dest[2]; + scale = 'page-height'; + break; + case 'FitR': + x = dest[2]; + y = dest[3]; + width = dest[4] - x; + height = dest[5] - y; + widthScale = (this.container.clientWidth - SCROLLBAR_PADDING) / + width / CSS_UNITS; + heightScale = (this.container.clientHeight - SCROLLBAR_PADDING) / + height / CSS_UNITS; + scale = Math.min(widthScale, heightScale); + break; + default: + return; + } + + if (scale && scale !== PDFView.currentScale) + PDFView.parseScale(scale, true, true); + else if (PDFView.currentScale === UNKNOWN_SCALE) + PDFView.parseScale(DEFAULT_SCALE, true, true); + + var boundingRect = [ + this.viewport.convertToViewportPoint(x, y), + this.viewport.convertToViewportPoint(x + width, y + height) + ]; + setTimeout(function pageViewScrollIntoViewRelayout() { + // letting page to re-layout before scrolling + var scale = PDFView.currentScale; + var x = Math.min(boundingRect[0][0], boundingRect[1][0]); + var y = Math.min(boundingRect[0][1], boundingRect[1][1]); + var width = Math.abs(boundingRect[0][0] - boundingRect[1][0]); + var height = Math.abs(boundingRect[0][1] - boundingRect[1][1]); + + scrollIntoView(div, {left: x, top: y, width: width, height: height}); + }, 0); + }; + + this.getTextContent = function pageviewGetTextContent() { + if (!this.textContent) { + this.textContent = this.pdfPage.getTextContent(); + } + return this.textContent; + }; + + this.draw = function pageviewDraw(callback) { + var pdfPage = this.pdfPage; + + if (!pdfPage) { + var promise = PDFView.getPage(this.id); + promise.then(function(pdfPage) { + this.setPdfPage(pdfPage); + this.draw(callback); + }.bind(this)); + return; + } + + if (this.renderingState !== RenderingStates.INITIAL) { + console.error('Must be in new state before drawing'); + } + + this.renderingState = RenderingStates.RUNNING; + + var canvas = document.createElement('canvas'); + canvas.id = 'page' + this.id; + div.appendChild(canvas); + this.canvas = canvas; + + var scale = this.scale, viewport = this.viewport; + var outputScale = PDFView.getOutputScale(); + canvas.width = Math.floor(viewport.width) * outputScale.sx; + canvas.height = Math.floor(viewport.height) * outputScale.sy; + + var textLayerDiv = null; + if (!PDFJS.disableTextLayer) { + textLayerDiv = document.createElement('div'); + textLayerDiv.className = 'textLayer'; + textLayerDiv.style.width = canvas.width + 'px'; + textLayerDiv.style.height = canvas.height + 'px'; + div.appendChild(textLayerDiv); + } + var textLayer = this.textLayer = + textLayerDiv ? new TextLayerBuilder(textLayerDiv, this.id - 1) : null; + + if (outputScale.scaled) { + var cssScale = 'scale(' + (1 / outputScale.sx) + ', ' + + (1 / outputScale.sy) + ')'; + CustomStyle.setProp('transform' , canvas, cssScale); + CustomStyle.setProp('transformOrigin' , canvas, '0% 0%'); + if (textLayerDiv) { + CustomStyle.setProp('transform' , textLayerDiv, cssScale); + CustomStyle.setProp('transformOrigin' , textLayerDiv, '0% 0%'); + } + } + + var ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + // TODO(mack): use data attributes to store these + ctx._scaleX = outputScale.sx; + ctx._scaleY = outputScale.sy; + if (outputScale.scaled) { + ctx.scale(outputScale.sx, outputScale.sy); + } +//#if (FIREFOX || MOZCENTRAL) +// // Checking if document fonts are used only once +// var checkIfDocumentFontsUsed = !PDFView.pdfDocument.embeddedFontsUsed; +//#endif + + // Rendering area + + var self = this; + var renderingWasReset = false; + function pageViewDrawCallback(error) { + if (renderingWasReset) { + return; + } + + self.renderingState = RenderingStates.FINISHED; + + if (self.loadingIconDiv) { + div.removeChild(self.loadingIconDiv); + delete self.loadingIconDiv; + } + +//#if (FIREFOX || MOZCENTRAL) +// if (checkIfDocumentFontsUsed && PDFView.pdfDocument.embeddedFontsUsed && +// !PDFView.supportsDocumentFonts) { +// console.error(mozL10n.get('web_fonts_disabled', null, +// 'Web fonts are disabled: unable to use embedded PDF fonts.')); +// PDFView.fallback(); +// } +//#endif + if (error) { + PDFView.error(mozL10n.get('rendering_error', null, + 'An error occurred while rendering the page.'), error); + } + + self.stats = pdfPage.stats; + self.updateStats(); + if (self.onAfterDraw) + self.onAfterDraw(); + + cache.push(self); + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('pagerender', true, true, { + pageNumber: pdfPage.pageNumber + }); + div.dispatchEvent(event); + + callback(); + } + + var renderContext = { + canvasContext: ctx, + viewport: this.viewport, + textLayer: textLayer, + continueCallback: function pdfViewcContinueCallback(cont) { + if (self.renderingState === RenderingStates.INITIAL) { + // The page update() was called, we just need to abort any rendering. + renderingWasReset = true; + return; + } + + if (PDFView.highestPriorityPage !== 'page' + self.id) { + self.renderingState = RenderingStates.PAUSED; + self.resume = function resumeCallback() { + self.renderingState = RenderingStates.RUNNING; + cont(); + }; + return; + } + cont(); + } + }; + this.pdfPage.render(renderContext).then( + function pdfPageRenderCallback() { + pageViewDrawCallback(null); + }, + function pdfPageRenderError(error) { + pageViewDrawCallback(error); + } + ); + + if (textLayer) { + this.getTextContent().then( + function textContentResolved(textContent) { + textLayer.setTextContent(textContent); + } + ); + } + + setupAnnotations(this.pdfPage, this.viewport); + div.setAttribute('data-loaded', true); + }; + + this.beforePrint = function pageViewBeforePrint() { + var pdfPage = this.pdfPage; + + var viewport = pdfPage.getViewport(1); + // Use the same hack we use for high dpi displays for printing to get better + // output until bug 811002 is fixed in FF. + var PRINT_OUTPUT_SCALE = 2; + var canvas = this.canvas = document.createElement('canvas'); + canvas.width = Math.floor(viewport.width) * PRINT_OUTPUT_SCALE; + canvas.height = Math.floor(viewport.height) * PRINT_OUTPUT_SCALE; + canvas.style.width = (PRINT_OUTPUT_SCALE * viewport.width) + 'pt'; + canvas.style.height = (PRINT_OUTPUT_SCALE * viewport.height) + 'pt'; + var cssScale = 'scale(' + (1 / PRINT_OUTPUT_SCALE) + ', ' + + (1 / PRINT_OUTPUT_SCALE) + ')'; + CustomStyle.setProp('transform' , canvas, cssScale); + CustomStyle.setProp('transformOrigin' , canvas, '0% 0%'); + + var printContainer = document.getElementById('printContainer'); + printContainer.appendChild(canvas); + + var self = this; + canvas.mozPrintCallback = function(obj) { + var ctx = obj.context; + + ctx.save(); + ctx.fillStyle = 'rgb(255, 255, 255)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.restore(); + ctx.scale(PRINT_OUTPUT_SCALE, PRINT_OUTPUT_SCALE); + + var renderContext = { + canvasContext: ctx, + viewport: viewport + }; + + pdfPage.render(renderContext).then(function() { + // Tell the printEngine that rendering this canvas/page has finished. + obj.done(); + self.pdfPage.destroy(); + }, function(error) { + console.error(error); + // Tell the printEngine that rendering this canvas/page has failed. + // This will make the print proces stop. + if ('abort' in obj) + obj.abort(); + else + obj.done(); + self.pdfPage.destroy(); + }); + }; + }; + + this.updateStats = function pageViewUpdateStats() { + if (!this.stats) { + return; + } + + if (PDFJS.pdfBug && Stats.enabled) { + var stats = this.stats; + Stats.add(this.id, stats); + } + }; +}; + +var ThumbnailView = function thumbnailView(container, id, defaultViewport) { + var anchor = document.createElement('a'); + anchor.href = PDFView.getAnchorUrl('#page=' + id); + anchor.title = mozL10n.get('thumb_page_title', {page: id}, 'Page {{page}}'); + anchor.onclick = function stopNavigation() { + PDFView.page = id; + return false; + }; + + + this.pdfPage = undefined; + this.viewport = defaultViewport; + this.pdfPageRotate = defaultViewport.rotate; + + this.rotation = 0; + this.pageWidth = this.viewport.width; + this.pageHeight = this.viewport.height; + this.pageRatio = this.pageWidth / this.pageHeight; + this.id = id; + + this.canvasWidth = 98; + this.canvasHeight = this.canvasWidth / this.pageWidth * this.pageHeight; + this.scale = (this.canvasWidth / this.pageWidth); + + var div = this.el = document.createElement('div'); + div.id = 'thumbnailContainer' + id; + div.className = 'thumbnail'; + + if (id === 1) { + // Highlight the thumbnail of the first page when no page number is + // specified (or exists in cache) when the document is loaded. + div.classList.add('selected'); + } + + var ring = document.createElement('div'); + ring.className = 'thumbnailSelectionRing'; + ring.style.width = this.canvasWidth + 'px'; + ring.style.height = this.canvasHeight + 'px'; + + div.appendChild(ring); + anchor.appendChild(div); + container.appendChild(anchor); + + this.hasImage = false; + this.renderingState = RenderingStates.INITIAL; + + this.setPdfPage = function thumbnailViewSetPdfPage(pdfPage) { + this.pdfPage = pdfPage; + this.pdfPageRotate = pdfPage.rotate; + this.viewport = pdfPage.getViewport(1); + this.update(); + }; + + this.update = function thumbnailViewUpdate(rot) { + if (!this.pdfPage) { + return; + } + + if (rot !== undefined) { + this.rotation = rot; + } + + var totalRotation = (this.rotation + this.pdfPage.rotate) % 360; + this.viewport = this.viewport.clone({ + scale: 1, + rotation: totalRotation + }); + this.pageWidth = this.viewport.width; + this.pageHeight = this.viewport.height; + this.pageRatio = this.pageWidth / this.pageHeight; + + this.canvasHeight = this.canvasWidth / this.pageWidth * this.pageHeight; + this.scale = (this.canvasWidth / this.pageWidth); + + div.removeAttribute('data-loaded'); + ring.textContent = ''; + ring.style.width = this.canvasWidth + 'px'; + ring.style.height = this.canvasHeight + 'px'; + + this.hasImage = false; + this.renderingState = RenderingStates.INITIAL; + this.resume = null; + }; + + this.getPageDrawContext = function thumbnailViewGetPageDrawContext() { + var canvas = document.createElement('canvas'); + canvas.id = 'thumbnail' + id; + + canvas.width = this.canvasWidth; + canvas.height = this.canvasHeight; + canvas.className = 'thumbnailImage'; + canvas.setAttribute('aria-label', mozL10n.get('thumb_page_canvas', + {page: id}, 'Thumbnail of Page {{page}}')); + + div.setAttribute('data-loaded', true); + + ring.appendChild(canvas); + + var ctx = canvas.getContext('2d'); + ctx.save(); + ctx.fillStyle = 'rgb(255, 255, 255)'; + ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight); + ctx.restore(); + return ctx; + }; + + this.drawingRequired = function thumbnailViewDrawingRequired() { + return !this.hasImage; + }; + + this.draw = function thumbnailViewDraw(callback) { + if (!this.pdfPage) { + var promise = PDFView.getPage(this.id); + promise.then(function(pdfPage) { + this.setPdfPage(pdfPage); + this.draw(callback); + }.bind(this)); + return; + } + + if (this.renderingState !== RenderingStates.INITIAL) { + console.error('Must be in new state before drawing'); + } + + this.renderingState = RenderingStates.RUNNING; + if (this.hasImage) { + callback(); + return; + } + + var self = this; + var ctx = this.getPageDrawContext(); + var drawViewport = this.viewport.clone({ scale: this.scale }); + var renderContext = { + canvasContext: ctx, + viewport: drawViewport, + continueCallback: function(cont) { + if (PDFView.highestPriorityPage !== 'thumbnail' + self.id) { + self.renderingState = RenderingStates.PAUSED; + self.resume = function() { + self.renderingState = RenderingStates.RUNNING; + cont(); + }; + return; + } + cont(); + } + }; + this.pdfPage.render(renderContext).then( + function pdfPageRenderCallback() { + self.renderingState = RenderingStates.FINISHED; + callback(); + }, + function pdfPageRenderError(error) { + self.renderingState = RenderingStates.FINISHED; + callback(); + } + ); + this.hasImage = true; + }; + + this.setImage = function thumbnailViewSetImage(img) { + if (this.hasImage || !img) + return; + this.renderingState = RenderingStates.FINISHED; + var ctx = this.getPageDrawContext(); + ctx.drawImage(img, 0, 0, img.width, img.height, + 0, 0, ctx.canvas.width, ctx.canvas.height); + + this.hasImage = true; + }; +}; + +var DocumentOutlineView = function documentOutlineView(outline) { + var outlineView = document.getElementById('outlineView'); + while (outlineView.firstChild) + outlineView.removeChild(outlineView.firstChild); + + function bindItemLink(domObj, item) { + domObj.href = PDFView.getDestinationHash(item.dest); + domObj.onclick = function documentOutlineViewOnclick(e) { + PDFView.navigateTo(item.dest); + return false; + }; + } + + if (!outline) { + var noOutline = document.createElement('div'); + noOutline.classList.add('noOutline'); + noOutline.textContent = mozL10n.get('no_outline', null, + 'No Outline Available'); + outlineView.appendChild(noOutline); + return; + } + + var queue = [{parent: outlineView, items: outline}]; + while (queue.length > 0) { + var levelData = queue.shift(); + var i, n = levelData.items.length; + for (i = 0; i < n; i++) { + var item = levelData.items[i]; + var div = document.createElement('div'); + div.className = 'outlineItem'; + var a = document.createElement('a'); + bindItemLink(a, item); + a.textContent = item.title; + div.appendChild(a); + + if (item.items.length > 0) { + var itemsDiv = document.createElement('div'); + itemsDiv.className = 'outlineItems'; + div.appendChild(itemsDiv); + queue.push({parent: itemsDiv, items: item.items}); + } + + levelData.parent.appendChild(div); + } + } +}; + +// optimised CSS custom property getter/setter +var CustomStyle = (function CustomStyleClosure() { + + // As noted on: http://www.zachstronaut.com/posts/2009/02/17/ + // animate-css-transforms-firefox-webkit.html + // in some versions of IE9 it is critical that ms appear in this list + // before Moz + var prefixes = ['ms', 'Moz', 'Webkit', 'O']; + var _cache = { }; + + function CustomStyle() { + } + + CustomStyle.getProp = function get(propName, element) { + // check cache only when no element is given + if (arguments.length == 1 && typeof _cache[propName] == 'string') { + return _cache[propName]; + } + + element = element || document.documentElement; + var style = element.style, prefixed, uPropName; + + // test standard property first + if (typeof style[propName] == 'string') { + return (_cache[propName] = propName); + } + + // capitalize + uPropName = propName.charAt(0).toUpperCase() + propName.slice(1); + + // test vendor specific properties + for (var i = 0, l = prefixes.length; i < l; i++) { + prefixed = prefixes[i] + uPropName; + if (typeof style[prefixed] == 'string') { + return (_cache[propName] = prefixed); + } + } + + //if all fails then set to undefined + return (_cache[propName] = 'undefined'); + }; + + CustomStyle.setProp = function set(propName, element, str) { + var prop = this.getProp(propName); + if (prop != 'undefined') + element.style[prop] = str; + }; + + return CustomStyle; +})(); + +var TextLayerBuilder = function textLayerBuilder(textLayerDiv, pageIdx) { + var textLayerFrag = document.createDocumentFragment(); + + this.textLayerDiv = textLayerDiv; + this.layoutDone = false; + this.divContentDone = false; + this.pageIdx = pageIdx; + this.matches = []; + + this.beginLayout = function textLayerBuilderBeginLayout() { + this.textDivs = []; + this.textLayerQueue = []; + this.renderingDone = false; + }; + + this.endLayout = function textLayerBuilderEndLayout() { + this.layoutDone = true; + this.insertDivContent(); + }; + + this.renderLayer = function textLayerBuilderRenderLayer() { + var self = this; + var textDivs = this.textDivs; + var bidiTexts = this.textContent.bidiTexts; + var textLayerDiv = this.textLayerDiv; + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + // No point in rendering so many divs as it'd make the browser unusable + // even after the divs are rendered + var MAX_TEXT_DIVS_TO_RENDER = 100000; + if (textDivs.length > MAX_TEXT_DIVS_TO_RENDER) + return; + + for (var i = 0, ii = textDivs.length; i < ii; i++) { + var textDiv = textDivs[i]; + if ('isWhitespace' in textDiv.dataset) { + continue; + } + textLayerFrag.appendChild(textDiv); + + ctx.font = textDiv.style.fontSize + ' ' + textDiv.style.fontFamily; + var width = ctx.measureText(textDiv.textContent).width; + + if (width > 0) { + var textScale = textDiv.dataset.canvasWidth / width; + + var transform = 'scale(' + textScale + ', 1)'; + if (bidiTexts[i].dir === 'ttb') { + transform = 'rotate(90deg) ' + transform; + } + CustomStyle.setProp('transform' , textDiv, transform); + CustomStyle.setProp('transformOrigin' , textDiv, '0% 0%'); + + textLayerDiv.appendChild(textDiv); + } + } + + this.renderingDone = true; + this.updateMatches(); + + textLayerDiv.appendChild(textLayerFrag); + }; + + this.setupRenderLayoutTimer = function textLayerSetupRenderLayoutTimer() { + // Schedule renderLayout() if user has been scrolling, otherwise + // run it right away + var RENDER_DELAY = 200; // in ms + var self = this; + if (Date.now() - PDFView.lastScroll > RENDER_DELAY) { + // Render right away + this.renderLayer(); + } else { + // Schedule + if (this.renderTimer) + clearTimeout(this.renderTimer); + this.renderTimer = setTimeout(function() { + self.setupRenderLayoutTimer(); + }, RENDER_DELAY); + } + }; + + this.appendText = function textLayerBuilderAppendText(geom) { + var textDiv = document.createElement('div'); + + // vScale and hScale already contain the scaling to pixel units + var fontHeight = geom.fontSize * Math.abs(geom.vScale); + textDiv.dataset.canvasWidth = geom.canvasWidth * geom.hScale; + textDiv.dataset.fontName = geom.fontName; + + textDiv.style.fontSize = fontHeight + 'px'; + textDiv.style.fontFamily = geom.fontFamily; + textDiv.style.left = geom.x + 'px'; + textDiv.style.top = (geom.y - fontHeight) + 'px'; + + // The content of the div is set in the `setTextContent` function. + + this.textDivs.push(textDiv); + }; + + this.insertDivContent = function textLayerUpdateTextContent() { + // Only set the content of the divs once layout has finished, the content + // for the divs is available and content is not yet set on the divs. + if (!this.layoutDone || this.divContentDone || !this.textContent) + return; + + this.divContentDone = true; + + var textDivs = this.textDivs; + var bidiTexts = this.textContent.bidiTexts; + + for (var i = 0; i < bidiTexts.length; i++) { + var bidiText = bidiTexts[i]; + var textDiv = textDivs[i]; + if (!/\S/.test(bidiText.str)) { + textDiv.dataset.isWhitespace = true; + continue; + } + + textDiv.textContent = bidiText.str; + // bidiText.dir may be 'ttb' for vertical texts. + textDiv.dir = bidiText.dir === 'rtl' ? 'rtl' : 'ltr'; + } + + this.setupRenderLayoutTimer(); + }; + + this.setTextContent = function textLayerBuilderSetTextContent(textContent) { + this.textContent = textContent; + this.insertDivContent(); + }; + + this.convertMatches = function textLayerBuilderConvertMatches(matches) { + var i = 0; + var iIndex = 0; + var bidiTexts = this.textContent.bidiTexts; + var end = bidiTexts.length - 1; + var queryLen = PDFFindController.state.query.length; + + var lastDivIdx = -1; + var pos; + + var ret = []; + + // Loop over all the matches. + for (var m = 0; m < matches.length; m++) { + var matchIdx = matches[m]; + // # Calculate the begin position. + + // Loop over the divIdxs. + while (i !== end && matchIdx >= (iIndex + bidiTexts[i].str.length)) { + iIndex += bidiTexts[i].str.length; + i++; + } + + // TODO: Do proper handling here if something goes wrong. + if (i == bidiTexts.length) { + console.error('Could not find matching mapping'); + } + + var match = { + begin: { + divIdx: i, + offset: matchIdx - iIndex + } + }; + + // # Calculate the end position. + matchIdx += queryLen; + + // Somewhat same array as above, but use a > instead of >= to get the end + // position right. + while (i !== end && matchIdx > (iIndex + bidiTexts[i].str.length)) { + iIndex += bidiTexts[i].str.length; + i++; + } + + match.end = { + divIdx: i, + offset: matchIdx - iIndex + }; + ret.push(match); + } + + return ret; + }; + + this.renderMatches = function textLayerBuilder_renderMatches(matches) { + // Early exit if there is nothing to render. + if (matches.length === 0) { + return; + } + + var bidiTexts = this.textContent.bidiTexts; + var textDivs = this.textDivs; + var prevEnd = null; + var isSelectedPage = this.pageIdx === PDFFindController.selected.pageIdx; + var selectedMatchIdx = PDFFindController.selected.matchIdx; + var highlightAll = PDFFindController.state.highlightAll; + + var infty = { + divIdx: -1, + offset: undefined + }; + + function beginText(begin, className) { + var divIdx = begin.divIdx; + var div = textDivs[divIdx]; + div.textContent = ''; + + var content = bidiTexts[divIdx].str.substring(0, begin.offset); + var node = document.createTextNode(content); + if (className) { + var isSelected = isSelectedPage && + divIdx === selectedMatchIdx; + var span = document.createElement('span'); + span.className = className + (isSelected ? ' selected' : ''); + span.appendChild(node); + div.appendChild(span); + return; + } + div.appendChild(node); + } + + function appendText(from, to, className) { + var divIdx = from.divIdx; + var div = textDivs[divIdx]; + + var content = bidiTexts[divIdx].str.substring(from.offset, to.offset); + var node = document.createTextNode(content); + if (className) { + var span = document.createElement('span'); + span.className = className; + span.appendChild(node); + div.appendChild(span); + return; + } + div.appendChild(node); + } + + function highlightDiv(divIdx, className) { + textDivs[divIdx].className = className; + } + + var i0 = selectedMatchIdx, i1 = i0 + 1, i; + + if (highlightAll) { + i0 = 0; + i1 = matches.length; + } else if (!isSelectedPage) { + // Not highlighting all and this isn't the selected page, so do nothing. + return; + } + + for (i = i0; i < i1; i++) { + var match = matches[i]; + var begin = match.begin; + var end = match.end; + + var isSelected = isSelectedPage && i === selectedMatchIdx; + var highlightSuffix = (isSelected ? ' selected' : ''); + if (isSelected) + scrollIntoView(textDivs[begin.divIdx], {top: -50}); + + // Match inside new div. + if (!prevEnd || begin.divIdx !== prevEnd.divIdx) { + // If there was a previous div, then add the text at the end + if (prevEnd !== null) { + appendText(prevEnd, infty); + } + // clears the divs and set the content until the begin point. + beginText(begin); + } else { + appendText(prevEnd, begin); + } + + if (begin.divIdx === end.divIdx) { + appendText(begin, end, 'highlight' + highlightSuffix); + } else { + appendText(begin, infty, 'highlight begin' + highlightSuffix); + for (var n = begin.divIdx + 1; n < end.divIdx; n++) { + highlightDiv(n, 'highlight middle' + highlightSuffix); + } + beginText(end, 'highlight end' + highlightSuffix); + } + prevEnd = end; + } + + if (prevEnd) { + appendText(prevEnd, infty); + } + }; + + this.updateMatches = function textLayerUpdateMatches() { + // Only show matches, once all rendering is done. + if (!this.renderingDone) + return; + + // Clear out all matches. + var matches = this.matches; + var textDivs = this.textDivs; + var bidiTexts = this.textContent.bidiTexts; + var clearedUntilDivIdx = -1; + + // Clear out all current matches. + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx); + for (var n = begin; n <= match.end.divIdx; n++) { + var div = textDivs[n]; + div.textContent = bidiTexts[n].str; + div.className = ''; + } + clearedUntilDivIdx = match.end.divIdx + 1; + } + + if (!PDFFindController.active) + return; + + // Convert the matches on the page controller into the match format used + // for the textLayer. + this.matches = matches = + this.convertMatches(PDFFindController.pageMatches[this.pageIdx] || []); + + this.renderMatches(this.matches); + }; +}; + +document.addEventListener('DOMContentLoaded', function webViewerLoad(evt) { + PDFView.initialize(); + var params = PDFView.parseQueryString(document.location.search.substring(1)); + +//#if !(FIREFOX || MOZCENTRAL) + var file = params.file || DEFAULT_URL; +//#else +//var file = window.location.toString() +//#endif + +//#if !(FIREFOX || MOZCENTRAL) + if (!window.File || !window.FileReader || !window.FileList || !window.Blob) { + document.getElementById('openFile').setAttribute('hidden', 'true'); + } else { + document.getElementById('fileInput').value = null; + } +//#else +//document.getElementById('openFile').setAttribute('hidden', 'true'); +//#endif + + // Special debugging flags in the hash section of the URL. + var hash = document.location.hash.substring(1); + var hashParams = PDFView.parseQueryString(hash); + + if ('disableWorker' in hashParams) + PDFJS.disableWorker = (hashParams['disableWorker'] === 'true'); + +//#if !(FIREFOX || MOZCENTRAL) + var locale = navigator.language; + if ('locale' in hashParams) + locale = hashParams['locale']; + mozL10n.setLanguage(locale); +//#endif + + if ('textLayer' in hashParams) { + switch (hashParams['textLayer']) { + case 'off': + PDFJS.disableTextLayer = true; + break; + case 'visible': + case 'shadow': + case 'hover': + var viewer = document.getElementById('viewer'); + viewer.classList.add('textLayer-' + hashParams['textLayer']); + break; + } + } + +//#if !(FIREFOX || MOZCENTRAL) + if ('pdfBug' in hashParams) { +//#else +//if ('pdfBug' in hashParams && FirefoxCom.requestSync('pdfBugEnabled')) { +//#endif + PDFJS.pdfBug = true; + var pdfBug = hashParams['pdfBug']; + var enabled = pdfBug.split(','); + PDFBug.enable(enabled); + PDFBug.init(); + } + + if (!PDFView.supportsPrinting) { + document.getElementById('print').classList.add('hidden'); + } + + if (!PDFView.supportsFullscreen) { + document.getElementById('fullscreen').classList.add('hidden'); + } + + if (PDFView.supportsIntegratedFind) { + document.querySelector('#viewFind').classList.add('hidden'); + } + + // Listen for warnings to trigger the fallback UI. Errors should be caught + // and call PDFView.error() so we don't need to listen for those. + PDFJS.LogManager.addLogger({ + warn: function() { + PDFView.fallback(); + } + }); + + var mainContainer = document.getElementById('mainContainer'); + var outerContainer = document.getElementById('outerContainer'); + mainContainer.addEventListener('transitionend', function(e) { + if (e.target == mainContainer) { + var event = document.createEvent('UIEvents'); + event.initUIEvent('resize', false, false, window, 0); + window.dispatchEvent(event); + outerContainer.classList.remove('sidebarMoving'); + } + }, true); + + document.getElementById('sidebarToggle').addEventListener('click', + function() { + this.classList.toggle('toggled'); + outerContainer.classList.add('sidebarMoving'); + outerContainer.classList.toggle('sidebarOpen'); + PDFView.sidebarOpen = outerContainer.classList.contains('sidebarOpen'); + PDFView.renderHighestPriority(); + }); + + document.getElementById('viewThumbnail').addEventListener('click', + function() { + PDFView.switchSidebarView('thumbs'); + }); + + document.getElementById('viewOutline').addEventListener('click', + function() { + PDFView.switchSidebarView('outline'); + }); + + document.getElementById('previous').addEventListener('click', + function() { + PDFView.page--; + }); + + document.getElementById('next').addEventListener('click', + function() { + PDFView.page++; + }); + + document.querySelector('.zoomIn').addEventListener('click', + function() { + PDFView.zoomIn(); + }); + + document.querySelector('.zoomOut').addEventListener('click', + function() { + PDFView.zoomOut(); + }); + + document.getElementById('fullscreen').addEventListener('click', + function() { + PDFView.fullscreen(); + }); + + document.getElementById('openFile').addEventListener('click', + function() { + document.getElementById('fileInput').click(); + }); + + document.getElementById('print').addEventListener('click', + function() { + window.print(); + }); + + document.getElementById('download').addEventListener('click', + function() { + PDFView.download(); + }); + + document.getElementById('pageNumber').addEventListener('click', + function() { + this.select(); + }); + + document.getElementById('pageNumber').addEventListener('change', + function() { + // Handle the user inputting a floating point number. + PDFView.page = (this.value | 0); + + if (this.value !== (this.value | 0).toString()) { + this.value = PDFView.page; + } + }); + + document.getElementById('scaleSelect').addEventListener('change', + function() { + PDFView.parseScale(this.value); + }); + + document.getElementById('first_page').addEventListener('click', + function() { + PDFView.page = 1; + }); + + document.getElementById('last_page').addEventListener('click', + function() { + PDFView.page = PDFView.pdfDocument.numPages; + }); + + document.getElementById('page_rotate_ccw').addEventListener('click', + function() { + PDFView.rotatePages(-90); + }); + + document.getElementById('page_rotate_cw').addEventListener('click', + function() { + PDFView.rotatePages(90); + }); + +//#if (FIREFOX || MOZCENTRAL) +//if (FirefoxCom.requestSync('getLoadingType') == 'passive') { +// PDFView.setTitleUsingUrl(file); +// PDFView.initPassiveLoading(); +// return; +//} +//#endif + +//#if !B2G + PDFView.open(file, 0); +//#endif +}, true); + +function updateViewarea() { + + if (!PDFView.initialized) + return; + var visible = PDFView.getVisiblePages(); + var visiblePages = visible.views; + if (visiblePages.length === 0) { + return; + } + + PDFView.renderHighestPriority(); + + var currentId = PDFView.page; + var firstPage = visible.first; + + for (var i = 0, ii = visiblePages.length, stillFullyVisible = false; + i < ii; ++i) { + var page = visiblePages[i]; + + if (page.percent < 100) + break; + + if (page.id === PDFView.page) { + stillFullyVisible = true; + break; + } + } + + if (!stillFullyVisible) { + currentId = visiblePages[0].id; + } + + if (!PDFView.isFullscreen) { + updateViewarea.inProgress = true; // used in "set page" + PDFView.page = currentId; + updateViewarea.inProgress = false; + } + + var currentScale = PDFView.currentScale; + var currentScaleValue = PDFView.currentScaleValue; + var normalizedScaleValue = currentScaleValue == currentScale ? + currentScale * 100 : currentScaleValue; + + var pageNumber = firstPage.id; + var pdfOpenParams = '#page=' + pageNumber; + pdfOpenParams += '&zoom=' + normalizedScaleValue; + var currentPage = PDFView.pages[pageNumber - 1]; + var topLeft = currentPage.getPagePoint(PDFView.container.scrollLeft, + (PDFView.container.scrollTop - firstPage.y)); + pdfOpenParams += ',' + Math.round(topLeft[0]) + ',' + Math.round(topLeft[1]); + + var store = PDFView.store; + store.initializedPromise.then(function() { + store.set('exists', true); + store.set('page', pageNumber); + store.set('zoom', normalizedScaleValue); + store.set('scrollLeft', Math.round(topLeft[0])); + store.set('scrollTop', Math.round(topLeft[1])); + }); + var href = PDFView.getAnchorUrl(pdfOpenParams); + document.getElementById('viewBookmark').href = href; +} + +window.addEventListener('resize', function webViewerResize(evt) { + if (PDFView.initialized && + (document.getElementById('pageWidthOption').selected || + document.getElementById('pageFitOption').selected || + document.getElementById('pageAutoOption').selected)) + PDFView.parseScale(document.getElementById('scaleSelect').value); + updateViewarea(); +}); + +window.addEventListener('hashchange', function webViewerHashchange(evt) { + PDFView.setHash(document.location.hash.substring(1)); +}); + +window.addEventListener('change', function webViewerChange(evt) { + var files = evt.target.files; + if (!files || files.length === 0) + return; + + // Read the local file into a Uint8Array. + var fileReader = new FileReader(); + fileReader.onload = function webViewerChangeFileReaderOnload(evt) { + var buffer = evt.target.result; + var uint8Array = new Uint8Array(buffer); + PDFView.open(uint8Array, 0); + }; + + var file = files[0]; + fileReader.readAsArrayBuffer(file); + PDFView.setTitleUsingUrl(file.name); + + // URL does not reflect proper document location - hiding some icons. + document.getElementById('viewBookmark').setAttribute('hidden', 'true'); + document.getElementById('download').setAttribute('hidden', 'true'); +}, true); + +function selectScaleOption(value) { + var options = document.getElementById('scaleSelect').options; + var predefinedValueFound = false; + for (var i = 0; i < options.length; i++) { + var option = options[i]; + if (option.value != value) { + option.selected = false; + continue; + } + option.selected = true; + predefinedValueFound = true; + } + return predefinedValueFound; +} + +window.addEventListener('localized', function localized(evt) { + document.getElementsByTagName('html')[0].dir = mozL10n.getDirection(); + + // Adjust the width of the zoom box to fit the content. + PDFView.animationStartedPromise.then( + function() { + var container = document.getElementById('scaleSelectContainer'); + var select = document.getElementById('scaleSelect'); + select.setAttribute('style', 'min-width: inherit;'); + var width = select.clientWidth + 8; + select.setAttribute('style', 'min-width: ' + (width + 20) + 'px;'); + container.setAttribute('style', 'min-width: ' + width + 'px; ' + + 'max-width: ' + width + 'px;'); + }); +}, true); + +window.addEventListener('scalechange', function scalechange(evt) { + var customScaleOption = document.getElementById('customScaleOption'); + customScaleOption.selected = false; + + if (!evt.resetAutoSettings && + (document.getElementById('pageWidthOption').selected || + document.getElementById('pageFitOption').selected || + document.getElementById('pageAutoOption').selected)) { + updateViewarea(); + return; + } + + var predefinedValueFound = selectScaleOption('' + evt.scale); + if (!predefinedValueFound) { + customScaleOption.textContent = Math.round(evt.scale * 10000) / 100 + '%'; + customScaleOption.selected = true; + } + + document.getElementById('zoom_out').disabled = (evt.scale === MIN_SCALE); + document.getElementById('zoom_in').disabled = (evt.scale === MAX_SCALE); + + updateViewarea(); +}, true); + +window.addEventListener('pagechange', function pagechange(evt) { + var page = evt.pageNumber; + if (PDFView.previousPageNumber !== page) { + document.getElementById('pageNumber').value = page; + var selected = document.querySelector('.thumbnail.selected'); + if (selected) + selected.classList.remove('selected'); + var thumbnail = document.getElementById('thumbnailContainer' + page); + thumbnail.classList.add('selected'); + var visibleThumbs = PDFView.getVisibleThumbs(); + var numVisibleThumbs = visibleThumbs.views.length; + // If the thumbnail isn't currently visible scroll it into view. + if (numVisibleThumbs > 0) { + var first = visibleThumbs.first.id; + // Account for only one thumbnail being visible. + var last = numVisibleThumbs > 1 ? + visibleThumbs.last.id : first; + if (page <= first || page >= last) + scrollIntoView(thumbnail); + } + + } + document.getElementById('previous').disabled = (page <= 1); + document.getElementById('next').disabled = (page >= PDFView.pages.length); +}, true); + +// Firefox specific event, so that we can prevent browser from zooming +window.addEventListener('DOMMouseScroll', function(evt) { + if (evt.ctrlKey) { + evt.preventDefault(); + + var ticks = evt.detail; + var direction = (ticks > 0) ? 'zoomOut' : 'zoomIn'; + for (var i = 0, length = Math.abs(ticks); i < length; i++) + PDFView[direction](); + } else if (PDFView.isFullscreen) { + var FIREFOX_DELTA_FACTOR = -40; + PDFView.mouseScroll(evt.detail * FIREFOX_DELTA_FACTOR); + } +}, false); + +window.addEventListener('mousemove', function mousemove(evt) { + if (PDFView.isFullscreen) { + PDFView.showPresentationControls(); + } +}, false); + +window.addEventListener('mousedown', function mousedown(evt) { + if (PDFView.isFullscreen && evt.button === 0) { + // Enable clicking of links in fullscreen mode. + // Note: Only links that point to the currently loaded PDF document works. + var targetHref = evt.target.href; + var internalLink = targetHref && (targetHref.replace(/#.*$/, '') === + window.location.href.replace(/#.*$/, '')); + if (!internalLink) { + // Unless an internal link was clicked, advance a page in fullscreen mode. + evt.preventDefault(); + PDFView.page++; + } + } +}, false); + +window.addEventListener('click', function click(evt) { + if (PDFView.isFullscreen && evt.button === 0) { + // Necessary since preventDefault() in 'mousedown' won't stop + // the event propagation in all circumstances. + evt.preventDefault(); + } +}, false); + +window.addEventListener('keydown', function keydown(evt) { + var handled = false; + var cmd = (evt.ctrlKey ? 1 : 0) | + (evt.altKey ? 2 : 0) | + (evt.shiftKey ? 4 : 0) | + (evt.metaKey ? 8 : 0); + + // First, handle the key bindings that are independent whether an input + // control is selected or not. + if (cmd === 1 || cmd === 8 || cmd === 5 || cmd === 12) { + // either CTRL or META key with optional SHIFT. + switch (evt.keyCode) { + case 70: + if (!PDFView.supportsIntegratedFind) { + PDFFindBar.toggle(); + handled = true; + } + break; + case 61: // FF/Mac '=' + case 107: // FF '+' and '=' + case 187: // Chrome '+' + case 171: // FF with German keyboard + PDFView.zoomIn(); + handled = true; + break; + case 173: // FF/Mac '-' + case 109: // FF '-' + case 189: // Chrome '-' + PDFView.zoomOut(); + handled = true; + break; + case 48: // '0' + case 96: // '0' on Numpad of Swedish keyboard + PDFView.parseScale(DEFAULT_SCALE, true); + handled = false; // keeping it unhandled (to restore page zoom to 100%) + break; + } + } + + // CTRL or META with or without SHIFT. + if (cmd == 1 || cmd == 8 || cmd == 5 || cmd == 12) { + switch (evt.keyCode) { + case 71: // g + if (!PDFView.supportsIntegratedFind) { + PDFFindBar.dispatchEvent('again', cmd == 5 || cmd == 12); + handled = true; + } + break; + } + } + + if (handled) { + evt.preventDefault(); + return; + } + + // Some shortcuts should not get handled if a control/input element + // is selected. + var curElement = document.activeElement; + if (curElement && (curElement.tagName == 'INPUT' || + curElement.tagName == 'SELECT')) { + return; + } + var controlsElement = document.getElementById('toolbar'); + while (curElement) { + if (curElement === controlsElement && !PDFView.isFullscreen) + return; // ignoring if the 'toolbar' element is focused + curElement = curElement.parentNode; + } + + if (cmd === 0) { // no control key pressed at all. + switch (evt.keyCode) { + case 38: // up arrow + case 33: // pg up + case 8: // backspace + if (!PDFView.isFullscreen && PDFView.currentScaleValue !== 'page-fit') { + break; + } + /* in fullscreen mode */ + /* falls through */ + case 37: // left arrow + // horizontal scrolling using arrow keys + if (PDFView.isHorizontalScrollbarEnabled) { + break; + } + /* falls through */ + case 75: // 'k' + case 80: // 'p' + PDFView.page--; + handled = true; + break; + case 27: // esc key + if (!PDFView.supportsIntegratedFind && PDFFindBar.opened) { + PDFFindBar.close(); + handled = true; + } + break; + case 40: // down arrow + case 34: // pg down + case 32: // spacebar + if (!PDFView.isFullscreen && PDFView.currentScaleValue !== 'page-fit') { + break; + } + /* falls through */ + case 39: // right arrow + // horizontal scrolling using arrow keys + if (PDFView.isHorizontalScrollbarEnabled) { + break; + } + /* falls through */ + case 74: // 'j' + case 78: // 'n' + PDFView.page++; + handled = true; + break; + + case 36: // home + if (PDFView.isFullscreen) { + PDFView.page = 1; + handled = true; + } + break; + case 35: // end + if (PDFView.isFullscreen) { + PDFView.page = PDFView.pdfDocument.numPages; + handled = true; + } + break; + + case 82: // 'r' + PDFView.rotatePages(90); + break; + } + } + + if (cmd == 4) { // shift-key + switch (evt.keyCode) { + case 82: // 'r' + PDFView.rotatePages(-90); + break; + } + } + + if (handled) { + evt.preventDefault(); + PDFView.clearMouseScrollState(); + } +}); + +window.addEventListener('beforeprint', function beforePrint(evt) { + PDFView.beforePrint(); +}); + +window.addEventListener('afterprint', function afterPrint(evt) { + PDFView.afterPrint(); +}); + +(function fullscreenClosure() { + function fullscreenChange(e) { + var isFullscreen = document.fullscreenElement || document.mozFullScreen || + document.webkitIsFullScreen; + + if (!isFullscreen) { + PDFView.exitFullscreen(); + } + } + + window.addEventListener('fullscreenchange', fullscreenChange, false); + window.addEventListener('mozfullscreenchange', fullscreenChange, false); + window.addEventListener('webkitfullscreenchange', fullscreenChange, false); +})(); + +(function animationStartedClosure() { + // The offsetParent is not set until the pdf.js iframe or object is visible. + // Waiting for first animation. + var requestAnimationFrame = window.requestAnimationFrame || + window.mozRequestAnimationFrame || + window.webkitRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function startAtOnce(callback) { callback(); }; + PDFView.animationStartedPromise = new PDFJS.Promise(); + requestAnimationFrame(function onAnimationFrame() { + PDFView.animationStartedPromise.resolve(); + }); +})(); + +//#if B2G +//window.navigator.mozSetMessageHandler('activity', function(activity) { +// var url = activity.source.data.url; +// PDFView.open(url); +// var cancelButton = document.getElementById('activityClose'); +// cancelButton.addEventListener('click', function() { +// activity.postResult('close'); +// }); +//}); +//#endif diff --git a/mediagoblin/submit/forms.py b/mediagoblin/submit/forms.py index bd1e904f..e9bd93fd 100644 --- a/mediagoblin/submit/forms.py +++ b/mediagoblin/submit/forms.py @@ -18,7 +18,7 @@ import wtforms from mediagoblin.tools.text import tag_length_validator -from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ from mediagoblin.tools.licenses import licenses_as_choices diff --git a/mediagoblin/submit/lib.py b/mediagoblin/submit/lib.py index a5483471..7c3b8ab3 100644 --- a/mediagoblin/submit/lib.py +++ b/mediagoblin/submit/lib.py @@ -40,7 +40,7 @@ def prepare_queue_task(app, entry, filename): """ Prepare a MediaEntry for the processing queue and get a queue file """ - # We generate this ourselves so we know what the taks id is for + # We generate this ourselves so we know what the task id is for # retrieval later. # (If we got it off the task's auto-generation, there'd be diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index 9d31c844..e964ec12 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -114,6 +114,7 @@ def submit_start(request): {'submit_form': submit_form, 'app_config': mg_globals.app_config}) + @require_active_login def add_collection(request, media=None): """ @@ -122,32 +123,30 @@ def add_collection(request, media=None): submit_form = submit_forms.AddCollectionForm(request.form) if request.method == 'POST' and submit_form.validate(): - try: - collection = request.db.Collection() - - collection.title = unicode(submit_form.title.data) - collection.description = unicode(submit_form.description.data) - collection.creator = request.user.id - collection.generate_slug() - - # Make sure this user isn't duplicating an existing collection - existing_collection = request.db.Collection.find_one({ - 'creator': request.user.id, - 'title':collection.title}) - - if existing_collection: - messages.add_message( - request, messages.ERROR, _('You already have a collection called "%s"!' % collection.title)) - else: - collection.save() - - add_message(request, SUCCESS, _('Collection "%s" added!' % collection.title)) + collection = request.db.Collection() + + collection.title = unicode(submit_form.title.data) + collection.description = unicode(submit_form.description.data) + collection.creator = request.user.id + collection.generate_slug() + + # Make sure this user isn't duplicating an existing collection + existing_collection = request.db.Collection.find_one({ + 'creator': request.user.id, + 'title':collection.title}) + + if existing_collection: + add_message(request, messages.ERROR, + _('You already have a collection called "%s"!') \ + % collection.title) + else: + collection.save() - return redirect(request, "mediagoblin.user_pages.user_home", - user=request.user.username) + add_message(request, SUCCESS, + _('Collection "%s" added!') % collection.title) - except Exception as e: - raise + return redirect(request, "mediagoblin.user_pages.user_home", + user=request.user.username) return render_to_response( request, diff --git a/mediagoblin/templates/mediagoblin/admin/panel.html b/mediagoblin/templates/mediagoblin/admin/panel.html index 6bcb5c24..1c3c866e 100644 --- a/mediagoblin/templates/mediagoblin/admin/panel.html +++ b/mediagoblin/templates/mediagoblin/admin/panel.html @@ -104,7 +104,7 @@ <td>{{ media_entry.id }}</td> <td>{{ media_entry.get_uploader.username }}</td> <td><a href="{{ media_entry.url_for_self(request.urlgen) }}">{{ media_entry.title }}</a></td> - <td>{{ media_entry.created.strftime("%F %R") }}</td> + <td><span title='{{ media_entry.created.strftime("%F %R") }}'>{{ timesince(media_entry.created) }}</span></td> </tr> {% endfor %} </table> diff --git a/mediagoblin/templates/mediagoblin/base.html b/mediagoblin/templates/mediagoblin/base.html index 66b95661..9c42a756 100644 --- a/mediagoblin/templates/mediagoblin/base.html +++ b/mediagoblin/templates/mediagoblin/base.html @@ -16,7 +16,10 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. -#} <!doctype html> -<html> +<html +{% block mediagoblin_html_tag %} +{% endblock mediagoblin_html_tag %} +> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> diff --git a/mediagoblin/templates/mediagoblin/media_displays/pdf.html b/mediagoblin/templates/mediagoblin/media_displays/pdf.html new file mode 100644 index 00000000..e946f3ab --- /dev/null +++ b/mediagoblin/templates/mediagoblin/media_displays/pdf.html @@ -0,0 +1,84 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#} + +{% extends 'mediagoblin/user_pages/media.html' %} + +{% set medium_view = request.app.public_store.file_url( + media.media_files['medium']) %} + +{% if 'pdf' in media.media_files %} + {% set pdf_view = request.app.public_store.file_url( + media.media_files['pdf']) %} +{% else %} + {% set pdf_view = request.app.public_store.file_url( + media.media_files['original']) %} +{% endif %} + +{% set pdf_js = global_config.get('media_type:mediagoblin.media_types.pdf', {}).get('pdf_js', False) %} + +{% if pdf_js %} + {% block mediagoblin_html_tag %} + dir="ltr" mozdisallowselectionprint moznomarginboxes + {% endblock mediagoblin_html_tag %} +{% endif %} + +{% block mediagoblin_head -%} + {{ super() }} + + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> + +{%- endblock %} + +{% block mediagoblin_media %} +{% if pdf_js %} +<iframe width=640px height=480px + src="{{ request.staticdirect('/extlib/pdf.js/web/viewer.html') }}?file={{ pdf_view }} "> +</iframe> + +{% else %} + <a href="{{ pdf_view }}"> + <img id="medium" + class="media_image" + src="{{ medium_view }}" + alt="{% trans media_title=media.title -%} Image for {{ media_title}}{% endtrans %}"/> + </a> +{% endif %} +{% endblock %} + +{% block mediagoblin_sidebar %} + <h3>{% trans %}Download{% endtrans %}</h3> + <ul> + {% if 'original' in media.media_files %} + <li> + <a href="{{ request.app.public_store.file_url( + media.media_files.original) }}"> + {%- trans %}Original file{% endtrans -%} + </a> + </li> + {% endif %} + {% if 'pdf' in media.media_files %} + <li> + <a href="{{ request.app.public_store.file_url( + media.media_files.pdf) }}"> + {%- trans %}PDF file{% endtrans -%} + </a> + </li> + {% endif %} + </ul> +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/user_pages/collection_confirm_delete.html b/mediagoblin/templates/mediagoblin/user_pages/collection_confirm_delete.html index 2b790584..694eb979 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/collection_confirm_delete.html +++ b/mediagoblin/templates/mediagoblin/user_pages/collection_confirm_delete.html @@ -41,9 +41,10 @@ <div class="form_submit_buttons"> {# TODO: This isn't a button really... might do unexpected things :) #} - <a class="button_action" href="{{ request.urlgen('mediagoblin.user_pages.user_collection', - collection=collection.slug, - user=request.user.username) }}">{% trans %}Cancel{% endtrans %}</a> + <a class="button_action" href=" + {{- collection.url_for_self(request.urlgen) }}"> + {%- trans %}Cancel{% endtrans -%} + </a> <input type="submit" value="{% trans %}Delete permanently{% endtrans %}" class="button_form" /> {{ csrf_token }} </div> diff --git a/mediagoblin/templates/mediagoblin/user_pages/collection_item_confirm_remove.html b/mediagoblin/templates/mediagoblin/user_pages/collection_item_confirm_remove.html index 449cc3ce..dc31d90f 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/collection_item_confirm_remove.html +++ b/mediagoblin/templates/mediagoblin/user_pages/collection_item_confirm_remove.html @@ -47,9 +47,10 @@ <div class="form_submit_buttons"> {# TODO: This isn't a button really... might do unexpected things :) #} - <a class="button_action" href="{{ request.urlgen('mediagoblin.user_pages.user_collection', - collection=collection_item.in_collection.slug, - user=request.user.username) }}">{% trans %}Cancel{% endtrans %}</a> + <a class="button_action" href=" + {{- collection_item.in_collection.url_for_self(request.urlgen) }}"> + {%- trans %}Cancel{% endtrans -%} + </a> <input type="submit" value="{% trans %}Remove{% endtrans %}" class="button_form" /> {{ csrf_token }} </div> diff --git a/mediagoblin/templates/mediagoblin/user_pages/collection_list.html b/mediagoblin/templates/mediagoblin/user_pages/collection_list.html index abf22623..8ac0b988 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/collection_list.html +++ b/mediagoblin/templates/mediagoblin/user_pages/collection_list.html @@ -15,7 +15,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. #} -{% extends "mediagoblin/base.html" %} +{%- extends "mediagoblin/base.html" %} {% block title %} {%- trans username=user.username -%} @@ -39,17 +39,17 @@ <a href="{{ request.urlgen('mediagoblin.submit.collection', user=user.username) }}"> {%- trans %}Create new collection{% endtrans -%} + </a> </p> {% endif %} {% endif %} <ul> {% for coll in collections %} - {% set coll_url = request.urlgen( - 'mediagoblin.user_pages.user_collection', - user=user.username, - collection=coll.slug) %} - <li><a href="{{ coll_url }}">{{ coll.title }}</li> + {%- set coll_url = coll.url_for_self(request.urlgen) %} + <li> + <a href="{{ coll_url }}">{{ coll.title }}</a> + </li> {% endfor %} </ul> diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index 04be81aa..6d32d009 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -119,16 +119,20 @@ <div class="comment_author"> <img src="{{ request.staticdirect('/images/icon_comment.png') }}" /> <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', - user=comment_author.username) }}"> + user=comment_author.username) }}" + class="comment_authorlink"> {{- comment_author.username -}} </a> - {% trans %}at{% endtrans %} <a href="{{ request.urlgen('mediagoblin.user_pages.media_home.view_comment', comment=comment.id, user=media.get_uploader.username, - media=media.slug_or_id) }}#comment"> - {{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}} - </a>: + media=media.slug_or_id) }}#comment" + class="comment_whenlink"> + <span title='{{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}}'> + {%- trans formatted_time=timesince(comment.created) -%} + {{ formatted_time }} ago + {%- endtrans -%} + </span></a>: </div> <div class="comment_content"> {% autoescape False -%} @@ -143,10 +147,12 @@ {% endif %} </div> <div class="media_sidebar"> - {% trans date=media.created.strftime("%Y-%m-%d") -%} - <h3>Added on</h3> - <p>{{ date }}</p> - {%- endtrans %} + <h3>Added</h3> + <p><span title="{{ media.created.strftime("%I:%M%p %Y-%m-%d") }}"> + {%- trans formatted_time=timesince(media.created) -%} + {{ formatted_time }} ago + {%- endtrans -%} + </span></p> {% if media.tags %} {% include "mediagoblin/utils/tags.html" %} {% endif %} diff --git a/mediagoblin/templates/mediagoblin/utils/wtforms.html b/mediagoblin/templates/mediagoblin/utils/wtforms.html index 35b4aa04..be6976c2 100644 --- a/mediagoblin/templates/mediagoblin/utils/wtforms.html +++ b/mediagoblin/templates/mediagoblin/utils/wtforms.html @@ -19,7 +19,7 @@ {# Render the label for a field #} {% macro render_label(field) %} {%- if field.label.text -%} - <label for="{{ field.label.field_id }}">{{ _(field.label.text) }}</label> + <label for="{{ field.label.field_id }}">{{ field.label.text }}</label> {%- endif -%} {%- endmacro %} @@ -39,11 +39,11 @@ {{ field }} {%- if field.errors -%} {% for error in field.errors %} - <p class="form_field_error">{{ _(error) }}</p> + <p class="form_field_error">{{ error }}</p> {% endfor %} {%- endif %} {%- if field.description %} - <p class="form_field_description">{{ _(field.description)|safe }}</p> + <p class="form_field_description">{{ field.description|safe }}</p> {%- endif %} </div> {%- endmacro %} @@ -59,7 +59,7 @@ {% macro render_table(form) -%} {% for field in form %} <tr> - <th>{{ _(field.label.text) }}</th> + <th>{{ field.label.text }}</th> <td> {{field}} {% if field.errors %} diff --git a/mediagoblin/tests/appconfig_plugin_specs.ini b/mediagoblin/tests/appconfig_plugin_specs.ini new file mode 100644 index 00000000..5511cd97 --- /dev/null +++ b/mediagoblin/tests/appconfig_plugin_specs.ini @@ -0,0 +1,21 @@ +[mediagoblin] +direct_remote_path = /mgoblin_static/ +email_sender_address = "notice@mediagoblin.example.org" + +## Uncomment and change to your DB's appropiate setting. +## Default is a local sqlite db "mediagoblin.db". +# sql_engine = postgresql:///gmg + +# set to false to enable sending notices +email_debug_mode = true + +# Set to false to disable registrations +allow_registration = true + +[plugins] +[[mediagoblin.tests.testplugins.pluginspec]] +some_string = "not blork" +some_int = "not an int" + +# this one shouldn't have its own config +[[mediagoblin.tests.testplugins.callables1]] diff --git a/mediagoblin/tests/resources.py b/mediagoblin/tests/resources.py new file mode 100644 index 00000000..f7b3037d --- /dev/null +++ b/mediagoblin/tests/resources.py @@ -0,0 +1,41 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from pkg_resources import resource_filename + + +def resource(filename): + return resource_filename('mediagoblin.tests', 'test_submission/' + filename) + + +GOOD_JPG = resource('good.jpg') +GOOD_PNG = resource('good.png') +EVIL_FILE = resource('evil') +EVIL_JPG = resource('evil.jpg') +EVIL_PNG = resource('evil.png') +BIG_BLUE = resource('bigblue.png') +GOOD_PDF = resource('good.pdf') + + +def resource_exif(f): + return resource_filename('mediagoblin.tests', 'test_exif/' + f) + + +GOOD_JPG = resource_exif('good.jpg') +EMPTY_JPG = resource_exif('empty.jpg') +BAD_JPG = resource_exif('bad.jpg') +GPS_JPG = resource_exif('has-gps.jpg') diff --git a/mediagoblin/tests/test_api.py b/mediagoblin/tests/test_api.py index cff25776..89cf1026 100644 --- a/mediagoblin/tests/test_api.py +++ b/mediagoblin/tests/test_api.py @@ -18,31 +18,17 @@ import logging import base64 -from pkg_resources import resource_filename - import pytest from mediagoblin import mg_globals from mediagoblin.tools import template, pluginapi from mediagoblin.tests.tools import fixture_add_user +from .resources import GOOD_JPG, GOOD_PNG, EVIL_FILE, EVIL_JPG, EVIL_PNG, \ + BIG_BLUE _log = logging.getLogger(__name__) -def resource(filename): - ''' - Borrowed from the submission tests - ''' - return resource_filename('mediagoblin.tests', 'test_submission/' + filename) - - -GOOD_JPG = resource('good.jpg') -GOOD_PNG = resource('good.png') -EVIL_FILE = resource('evil') -EVIL_JPG = resource('evil.jpg') -EVIL_PNG = resource('evil.png') -BIG_BLUE = resource('bigblue.png') - class TestAPI(object): def setup(self): diff --git a/mediagoblin/tests/test_exif.py b/mediagoblin/tests/test_exif.py index 100d17f0..824de3c2 100644 --- a/mediagoblin/tests/test_exif.py +++ b/mediagoblin/tests/test_exif.py @@ -15,39 +15,20 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import pkg_resources -import Image +try: + from PIL import Image +except ImportError: + import Image from mediagoblin.tools.exif import exif_fix_image_orientation, \ extract_exif, clean_exif, get_gps_data, get_useful +from .resources import GOOD_JPG, EMPTY_JPG, BAD_JPG, GPS_JPG def assert_in(a, b): assert a in b, "%r not in %r" % (a, b) -GOOD_JPG = pkg_resources.resource_filename( - 'mediagoblin.tests', - os.path.join( - 'test_exif', - 'good.jpg')) -EMPTY_JPG = pkg_resources.resource_filename( - 'mediagoblin.tests', - os.path.join( - 'test_exif', - 'empty.jpg')) -BAD_JPG = pkg_resources.resource_filename( - 'mediagoblin.tests', - os.path.join( - 'test_exif', - 'bad.jpg')) -GPS_JPG = pkg_resources.resource_filename( - 'mediagoblin.tests', - os.path.join( - 'test_exif', - 'has-gps.jpg')) - - def test_exif_extraction(): ''' Test EXIF extraction from a good image diff --git a/mediagoblin/tests/test_http_callback.py b/mediagoblin/tests/test_http_callback.py index e2c85d0d..a0511af7 100644 --- a/mediagoblin/tests/test_http_callback.py +++ b/mediagoblin/tests/test_http_callback.py @@ -16,6 +16,7 @@ import json +import pytest from urlparse import urlparse, parse_qs from mediagoblin import mg_globals @@ -26,21 +27,24 @@ from mediagoblin.tests import test_oauth as oauth class TestHTTPCallback(object): - def _setup(self, test_app): + @pytest.fixture(autouse=True) + def setup(self, test_app): + self.test_app = test_app + self.db = mg_globals.database self.user_password = u'secret' self.user = fixture_add_user(u'call_back', self.user_password) - self.login(test_app) + self.login() - def login(self, testapp): - testapp.post('/auth/login/', { + def login(self): + self.test_app.post('/auth/login/', { 'username': self.user.username, 'password': self.user_password}) - def get_access_token(self, testapp, client_id, client_secret, code): - response = testapp.get('/oauth/access_token', { + def get_access_token(self, client_id, client_secret, code): + response = self.test_app.get('/oauth/access_token', { 'code': code, 'client_id': client_id, 'client_secret': client_secret}) @@ -49,15 +53,12 @@ class TestHTTPCallback(object): return response_data['access_token'] - def test_callback(self, test_app): + def test_callback(self): ''' Test processing HTTP callback ''' - self._setup(test_app) - self.oauth = oauth.TestOAuth() - self.oauth._setup(test_app) + self.oauth.setup(self.test_app) - redirect, client_id = self.oauth.test_4_authorize_confidential_client( - test_app) + redirect, client_id = self.oauth.test_4_authorize_confidential_client() code = parse_qs(urlparse(redirect.location).query)['code'][0] @@ -66,11 +67,11 @@ class TestHTTPCallback(object): client_secret = client.secret - access_token = self.get_access_token(test_app, client_id, client_secret, code) + access_token = self.get_access_token(client_id, client_secret, code) callback_url = 'https://foo.example?secrettestmediagoblinparam' - res = test_app.post('/api/submit?client_id={0}&access_token={1}\ + self.test_app.post('/api/submit?client_id={0}&access_token={1}\ &client_secret={2}'.format( client_id, access_token, diff --git a/mediagoblin/tests/test_mgoblin_app.ini b/mediagoblin/tests/test_mgoblin_app.ini index b78abe64..9f95a398 100644 --- a/mediagoblin/tests/test_mgoblin_app.ini +++ b/mediagoblin/tests/test_mgoblin_app.ini @@ -16,6 +16,8 @@ allow_attachments = True # mediagoblin.init.celery.from_celery celery_setup_elsewhere = true +media_types = mediagoblin.media_types.image, mediagoblin.media_types.pdf + [storage:publicstore] base_dir = %(here)s/test_user_dev/media/public base_url = /mgoblin_media/ diff --git a/mediagoblin/tests/test_oauth.py b/mediagoblin/tests/test_oauth.py index 901556fe..ea3bd798 100644 --- a/mediagoblin/tests/test_oauth.py +++ b/mediagoblin/tests/test_oauth.py @@ -17,6 +17,7 @@ import json import logging +import pytest from urlparse import parse_qs, urlparse from mediagoblin import mg_globals @@ -28,7 +29,10 @@ _log = logging.getLogger(__name__) class TestOAuth(object): - def _setup(self, test_app): + @pytest.fixture(autouse=True) + def setup(self, test_app): + self.test_app = test_app + self.db = mg_globals.database self.pman = pluginapi.PluginManager() @@ -36,17 +40,17 @@ class TestOAuth(object): self.user_password = u'4cc355_70k3N' self.user = fixture_add_user(u'joauth', self.user_password) - self.login(test_app) + self.login() - def login(self, test_app): - test_app.post( - '/auth/login/', { - 'username': self.user.username, - 'password': self.user_password}) + def login(self): + self.test_app.post( + '/auth/login/', { + 'username': self.user.username, + 'password': self.user_password}) - def register_client(self, test_app, name, client_type, description=None, - redirect_uri=''): - return test_app.post( + def register_client(self, name, client_type, description=None, + redirect_uri=''): + return self.test_app.post( '/oauth/client/register', { 'name': name, 'description': description, @@ -56,12 +60,10 @@ class TestOAuth(object): def get_context(self, template_name): return template.TEMPLATE_TEST_CONTEXT[template_name] - def test_1_public_client_registration_without_redirect_uri(self, test_app): + def test_1_public_client_registration_without_redirect_uri(self): ''' Test 'public' OAuth client registration without any redirect uri ''' - self._setup(test_app) - - response = self.register_client(test_app, u'OMGOMGOMG', 'public', - 'OMGOMG Apache License v2') + response = self.register_client( + u'OMGOMGOMG', 'public', 'OMGOMG Apache License v2') ctx = self.get_context('oauth/client/register.html') @@ -71,29 +73,30 @@ class TestOAuth(object): assert response.status_int == 200 # Should display an error - assert ctx['form'].redirect_uri.errors + assert len(ctx['form'].redirect_uri.errors) # Should not pass through assert not client - def test_2_successful_public_client_registration(self, test_app): + def test_2_successful_public_client_registration(self): ''' Successfully register a public client ''' - self._setup(test_app) - self.register_client(test_app, u'OMGOMG', 'public', 'OMG!', - 'http://foo.example') + uri = 'http://foo.example' + self.register_client( + u'OMGOMG', 'public', 'OMG!', uri) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.name == u'OMGOMG').first() + # redirect_uri should be set + assert client.redirect_uri == uri + # Client should have been registered assert client - def test_3_successful_confidential_client_reg(self, test_app): + def test_3_successful_confidential_client_reg(self): ''' Register a confidential OAuth client ''' - self._setup(test_app) - response = self.register_client( - test_app, u'GMOGMO', 'confidential', 'NO GMO!') + u'GMOGMO', 'confidential', 'NO GMO!') assert response.status_int == 302 @@ -105,18 +108,16 @@ class TestOAuth(object): return client - def test_4_authorize_confidential_client(self, test_app): + def test_4_authorize_confidential_client(self): ''' Authorize a confidential client as a logged in user ''' - self._setup(test_app) - - client = self.test_3_successful_confidential_client_reg(test_app) + client = self.test_3_successful_confidential_client_reg() client_identifier = client.identifier redirect_uri = 'https://foo.example' - response = test_app.get('/oauth/authorize', { + response = self.test_app.get('/oauth/authorize', { 'client_id': client.identifier, - 'scope': 'admin', + 'scope': 'all', 'redirect_uri': redirect_uri}) # User-agent should NOT be redirected @@ -127,7 +128,7 @@ class TestOAuth(object): form = ctx['form'] # Short for client authorization post reponse - capr = test_app.post( + capr = self.test_app.post( '/oauth/client/authorize', { 'client_id': form.client_id.data, 'allow': 'Allow', @@ -142,21 +143,19 @@ class TestOAuth(object): return authorization_response, client_identifier def get_code_from_redirect_uri(self, uri): + ''' Get the value of ?code= from an URI ''' return parse_qs(urlparse(uri).query)['code'][0] - def test_token_endpoint_successful_confidential_request(self, test_app): + def test_token_endpoint_successful_confidential_request(self): ''' Successful request against token endpoint ''' - self._setup(test_app) - - code_redirect, client_id = self.test_4_authorize_confidential_client( - test_app) + code_redirect, client_id = self.test_4_authorize_confidential_client() code = self.get_code_from_redirect_uri(code_redirect.location) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == unicode(client_id)).first() - token_res = test_app.get('/oauth/access_token?client_id={0}&\ + token_res = self.test_app.get('/oauth/access_token?client_id={0}&\ code={1}&client_secret={2}'.format(client_id, code, client.secret)) assert token_res.status_int == 200 @@ -170,19 +169,21 @@ code={1}&client_secret={2}'.format(client_id, code, client.secret)) assert type(token_data['expires_in']) == int assert token_data['expires_in'] > 0 - def test_token_endpont_missing_id_confidential_request(self, test_app): - ''' Unsuccessful request against token endpoint, missing client_id ''' - self._setup(test_app) + # There should be a refresh token provided in the token data + assert len(token_data['refresh_token']) + + return client_id, token_data - code_redirect, client_id = self.test_4_authorize_confidential_client( - test_app) + def test_token_endpont_missing_id_confidential_request(self): + ''' Unsuccessful request against token endpoint, missing client_id ''' + code_redirect, client_id = self.test_4_authorize_confidential_client() code = self.get_code_from_redirect_uri(code_redirect.location) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == unicode(client_id)).first() - token_res = test_app.get('/oauth/access_token?\ + token_res = self.test_app.get('/oauth/access_token?\ code={0}&client_secret={1}'.format(code, client.secret)) assert token_res.status_int == 200 @@ -192,4 +193,30 @@ code={0}&client_secret={1}'.format(code, client.secret)) assert 'error' in token_data assert not 'access_token' in token_data assert token_data['error'] == 'invalid_request' - assert token_data['error_description'] == 'Missing client_id in request' + assert len(token_data['error_description']) + + def test_refresh_token(self): + ''' Try to get a new access token using the refresh token ''' + # Get an access token and a refresh token + client_id, token_data =\ + self.test_token_endpoint_successful_confidential_request() + + client = self.db.OAuthClient.query.filter( + self.db.OAuthClient.identifier == client_id).first() + + token_res = self.test_app.get('/oauth/access_token', + {'refresh_token': token_data['refresh_token'], + 'client_id': client_id, + 'client_secret': client.secret + }) + + assert token_res.status_int == 200 + + new_token_data = json.loads(token_res.body) + + assert not 'error' in new_token_data + assert 'access_token' in new_token_data + assert 'token_type' in new_token_data + assert 'expires_in' in new_token_data + assert type(new_token_data['expires_in']) == int + assert new_token_data['expires_in'] > 0 diff --git a/mediagoblin/tests/test_pdf.py b/mediagoblin/tests/test_pdf.py new file mode 100644 index 00000000..b4d1940a --- /dev/null +++ b/mediagoblin/tests/test_pdf.py @@ -0,0 +1,39 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import tempfile +import shutil +import os +import pytest + +from mediagoblin.media_types.pdf.processing import ( + pdf_info, check_prerequisites, create_pdf_thumb) +from .resources import GOOD_PDF as GOOD + + +@pytest.mark.skipif("not check_prerequisites()") +def test_pdf(): + good_dict = {'pdf_version_major': 1, 'pdf_title': '', + 'pdf_page_size_width': 612, 'pdf_author': '', + 'pdf_keywords': '', 'pdf_pages': 10, + 'pdf_producer': 'dvips + GNU Ghostscript 7.05', + 'pdf_version_minor': 3, + 'pdf_creator': 'LaTeX with hyperref package', + 'pdf_page_size_height': 792} + assert pdf_info(GOOD) == good_dict + temp_dir = tempfile.mkdtemp() + create_pdf_thumb(GOOD, os.path.join(temp_dir, 'good_256_256.png'), 256, 256) + shutil.rmtree(temp_dir) diff --git a/mediagoblin/tests/test_pluginapi.py b/mediagoblin/tests/test_pluginapi.py index d40a5081..809b5ce9 100644 --- a/mediagoblin/tests/test_pluginapi.py +++ b/mediagoblin/tests/test_pluginapi.py @@ -18,9 +18,12 @@ import sys from configobj import ConfigObj import pytest +import pkg_resources +from validate import VdtTypeError from mediagoblin import mg_globals from mediagoblin.init.plugins import setup_plugins +from mediagoblin.init.config import read_mediagoblin_config from mediagoblin.tools import pluginapi @@ -177,19 +180,22 @@ def test_disabled_plugin(): assert len(pman.plugins) == 0 +CONFIG_ALL_CALLABLES = [ + ('mediagoblin', {}, []), + ('plugins', {}, [ + ('mediagoblin.tests.testplugins.callables1', {}, []), + ('mediagoblin.tests.testplugins.callables2', {}, []), + ('mediagoblin.tests.testplugins.callables3', {}, []), + ]) + ] + + @with_cleanup() -def test_callable_runone(): +def test_hook_handle(): """ - Test the callable_runone method + Test the hook_handle method """ - cfg = build_config([ - ('mediagoblin', {}, []), - ('plugins', {}, [ - ('mediagoblin.tests.testplugins.callables1', {}, []), - ('mediagoblin.tests.testplugins.callables2', {}, []), - ('mediagoblin.tests.testplugins.callables3', {}, []), - ]) - ]) + cfg = build_config(CONFIG_ALL_CALLABLES) mg_globals.app_config = cfg['mediagoblin'] mg_globals.global_config = cfg @@ -198,50 +204,42 @@ def test_callable_runone(): # Just one hook provided call_log = [] - assert pluginapi.callable_runone( + assert pluginapi.hook_handle( "just_one", call_log) == "Called just once" assert call_log == ["expect this one call"] # Nothing provided and unhandled not okay call_log = [] - with pytest.raises(pluginapi.UnhandledCallable): - pluginapi.callable_runone( - "nothing_handling", call_log) + pluginapi.hook_handle( + "nothing_handling", call_log) == None assert call_log == [] # Nothing provided and unhandled okay call_log = [] - assert pluginapi.callable_runone( + assert pluginapi.hook_handle( "nothing_handling", call_log, unhandled_okay=True) is None assert call_log == [] # Multiple provided, go with the first! call_log = [] - assert pluginapi.callable_runone( + assert pluginapi.hook_handle( "multi_handle", call_log) == "the first returns" assert call_log == ["Hi, I'm the first"] # Multiple provided, one has CantHandleIt call_log = [] - assert pluginapi.callable_runone( + assert pluginapi.hook_handle( "multi_handle_with_canthandle", call_log) == "the second returns" assert call_log == ["Hi, I'm the second"] @with_cleanup() -def test_callable_runall(): +def test_hook_runall(): """ - Test the callable_runall method + Test the hook_runall method """ - cfg = build_config([ - ('mediagoblin', {}, []), - ('plugins', {}, [ - ('mediagoblin.tests.testplugins.callables1', {}, []), - ('mediagoblin.tests.testplugins.callables2', {}, []), - ('mediagoblin.tests.testplugins.callables3', {}, []), - ]) - ]) + cfg = build_config(CONFIG_ALL_CALLABLES) mg_globals.app_config = cfg['mediagoblin'] mg_globals.global_config = cfg @@ -250,19 +248,19 @@ def test_callable_runall(): # Just one hook, check results call_log = [] - assert pluginapi.callable_runall( - "just_one", call_log) == ["Called just once", None, None] + assert pluginapi.hook_runall( + "just_one", call_log) == ["Called just once"] assert call_log == ["expect this one call"] # None provided, check results call_log = [] - assert pluginapi.callable_runall( + assert pluginapi.hook_runall( "nothing_handling", call_log) == [] assert call_log == [] # Multiple provided, check results call_log = [] - assert pluginapi.callable_runall( + assert pluginapi.hook_runall( "multi_handle", call_log) == [ "the first returns", "the second returns", @@ -275,7 +273,7 @@ def test_callable_runall(): # Multiple provided, one has CantHandleIt, check results call_log = [] - assert pluginapi.callable_runall( + assert pluginapi.hook_runall( "multi_handle_with_canthandle", call_log) == [ "the second returns", "the third returns", @@ -283,3 +281,45 @@ def test_callable_runall(): assert call_log == [ "Hi, I'm the second", "Hi, I'm the third"] + + +@with_cleanup() +def test_hook_transform(): + """ + Test the hook_transform method + """ + cfg = build_config(CONFIG_ALL_CALLABLES) + + mg_globals.app_config = cfg['mediagoblin'] + mg_globals.global_config = cfg + + setup_plugins() + + assert pluginapi.hook_transform( + "expand_tuple", (-1, 0)) == (-1, 0, 1, 2, 3) + + +def test_plugin_config(): + """ + Make sure plugins can set up their own config + """ + config, validation_result = read_mediagoblin_config( + pkg_resources.resource_filename( + 'mediagoblin.tests', 'appconfig_plugin_specs.ini')) + + pluginspec_section = config['plugins'][ + 'mediagoblin.tests.testplugins.pluginspec'] + assert pluginspec_section['some_string'] == 'not blork' + assert pluginspec_section['dont_change_me'] == 'still the default' + + # Make sure validation works... this should be an error + assert isinstance( + validation_result[ + 'plugins'][ + 'mediagoblin.tests.testplugins.pluginspec'][ + 'some_int'], + VdtTypeError) + + # the callables thing shouldn't really have anything though. + assert len(config['plugins'][ + 'mediagoblin.tests.testplugins.callables1']) == 0 diff --git a/mediagoblin/tests/test_storage.py b/mediagoblin/tests/test_storage.py index 749f7b07..f6f1d18f 100644 --- a/mediagoblin/tests/test_storage.py +++ b/mediagoblin/tests/test_storage.py @@ -95,6 +95,14 @@ def get_tmp_filestorage(mount_url=None, fake_remote=False): return tmpdir, this_storage +def cleanup_storage(this_storage, tmpdir, *paths): + for p in paths: + while p: + assert this_storage.delete_dir(p) == True + p.pop(-1) + os.rmdir(tmpdir) + + def test_basic_storage__resolve_filepath(): tmpdir, this_storage = get_tmp_filestorage() @@ -111,7 +119,7 @@ def test_basic_storage__resolve_filepath(): this_storage._resolve_filepath, ['../../', 'etc', 'passwd']) - os.rmdir(tmpdir) + cleanup_storage(this_storage, tmpdir) def test_basic_storage_file_exists(): @@ -127,6 +135,7 @@ def test_basic_storage_file_exists(): assert not this_storage.file_exists(['dnedir1', 'dnedir2', 'somefile.lol']) this_storage.delete_file(['dir1', 'dir2', 'filename.txt']) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2']) def test_basic_storage_get_unique_filepath(): @@ -149,6 +158,7 @@ def test_basic_storage_get_unique_filepath(): assert new_filename == secure_filename(new_filename) os.remove(filename) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2']) def test_basic_storage_get_file(): @@ -189,6 +199,7 @@ def test_basic_storage_get_file(): this_storage.delete_file(filepath) this_storage.delete_file(new_filepath) this_storage.delete_file(['testydir', 'testyfile.txt']) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2'], ['testydir']) def test_basic_storage_delete_file(): @@ -204,11 +215,15 @@ def test_basic_storage_delete_file(): assert os.path.exists( os.path.join(tmpdir, 'dir1/dir2/ourfile.txt')) + assert this_storage.delete_dir(['dir1', 'dir2']) == False this_storage.delete_file(filepath) + assert this_storage.delete_dir(['dir1', 'dir2']) == True assert not os.path.exists( os.path.join(tmpdir, 'dir1/dir2/ourfile.txt')) + cleanup_storage(this_storage, tmpdir, ['dir1']) + def test_basic_storage_url_for_file(): # Not supplying a base_url should actually just bork. @@ -217,7 +232,7 @@ def test_basic_storage_url_for_file(): storage.NoWebServing, this_storage.file_url, ['dir1', 'dir2', 'filename.txt']) - os.rmdir(tmpdir) + cleanup_storage(this_storage, tmpdir) # base_url without domain tmpdir, this_storage = get_tmp_filestorage('/media/') @@ -225,7 +240,7 @@ def test_basic_storage_url_for_file(): ['dir1', 'dir2', 'filename.txt']) expected = '/media/dir1/dir2/filename.txt' assert result == expected - os.rmdir(tmpdir) + cleanup_storage(this_storage, tmpdir) # base_url with domain tmpdir, this_storage = get_tmp_filestorage( @@ -234,7 +249,7 @@ def test_basic_storage_url_for_file(): ['dir1', 'dir2', 'filename.txt']) expected = 'http://media.example.org/ourmedia/dir1/dir2/filename.txt' assert result == expected - os.rmdir(tmpdir) + cleanup_storage(this_storage, tmpdir) def test_basic_storage_get_local_path(): @@ -248,13 +263,13 @@ def test_basic_storage_get_local_path(): assert result == expected - os.rmdir(tmpdir) + cleanup_storage(this_storage, tmpdir) def test_basic_storage_is_local(): tmpdir, this_storage = get_tmp_filestorage() assert this_storage.local_storage is True - os.rmdir(tmpdir) + cleanup_storage(this_storage, tmpdir) def test_basic_storage_copy_locally(): @@ -275,6 +290,7 @@ def test_basic_storage_copy_locally(): os.remove(new_file_dest) os.rmdir(dest_tmpdir) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2']) def _test_copy_local_to_storage_works(tmpdir, this_storage): @@ -292,6 +308,7 @@ def _test_copy_local_to_storage_works(tmpdir, this_storage): 'r').read() == 'haha' this_storage.delete_file(['dir1', 'dir2', 'copiedto.txt']) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2']) def test_basic_storage_copy_local_to_storage(): diff --git a/mediagoblin/tests/test_submission.py b/mediagoblin/tests/test_submission.py index ac714252..162b2d19 100644 --- a/mediagoblin/tests/test_submission.py +++ b/mediagoblin/tests/test_submission.py @@ -20,26 +20,17 @@ sys.setdefaultencoding('utf-8') import urlparse import os - -from pkg_resources import resource_filename +import pytest from mediagoblin.tests.tools import fixture_add_user from mediagoblin import mg_globals from mediagoblin.db.models import MediaEntry from mediagoblin.tools import template from mediagoblin.media_types.image import MEDIA_MANAGER as img_MEDIA_MANAGER +from mediagoblin.media_types.pdf.processing import check_prerequisites as pdf_check_prerequisites -def resource(filename): - return resource_filename('mediagoblin.tests', 'test_submission/' + filename) - - -GOOD_JPG = resource('good.jpg') -GOOD_PNG = resource('good.png') -EVIL_FILE = resource('evil') -EVIL_JPG = resource('evil.jpg') -EVIL_PNG = resource('evil.png') -BIG_BLUE = resource('bigblue.png') -from .test_exif import GPS_JPG +from .resources import GOOD_JPG, GOOD_PNG, EVIL_FILE, EVIL_JPG, EVIL_PNG, \ + BIG_BLUE, GOOD_PDF, GPS_JPG GOOD_TAG_STRING = u'yin,yang' BAD_TAG_STRING = unicode('rage,' + 'f' * 26 + 'u' * 26) @@ -49,7 +40,8 @@ REQUEST_CONTEXT = ['mediagoblin/user_pages/user.html', 'request'] class TestSubmission: - def _setup(self, test_app): + @pytest.fixture(autouse=True) + def setup(self, test_app): self.test_app = test_app # TODO: Possibly abstract into a decorator like: @@ -88,9 +80,7 @@ class TestSubmission: comments = request.db.MediaComment.find({'media_entry': media_id}) assert count == len(list(comments)) - def test_missing_fields(self, test_app): - self._setup(test_app) - + def test_missing_fields(self): # Test blank form # --------------- response, form = self.do_post({}, *FORM_CONTEXT) @@ -117,14 +107,20 @@ class TestSubmission: self.logout() self.test_app.get(url) - def test_normal_jpg(self, test_app): - self._setup(test_app) + def test_normal_jpg(self): self.check_normal_upload(u'Normal upload 1', GOOD_JPG) - def test_normal_png(self, test_app): - self._setup(test_app) + def test_normal_png(self): self.check_normal_upload(u'Normal upload 2', GOOD_PNG) + @pytest.mark.skipif("not pdf_check_prerequisites()") + def test_normal_pdf(self): + response, context = self.do_post({'title': u'Normal upload 3 (pdf)'}, + do_follow=True, + **self.upload_data(GOOD_PDF)) + self.check_url(response, '/u/{0}/'.format(self.test_user.username)) + assert 'mediagoblin/user_pages/user.html' in context + def check_media(self, request, find_data, count=None): media = MediaEntry.find(find_data) if count is not None: @@ -133,9 +129,7 @@ class TestSubmission: return return media[0] - def test_tags(self, test_app): - self._setup(test_app) - + def test_tags(self): # Good tag string # -------- response, request = self.do_post({'title': u'Balanced Goblin 2', @@ -160,9 +154,7 @@ class TestSubmission: 'Tags that are too long: ' \ 'ffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuu'] - def test_delete(self, test_app): - self._setup(test_app) - + def test_delete(self): response, request = self.do_post({'title': u'Balanced Goblin'}, *REQUEST_CONTEXT, do_follow=True, **self.upload_data(GOOD_JPG)) @@ -207,9 +199,7 @@ class TestSubmission: self.check_media(request, {'id': media_id}, 0) self.check_comments(request, media_id, 0) - def test_evil_file(self, test_app): - self._setup(test_app) - + def test_evil_file(self): # Test non-suppoerted file with non-supported extension # ----------------------------------------------------- response, form = self.do_post({'title': u'Malicious Upload 1'}, @@ -220,26 +210,23 @@ class TestSubmission: str(form.file.errors[0]) - def test_get_media_manager(self, test_app): + def test_get_media_manager(self): """Test if the get_media_manger function returns sensible things """ - self._setup(test_app) - response, request = self.do_post({'title': u'Balanced Goblin'}, *REQUEST_CONTEXT, do_follow=True, **self.upload_data(GOOD_JPG)) media = self.check_media(request, {'title': u'Balanced Goblin'}, 1) assert media.media_type == u'mediagoblin.media_types.image' - assert media.media_manager == img_MEDIA_MANAGER + assert isinstance(media.media_manager, img_MEDIA_MANAGER) + assert media.media_manager.entry == media - def test_sniffing(self, test_app): + def test_sniffing(self): ''' Test sniffing mechanism to assert that regular uploads work as intended ''' - self._setup(test_app) - template.clear_test_template_context() response = self.test_app.post( '/submit/', { @@ -269,30 +256,22 @@ class TestSubmission: assert entry.state == 'failed' assert entry.fail_error == u'mediagoblin.processing:BadMediaFail' - def test_evil_jpg(self, test_app): - self._setup(test_app) - + def test_evil_jpg(self): # Test non-supported file with .jpg extension # ------------------------------------------- self.check_false_image(u'Malicious Upload 2', EVIL_JPG) - def test_evil_png(self, test_app): - self._setup(test_app) - + def test_evil_png(self): # Test non-supported file with .png extension # ------------------------------------------- self.check_false_image(u'Malicious Upload 3', EVIL_PNG) - def test_media_data(self, test_app): - self._setup(test_app) - + def test_media_data(self): self.check_normal_upload(u"With GPS data", GPS_JPG) media = self.check_media(None, {"title": u"With GPS data"}, 1) assert media.media_data.gps_latitude == 59.336666666666666 - def test_processing(self, test_app): - self._setup(test_app) - + def test_processing(self): public_store_dir = mg_globals.global_config[ 'storage:publicstore']['base_dir'] @@ -307,7 +286,7 @@ class TestSubmission: # Does the processed image have a good filename? filename = os.path.join( public_store_dir, - *media.media_files.get(key, [])) + *media.media_files[key]) assert filename.endswith('_' + basename) # Is it smaller than the last processed image we looked at? size = os.stat(filename).st_size diff --git a/mediagoblin/tests/test_submission/good.pdf b/mediagoblin/tests/test_submission/good.pdf Binary files differnew file mode 100644 index 00000000..ab5db006 --- /dev/null +++ b/mediagoblin/tests/test_submission/good.pdf diff --git a/mediagoblin/tests/test_timesince.py b/mediagoblin/tests/test_timesince.py new file mode 100644 index 00000000..6579eb09 --- /dev/null +++ b/mediagoblin/tests/test_timesince.py @@ -0,0 +1,57 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from datetime import datetime, timedelta + +from mediagoblin.tools.timesince import is_aware, timesince + + +def test_timesince(): + test_time = datetime.now() + + # it should ignore second and microseconds + assert timesince(test_time, test_time + timedelta(microseconds=1)) == "0 minutes" + assert timesince(test_time, test_time + timedelta(seconds=1)) == "0 minutes" + + # test minutes, hours, days, weeks, months and years (singular and plural) + assert timesince(test_time, test_time + timedelta(minutes=1)) == "1 minute" + assert timesince(test_time, test_time + timedelta(minutes=2)) == "2 minutes" + + assert timesince(test_time, test_time + timedelta(hours=1)) == "1 hour" + assert timesince(test_time, test_time + timedelta(hours=2)) == "2 hours" + + assert timesince(test_time, test_time + timedelta(days=1)) == "1 day" + assert timesince(test_time, test_time + timedelta(days=2)) == "2 days" + + assert timesince(test_time, test_time + timedelta(days=7)) == "1 week" + assert timesince(test_time, test_time + timedelta(days=14)) == "2 weeks" + + assert timesince(test_time, test_time + timedelta(days=30)) == "1 month" + assert timesince(test_time, test_time + timedelta(days=60)) == "2 months" + + assert timesince(test_time, test_time + timedelta(days=365)) == "1 year" + assert timesince(test_time, test_time + timedelta(days=730)) == "2 years" + + # okay now we want to test combinations + # e.g. 1 hour, 5 days + assert timesince(test_time, test_time + timedelta(days=5, hours=1)) == "5 days, 1 hour" + + assert timesince(test_time, test_time + timedelta(days=15)) == "2 weeks, 1 day" + + assert timesince(test_time, test_time + timedelta(days=97)) == "3 months, 1 week" + + assert timesince(test_time, test_time + timedelta(days=2250)) == "6 years, 2 months" + diff --git a/mediagoblin/tests/test_util.py b/mediagoblin/tests/test_util.py index e4c04b7a..bc14f528 100644 --- a/mediagoblin/tests/test_util.py +++ b/mediagoblin/tests/test_util.py @@ -104,6 +104,28 @@ def test_locale_to_lower_lower(): assert translate.locale_to_lower_lower('en_us') == 'en-us' +def test_gettext_lazy_proxy(): + from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ + from mediagoblin.tools.translate import pass_to_ugettext, set_thread_locale + proxy = _(u"Password") + orig = u"Password" + + set_thread_locale("es") + p1 = unicode(proxy) + p1_should = pass_to_ugettext(orig) + assert p1_should != orig, "Test useless, string not translated" + assert p1 == p1_should + + set_thread_locale("sv") + p2 = unicode(proxy) + p2_should = pass_to_ugettext(orig) + assert p2_should != orig, "Test broken, string not translated" + assert p2 == p2_should + + assert p1_should != p2_should, "Test broken, same translated string" + assert p1 != p2 + + def test_html_cleaner(): # Remove images result = text.clean_html( diff --git a/mediagoblin/tests/test_workbench.py b/mediagoblin/tests/test_workbench.py index 9cd49671..6695618b 100644 --- a/mediagoblin/tests/test_workbench.py +++ b/mediagoblin/tests/test_workbench.py @@ -21,7 +21,7 @@ import tempfile from mediagoblin.tools import workbench from mediagoblin.mg_globals import setup_globals from mediagoblin.decorators import get_workbench -from mediagoblin.tests.test_storage import get_tmp_filestorage +from mediagoblin.tests.test_storage import get_tmp_filestorage, cleanup_storage class TestWorkbench(object): @@ -76,6 +76,7 @@ class TestWorkbench(object): assert filename == os.path.join( tmpdir, 'dir1/dir2/ourfile.txt') this_storage.delete_file(filepath) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2']) # with a fake remote file storage tmpdir, this_storage = get_tmp_filestorage(fake_remote=True) @@ -102,6 +103,7 @@ class TestWorkbench(object): this_workbench.dir, 'thisfile.text') this_storage.delete_file(filepath) + cleanup_storage(this_storage, tmpdir, ['dir1', 'dir2']) this_workbench.destroy() def test_workbench_decorator(self): diff --git a/mediagoblin/tests/testplugins/callables1/__init__.py b/mediagoblin/tests/testplugins/callables1/__init__.py index 9c278b49..fe801a01 100644 --- a/mediagoblin/tests/testplugins/callables1/__init__.py +++ b/mediagoblin/tests/testplugins/callables1/__init__.py @@ -14,8 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from mediagoblin.tools.pluginapi import CantHandleIt - def setup_plugin(): pass @@ -30,12 +28,16 @@ def multi_handle(call_log): return "the first returns" def multi_handle_with_canthandle(call_log): - raise CantHandleIt("I just can't accept this stupid method") + return None + +def expand_tuple(this_tuple): + return this_tuple + (1,) hooks = { 'setup': setup_plugin, 'just_one': just_one, 'multi_handle': multi_handle, 'multi_handle_with_canthandle': multi_handle_with_canthandle, + 'expand_tuple': expand_tuple, } diff --git a/mediagoblin/tests/testplugins/callables2/__init__.py b/mediagoblin/tests/testplugins/callables2/__init__.py index aaab5b21..9d5cf950 100644 --- a/mediagoblin/tests/testplugins/callables2/__init__.py +++ b/mediagoblin/tests/testplugins/callables2/__init__.py @@ -29,10 +29,13 @@ def multi_handle_with_canthandle(call_log): call_log.append("Hi, I'm the second") return "the second returns" +def expand_tuple(this_tuple): + return this_tuple + (2,) hooks = { 'setup': setup_plugin, 'just_one': just_one, 'multi_handle': multi_handle, 'multi_handle_with_canthandle': multi_handle_with_canthandle, + 'expand_tuple': expand_tuple, } diff --git a/mediagoblin/tests/testplugins/callables3/__init__.py b/mediagoblin/tests/testplugins/callables3/__init__.py index 8d0c9c25..04efc8fc 100644 --- a/mediagoblin/tests/testplugins/callables3/__init__.py +++ b/mediagoblin/tests/testplugins/callables3/__init__.py @@ -29,10 +29,13 @@ def multi_handle_with_canthandle(call_log): call_log.append("Hi, I'm the third") return "the third returns" +def expand_tuple(this_tuple): + return this_tuple + (3,) hooks = { 'setup': setup_plugin, 'just_one': just_one, 'multi_handle': multi_handle, 'multi_handle_with_canthandle': multi_handle_with_canthandle, + 'expand_tuple': expand_tuple, } diff --git a/mediagoblin/tests/testplugins/pluginspec/__init__.py b/mediagoblin/tests/testplugins/pluginspec/__init__.py new file mode 100644 index 00000000..76ca2b1f --- /dev/null +++ b/mediagoblin/tests/testplugins/pluginspec/__init__.py @@ -0,0 +1,22 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +def setup_plugin(): + pass + +hooks = { + 'setup': setup_plugin, +} diff --git a/mediagoblin/tests/testplugins/pluginspec/config_spec.ini b/mediagoblin/tests/testplugins/pluginspec/config_spec.ini new file mode 100644 index 00000000..5c9c3bd7 --- /dev/null +++ b/mediagoblin/tests/testplugins/pluginspec/config_spec.ini @@ -0,0 +1,4 @@ +[plugin_spec] +some_string = string(default="blork") +some_int = integer(default=50) +dont_change_me = string(default="still the default")
\ No newline at end of file diff --git a/mediagoblin/tests/tools.py b/mediagoblin/tests/tools.py index a0498a6e..52635e18 100644 --- a/mediagoblin/tests/tools.py +++ b/mediagoblin/tests/tools.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. +import sys import os import pkg_resources import shutil @@ -28,7 +29,6 @@ from mediagoblin import mg_globals from mediagoblin.db.models import User, MediaEntry, Collection from mediagoblin.tools import testing from mediagoblin.init.config import read_mediagoblin_config -from mediagoblin.db.open import setup_connection_and_db_from_config from mediagoblin.db.base import Session from mediagoblin.meddleware import BaseMeddleware from mediagoblin.auth.lib import bcrypt_gen_password_hash @@ -50,7 +50,9 @@ USER_DEV_DIRECTORIES_TO_SETUP = ['media/public', 'media/queue'] BAD_CELERY_MESSAGE = """\ Sorry, you *absolutely* must run tests with the mediagoblin.init.celery.from_tests module. Like so: -$ CELERY_CONFIG_MODULE=mediagoblin.init.celery.from_tests ./bin/py.test""" + +$ CELERY_CONFIG_MODULE=mediagoblin.init.celery.from_tests {0} +""".format(sys.argv[0]) class BadCeleryEnviron(Exception): pass @@ -230,7 +232,7 @@ def fixture_media_entry(title=u"Some title", slug=None, entry.slug = slug entry.uploader = uploader or fixture_add_user().id entry.media_type = u'image' - + if gen_slug: entry.generate_slug() if save: diff --git a/mediagoblin/tools/pluginapi.py b/mediagoblin/tools/pluginapi.py index 283350a8..3f98aa8a 100644 --- a/mediagoblin/tools/pluginapi.py +++ b/mediagoblin/tools/pluginapi.py @@ -274,68 +274,94 @@ def get_hook_templates(hook_name): return PluginManager().get_template_hooks(hook_name) -########################### -# Callable convenience code -########################### +############################# +## Hooks: The Next Generation +############################# -class CantHandleIt(Exception): - """ - A callable may call this method if they look at the relevant - arguments passed and decide it's not possible for them to handle - things. - """ - pass -class UnhandledCallable(Exception): - """ - Raise this method if no callables were available to handle the - specified hook. Only used by callable_runone. +def hook_handle(hook_name, *args, **kwargs): """ - pass + Run through hooks attempting to find one that handle this hook. + All callables called with the same arguments until one handles + things and returns a non-None value. -def callable_runone(hookname, *args, **kwargs): - """ - Run the callable hook HOOKNAME... run until the first response, - then return. + (If you are writing a handler and you don't have a particularly + useful value to return even though you've handled this, returning + True is a good solution.) - This function will run stop at the first hook that handles the - result. Hooks raising CantHandleIt will be skipped. + Note that there is a special keyword argument: + if "default_handler" is passed in as a keyword argument, this will + be used if no handler is found. - Unless unhandled_okay is True, this will error out if no hooks - have been registered to handle this function. + Some examples of using this: + - You need an interface implemented, but only one fit for it + - You need to *do* something, but only one thing needs to do it. """ - callables = PluginManager().get_hook_callables(hookname) + default_handler = kwargs.pop('default_handler', None) + + callables = PluginManager().get_hook_callables(hook_name) - unhandled_okay = kwargs.pop("unhandled_okay", False) + result = None for callable in callables: - try: - return callable(*args, **kwargs) - except CantHandleIt: - continue + result = callable(*args, **kwargs) - if unhandled_okay is False: - raise UnhandledCallable( - "No hooks registered capable of handling '%s'" % hookname) + if result is not None: + break + if result is None and default_handler is not None: + result = default_handler(*args, **kwargs) + + return result -def callable_runall(hookname, *args, **kwargs): - """ - Run all callables for HOOKNAME. - This method will run *all* hooks that handle this method (skipping - those that raise CantHandleIt), and will return a list of all - results. +def hook_runall(hook_name, *args, **kwargs): + """ + Run through all callable hooks and pass in arguments. + + All non-None results are accrued in a list and returned from this. + (Other "false-like" values like False and friends are still + accrued, however.) + + Some examples of using this: + - You have an interface call where actually multiple things can + and should implement it + - You need to get a list of things from various plugins that + handle them and do something with them + - You need to *do* something, and actually multiple plugins need + to do it separately """ - callables = PluginManager().get_hook_callables(hookname) + callables = PluginManager().get_hook_callables(hook_name) results = [] for callable in callables: - try: - results.append(callable(*args, **kwargs)) - except CantHandleIt: - continue + result = callable(*args, **kwargs) + + if result is not None: + results.append(result) return results + + +def hook_transform(hook_name, arg): + """ + Run through a bunch of hook callables and transform some input. + + Note that unlike the other hook tools, this one only takes ONE + argument. This argument is passed to each function, which in turn + returns something that becomes the input of the next callable. + + Some examples of using this: + - You have an object, say a form, but you want plugins to each be + able to modify it. + """ + result = arg + + callables = PluginManager().get_hook_callables(hook_name) + + for callable in callables: + result = callable(result) + + return result diff --git a/mediagoblin/tools/processing.py b/mediagoblin/tools/processing.py index cff4cb9d..2abe6452 100644 --- a/mediagoblin/tools/processing.py +++ b/mediagoblin/tools/processing.py @@ -21,8 +21,6 @@ import traceback from urllib2 import urlopen, Request, HTTPError from urllib import urlencode -from mediagoblin.tools.common import TESTS_ENABLED - _log = logging.getLogger(__name__) TESTS_CALLBACKS = {} diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index 80df1f5a..aaf31d0b 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -99,3 +99,10 @@ def redirect(request, *args, **kwargs): if querystring: location += querystring return werkzeug.utils.redirect(location) + + +def redirect_obj(request, obj): + """Redirect to the page for the given object. + + Requires obj to have a .url_for_self method.""" + return redirect(request, location=obj.url_for_self(request.urlgen)) diff --git a/mediagoblin/tools/routing.py b/mediagoblin/tools/routing.py index 791cd1e6..a15795fe 100644 --- a/mediagoblin/tools/routing.py +++ b/mediagoblin/tools/routing.py @@ -16,6 +16,7 @@ import logging +import six from werkzeug.routing import Map, Rule from mediagoblin.tools.common import import_component @@ -43,7 +44,7 @@ def endpoint_to_controller(rule): _log.debug('endpoint: {0} view_func: {1}'.format(endpoint, view_func)) # import the endpoint, or if it's already a callable, call that - if isinstance(view_func, basestring): + if isinstance(view_func, six.string_types): view_func = import_component(view_func) rule.gmg_controller = view_func diff --git a/mediagoblin/tools/template.py b/mediagoblin/tools/template.py index 74d811eb..54aeac92 100644 --- a/mediagoblin/tools/template.py +++ b/mediagoblin/tools/template.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from math import ceil import jinja2 from jinja2.ext import Extension @@ -27,11 +26,13 @@ from mediagoblin import mg_globals from mediagoblin import messages from mediagoblin import _version from mediagoblin.tools import common -from mediagoblin.tools.translate import get_gettext_translation +from mediagoblin.tools.translate import set_thread_locale from mediagoblin.tools.pluginapi import get_hook_templates +from mediagoblin.tools.timesince import timesince from mediagoblin.meddleware.csrf import render_csrf_form_token + SETUP_JINJA_ENVS = {} @@ -42,7 +43,7 @@ def get_jinja_env(template_loader, locale): (In the future we may have another system for providing theming; for now this is good enough.) """ - mg_globals.thread_scope.translations = get_gettext_translation(locale) + set_thread_locale(locale) # If we have a jinja environment set up with this locale, just # return that one. @@ -73,6 +74,9 @@ def get_jinja_env(template_loader, locale): template_env.filters['urlencode'] = url_quote_plus + # add human readable fuzzy date time + template_env.globals['timesince'] = timesince + # allow for hooking up plugin templates template_env.globals['get_hook_templates'] = get_hook_templates diff --git a/mediagoblin/tools/timesince.py b/mediagoblin/tools/timesince.py new file mode 100644 index 00000000..b761c1be --- /dev/null +++ b/mediagoblin/tools/timesince.py @@ -0,0 +1,95 @@ +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of Django nor the names of its contributors may be used +# to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import unicode_literals + +import datetime +import pytz + +from mediagoblin.tools.translate import pass_to_ugettext, lazy_pass_to_ungettext as _ + +"""UTC time zone as a tzinfo instance.""" +utc = pytz.utc if pytz else UTC() + +def is_aware(value): + """ + Determines if a given datetime.datetime is aware. + + The logic is described in Python's docs: + http://docs.python.org/library/datetime.html#datetime.tzinfo + """ + return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None + +def timesince(d, now=None, reversed=False): + """ + Takes two datetime objects and returns the time between d and now + as a nicely formatted string, e.g. "10 minutes". If d occurs after now, + then "0 minutes" is returned. + + Units used are years, months, weeks, days, hours, and minutes. + Seconds and microseconds are ignored. Up to two adjacent units will be + displayed. For example, "2 weeks, 3 days" and "1 year, 3 months" are + possible outputs, but "2 weeks, 3 hours" and "1 year, 5 days" are not. + + Adapted from http://blog.natbat.co.uk/archive/2003/Jun/14/time_since + """ + chunks = ( + (60 * 60 * 24 * 365, lambda n: _('year', 'years', n)), + (60 * 60 * 24 * 30, lambda n: _('month', 'months', n)), + (60 * 60 * 24 * 7, lambda n : _('week', 'weeks', n)), + (60 * 60 * 24, lambda n : _('day', 'days', n)), + (60 * 60, lambda n: _('hour', 'hours', n)), + (60, lambda n: _('minute', 'minutes', n)) + ) + # Convert datetime.date to datetime.datetime for comparison. + if not isinstance(d, datetime.datetime): + d = datetime.datetime(d.year, d.month, d.day) + if now and not isinstance(now, datetime.datetime): + now = datetime.datetime(now.year, now.month, now.day) + + if not now: + now = datetime.datetime.now(utc if is_aware(d) else None) + + delta = (d - now) if reversed else (now - d) + # ignore microseconds + since = delta.days * 24 * 60 * 60 + delta.seconds + if since <= 0: + # d is in the future compared to now, stop processing. + return '0 ' + pass_to_ugettext('minutes') + for i, (seconds, name) in enumerate(chunks): + count = since // seconds + if count != 0: + break + s = pass_to_ugettext('%(number)d %(type)s') % {'number': count, 'type': name(count)} + if i + 1 < len(chunks): + # Now get the second item + seconds2, name2 = chunks[i + 1] + count2 = (since - (seconds * count)) // seconds2 + if count2 != 0: + s += pass_to_ugettext(', %(number)d %(type)s') % {'number': count2, 'type': name2(count2)} + return s diff --git a/mediagoblin/tools/translate.py b/mediagoblin/tools/translate.py index 1d37c4de..b20e57d1 100644 --- a/mediagoblin/tools/translate.py +++ b/mediagoblin/tools/translate.py @@ -42,6 +42,22 @@ def set_available_locales(): AVAILABLE_LOCALES = locales +class ReallyLazyProxy(LazyProxy): + """ + Like LazyProxy, except that it doesn't cache the value ;) + """ + @property + def value(self): + return self._func(*self._args, **self._kwargs) + + def __repr__(self): + return "<%s for %s(%r, %r)>" % ( + self.__class__.__name__, + self._func, + self._args, + self._kwargs) + + def locale_to_lower_upper(locale): """ Take a locale, regardless of style, and format it like "en_US" @@ -112,6 +128,11 @@ def get_gettext_translation(locale): return this_gettext +def set_thread_locale(locale): + """Set the current translation for this thread""" + mg_globals.thread_scope.translations = get_gettext_translation(locale) + + def pass_to_ugettext(*args, **kwargs): """ Pass a translation on to the appropriate ugettext method. @@ -122,6 +143,16 @@ def pass_to_ugettext(*args, **kwargs): return mg_globals.thread_scope.translations.ugettext( *args, **kwargs) +def pass_to_ungettext(*args, **kwargs): + """ + Pass a translation on to the appropriate ungettext method. + + The reason we can't have a global ugettext method is because + mg_globals gets swapped out by the application per-request. + """ + return mg_globals.thread_scope.translations.ungettext( + *args, **kwargs) + def lazy_pass_to_ugettext(*args, **kwargs): """ @@ -134,7 +165,7 @@ def lazy_pass_to_ugettext(*args, **kwargs): you would want to use the lazy version for _. """ - return LazyProxy(pass_to_ugettext, *args, **kwargs) + return ReallyLazyProxy(pass_to_ugettext, *args, **kwargs) def pass_to_ngettext(*args, **kwargs): @@ -156,7 +187,17 @@ def lazy_pass_to_ngettext(*args, **kwargs): level but you need it to not translate until the time that it's used as a string. """ - return LazyProxy(pass_to_ngettext, *args, **kwargs) + return ReallyLazyProxy(pass_to_ngettext, *args, **kwargs) + +def lazy_pass_to_ungettext(*args, **kwargs): + """ + Lazily pass to ungettext. + + This is useful if you have to define a translation on a module + level but you need it to not translate until the time that it's + used as a string. + """ + return ReallyLazyProxy(pass_to_ungettext, *args, **kwargs) def fake_ugettext_passthrough(string): diff --git a/mediagoblin/user_pages/forms.py b/mediagoblin/user_pages/forms.py index e9746a6c..9a193680 100644 --- a/mediagoblin/user_pages/forms.py +++ b/mediagoblin/user_pages/forms.py @@ -16,7 +16,7 @@ import wtforms from wtforms.ext.sqlalchemy.fields import QuerySelectField -from mediagoblin.tools.translate import fake_ugettext_passthrough as _ +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ class MediaCommentForm(wtforms.Form): comment_content = wtforms.TextAreaField( diff --git a/mediagoblin/user_pages/lib.py b/mediagoblin/user_pages/lib.py index 8a064a7c..2f47e4b1 100644 --- a/mediagoblin/user_pages/lib.py +++ b/mediagoblin/user_pages/lib.py @@ -18,6 +18,8 @@ from mediagoblin.tools.mail import send_email from mediagoblin.tools.template import render_template from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin import mg_globals +from mediagoblin.db.base import Session +from mediagoblin.db.models import CollectionItem def send_comment_email(user, comment, media, request): @@ -55,3 +57,21 @@ def send_comment_email(user, comment, media, request): instance_title=mg_globals.app_config['html_title']) \ + _('commented on your post'), rendered_email) + + +def add_media_to_collection(collection, media, note=None, commit=True): + collection_item = CollectionItem() + collection_item.collection = collection.id + collection_item.media_entry = media.id + if note: + collection_item.note = note + Session.add(collection_item) + + collection.items = collection.items + 1 + Session.add(collection) + + media.collected = media.collected + 1 + Session.add(media) + + if commit: + Session.commit() diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index b3e613c4..738cc054 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -20,11 +20,13 @@ import datetime from mediagoblin import messages, mg_globals from mediagoblin.db.models import (MediaEntry, MediaTag, Collection, CollectionItem, User) -from mediagoblin.tools.response import render_to_response, render_404, redirect +from mediagoblin.tools.response import render_to_response, render_404, \ + redirect, redirect_obj from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin.tools.pagination import Pagination from mediagoblin.user_pages import forms as user_forms -from mediagoblin.user_pages.lib import send_comment_email +from mediagoblin.user_pages.lib import (send_comment_email, + add_media_to_collection) from mediagoblin.decorators import (uses_pagination, get_user_media_entry, get_media_entry_by_id, @@ -183,7 +185,7 @@ def media_post_comment(request, media): media_uploader.wants_comment_notification): send_comment_email(media_uploader, comment, media, request) - return redirect(request, location=media.url_for_self(request.urlgen)) + return redirect_obj(request, media) @get_media_entry_by_id @@ -254,25 +256,13 @@ def media_collect(request, media): _('"%s" already in collection "%s"') % (media.title, collection.title)) else: # Add item to collection - collection_item = request.db.CollectionItem() - collection_item.collection = collection.id - collection_item.media_entry = media.id - collection_item.note = form.note.data - collection_item.save() - - collection.items = collection.items + 1 - collection.save() - - media.collected = media.collected + 1 - media.save() + add_media_to_collection(collection, media, form.note.data) messages.add_message(request, messages.SUCCESS, _('"%s" added to collection "%s"') % (media.title, collection.title)) - return redirect(request, "mediagoblin.user_pages.media_home", - user=media.get_uploader.username, - media=media.slug_or_id) + return redirect_obj(request, media) #TODO: Why does @user_may_delete_media not implicate @require_active_login? @@ -297,8 +287,7 @@ def media_confirm_delete(request, media): messages.add_message( request, messages.ERROR, _("The media was not deleted because you didn't check that you were sure.")) - return redirect(request, - location=media.url_for_self(request.urlgen)) + return redirect_obj(request, media) if ((request.user.is_admin and request.user.id != media.uploader)): @@ -384,9 +373,7 @@ def collection_item_confirm_remove(request, collection_item): request, messages.ERROR, _("The item was not removed because you didn't check that you were sure.")) - return redirect(request, "mediagoblin.user_pages.user_collection", - user=username, - collection=collection.slug) + return redirect_obj(request, collection) if ((request.user.is_admin and request.user.id != collection_item.in_collection.creator)): @@ -424,8 +411,8 @@ def collection_confirm_delete(request, collection): item.delete() collection.delete() - messages.add_message( - request, messages.SUCCESS, _('You deleted the collection "%s"' % collection_title)) + messages.add_message(request, messages.SUCCESS, + _('You deleted the collection "%s"') % collection_title) return redirect(request, "mediagoblin.user_pages.user_home", user=username) @@ -434,9 +421,7 @@ def collection_confirm_delete(request, collection): request, messages.ERROR, _("The collection was not deleted because you didn't check that you were sure.")) - return redirect(request, "mediagoblin.user_pages.user_collection", - user=username, - collection=collection.slug) + return redirect_obj(request, collection) if ((request.user.is_admin and request.user.id != collection.creator)): @@ -543,9 +528,7 @@ def collection_atom_feed(request): ATOM feed id is a tag URI (see http://en.wikipedia.org/wiki/Tag_URI) """ atomlinks = [{ - 'href': request.urlgen( - 'mediagoblin.user_pages.user_collection', - qualified=True, user=request.matchdict['user'], collection=collection.slug), + 'href': collection.url_for_self(request.urlgen, qualified=True), 'rel': 'alternate', 'type': 'text/html' }] @@ -61,6 +61,8 @@ setup( 'sqlalchemy-migrate', 'mock', 'itsdangerous', + 'pytz', + 'six', ## This is optional! # 'translitcodec', ## For now we're expecting that users will install this from |