Browse Source

* Propsetting a bunch of svn:ignores.

 * Added a bunch of thumbnail-related methods to controller.Files.
 * Modified Files.download() method to redirect to image preview if
   requested.
 * Implemented image preview to popup full image in a separate window.
 * Added empty stubs for relevant unit tests. Still to-do.
 * Added new dependency on python-imaging package (PIL).
 * Updated file info popup to include clickable thumbnail.
Dan Helfman 10 years ago
parent
commit
03f015f99a

+ 3
- 1
INSTALL View File

@@ -10,12 +10,14 @@ First, install the prerequisites:
10 10
  * psycopg 2.0
11 11
  * simplejson 1.3
12 12
  * pytz 2006p
13
+ * Python Imaging Library 1.1
13 14
 
14 15
 In Debian GNU/Linux, you can issue the following command to install these
15 16
 packages:
16 17
 
17 18
   apt-get install python2.4 python-cherrypy postgresql-8.1 \
18
-          postgresql-contrib-8.1 python-psycopg2 python-simplejson python-tz
19
+          postgresql-contrib-8.1 python-psycopg2 python-simplejson \
20
+          python-tz python-imaging
19 21
 
20 22
 
21 23
 database setup

+ 154
- 4
controller/Files.py View File

@@ -5,6 +5,8 @@ import time
5 5
 import urllib
6 6
 import tempfile
7 7
 import cherrypy
8
+from PIL import Image
9
+from cStringIO import StringIO
8 10
 from threading import Lock, Event
9 11
 from Expose import expose
10 12
 from Validate import validate, Valid_int, Valid_bool, Validation_error
@@ -17,6 +19,7 @@ from view.Upload_page import Upload_page
17 19
 from view.Blank_page import Blank_page
18 20
 from view.Json import Json
19 21
 from view.Progress_bar import stream_progress, stream_quota_error, quota_error_script, general_error_script
22
+from view.File_preview_page import File_preview_page
20 23
 
21 24
 
22 25
 class Access_error( Exception ):
@@ -238,9 +241,10 @@ class Files( object ):
238 241
   @validate(
239 242
     file_id = Valid_id(),
240 243
     quote_filename = Valid_bool( none_okay = True ),
244
+    preview = Valid_bool( none_okay = True ),
241 245
     user_id = Valid_id( none_okay = True ),
242 246
   )
243
-  def download( self, file_id, quote_filename = False, user_id = None ):
247
+  def download( self, file_id, quote_filename = False, preview = True, user_id = None ):
244 248
     """
245 249
     Return the contents of file that a user has previously uploaded.
246 250
 
@@ -250,14 +254,17 @@ class Files( object ):
250 254
     @param quote_filename: True to URL quote the filename of the downloaded file, False to leave it
251 255
                            as UTF-8. IE expects quoting while Firefox doesn't (optional, defaults
252 256
                            to False)
257
+    @type preview: bool
258
+    @param preview: True to redirect to a preview page if the file is a valid image, False to
259
+                    unconditionally initiate a download
253 260
     @type user_id: unicode or NoneType
254 261
     @param user_id: id of current logged-in user (if any)
255
-    @rtype: unicode
262
+    @rtype: generator
256 263
     @return: file data
257 264
     @raise Access_error: the current user doesn't have access to the notebook that the file is in
258 265
     """
259 266
     # release the session lock before beginning to stream the download. otherwise, if the
260
-    # upload is cancelled before it's done, the lock won't be released
267
+    # download is cancelled before it's done, the lock won't be released
261 268
     try:
262 269
       cherrypy.session.release_lock()
263 270
     except KeyError:
@@ -268,7 +275,14 @@ class Files( object ):
268 275
     if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ):
269 276
       raise Access_error()
270 277
 
271
-    db_file = self.__database.load( File, file_id )
278
+    # if the file is openable as an image, then allow the user to view it instead of downloading it
279
+    if preview:
280
+      server_filename = Upload_file.make_server_filename( file_id )
281
+      try:
282
+        Image.open( server_filename )
283
+        return dict( redirect = u"/files/preview?file_id=%s&quote_filename=%s" % ( file_id, quote_filename ) )
284
+      except IOError:
285
+        pass
272 286
 
273 287
     cherrypy.response.headerMap[ u"Content-Type" ] = db_file.content_type
274 288
 
@@ -290,6 +304,142 @@ class Files( object ):
290 304
 
291 305
     return stream()
292 306
 
307
+  @expose( view = File_preview_page )
308
+  @end_transaction
309
+  @grab_user_id
310
+  @validate(
311
+    file_id = Valid_id(),
312
+    quote_filename = Valid_bool( none_okay = True ),
313
+    user_id = Valid_id( none_okay = True ),
314
+  )
315
+  def preview( self, file_id, quote_filename = False, user_id = None ):
316
+    """
317
+    Return the contents of file that a user has previously uploaded.
318
+
319
+    @type file_id: unicode
320
+    @param file_id: id of the file to view
321
+    @type quote_filename: bool
322
+    @param quote_filename: quote_filename value to include in download URL
323
+    @type user_id: unicode or NoneType
324
+    @param user_id: id of current logged-in user (if any)
325
+    @rtype: unicode
326
+    @return: file data
327
+    @raise Access_error: the current user doesn't have access to the notebook that the file is in
328
+    """
329
+    db_file = self.__database.load( File, file_id )
330
+
331
+    if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ):
332
+      raise Access_error()
333
+
334
+    filename = db_file.filename.replace( '"', r"\"" ).encode( "utf8" )
335
+
336
+    return dict(
337
+      file_id = file_id,
338
+      filename = filename,
339
+      quote_filename = quote_filename,
340
+    )
341
+
342
+  @expose()
343
+  @end_transaction
344
+  @grab_user_id
345
+  @validate(
346
+    file_id = Valid_id(),
347
+    user_id = Valid_id( none_okay = True ),
348
+  )
349
+  def thumbnail( self, file_id, user_id = None ):
350
+    """
351
+    Return a thumbnail for a file that a user has previously uploaded. If a thumbnail cannot be
352
+    generated for the given file, return a default thumbnail image.
353
+
354
+    @type file_id: unicode
355
+    @param file_id: id of the file to return a thumbnail for
356
+    @type user_id: unicode or NoneType
357
+    @param user_id: id of current logged-in user (if any)
358
+    @rtype: generator
359
+    @return: thumbnail image data
360
+    @raise Access_error: the current user doesn't have access to the notebook that the file is in
361
+    """
362
+    try:
363
+      cherrypy.session.release_lock()
364
+    except KeyError:
365
+      pass
366
+
367
+    db_file = self.__database.load( File, file_id )
368
+
369
+    if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ):
370
+      raise Access_error()
371
+
372
+    cherrypy.response.headerMap[ u"Content-Type" ] = u"image/png"
373
+
374
+    # attempt to open the file as an image
375
+    server_filename = Upload_file.make_server_filename( file_id )
376
+    try:
377
+      image = Image.open( server_filename )
378
+
379
+      # scale the image down into a thumbnail
380
+      THUMBNAIL_MAX_SIZE = ( 75, 75 ) # in pixels
381
+      image.thumbnail( THUMBNAIL_MAX_SIZE, Image.ANTIALIAS )
382
+    except IOError:
383
+      image = Image.open( "static/images/default_thumbnail.png" )
384
+
385
+    # save the image into a memory buffer
386
+    image_buffer = StringIO()
387
+    image.save( image_buffer, "PNG" )
388
+    image_buffer.seek( 0 )
389
+
390
+    def stream( image_buffer ):
391
+      CHUNK_SIZE = 8192
392
+
393
+      while True:
394
+        data = image_buffer.read( CHUNK_SIZE )
395
+        if len( data ) == 0: break
396
+        yield data        
397
+
398
+    return stream( image_buffer )
399
+
400
+  @expose()
401
+  @end_transaction
402
+  @grab_user_id
403
+  @validate(
404
+    file_id = Valid_id(),
405
+    user_id = Valid_id( none_okay = True ),
406
+  )
407
+  def image( self, file_id, user_id = None ):
408
+    """
409
+    Return the contents of an image file that a user has previously uploaded. This is distinct
410
+    from the download() method above in that it doesn't set HTTP headers for a file download.
411
+
412
+    @type file_id: unicode
413
+    @param file_id: id of the file to return
414
+    @type user_id: unicode or NoneType
415
+    @param user_id: id of current logged-in user (if any)
416
+    @rtype: generator
417
+    @return: image data
418
+    @raise Access_error: the current user doesn't have access to the notebook that the file is in
419
+    """
420
+    try:
421
+      cherrypy.session.release_lock()
422
+    except KeyError:
423
+      pass
424
+
425
+    db_file = self.__database.load( File, file_id )
426
+
427
+    if not db_file or not self.__users.check_access( user_id, db_file.notebook_id ):
428
+      raise Access_error()
429
+
430
+    cherrypy.response.headerMap[ u"Content-Type" ] = db_file.content_type
431
+
432
+    def stream():
433
+      CHUNK_SIZE = 8192
434
+      local_file = Upload_file.open_file( file_id )
435
+
436
+      while True:
437
+        data = local_file.read( CHUNK_SIZE )
438
+        if len( data ) == 0: break
439
+        yield data        
440
+
441
+    return stream()
442
+
293 443
   @expose( view = Upload_page )
294 444
   @strongly_expire
295 445
   @end_transaction

+ 69
- 0
controller/test/Test_files.py View File

@@ -183,6 +183,24 @@ class Test_files( Test_controller ):
183 183
   def test_download_with_unicode_unquoted_filename( self ):
184 184
     self.test_download( self.unicode_filename, quote_filename = False )
185 185
 
186
+  def test_download_image_with_preview_none( self ):
187
+    raise NotImplementedError()
188
+
189
+  def test_download_image_with_preview_true( self ):
190
+    raise NotImplementedError()
191
+
192
+  def test_download_image_with_preview_false( self ):
193
+    raise NotImplementedError()
194
+
195
+  def test_download_non_image_with_preview_none( self ):
196
+    raise NotImplementedError()
197
+
198
+  def test_download_non_image_with_preview_true( self ):
199
+    raise NotImplementedError()
200
+
201
+  def test_download_non_image_with_preview_false( self ):
202
+    raise NotImplementedError()
203
+
186 204
   def test_download_without_login( self ):
187 205
     self.login()
188 206
 
@@ -238,6 +256,57 @@ class Test_files( Test_controller ):
238 256
 
239 257
     assert u"access" in result[ u"body" ][ 0 ]
240 258
 
259
+  def test_preview( self ):
260
+    raise NotImplementedError()
261
+
262
+  def test_preview_with_unicode_filename( self ):
263
+    raise NotImplementedError()
264
+
265
+  def test_preview_with_quote_filename_true( self ):
266
+    raise NotImplementedError()
267
+
268
+  def test_preview_with_quote_filename_false( self ):
269
+    raise NotImplementedError()
270
+
271
+  def test_preview_without_login( self ):
272
+    raise NotImplementedError()
273
+
274
+  def test_preview_without_access( self ):
275
+    raise NotImplementedError()
276
+
277
+  def test_preview_with_unknown_file_id( self ):
278
+    raise NotImplementedError()
279
+
280
+  def test_thumbnail( self ):
281
+    raise NotImplementedError()
282
+
283
+  def test_thumbnail_with_non_image( self ):
284
+    raise NotImplementedError()
285
+
286
+  def test_thumbnail_without_login( self ):
287
+    raise NotImplementedError()
288
+
289
+  def test_thumbnail_without_access( self ):
290
+    raise NotImplementedError()
291
+
292
+  def test_thumbnail_with_unknown_file_id( self ):
293
+    raise NotImplementedError()
294
+
295
+  def test_image( self ):
296
+    raise NotImplementedError()
297
+
298
+  def test_image_with_non_image( self ):
299
+    raise NotImplementedError()
300
+
301
+  def test_image_without_login( self ):
302
+    raise NotImplementedError()
303
+
304
+  def test_image_without_access( self ):
305
+    raise NotImplementedError()
306
+
307
+  def test_image_with_unknown_file_id( self ):
308
+    raise NotImplementedError()
309
+
241 310
   def test_upload_page( self ):
242 311
     self.login()
243 312
 

+ 7
- 0
static/css/style.css View File

@@ -643,3 +643,10 @@ img {
643 643
   width: 40em;
644 644
   height: 4em;
645 645
 }
646
+
647
+.file_thumbnail {
648
+  margin-right: 0.5em;
649
+  vertical-align: top;
650
+  float: left;
651
+  cursor: pointer;
652
+}

BIN
static/images/default_thumbnail.png View File


BIN
static/images/default_thumbnail.xcf View File


BIN
static/images/luminotes_title.jpg View File


+ 5
- 3
static/js/Editor.js View File

@@ -379,9 +379,11 @@ Editor.prototype.mouse_clicked = function ( event ) {
379 379
 
380 380
     // special case for links to uploaded files
381 381
     if ( !link.target && /\/files\//.test( link.href ) ) {
382
-      if ( !/\/files\/new$/.test( link.href ) )
383
-        location.href = link.href;
384
-      return false;
382
+      if ( !/\/files\/new$/.test( link.href ) ) {
383
+        window.open( link.href );
384
+        event.stop();
385
+      }
386
+      return true;
385 387
     }
386 388
 
387 389
     event.stop();

+ 14
- 3
static/js/Wiki.js View File

@@ -2537,15 +2537,26 @@ function File_link_pulldown( wiki, notebook_id, invoker, editor, link ) {
2537 2537
     "title": "delete file"
2538 2538
   } );
2539 2539
 
2540
+  var query = parse_query( link );
2541
+  this.file_id = query.file_id;
2542
+
2543
+  if ( /MSIE/.test( navigator.userAgent ) )
2544
+    var quote_filename = true;
2545
+  else
2546
+    var quote_filename = false;
2547
+
2548
+  appendChildNodes( this.div, createDOM( "span", {},
2549
+    createDOM( "a", { href: "/files/download?file_id=" + this.file_id + "&quote_filename=" + quote_filename, target: "_new" },
2550
+      createDOM( "img", { "src": "/files/thumbnail?file_id=" + this.file_id, "class": "file_thumbnail" } )
2551
+    )
2552
+  ) );
2553
+
2540 2554
   appendChildNodes( this.div, createDOM( "span", { "class": "field_label" }, "filename: " ) );
2541 2555
   appendChildNodes( this.div, this.filename_field );
2542 2556
   appendChildNodes( this.div, this.file_size );
2543 2557
   appendChildNodes( this.div, " " );
2544 2558
   appendChildNodes( this.div, delete_button );
2545 2559
 
2546
-  var query = parse_query( link );
2547
-  this.file_id = query.file_id;
2548
-
2549 2560
   // get the file's name and size from the server
2550 2561
   this.invoker.invoke(
2551 2562
     "/files/stats", "GET", {

+ 23
- 0
view/File_preview_page.py View File

@@ -0,0 +1,23 @@
1
+from Tags import Html, Head, Title, Body, Img, Div, A
2
+
3
+
4
+class File_preview_page( Html ):
5
+  def __init__( self, file_id, filename, quote_filename ):
6
+    Html.__init__(
7
+      self,
8
+      Head(
9
+        Title( filename ),
10
+      ),
11
+      Body(
12
+        A(
13
+          Img( src = u"/files/image?file_id=%s" % file_id, style = "border: 0;" ),
14
+          href = u"/files/download?file_id=%s&quote_filename=%s&preview=False" % ( file_id, quote_filename ),
15
+        ),
16
+        Div(
17
+          A(
18
+            u"download %s" % filename,
19
+            href = u"/files/download?file_id=%s&quote_filename=%s&preview=False" % ( file_id, quote_filename ),
20
+          ),
21
+        ),
22
+      ),
23
+    )

Loading…
Cancel
Save