diff --git a/INSTALL b/INSTALL index f2b9d61..957a217 100644 --- a/INSTALL +++ b/INSTALL @@ -10,12 +10,14 @@ First, install the prerequisites: * psycopg 2.0 * simplejson 1.3 * pytz 2006p + * Python Imaging Library 1.1 In Debian GNU/Linux, you can issue the following command to install these packages: apt-get install python2.4 python-cherrypy postgresql-8.1 \ - postgresql-contrib-8.1 python-psycopg2 python-simplejson python-tz + postgresql-contrib-8.1 python-psycopg2 python-simplejson \ + python-tz python-imaging database setup diff --git a/controller/Files.py b/controller/Files.py index d7c5ee4..94b099d 100644 --- a/controller/Files.py +++ b/controller/Files.py @@ -5,6 +5,8 @@ import time import urllib import tempfile import cherrypy +from PIL import Image +from cStringIO import StringIO from threading import Lock, Event from Expose import expose from Validate import validate, Valid_int, Valid_bool, Validation_error @@ -17,6 +19,7 @@ from view.Upload_page import Upload_page from view.Blank_page import Blank_page from view.Json import Json from view.Progress_bar import stream_progress, stream_quota_error, quota_error_script, general_error_script +from view.File_preview_page import File_preview_page class Access_error( Exception ): @@ -238,9 +241,10 @@ class Files( object ): @validate( file_id = Valid_id(), quote_filename = Valid_bool( none_okay = True ), + preview = Valid_bool( none_okay = True ), user_id = Valid_id( none_okay = True ), ) - def download( self, file_id, quote_filename = False, user_id = None ): + def download( self, file_id, quote_filename = False, preview = True, user_id = None ): """ Return the contents of file that a user has previously uploaded. @@ -250,14 +254,17 @@ class Files( object ): @param quote_filename: True to URL quote the filename of the downloaded file, False to leave it as UTF-8. IE expects quoting while Firefox doesn't (optional, defaults to False) + @type preview: bool + @param preview: True to redirect to a preview page if the file is a valid image, False to + unconditionally initiate a download @type user_id: unicode or NoneType @param user_id: id of current logged-in user (if any) - @rtype: unicode + @rtype: generator @return: file data @raise Access_error: the current user doesn't have access to the notebook that the file is in """ # release the session lock before beginning to stream the download. otherwise, if the - # upload is cancelled before it's done, the lock won't be released + # download is cancelled before it's done, the lock won't be released try: cherrypy.session.release_lock() except KeyError: @@ -268,7 +275,14 @@ class Files( object ): if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): raise Access_error() - db_file = self.__database.load( File, file_id ) + # if the file is openable as an image, then allow the user to view it instead of downloading it + if preview: + server_filename = Upload_file.make_server_filename( file_id ) + try: + Image.open( server_filename ) + return dict( redirect = u"/files/preview?file_id=%s"e_filename=%s" % ( file_id, quote_filename ) ) + except IOError: + pass cherrypy.response.headerMap[ u"Content-Type" ] = db_file.content_type @@ -290,6 +304,142 @@ class Files( object ): return stream() + @expose( view = File_preview_page ) + @end_transaction + @grab_user_id + @validate( + file_id = Valid_id(), + quote_filename = Valid_bool( none_okay = True ), + user_id = Valid_id( none_okay = True ), + ) + def preview( self, file_id, quote_filename = False, user_id = None ): + """ + Return the contents of file that a user has previously uploaded. + + @type file_id: unicode + @param file_id: id of the file to view + @type quote_filename: bool + @param quote_filename: quote_filename value to include in download URL + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype: unicode + @return: file data + @raise Access_error: the current user doesn't have access to the notebook that the file is in + """ + db_file = self.__database.load( File, file_id ) + + if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): + raise Access_error() + + filename = db_file.filename.replace( '"', r"\"" ).encode( "utf8" ) + + return dict( + file_id = file_id, + filename = filename, + quote_filename = quote_filename, + ) + + @expose() + @end_transaction + @grab_user_id + @validate( + file_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def thumbnail( self, file_id, user_id = None ): + """ + Return a thumbnail for a file that a user has previously uploaded. If a thumbnail cannot be + generated for the given file, return a default thumbnail image. + + @type file_id: unicode + @param file_id: id of the file to return a thumbnail for + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype: generator + @return: thumbnail image data + @raise Access_error: the current user doesn't have access to the notebook that the file is in + """ + try: + cherrypy.session.release_lock() + except KeyError: + pass + + db_file = self.__database.load( File, file_id ) + + if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): + raise Access_error() + + cherrypy.response.headerMap[ u"Content-Type" ] = u"image/png" + + # attempt to open the file as an image + server_filename = Upload_file.make_server_filename( file_id ) + try: + image = Image.open( server_filename ) + + # scale the image down into a thumbnail + THUMBNAIL_MAX_SIZE = ( 75, 75 ) # in pixels + image.thumbnail( THUMBNAIL_MAX_SIZE, Image.ANTIALIAS ) + except IOError: + image = Image.open( "static/images/default_thumbnail.png" ) + + # save the image into a memory buffer + image_buffer = StringIO() + image.save( image_buffer, "PNG" ) + image_buffer.seek( 0 ) + + def stream( image_buffer ): + CHUNK_SIZE = 8192 + + while True: + data = image_buffer.read( CHUNK_SIZE ) + if len( data ) == 0: break + yield data + + return stream( image_buffer ) + + @expose() + @end_transaction + @grab_user_id + @validate( + file_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def image( self, file_id, user_id = None ): + """ + Return the contents of an image file that a user has previously uploaded. This is distinct + from the download() method above in that it doesn't set HTTP headers for a file download. + + @type file_id: unicode + @param file_id: id of the file to return + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype: generator + @return: image data + @raise Access_error: the current user doesn't have access to the notebook that the file is in + """ + try: + cherrypy.session.release_lock() + except KeyError: + pass + + db_file = self.__database.load( File, file_id ) + + if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ): + raise Access_error() + + cherrypy.response.headerMap[ u"Content-Type" ] = db_file.content_type + + def stream(): + CHUNK_SIZE = 8192 + local_file = Upload_file.open_file( file_id ) + + while True: + data = local_file.read( CHUNK_SIZE ) + if len( data ) == 0: break + yield data + + return stream() + @expose( view = Upload_page ) @strongly_expire @end_transaction diff --git a/controller/test/Test_files.py b/controller/test/Test_files.py index c3d294b..782f47b 100644 --- a/controller/test/Test_files.py +++ b/controller/test/Test_files.py @@ -183,6 +183,24 @@ class Test_files( Test_controller ): def test_download_with_unicode_unquoted_filename( self ): self.test_download( self.unicode_filename, quote_filename = False ) + def test_download_image_with_preview_none( self ): + raise NotImplementedError() + + def test_download_image_with_preview_true( self ): + raise NotImplementedError() + + def test_download_image_with_preview_false( self ): + raise NotImplementedError() + + def test_download_non_image_with_preview_none( self ): + raise NotImplementedError() + + def test_download_non_image_with_preview_true( self ): + raise NotImplementedError() + + def test_download_non_image_with_preview_false( self ): + raise NotImplementedError() + def test_download_without_login( self ): self.login() @@ -238,6 +256,57 @@ class Test_files( Test_controller ): assert u"access" in result[ u"body" ][ 0 ] + def test_preview( self ): + raise NotImplementedError() + + def test_preview_with_unicode_filename( self ): + raise NotImplementedError() + + def test_preview_with_quote_filename_true( self ): + raise NotImplementedError() + + def test_preview_with_quote_filename_false( self ): + raise NotImplementedError() + + def test_preview_without_login( self ): + raise NotImplementedError() + + def test_preview_without_access( self ): + raise NotImplementedError() + + def test_preview_with_unknown_file_id( self ): + raise NotImplementedError() + + def test_thumbnail( self ): + raise NotImplementedError() + + def test_thumbnail_with_non_image( self ): + raise NotImplementedError() + + def test_thumbnail_without_login( self ): + raise NotImplementedError() + + def test_thumbnail_without_access( self ): + raise NotImplementedError() + + def test_thumbnail_with_unknown_file_id( self ): + raise NotImplementedError() + + def test_image( self ): + raise NotImplementedError() + + def test_image_with_non_image( self ): + raise NotImplementedError() + + def test_image_without_login( self ): + raise NotImplementedError() + + def test_image_without_access( self ): + raise NotImplementedError() + + def test_image_with_unknown_file_id( self ): + raise NotImplementedError() + def test_upload_page( self ): self.login() diff --git a/static/css/style.css b/static/css/style.css index cc77771..3469b6a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -643,3 +643,10 @@ img { width: 40em; height: 4em; } + +.file_thumbnail { + margin-right: 0.5em; + vertical-align: top; + float: left; + cursor: pointer; +} diff --git a/static/images/default_thumbnail.png b/static/images/default_thumbnail.png new file mode 100644 index 0000000..26971a5 Binary files /dev/null and b/static/images/default_thumbnail.png differ diff --git a/static/images/default_thumbnail.xcf b/static/images/default_thumbnail.xcf new file mode 100644 index 0000000..21e861a Binary files /dev/null and b/static/images/default_thumbnail.xcf differ diff --git a/static/images/luminotes_title.jpg b/static/images/luminotes_title.jpg new file mode 100644 index 0000000..270c4a7 Binary files /dev/null and b/static/images/luminotes_title.jpg differ diff --git a/static/js/Editor.js b/static/js/Editor.js index 0aae27f..38a7ed6 100644 --- a/static/js/Editor.js +++ b/static/js/Editor.js @@ -379,9 +379,11 @@ Editor.prototype.mouse_clicked = function ( event ) { // special case for links to uploaded files if ( !link.target && /\/files\//.test( link.href ) ) { - if ( !/\/files\/new$/.test( link.href ) ) - location.href = link.href; - return false; + if ( !/\/files\/new$/.test( link.href ) ) { + window.open( link.href ); + event.stop(); + } + return true; } event.stop(); diff --git a/static/js/Wiki.js b/static/js/Wiki.js index 2e7ead7..2ca646d 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -2537,15 +2537,26 @@ function File_link_pulldown( wiki, notebook_id, invoker, editor, link ) { "title": "delete file" } ); + var query = parse_query( link ); + this.file_id = query.file_id; + + if ( /MSIE/.test( navigator.userAgent ) ) + var quote_filename = true; + else + var quote_filename = false; + + appendChildNodes( this.div, createDOM( "span", {}, + createDOM( "a", { href: "/files/download?file_id=" + this.file_id + ""e_filename=" + quote_filename, target: "_new" }, + createDOM( "img", { "src": "/files/thumbnail?file_id=" + this.file_id, "class": "file_thumbnail" } ) + ) + ) ); + appendChildNodes( this.div, createDOM( "span", { "class": "field_label" }, "filename: " ) ); appendChildNodes( this.div, this.filename_field ); appendChildNodes( this.div, this.file_size ); appendChildNodes( this.div, " " ); appendChildNodes( this.div, delete_button ); - var query = parse_query( link ); - this.file_id = query.file_id; - // get the file's name and size from the server this.invoker.invoke( "/files/stats", "GET", { diff --git a/view/File_preview_page.py b/view/File_preview_page.py new file mode 100644 index 0000000..3b31cf6 --- /dev/null +++ b/view/File_preview_page.py @@ -0,0 +1,23 @@ +from Tags import Html, Head, Title, Body, Img, Div, A + + +class File_preview_page( Html ): + def __init__( self, file_id, filename, quote_filename ): + Html.__init__( + self, + Head( + Title( filename ), + ), + Body( + A( + Img( src = u"/files/image?file_id=%s" % file_id, style = "border: 0;" ), + href = u"/files/download?file_id=%s"e_filename=%s&preview=False" % ( file_id, quote_filename ), + ), + Div( + A( + u"download %s" % filename, + href = u"/files/download?file_id=%s"e_filename=%s&preview=False" % ( file_id, quote_filename ), + ), + ), + ), + )