diff --git a/NEWS b/NEWS index 817bd3b..bce5fc6 100644 --- a/NEWS +++ b/NEWS @@ -1,2 +1,6 @@ +1.0.1: November ??, 2007 + * Ability to create, rename, delete, and switch between multiple wiki + notebooks in a single account. + 1.0.0: November 12, 2007 * Initial release. diff --git a/controller/Notebooks.py b/controller/Notebooks.py index 211c246..52dacb9 100644 --- a/controller/Notebooks.py +++ b/controller/Notebooks.py @@ -8,6 +8,7 @@ from Expire import strongly_expire from Html_nuker import Html_nuker from model.Notebook import Notebook from model.Note import Note +from model.User import User from view.Main_page import Main_page from view.Json import Json from view.Html_file import Html_file @@ -702,6 +703,85 @@ class Notebooks( object ): notes = startup_notes + other_notes, ) + @expose( view = Json ) + @grab_user_id + @validate( + user_id = Valid_id( none_okay = True ), + ) + def create( self, user_id ): + """ + Create a new notebook and give it a default name. + + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype dict + @return { "redirect": notebookurl } + @raise Access_error: the current user doesn't have access to create a notebook + @raise Validation_error: one of the arguments is invalid + """ + if user_id is None: + raise Access_error() + + user = self.__database.load( User, user_id ) + + # create the notebook along with a trash + trash_id = self.__database.next_id( Notebook, commit = False ) + trash = Notebook.create( trash_id, u"trash" ) + self.__database.save( trash, commit = False ) + + notebook_id = self.__database.next_id( Notebook, commit = False ) + notebook = Notebook.create( notebook_id, u"new notebook", trash_id ) + self.__database.save( notebook, commit = False ) + + # record the fact that the user has access to their new notebook + self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True ), commit = False ) + self.__database.execute( user.sql_save_notebook( trash_id, read_write = True ), commit = False ) + self.__database.commit() + + return dict( + redirect = u"/notebooks/%s" % notebook_id, + ) + + @expose( view = Json ) + @grab_user_id + @validate( + notebook_id = Valid_id(), + name = Valid_string( min = 1, max = 100 ), + user_id = Valid_id( none_okay = True ), + ) + def rename( self, notebook_id, name, user_id ): + """ + Change the name of the given notebook. + + @type notebook_id: unicode + @param notebook_id: id of notebook to rename + @type name: unicode + @param name: new name of the notebook + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype dict + @return {} + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + """ + if not self.__users.check_access( user_id, notebook_id, read_write = True ): + raise Access_error() + + notebook = self.__database.load( Notebook, notebook_id ) + + if not notebook: + raise Access_error() + + # prevent just anyone from making official Luminotes notebooks + if name.startswith( u"Luminotes" ) and not notebook.name.startswith( u"Luminotes" ): + raise Access_error( "That notebook name is not available. Please try a different one." ) + + notebook.name = name + self.__database.save( notebook, commit = False ) + self.__database.commit() + + return dict() + def load_recent_notes( self, notebook_id, start = 0, count = 10, user_id = None ): """ Provide the information necessary to display the page for a particular notebook's most recent diff --git a/controller/Root.py b/controller/Root.py index b4fa32a..5eb5c21 100644 --- a/controller/Root.py +++ b/controller/Root.py @@ -108,6 +108,7 @@ class Root( object ): result = self.__users.current( user_id ) main_notebooks = [ nb for nb in result[ "notebooks" ] if nb.name == u"Luminotes" ] + result.update( self.__notebooks.contents( main_notebooks[ 0 ].object_id, user_id = user_id ) ) return result diff --git a/controller/test/Test_notebooks.py b/controller/test/Test_notebooks.py index d555986..8ea7f97 100644 --- a/controller/test/Test_notebooks.py +++ b/controller/test/Test_notebooks.py @@ -1650,6 +1650,94 @@ class Test_notebooks( Test_controller ): assert result.get( "error" ) + def test_create( self ): + self.login() + + result = self.http_post( "/notebooks/create", dict(), session_id = self.session_id ) + + assert result[ u"redirect" ].startswith( u"/notebooks/" ) + + new_notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ] + notebook = self.database.last_saved_obj + + assert isinstance( notebook, Notebook ) + assert notebook.object_id == new_notebook_id + assert notebook.name == u"new notebook" + assert notebook.read_write == True + assert notebook.trash_id + + def test_contents_after_create( self ): + self.login() + + result = self.http_post( "/notebooks/create", dict(), session_id = self.session_id ) + new_notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ] + + result = cherrypy.root.notebooks.contents( + notebook_id = new_notebook_id, + user_id = self.user.object_id, + ) + + notebook = result[ "notebook" ] + assert result[ "total_notes_count" ] == 0 + assert result[ "startup_notes" ] == [] + assert result[ "notes" ] == [] + + assert notebook.object_id == new_notebook_id + assert notebook.read_write == True + + def test_create_without_login( self ): + result = self.http_post( "/notebooks/create", dict() ) + + assert result[ u"error" ] + + def test_rename( self ): + self.login() + + new_name = u"renamed notebook" + result = self.http_post( "/notebooks/rename", dict( + notebook_id = self.notebook.object_id, + name = new_name, + ), session_id = self.session_id ) + + assert u"error" not in result + + def test_contents_after_rename( self ): + self.login() + + new_name = u"renamed notebook" + self.http_post( "/notebooks/rename", dict( + notebook_id = self.notebook.object_id, + name = new_name, + ), session_id = self.session_id ) + + result = cherrypy.root.notebooks.contents( + notebook_id = self.notebook.object_id, + user_id = self.user.object_id, + ) + + notebook = result[ "notebook" ] + assert notebook.name == new_name + + def test_rename_without_login( self ): + new_name = u"renamed notebook" + result = self.http_post( "/notebooks/rename", dict( + notebook_id = self.notebook.object_id, + name = new_name, + ) ) + + assert result[ u"error" ] + + def test_rename_with_reserved_name( self ): + self.login() + + new_name = u"Luminotes blog" + result = self.http_post( "/notebooks/rename", dict( + notebook_id = self.notebook.object_id, + name = new_name, + ), session_id = self.session_id ) + + assert result[ u"error" ] + def test_recent_notes( self ): result = cherrypy.root.notebooks.load_recent_notes( self.notebook.object_id, diff --git a/controller/test/Test_root.py b/controller/test/Test_root.py index 2fc7964..8808754 100644 --- a/controller/test/Test_root.py +++ b/controller/test/Test_root.py @@ -64,7 +64,9 @@ class Test_root( Test_controller ): def test_index( self ): result = self.http_get( "/" ) + assert result + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id def test_index_after_login( self ): self.login() @@ -88,6 +90,7 @@ class Test_root( Test_controller ): assert result assert result.get( u"redirect" ) is None + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id def test_default( self ): result = self.http_get( @@ -98,6 +101,7 @@ class Test_root( Test_controller ): assert result[ u"notes" ] assert len( result[ u"notes" ] ) == 1 assert result[ u"notes" ][ 0 ].object_id == self.anon_note.object_id + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id def test_default_with_unknown_note( self ): result = self.http_get( @@ -134,6 +138,7 @@ class Test_root( Test_controller ): assert result assert u"error" not in result + assert result[ u"notebook" ].object_id == self.blog_notebook.object_id def test_blog_with_note_id( self ): result = self.http_get( @@ -142,6 +147,7 @@ class Test_root( Test_controller ): assert result assert u"error" not in result + assert result[ u"notebook" ].object_id == self.blog_notebook.object_id def test_blog_rss( self ): result = self.http_get( @@ -150,6 +156,7 @@ class Test_root( Test_controller ): assert result assert u"error" not in result + assert result[ u"notebook" ].object_id == self.blog_notebook.object_id def test_guide( self ): result = self.http_get( @@ -158,6 +165,7 @@ class Test_root( Test_controller ): assert result assert u"error" not in result + assert result[ u"notebook" ].object_id == self.guide_notebook.object_id def test_privacy( self ): result = self.http_get( @@ -166,6 +174,7 @@ class Test_root( Test_controller ): assert result assert u"error" not in result + assert result[ u"notebook" ].object_id == self.privacy_notebook.object_id def test_next_id( self ): result = self.http_get( "/next_id" ) diff --git a/static/css/style.css b/static/css/style.css index 2dd829d..1bb866b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -266,6 +266,14 @@ ol li { padding: 0.2em; } +#notebook_header_name:hover { + color: #ff6600; +} + +#rename_form { + margin: 0; +} + #notebook_border { padding: 0 0 0 0.4em; } diff --git a/static/js/Wiki.js b/static/js/Wiki.js index 28bce79..5ab29fa 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -201,6 +201,30 @@ Wiki.prototype.populate = function ( startup_notes, current_notes, note_read_wri self.save_editor( null, true ); } ); } + + var add_notebook_link = getElement( "add_notebook_link" ); + if ( add_notebook_link ) { + connect( add_notebook_link, "onclick", function ( event ) { + self.invoker.invoke( "/notebooks/create", "POST" ); + event.stop(); + } ); + } + + var rename_notebook_link = getElement( "rename_notebook_link" ); + if ( rename_notebook_link ) { + connect( rename_notebook_link, "onclick", function ( event ) { + self.start_notebook_rename(); + event.stop(); + } ); + } + + var notebook_header_name = getElement( "notebook_header_name" ); + if ( notebook_header_name ) { + connect( notebook_header_name, "onclick", function ( event ) { + self.start_notebook_rename(); + event.stop(); + } ); + } } Wiki.prototype.background_clicked = function ( event ) { @@ -1295,6 +1319,101 @@ Wiki.prototype.create_all_notes_link = function ( note_id, note_title ) { ); } +Wiki.prototype.start_notebook_rename = function () { + this.clear_messages(); + this.clear_pulldowns(); + + // if a renaming is already in progress, end the renaming instead of starting one + var notebook_name_field = getElement( "notebook_name_field" ); + if ( notebook_name_field ) { + this.end_notebook_rename(); + return; + } + + notebook_name_field = createDOM( + "input", { + "type": "text", + "value": this.notebook.name, + "id": "notebook_name_field", + "name": "notebook_name_field", + "size": "30", + "maxlength": "100", + "class": "text_field" + } + ); + + var ok_button = createDOM( + "input", { + "type": "button", + "class": "message_button", + "value": "ok", + "title": "dismiss this message" + } + ); + + var rename_form = createDOM( + "form", { "id": "rename_form" }, notebook_name_field, ok_button + ); + + replaceChildNodes( "notebook_header_area", rename_form ); + + var self = this; + connect( rename_form, "onsubmit", function ( event ) { + self.end_notebook_rename(); + event.stop(); + } ); + connect( ok_button, "onclick", function ( event ) { + self.end_notebook_rename(); + event.stop(); + } ); + + notebook_name_field.focus(); + notebook_name_field.select(); +} + +Wiki.prototype.end_notebook_rename = function () { + var new_notebook_name = getElement( "notebook_name_field" ).value; + + // if the new name is blank or reserved, don't actually rename the notebook + if ( /^\s*$/.test( new_notebook_name ) ) + new_notebook_name = this.notebook.name; + + if ( /^\s*Luminotes/.test( new_notebook_name ) ) { + new_notebook_name = this.notebook.name; + this.display_error( "That notebook name is not available. Please try a different one." ); + } + + // rename the notebook in the header + var notebook_header_name = createDOM( + "span", + { "id": "notebook_header_name" }, + createDOM( "strong", {}, new_notebook_name ) + ); + replaceChildNodes( "notebook_header_area", notebook_header_name ); + + var self = this; + connect( notebook_header_name, "onclick", function ( event ) { + self.start_notebook_rename(); + event.stop(); + } ); + + // rename the notebook link on the right side of the page + replaceChildNodes( + "notebook_" + this.notebook.object_id, + document.createTextNode( new_notebook_name ) + ); + + // if the name has changed, then send the new name to the server + if ( new_notebook_name == this.notebook.name ) + return; + + this.notebook.name = new_notebook_name; + this.invoker.invoke( "/notebooks/rename", "POST", { + "notebook_id": this.notebook_id, + "name": new_notebook_name + } ); +} + Wiki.prototype.toggle_editor_changes = function ( event, editor ) { // if the pulldown is already open, then just close it var pulldown_id = "changes_" + editor.id; diff --git a/view/Link_area.py b/view/Link_area.py index 0723895..62ac025 100644 --- a/view/Link_area.py +++ b/view/Link_area.py @@ -13,7 +13,7 @@ class Link_area( Div ): ( parent_id is None ) and Div( A( u"all notes", - href = u"/notebooks/%s" % notebook.object_id, + href = u"#", id = u"all_notes_link", title = u"View a list of all notes in this notebook.", ), @@ -45,6 +45,16 @@ class Link_area( Div ): ) or None, notebook.read_write and Span( + ( notebook.name != u"trash" ) and Div( + A( + u"rename notebook", + href = u"#", + id = u"rename_notebook_link", + title = u"Change the name of this notebook.", + ), + class_ = u"link_area_item", + ) or None, + notebook.trash_id and Div( A( u"trash", @@ -89,6 +99,15 @@ class Link_area( Div ): ), class_ = u"link_area_item", ) for nb in linked_notebooks ], + Div( + A( + u"add new notebook", + href = u"#", + id = u"add_notebook_link", + title = u"Create a new wiki notebook.", + ), + class_ = u"link_area_item", + ), id = u"notebooks_area" ), diff --git a/view/Main_page.py b/view/Main_page.py index 6070d8b..7520ad7 100644 --- a/view/Main_page.py +++ b/view/Main_page.py @@ -123,9 +123,7 @@ class Main_page( Page ): ), Rounded_div( ( notebook.name == u"trash" ) and u"trash_notebook" or u"current_notebook", - ( len( notes ) > 0 ) and \ - A( Strong( notebook.name ), href = notebook_path ) \ - or Strong( notebook.name ), + ( notebook.name == u"trash" ) and Strong( u"trash" ) or Span( Strong( notebook.name ), id = u"notebook_header_name" ), parent_id and Span( u" | ", A( u"empty trash", href = u"/notebooks/%s" % notebook.object_id, id = u"empty_trash_link" ),