diff --git a/config/Common.py b/config/Common.py index 53efaf0..5c93d91 100644 --- a/config/Common.py +++ b/config/Common.py @@ -63,4 +63,7 @@ settings = { """ """, }, + "/notebooks/upload_file": { + "stream_response": True + }, } diff --git a/controller/Expose.py b/controller/Expose.py index db1cab3..5d02a75 100644 --- a/controller/Expose.py +++ b/controller/Expose.py @@ -63,6 +63,10 @@ def expose( view = None, rss = None ): cherrypy.root.report_traceback() result = dict( error = u"An error occurred when processing your request. Please try again or contact support." ) + # if the result is a generator, it's streaming data, so just let CherryPy handle it + if hasattr( result, "gi_running" ): + return result + redirect = result.get( u"redirect", None ) # try using the supplied view to render the result diff --git a/controller/Notebooks.py b/controller/Notebooks.py index 812618e..6bc5b4e 100644 --- a/controller/Notebooks.py +++ b/controller/Notebooks.py @@ -1,5 +1,7 @@ import re +import cgi import cherrypy +from cherrypy.filters import basefilter from datetime import datetime from Expose import expose from Validate import validate, Valid_string, Validation_error, Valid_bool @@ -15,6 +17,7 @@ from model.User_revision import User_revision from view.Main_page import Main_page from view.Json import Json from view.Html_file import Html_file +from view.Upload_page import Upload_page class Access_error( Exception ): @@ -31,8 +34,35 @@ class Access_error( Exception ): ) +class Upload_error( Exception ): + def __init__( self, message = None ): + if message is None: + message = u"An error occurred when uploading the file." + + Exception.__init__( self, message ) + self.__message = message + + def to_dict( self ): + return dict( + error = self.__message + ) + + +class File_upload_filter( basefilter.BaseFilter ): + def before_request_body( self ): + if cherrypy.request.path != "/notebooks/upload_file": + return + + if cherrypy.request.method != "POST": + raise Upload_error() + + # tell CherryPy not to parse the POST data itself for this URL + cherrypy.request.processRequestBody = False + + class Notebooks( object ): WHITESPACE_PATTERN = re.compile( u"\s+" ) + _cpFilterList = [ File_upload_filter() ] """ Controller for dealing with notebooks and their notes, corresponding to the "/notebooks" URL. @@ -1095,3 +1125,149 @@ class Notebooks( object ): result[ "count" ] = count return result + + @expose( view = Upload_page ) + @validate( + notebook_id = Valid_id(), + note_id = Valid_id(), + ) + def upload_page( self, notebook_id, note_id ): + """ + Provide the information necessary to display the file upload page. + + @type notebook_id: unicode + @param notebook_id: id of the notebook that the upload will be to + @type note_id: unicode + @param note_id: id of the note that the upload will be to + @rtype: unicode + @return: rendered HTML page + """ + return dict( + notebook_id = notebook_id, + note_id = note_id, + ) + + @expose() + @strongly_expire + @grab_user_id + @validate( + user_id = Valid_id( none_okay = True ), + ) + def upload_file( self, user_id ): + """ + Upload a file from the client for attachment to a particular note. + + @type notebook_id: unicode + @param notebook_id: id of the notebook that the upload is to + @type note_id: unicode + @param note_id: id of the note that the upload is to + @raise Access_error: the current user doesn't have access to the given notebook or note + @rtype: unicode + @return: rendered HTML page + """ + cherrypy.server.max_request_body_size = 0 # remove file size limit of 100 MB + cherrypy.response.timeout = 3600 # increase upload timeout to one hour (default is 5 min) + cherrypy.server.socket_timeout = 60 # increase socket timeout to one minute (default is 10 sec) + # TODO: increase to 8k + CHUNK_SIZE = 1#8 * 1024 # 8 Kb + + headers = {} + for key, val in cherrypy.request.headers.iteritems(): + headers[ key.lower() ] = val + + try: + file_size = int( headers.get( "content-length", 0 ) ) + except ValueError: + raise Upload_error() + if file_size <= 0: + raise Upload_error() + + parsed_form = cgi.FieldStorage( fp = cherrypy.request.rfile, headers = headers, environ = { "REQUEST_METHOD": "POST" }, keep_blank_values = 1) + upload = parsed_form[ u"file" ] + notebook_id = parsed_form.getvalue( u"notebook_id" ) + note_id = parsed_form.getvalue( u"note_id" ) + filename = upload.filename.strip() + + if not self.__users.check_access( user_id, notebook_id ): + raise Access_error() + + def process_upload(): + """ + Process the file upload while streaming a progress meter as it uploads. + """ + progress_bytes = 0 + fraction_reported = 0.0 + progress_width_em = 20 + tick_increment = 0.01 + progress_bar = u'' % \ + ( progress_width_em * tick_increment ) + + yield \ + u""" + + + + + + + + """ + + if not filename: + yield \ + u""" +
upload error:
+ Please check that the filename is valid. + """ + return + + base_filename = filename.split( u"/" )[ -1 ].split( u"\\" )[ -1 ] + yield \ + u""" +
uploading %s:
+
+ %s +
+ + """ % ( cgi.escape( base_filename ), progress_bar, progress_width_em ) + + import time + while True: + chunk = upload.file.read( CHUNK_SIZE ) + if not chunk: break + progress_bytes += len( chunk ) + fraction_done = float( progress_bytes ) / float( file_size ) + + if fraction_done > fraction_reported + tick_increment: + yield ';' % fraction_reported + fraction_reported += tick_increment + time.sleep(0.025) # TODO: removeme + + # TODO: write to the database + + if fraction_reported == 0: + yield "An error occurred when uploading the file." + return + + # the file finished uploading, so fill out the progress meter to 100% + if fraction_reported < 1.0: + yield ';' + + yield \ + u""" + + + + """ + + upload.file.close() + cherrypy.request.rfile.close() + + return process_upload() diff --git a/static/css/note.css b/static/css/note.css index f2692f2..a32249d 100644 --- a/static/css/note.css +++ b/static/css/note.css @@ -10,6 +10,12 @@ background-image: url(/static/images/link_button.png); } +#attach_button_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/attach_button.png); +} + #bold_button_preload { height: 0; overflow: hidden; diff --git a/static/css/style.css b/static/css/style.css index 02b6ebf..80c1427 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -71,6 +71,12 @@ img { background-image: url(/static/images/link_button_hover.png); } +#attach_button_hover_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/attach_button_hover.png); +} + #bold_button_hover_preload { height: 0; overflow: hidden; @@ -119,6 +125,12 @@ img { background-image: url(/static/images/link_button_down_hover.png); } +#attach_button_down_hover_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/attach_button_down_hover.png); +} + #bold_button_down_hover_preload { height: 0; overflow: hidden; @@ -167,6 +179,12 @@ img { background-image: url(/static/images/link_button_down.png); } +#attach_button_down_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/attach_button_down.png); +} + #bold_button_down_preload { height: 0; overflow: hidden; @@ -398,7 +416,7 @@ img { overflow: auto; padding: 0.5em; border: 1px solid #000000; - background: #ffff99; + background-color: #ffff99; } .pulldown_link { @@ -596,3 +614,10 @@ img { font-weight: bold; color: #ff6600; } + +#upload_frame { + padding: 0; + margin: 0; + width: 40em; + height: 4em; +} diff --git a/static/css/upload.css b/static/css/upload.css new file mode 100644 index 0000000..5fe1595 --- /dev/null +++ b/static/css/upload.css @@ -0,0 +1,46 @@ +html, body { + padding: 0; + margin: 0; + line-height: 140%; + font-family: sans-serif; + background-color: #ffff99; +} + +form { + margin-bottom: 0.5em; +} + +div { + margin-bottom: 0.5em; +} + +.field_label { + font-weight: bold; +} + +.button { + border-style: outset; + border-width: 0px; + background-color: #d0e0f0; + font-size: 100%; + outline: none; + cursor: pointer; + margin-left: 0.25em; +} + +.button:hover { + background-color: #ffcc66; +} + +#progress_border { + border: 1px solid #000000; + background-color: #ffffff; + width: 20em; + height: 1em; +} + +#tick_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/tick.png); +} diff --git a/static/html/features.html b/static/html/features.html index 0edff0b..162e8ff 100644 --- a/static/html/features.html +++ b/static/html/features.html @@ -52,6 +52,7 @@ need to download or install anything if you just want to make a wiki. + diff --git a/static/images/attach_button.png b/static/images/attach_button.png new file mode 100644 index 0000000..8e7b2dc Binary files /dev/null and b/static/images/attach_button.png differ diff --git a/static/images/attach_button.xcf b/static/images/attach_button.xcf new file mode 100644 index 0000000..63a0e88 Binary files /dev/null and b/static/images/attach_button.xcf differ diff --git a/static/images/attach_button_down.png b/static/images/attach_button_down.png new file mode 100644 index 0000000..bcb5a18 Binary files /dev/null and b/static/images/attach_button_down.png differ diff --git a/static/images/attach_button_down.xcf b/static/images/attach_button_down.xcf new file mode 100644 index 0000000..1e53ef2 Binary files /dev/null and b/static/images/attach_button_down.xcf differ diff --git a/static/images/attach_button_down_hover.png b/static/images/attach_button_down_hover.png new file mode 100644 index 0000000..8950330 Binary files /dev/null and b/static/images/attach_button_down_hover.png differ diff --git a/static/images/attach_button_down_hover.xcf b/static/images/attach_button_down_hover.xcf new file mode 100644 index 0000000..9c64d16 Binary files /dev/null and b/static/images/attach_button_down_hover.xcf differ diff --git a/static/images/attach_button_hover.png b/static/images/attach_button_hover.png new file mode 100644 index 0000000..fe3aeed Binary files /dev/null and b/static/images/attach_button_hover.png differ diff --git a/static/images/attach_button_hover.xcf b/static/images/attach_button_hover.xcf new file mode 100644 index 0000000..154c444 Binary files /dev/null and b/static/images/attach_button_hover.xcf differ diff --git a/static/images/images.txt b/static/images/images.txt index 1fd9dd1..16f104b 100644 --- a/static/images/images.txt +++ b/static/images/images.txt @@ -2,7 +2,8 @@ Button dimensions are 40x40 pixels. Button fonts are Bitstream Vera Sans (regular, bold, and mono oblique). Most buttons are at 22 pt. The link button is at 12 pt. The list buttons are at 10 -pt with a -4 pixel line spacing. +pt with a -4 pixel line spacing. Down (pressed) buttons have their text offset +two pixels down and two pixels to the right. To make the white glowing effect (which isn't present on any buttons currently), start with black text on a transparent background in the Gimp. diff --git a/static/images/paperclip.png b/static/images/paperclip.png new file mode 100644 index 0000000..3878ed5 Binary files /dev/null and b/static/images/paperclip.png differ diff --git a/static/images/paperclip.svg b/static/images/paperclip.svg new file mode 100644 index 0000000..582d8b9 --- /dev/null +++ b/static/images/paperclip.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Mail Attachment + 2005-11-04 + + + Andreas Nilsson + + + http://tango-project.org + + + attachment + file + + + + + + Garrett LeSage + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/tick.png b/static/images/tick.png new file mode 100644 index 0000000..477d2e6 Binary files /dev/null and b/static/images/tick.png differ diff --git a/static/js/Editor.js b/static/js/Editor.js index 011de3c..bd46c45 100644 --- a/static/js/Editor.js +++ b/static/js/Editor.js @@ -492,6 +492,46 @@ Editor.prototype.end_link = function () { return link; } +Editor.prototype.insert_file_link = function ( filename, file_id ) { + // get the current selection, which is the link title + if ( this.iframe.contentWindow && this.iframe.contentWindow.getSelection ) { // browsers such as Firefox + var selection = this.iframe.contentWindow.getSelection(); + + // if no text is selected, then insert a link with the filename as the link title + if ( selection.toString().length == 0 ) { + this.insert_html( '' + filename + '' ); + var placeholder = withDocument( this.document, function () { return getElement( "placeholder_title" ); } ); + selection.selectAllChildren( placeholder ); + + this.exec_command( "createLink", "/files/" + file_id ); + selection.collapseToEnd(); + + // replace the placeholder title span with just the filename, yielding an unselected link + var link = placeholder.parentNode; + link.innerHTML = filename; + link.target = "_new"; + // otherwise, just create a link with the selected text as the link title + } else { + this.exec_command( "createLink", "/files/" + file_id ); + var link = this.find_link_at_cursor(); + link.target = "_new"; + } + } else if ( this.document.selection ) { // browsers such as IE + var range = this.document.selection.createRange(); + + // if no text is selected, then insert a link with the filename as the link title + if ( range.text.length == 0 ) { + range.text = filename; + range.moveStart( "character", -1 * filename.length ); + range.select(); + } + + this.exec_command( "createLink", "/files/" + file_id ); + var link = this.find_link_at_cursor(); + link.target = "_new"; + } +} + Editor.prototype.find_link_at_cursor = function () { if ( this.iframe.contentWindow && this.iframe.contentWindow.getSelection ) { // browsers such as Firefox var selection = this.iframe.contentWindow.getSelection(); @@ -542,6 +582,18 @@ Editor.prototype.find_link_at_cursor = function () { return null; } +Editor.prototype.node_at_cursor = function () { + if ( this.iframe.contentWindow && this.iframe.contentWindow.getSelection ) { // browsers such as Firefox + var selection = this.iframe.contentWindow.getSelection(); + return selection.anchorNode; + } else if ( this.document.selection ) { // browsers such as IE + var range = this.document.selection.createRange(); + return range.parentElement(); + } + + return null; +} + Editor.prototype.focus = function () { if ( /Opera/.test( navigator.userAgent ) ) this.iframe.focus(); diff --git a/static/js/Wiki.js b/static/js/Wiki.js index eee490a..78c66b1 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -253,6 +253,7 @@ Wiki.prototype.populate = function ( startup_notes, current_notes, note_read_wri connect( window, "onunload", function ( event ) { self.editor_focused( null, true ); } ); connect( "newNote", "onclick", this, "create_blank_editor" ); connect( "createLink", "onclick", this, "toggle_link_button" ); + connect( "attachFile", "onclick", this, "toggle_attach_button" ); connect( "bold", "onclick", function ( event ) { self.toggle_button( event, "bold" ); } ); connect( "italic", "onclick", function ( event ) { self.toggle_button( event, "italic" ); } ); connect( "underline", "onclick", function ( event ) { self.toggle_button( event, "underline" ); } ); @@ -262,6 +263,7 @@ Wiki.prototype.populate = function ( startup_notes, current_notes, note_read_wri this.make_image_button( "newNote", "new_note", true ); this.make_image_button( "createLink", "link" ); + this.make_image_button( "attachFile", "attach" ); this.make_image_button( "bold" ); this.make_image_button( "italic" ); this.make_image_button( "underline" ); @@ -958,6 +960,27 @@ Wiki.prototype.toggle_link_button = function ( event ) { event.stop(); } +Wiki.prototype.toggle_attach_button = function ( event ) { + if ( this.focused_editor && this.focused_editor.read_write ) { + this.focused_editor.focus(); + + // if the pulldown is already open, then just close it + var pulldown_id = "upload_" + this.focused_editor.id; + var existing_div = getElement( pulldown_id ); + if ( existing_div ) { + existing_div.pulldown.shutdown(); + return; + } + + this.clear_messages(); + this.clear_pulldowns(); + + new Upload_pulldown( this, this.notebook_id, this.invoker, this.focused_editor, this.focused_editor.node_at_cursor() ); + } + + event.stop(); +} + Wiki.prototype.hide_editor = function ( event, editor ) { this.clear_messages(); this.clear_pulldowns(); @@ -2186,3 +2209,58 @@ Link_pulldown.prototype.shutdown = function () { if ( this.link ) this.link.pulldown = null; } + +function Upload_pulldown( wiki, notebook_id, invoker, editor, anchor ) { + this.anchor = anchor; + + Pulldown.call( this, wiki, notebook_id, "upload_" + editor.id, anchor, editor.iframe ); + wiki.down_image_button( "attachFile" ); + + this.invoker = invoker; + this.editor = editor; + this.iframe = createDOM( "iframe", { + "src": "/notebooks/upload_page?notebook_id=" + notebook_id + "¬e_id=" + editor.id, + "frameBorder": "0", + "scrolling": "no", + "id": "upload_frame", + "name": "upload_frame" + } ); + + var self = this; + connect( this.iframe, "onload", function ( event ) { self.init_frame(); } ); + + appendChildNodes( this.div, this.iframe ); +} + +Upload_pulldown.prototype = new function () { this.prototype = Pulldown.prototype; }; +Upload_pulldown.prototype.constructor = Upload_pulldown; + +Upload_pulldown.prototype.init_frame = function () { + var self = this; + + withDocument( this.iframe.contentDocument, function () { + connect( "upload_button", "onclick", function ( event ) { + withDocument( self.iframe.contentDocument, function () { + self.upload_started( getElement( "file" ).value ); + } ); + } ); + } ); +} + +Upload_pulldown.prototype.upload_started = function ( filename ) { + // get the basename of the file + var pieces = filename.split( "/" ); + filename = pieces[ pieces.length - 1 ]; + pieces = filename.split( "\\" ); + filename = pieces[ pieces.length - 1 ]; + + this.editor.insert_file_link( filename ); +} + +Upload_pulldown.prototype.shutdown = function () { + Pulldown.prototype.shutdown.call( this ); + this.wiki.up_image_button( "attachFile" ); + + disconnectAll( this.file_input ); +} + diff --git a/view/Link_area.py b/view/Link_area.py index 33bdcc6..8db4632 100644 --- a/view/Link_area.py +++ b/view/Link_area.py @@ -86,10 +86,6 @@ class Link_area( Div ): id = u"share_notebook_link", title = u"Share this notebook with others.", ), - Span( - u"new!", - class_ = u"new_feature_text", - ), class_ = u"link_area_item", ) or None, diff --git a/view/Toolbar.py b/view/Toolbar.py index 7c82fdf..9361906 100644 --- a/view/Toolbar.py +++ b/view/Toolbar.py @@ -21,6 +21,13 @@ class Toolbar( Div ): width = u"40", height = u"40", class_ = "image_button", ) ), + Div( Input( + type = u"image", + id = u"attachFile", title = u"attach file", + src = u"/static/images/attach_button.png", + width = u"40", height = u"40", + class_ = "image_button", + ) ), ), P( Div( Input( @@ -73,6 +80,7 @@ class Toolbar( Div ): Span( id = "new_note_button_hover_preload" ), Span( id = "link_button_hover_preload" ), + Span( id = "attach_button_hover_preload" ), Span( id = "bold_button_hover_preload" ), Span( id = "italic_button_hover_preload" ), Span( id = "underline_button_hover_preload" ), @@ -82,6 +90,7 @@ class Toolbar( Div ): Span( id = "new_note_button_down_hover_preload" ), Span( id = "link_button_down_hover_preload" ), + Span( id = "attach_button_down_hover_preload" ), Span( id = "bold_button_down_hover_preload" ), Span( id = "italic_button_down_hover_preload" ), Span( id = "underline_button_down_hover_preload" ), @@ -91,6 +100,7 @@ class Toolbar( Div ): Span( id = "new_note_button_down_preload" ), Span( id = "link_button_down_preload" ), + Span( id = "attach_button_down_preload" ), Span( id = "bold_button_down_preload" ), Span( id = "italic_button_down_preload" ), Span( id = "underline_button_down_preload" ), diff --git a/view/Upload_page.py b/view/Upload_page.py new file mode 100644 index 0000000..09981bb --- /dev/null +++ b/view/Upload_page.py @@ -0,0 +1,26 @@ +from Tags import Html, Head, Link, Meta, Body, P, Form, Span, Input + + +class Upload_page( Html ): + def __init__( self, notebook_id, note_id ): + Html.__init__( + self, + Head( + Link( href = u"/static/css/upload.css", type = u"text/css", rel = u"stylesheet" ), + Meta( content = u"text/html; charset=UTF-8", http_equiv = u"content-type" ), + ), + Body( + Form( + Span( u"attach file: ", class_ = u"field_label" ), + Input( type = u"file", id = u"file", name = u"file", size = u"30" ), + Input( type = u"submit", id = u"upload_button", class_ = u"button", value = u"upload" ), + Input( type = u"hidden", id = u"notebook_id", name = u"notebook_id", value = notebook_id ), + Input( type = u"hidden", id = u"note_id", name = u"note_id", value = note_id ), + action = u"/notebooks/upload_file", + method = u"post", + enctype = u"multipart/form-data", + ), + P( u"Please select a file to upload." ), + Span( id = u"tick_preload" ), + ), + )