diff --git a/controller/Notebooks.py b/controller/Notebooks.py index 0230d08..c3cb468 100644 --- a/controller/Notebooks.py +++ b/controller/Notebooks.py @@ -217,6 +217,48 @@ class Notebooks( object ): note = note, ) + @expose( view = Json ) + @strongly_expire + @wait_for_update + @grab_user_id + @async + @update_client + @validate( + notebook_id = Valid_id(), + note_title = Valid_string( min = 1, max = 500 ), + user_id = Valid_id( none_okay = True ), + ) + def lookup_note_id( self, notebook_id, note_title, user_id ): + """ + Return a note's id by looking up its title. + + @type notebook_id: unicode + @param notebook_id: id of notebook the note is in + @type note_title: unicode + @param note_title: title of the note id to return + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any), determined by @grab_user_id + @rtype: json dict + @return: { 'note_id': noteid or None } + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + """ + self.check_access( notebook_id, user_id, self.__scheduler.thread ) + if not ( yield Scheduler.SLEEP ): + raise Access_error() + + self.__database.load( notebook_id, self.__scheduler.thread ) + notebook = ( yield Scheduler.SLEEP ) + + if notebook is None: + note = None + else: + note = notebook.lookup_note_by_title( note_title ) + + yield dict( + note_id = note and note.object_id or None, + ) + @expose( view = Json ) @wait_for_update @grab_user_id diff --git a/controller/test/Test_notebooks.py b/controller/test/Test_notebooks.py index 162cd0c..d15f83a 100644 --- a/controller/test/Test_notebooks.py +++ b/controller/test/Test_notebooks.py @@ -261,6 +261,44 @@ class Test_notebooks( Test_controller ): note = result[ "note" ] assert note == None + def test_lookup_note_id( self ): + self.login() + + result = self.http_post( "/notebooks/lookup_note_id/", dict( + notebook_id = self.notebook.object_id, + note_title = self.note.title, + ), session_id = self.session_id ) + + assert result.get( "note_id" ) == self.note.object_id + + def test_lookup_note_id_without_login( self ): + result = self.http_post( "/notebooks/lookup_note_id/", dict( + notebook_id = self.notebook.object_id, + note_title = self.note.title, + ), session_id = self.session_id ) + + assert result.get( "error" ) + + def test_lookup_note_id_with_unknown_notebook( self ): + self.login() + + result = self.http_post( "/notebooks/lookup_note_id/", dict( + notebook_id = self.unknown_notebook_id, + note_title = self.note.title, + ), session_id = self.session_id ) + + assert result.get( "error" ) + + def test_lookup_unknown_note_id( self ): + self.login() + + result = self.http_post( "/notebooks/lookup_note_id/", dict( + notebook_id = self.notebook.object_id, + note_title = "unknown title", + ), session_id = self.session_id ) + + assert result.get( "note_id" ) == None + def test_save_note( self, startup = False ): self.login() diff --git a/static/css/note.css b/static/css/note.css index d6eb167..1e9642f 100644 --- a/static/css/note.css +++ b/static/css/note.css @@ -1,5 +1,6 @@ body { padding: 1em; + font-size: 100%; line-height: 140%; } diff --git a/static/css/style.css b/static/css/style.css index 7e014cc..8e425fd 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -300,3 +300,15 @@ ol li { margin-left: 2em; margin-right: 2em; } + +.field_label { + font-weight: bold; +} + +.text_field { + margin-right: 0.5em; + padding: 0.25em; + border: #999999 1px solid; + -moz-border-radius: 0.5em; + -webkit-border-radius: 0.5em; +} diff --git a/static/js/Editor.js b/static/js/Editor.js index 31519aa..09a10d6 100644 --- a/static/js/Editor.js +++ b/static/js/Editor.js @@ -179,7 +179,6 @@ Editor.prototype.finish_init = function () { this.scrape_title(); if ( this.init_focus ) this.focus(); - signal( this, "state_changed", this ); } Editor.prototype.highlight = function ( scroll ) { @@ -247,6 +246,24 @@ Editor.prototype.resize = function () { setElementDimensions( this.iframe, dimensions ); } +Editor.prototype.resolve_link = function ( link ) { + // in case the link is to ourself, first grab the most recent version of our title + this.scrape_title(); + + var id; + var link_title = scrapeText( link ); + var editor = note_titles[ link_title ]; + // if the link's title corresponds to an open note id, set that as the link's destination + if ( editor ) { + id = editor.id; + link.href = "/notebooks/" + this.notebook_id + "?note_id=" + id; + // otherwise, resolve the link by looking up the link's title on the server + } else { + signal( this, "resolve_link", link_title, link ); + return; + } +} + Editor.prototype.key_pressed = function ( event ) { signal( this, "key_pressed", this, event ); @@ -287,34 +304,17 @@ Editor.prototype.mouse_clicked = function ( event ) { event.stop(); - // in case the link is to ourself, first grab the most recent version of our title - this.scrape_title(); - - var id; - var link_title = scrapeText( link ); - var editor = note_titles[ link_title ]; - var href_leaf = link.href.split( "?note_id=" ).pop(); - // if the link's title corresponds to an open note id, set that as the link's destination - if ( editor ) { - id = editor.id; - link.href = "/notebooks/" + this.notebook_id + "?note_id=" + id; - // if this is a new link, get a new note id and set it for the link's destination - } else if ( href_leaf == "new" ) { - signal( this, "load_editor_by_title", link_title, this.iframe.id ); - return; - // otherwise, use the id from link's current destination - } else { - // the last part of the current link's href is the note id - id = href_leaf; - } - - // find the note corresponding to the linked id, or create a new note + // if the note corresponding to the linked id is already open, highlight it + var query = parse_query( link ); + var link_title = query.title || scrapeText( link ); + var id = query.note_id; var iframe = getElement( "note_" + id ); if ( iframe ) { iframe.editor.highlight(); return; } + // otherwise, load the note for that id signal( this, "load_editor", link_title, this.iframe.id, id ); } @@ -385,24 +385,22 @@ Editor.prototype.start_link = function () { this.exec_command( "createLink", "/notebooks/" + this.notebook_id + "?note_id=new" ); - var links = getElementsByTagAndClassName( "a", null, parent = this.document ); - for ( var i in links ) { - var link = links[ i ]; - var link_title = scrapeText( link ); - var char_code = link_title.charCodeAt( 0 ); - // look for links titled with a space or nbsp character - if ( link_title.length == 1 && char_code == 0x20 || char_code == 0xa0 ) { - for ( var j in link.childNodes ) { - var child = link.childNodes[ j ]; - if ( child.nodeType == 3 ) // type of text node - child.nodeValue = ""; - } - selection.collapse( link, 0 ); + // nuke the link title and collapse the selection, yielding a tasty new link that's completely + // titleless and unselected + var link = this.find_link_at_cursor(); + if ( link ) { + for ( var j in link.childNodes ) { + var child = link.childNodes[ j ]; + if ( child.nodeType == 3 ) // type of text node + child.nodeValue = ""; } + selection.collapse( link, 0 ); } // otherwise, just create a link with the selected text as the link title } else { this.exec_command( "createLink", "/notebooks/" + this.notebook_id + "?note_id=new" ); + var link = this.find_link_at_cursor(); + signal( this, "resolve_link", scrapeText( link ), link ); } } else if ( this.document.selection ) { // browsers such as IE var range = this.document.selection.createRange(); @@ -413,13 +411,18 @@ Editor.prototype.start_link = function () { range.text = " "; range.moveStart( "character", -1 ); range.select(); + this.exec_command( "createLink", "/notebooks/" + this.notebook_id + "?note_id=new" ); + } else { + this.exec_command( "createLink", "/notebooks/" + this.notebook_id + "?note_id=new" ); + var link = this.find_link_at_cursor(); + signal( this, "resolve_link", scrapeText( link ), link ); } - - this.exec_command( "createLink", "/notebooks/" + this.notebook_id + "?note_id=new" ); } } Editor.prototype.end_link = function () { + var link = this.find_link_at_cursor(); + if ( this.iframe.contentWindow && this.iframe.contentWindow.getSelection ) { // browsers such as Firefox this.exec_command( "unlink" ); } else if ( this.document.selection ) { // browsers such as IE @@ -439,6 +442,49 @@ Editor.prototype.end_link = function () { range.select(); range.pasteHTML( "" ); } + + var query = parse_query( link ); + var link_title = query.title || scrapeText( link ); + signal( this, "resolve_link", link_title, link ); +} + +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(); + var link = selection.anchorNode; + + while ( link.nodeName != "A" ) { + link = link.parentNode; + if ( !link ) + break; + } + + if ( link ) return link; + + // well, that didn't work, so try the selection's focus node instead + link = selection.focusNode; + + while ( link.nodeName != "A" ) { + link = link.parentNode; + if ( !link ) + return null; + } + + return link; + } else if ( this.document.selection ) { // browsers such as IE + var range = this.document.selection.createRange(); + var link = range.parentElement(); + + while ( link.nodeName != "A" ) { + link = link.parentNode; + if ( !link ) + return null; + } + + return link; + } + + return null; } Editor.prototype.focus = function () { @@ -514,3 +560,9 @@ Editor.prototype.shutdown = function( event ) { } catch ( e ) { } } } ); } + +// convenience function for parsing a link that has an href URL containing a query string +function parse_query( link ) { + return parseQueryString( link.href.split( "?" ).pop() ); +} + diff --git a/static/js/Wiki.js b/static/js/Wiki.js index ea1791e..2451cf0 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -6,6 +6,7 @@ function Wiki() { this.notebook_id = getElement( "notebook_id" ).value; this.read_write = false; this.startup_notes = new Array(); // map of startup notes: note id to bool + this.link_pulldowns = new Array(); // map of link pulldowns: link object to pulldown this.invoker = new Invoker(); connect( this.invoker, "error_message", this, "display_error" ); @@ -176,6 +177,19 @@ Wiki.prototype.create_blank_editor = function ( event ) { Wiki.prototype.load_editor = function ( note_title, from_iframe_id, note_id, revision ) { var self = this; + // if there's not a valid destination note id, then load by title instead of by id + if ( note_id == "new" || note_id == "null" ) { + this.invoker.invoke( + "/notebooks/load_note_by_title", "GET", { + "notebook_id": this.notebook_id, + "note_title": note_title, + "revision": revision + }, + function ( result ) { self.parse_loaded_editor( result, from_iframe_id, note_title, revision ); } + ); + return; + } + this.invoker.invoke( "/notebooks/load_note", "GET", { "notebook_id": this.notebook_id, @@ -186,15 +200,27 @@ Wiki.prototype.load_editor = function ( note_title, from_iframe_id, note_id, rev ); } -Wiki.prototype.load_editor_by_title = function ( note_title, from_iframe_id ) { - var self = this; +Wiki.prototype.resolve_link = function ( note_title, link, force ) { + // if the link already has an id and the force flag isn't set, then the link is already resolved, + // so we can just bail + if ( link.href ) { + var id = parse_query( link ).note_id; + if ( id != "new" && id != "null" && !force ) + return; + } + if ( note_title.length == 0 ) + return; + + var self = this; this.invoker.invoke( - "/notebooks/load_note_by_title", "GET", { + "/notebooks/lookup_note_id", "GET", { "notebook_id": this.notebook_id, "note_title": note_title }, - function ( result ) { self.parse_loaded_editor( result, from_iframe_id, note_title ); } + function ( result ) { + link.href = "/notebooks/" + self.notebook_id + "?note_id=" + result.note_id; + } ); } @@ -222,7 +248,6 @@ Wiki.prototype.parse_loaded_editor = function ( result, from_iframe_id, note_tit Wiki.prototype.create_editor = function ( id, note_text, deleted_from, revisions_list, from_iframe_id, note_title, read_write, highlight, focus ) { this.clear_messages(); - this.clear_pulldowns(); var self = this; if ( isUndefinedOrNull( id ) ) { @@ -266,7 +291,7 @@ Wiki.prototype.create_editor = function ( id, note_text, deleted_from, revisions } connect( editor, "load_editor", this, "load_editor" ); - connect( editor, "load_editor_by_title", this, "load_editor_by_title" ); + connect( editor, "resolve_link", this, "resolve_link" ); connect( editor, "hide_clicked", function ( event ) { self.hide_editor( event, editor ) } ); connect( editor, "submit_form", function ( url, form ) { self.invoker.invoke( url, "POST", null, null, form ); @@ -277,16 +302,38 @@ Wiki.prototype.create_editor = function ( id, note_text, deleted_from, revisions Wiki.prototype.editor_state_changed = function ( editor ) { this.update_toolbar(); + this.display_link_pulldown( editor ); +} + +Wiki.prototype.display_link_pulldown = function ( editor ) { + var link = editor.find_link_at_cursor(); + + if ( !link ) { + this.clear_pulldowns(); + return; + } + + var pulldown = this.link_pulldowns[ link ]; + if ( pulldown ) + pulldown.update_position(); + + // if the cursor is now on a link, display a link pulldown if there isn't already one open + if ( hasElementClass( "createLink", "button_down" ) ) { + if ( !pulldown ) { + this.clear_pulldowns(); + new Link_pulldown( this, this.notebook_id, this.invoker, editor, link ); + } + } } Wiki.prototype.editor_focused = function ( editor, fire_and_forget ) { this.clear_messages(); - this.clear_pulldowns(); if ( editor ) addElementClass( editor.iframe, "focused_note_frame" ); if ( this.focused_editor && this.focused_editor != editor ) { + this.clear_pulldowns(); removeElementClass( this.focused_editor.iframe, "focused_note_frame" ); // if the formerly focused editor is completely empty, then remove it as the user leaves it and switches to this editor @@ -388,6 +435,8 @@ Wiki.prototype.toggle_link_button = function ( event ) { this.focused_editor.start_link(); else this.focused_editor.end_link(); + + this.display_link_pulldown( this.focused_editor ); } event.stop(); @@ -666,26 +715,50 @@ Wiki.prototype.toggle_editor_options = function ( event, editor ) { connect( window, "onload", function ( event ) { new Wiki(); } ); -function Pulldown( wiki, notebook_id, pulldown_id, button ) { +function Pulldown( wiki, notebook_id, pulldown_id, anchor, relative_to ) { this.wiki = wiki; this.notebook_id = notebook_id; this.div = createDOM( "div", { "id": pulldown_id, "class": "pulldown" } ); this.div.pulldown = this; + this.anchor = anchor; + this.relative_to = relative_to; + addElementClass( this.div, "invisible" ); appendChildNodes( document.body, this.div ); - - var self = this; - - // position the pulldown under the button that opened it - var position = getElementPosition( button ); - var button_dimensions = getElementDimensions( button ); - var div_dimensions = getElementDimensions( this.div ); - position.y += button_dimensions.h; + var position = calculate_position( anchor, relative_to ); setElementPosition( this.div, position ); removeElementClass( this.div, "invisible" ); -} +} + +function calculate_position( anchor, relative_to ) { + // position the pulldown under the anchor + var position = getElementPosition( anchor ); + + if ( relative_to ) { + var relative_pos = getElementPosition( relative_to ); + if ( relative_pos ) { + position.x += relative_pos.x; + position.y += relative_pos.y; + } + } + + var anchor_dimensions = getElementDimensions( anchor ); + + // if the anchor has no height, move the position down a bit an arbitrary amount + if ( anchor_dimensions.h == 0 ) + position.y += 8; + else + position.y += anchor_dimensions.h + 4; + + return position; +} + +Pulldown.prototype.update_position = function () { + var position = calculate_position( this.anchor, this.relative_to ); + setElementPosition( this.div, position ); +} Pulldown.prototype.shutdown = function () { removeElement( this.div ); @@ -773,9 +846,132 @@ Changes_pulldown.prototype.link_clicked = function( event, note_id ) { event.stop(); } -Options_pulldown.prototype.shutdown = function () { +Changes_pulldown.prototype.shutdown = function () { Pulldown.prototype.shutdown.call( this ); for ( var i in this.links ) disconnectAll( this.links[ i ] ); } + + +function Link_pulldown( wiki, notebook_id, invoker, editor, link ) { + wiki.link_pulldowns[ link ] = this; + this.link = link; + + Pulldown.call( this, wiki, notebook_id, "link_" + editor.id, link, editor.iframe ); + + this.invoker = invoker; + this.editor = editor; + this.title_field = createDOM( "input", { "class": "text_field", "size": "25", "maxlength": "256" } ); + this.note_preview = createDOM( "span", {} ); + this.previous_title = ""; + + var self = this; + connect( this.title_field, "onclick", function ( event ) { self.title_field_clicked( event ); } ); + connect( this.title_field, "onchange", function ( event ) { self.title_field_changed( event ); } ); + connect( this.title_field, "onblur", function ( event ) { self.title_field_changed( event ); } ); + connect( this.title_field, "onkeydown", function ( event ) { self.title_field_key_pressed( event ); } ); + + appendChildNodes( this.div, createDOM( "span", { "class": "field_label" }, "links to: " ) ); + appendChildNodes( this.div, this.title_field ); + appendChildNodes( this.div, this.note_preview ); + + var query = parse_query( link ); + var link_title = query.title || scrapeText( link ); + var id = query.note_id; + if ( id == "new" || id == "null" ) { + this.title_field.value = link_title; + replaceChildNodes( self.note_preview, "empty note" ); + return; + } + + // if this link has an actual destination note id set, then load that note, displaying its title + // and a preview of its contents + this.invoker.invoke( + "/notebooks/load_note", "GET", { + "notebook_id": this.notebook_id, + "note_id": id + }, + function ( result ) { + if ( result.note ) { + self.title_field.value = result.note.title; + self.display_preview( result.note.title, result.note.contents ); + } else { + self.title_field.value = link_title; + replaceChildNodes( self.note_preview, "empty note" ); + } + } + ); +} + +Link_pulldown.prototype = Pulldown; +Link_pulldown.prototype.constructor = Link_pulldown; + +Link_pulldown.prototype.display_preview = function ( title, contents ) { + var contents_node = createDOM( "span", {} ); + contents_node.innerHTML = contents; + var contents = scrapeText( contents_node ); + + // remove the title from the scraped contents text + if ( contents.indexOf( title ) == 0 ) + contents = contents.substr( title.length ); + + if ( contents.length == 0 ) { + replaceChildNodes( this.note_preview, "empty note" ); + } else { + var max_preview_length = 40; + var preview = contents.substr( 0, max_preview_length ) + ( ( contents.length > max_preview_length ) ? "..." : "" ); + replaceChildNodes( this.note_preview, preview ); + } +} + +Link_pulldown.prototype.title_field_clicked = function ( event ) { + event.stop(); +} + +Link_pulldown.prototype.title_field_changed = function ( event ) { + // if the title is actually unchanged, then bail + if ( this.title_field.value == this.previous_title ) + return; + + var self = this; + replaceChildNodes( this.note_preview, "" ); + this.previous_title = this.title_field.value; + + this.invoker.invoke( + "/notebooks/load_note_by_title", "GET", { + "notebook_id": this.notebook_id, + "note_title": this.title_field.value + }, + function ( result ) { + if ( result.note ) { + self.link.href = "/notebooks/" + self.notebook_id + "?note_id=" + result.note.object_id; + self.display_preview( result.note.title, result.note.contents ); + } else { + self.link.href = "/notebooks/" + self.notebook_id + "?title=" + self.title_field.value + "¬e_id=null"; + replaceChildNodes( self.note_preview, "empty note" ); + } + } + ); +} + +Link_pulldown.prototype.title_field_key_pressed = function ( event ) { + // if enter is pressed, consider the title field altered. this is necessary because IE neglects + // to issue an onchange event when enter is pressed in an input field + if ( event.key().code == 13 ) { + this.title_field_changed(); + event.stop(); + } +} + +Link_pulldown.prototype.update_position = function ( anchor, relative_to ) { + Pulldown.prototype.update_position.call( this, anchor, relative_to ); +} + +Link_pulldown.prototype.shutdown = function () { + Pulldown.prototype.shutdown.call( this ); + + disconnectAll( this.title_field ); + if ( this.link ) + delete this.wiki.link_pulldowns[ this.link ]; +}