From fe139cc7493e0a834786633c7863bb3e6e8c06b2 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 27 Jun 2008 16:11:09 -0700 Subject: [PATCH] First pass for suggest-as-you-type for linking. --- controller/Notebooks.py | 46 +++++++++ model/Notebook.py | 29 +++++- model/Persistent.py | 10 ++ static/css/style.css | 9 ++ static/js/Wiki.js | 208 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 294 insertions(+), 8 deletions(-) diff --git a/controller/Notebooks.py b/controller/Notebooks.py index 15eade9..7b2dda9 100644 --- a/controller/Notebooks.py +++ b/controller/Notebooks.py @@ -904,6 +904,52 @@ class Notebooks( object ): storage_bytes = user.storage_bytes, ) + @expose( view = Json ) + @strongly_expire + @end_transaction + @grab_user_id + @validate( + notebook_id = Valid_id(), + search_text = unicode, + user_id = Valid_id( none_okay = True ), + ) + def search_titles( self, notebook_id, search_text, user_id ): + """ + Search the note titles within the given notebook for the given search text, and return matching + notes. The search is case-insensitive. The returned notes include title summaries with the + search term highlighted and are ordered by descending revision timestamp. + + @type notebook_id: unicode + @param notebook_id: id of notebook to search + @type search_text: unicode + @param search_text: search term + @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: { 'notes': [ matching notes ] } + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + @raise Search_error: the provided search_text is invalid + """ + if not self.__users.check_access( user_id, notebook_id ): + raise Access_error() + + MAX_SEARCH_TEXT_LENGTH = 256 + if len( search_text ) > MAX_SEARCH_TEXT_LENGTH: + raise Validation_error( u"search_text", None, unicode, message = u"is too long" ) + + if len( search_text ) == 0: + raise Validation_error( u"search_text", None, unicode, message = u"is missing" ) + + notes = self.__database.select_many( Note, Notebook.sql_search_titles( notebook_id, search_text ) ) + + for note in notes: + note.summary = note.summary.replace( search_text, u"%s" % search_text ) + + return dict( + notes = notes, + ) + @expose( view = Json ) @strongly_expire @end_transaction diff --git a/model/Notebook.py b/model/Notebook.py index 6b85003..2c74d12 100644 --- a/model/Notebook.py +++ b/model/Notebook.py @@ -1,7 +1,7 @@ import re from copy import copy from Note import Note -from Persistent import Persistent, quote +from Persistent import Persistent, quote, quote_fuzzy class Notebook( Persistent ): @@ -200,6 +200,33 @@ class Notebook( Persistent ): """ % ( quote( search_text ), quote( user_id ), quote( first_notebook_id ) ) + @staticmethod + def sql_search_titles( notebook_id, search_text ): + """ + Return a SQL string to perform a search for notes within the given notebook whose titles contain + the given search_text. This is a case-insensitive search. + + @type search_text: unicode + @param search_text: text to search for within the notes + """ + # strip out all search operators + search_text = Notebook.SEARCH_OPERATORS.sub( u"", search_text ).strip() + + return \ + """ + select id, revision, title, contents, notebook_id, startup, deleted_from_id, rank, user_id, null, + title as summary + from + note_current + where + notebook_id = %s and + deleted_from_id is null and + title ilike %s + order by + revision desc limit 20; + """ % ( quote( notebook_id ), + quote_fuzzy( search_text ) ) + def sql_highest_note_rank( self ): """ Return a SQL string to determine the highest numbered rank of all notes in this notebook." diff --git a/model/Persistent.py b/model/Persistent.py index ec08d4f..92aeaea 100644 --- a/model/Persistent.py +++ b/model/Persistent.py @@ -85,3 +85,13 @@ def quote( value ): value = unicode( value ) return "'%s'" % value.replace( "'", "''" ).replace( "\\", "\\\\" ) + + +def quote_fuzzy( value ): + if value is None: + return "null" + + value = unicode( value ) + + value = value.replace( "'", "''" ).replace( "\\", "\\\\" ) + return "'%" + value + "%'" diff --git a/static/css/style.css b/static/css/style.css index 5238901..6fc1ba3 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -514,6 +514,7 @@ h1 { .pulldown { position: absolute; + font-size: 72%; text-align: left; max-height: 20em; overflow: auto; @@ -532,6 +533,14 @@ h1 { text-decoration: none; } +.suggestion { + padding: 0.25em 0.5em 0.25em 0.5em; +} + +.selected_suggestion { + background-color: #d0e0f0; +} + .pulldown_label { color: #000000; } diff --git a/static/js/Wiki.js b/static/js/Wiki.js index 86eff5f..ca6f401 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -445,7 +445,7 @@ Wiki.prototype.load_editor = function ( note_title, note_id, revision, previous_ if ( link ) { var pulldown = link.pulldown; var pulldown_title = undefined; - if ( pulldown ) { + if ( pulldown && pulldown.title_field ) { pulldown_title = strip( pulldown.title_field.value ); if ( pulldown_title ) note_title = pulldown_title; @@ -823,16 +823,44 @@ Wiki.prototype.display_link_pulldown = function ( editor, link, ephemeral ) { if ( !link ) link = editor.find_link_at_cursor(); - // if there's no link at the current cursor location, or there is a link but it was just started, - // bail - if ( !link || link == editor.link_started ) { + // if there's no link at the current cursor location, bail + if ( !link ) { this.clear_pulldowns(); return; } var pulldown = link.pulldown; - if ( pulldown ) - pulldown.update_position(); + var query = parse_query( link ); + var title = link_title( link, query ); + + // display a Suggest_pulldown once something is typed for the link title, as long as the link + // doesn't yet have a note_id + if ( !pulldown && title.length > 0 && query.note_id == "new" ) { + this.clear_pulldowns(); + var self = this; + var suggest_pulldown = new Suggest_pulldown( this, this.notebook_id, this.invoker, editor, link, title, editor.document ); + connect( suggest_pulldown, "suggestion_selected", function ( note ) { + self.update_link_with_suggestion( editor, link, note ) + } ); + return; + } + + // if there is a link but it was just started, bail + if ( link == editor.link_started && !pulldown ) { + this.clear_pulldowns(); + return; + } + + if ( pulldown ) { + // if a Suggest_pulldown is open for the link, update the pulldown and bail + if ( pulldown.update_suggestions ) { + pulldown.update_suggestions( title ); + return; + // otherwise, just update the pulldown's position + } else { + pulldown.update_position(); + } + } var link_contains_image = getElementsByTagAndClassName( "img", null, link ); @@ -853,6 +881,26 @@ Wiki.prototype.display_link_pulldown = function ( editor, link, ephemeral ) { } } +Wiki.prototype.update_link_with_suggestion = function ( editor, link, note ) { + link.innerHTML = note.title; + + // manually position the text cursor at the end of the link title + if ( editor.iframe.contentWindow && editor.iframe.contentWindow.getSelection ) { // browsers such as Firefox + var selection = editor.iframe.contentWindow.getSelection(); + selection.selectAllChildren( link ); + selection.collapseToEnd(); + } + + link.href = "/notebooks/" + this.notebook_id + "?note_id=" + note.object_id; + + link.pulldown.shutdown(); + link.pulldown = null; + editor.focus(); + editor.end_link(); + + this.display_link_pulldown( editor, link ); +} + Wiki.prototype.editor_focused = function ( editor, synchronous ) { if ( editor ) addElementClass( editor.iframe, "focused_note_frame" ); @@ -1181,6 +1229,7 @@ Wiki.prototype.toggle_attach_button = function ( event ) { if ( existing_div ) { existing_div.pulldown.shutdown(); + existing_div.pulldown = null; return; } @@ -1477,7 +1526,6 @@ Wiki.prototype.submit_form = function ( form ) { } else if ( url == "/users/signup_group_member" ) { callback = function ( result ) { var group_id = getFirstElementByTagAndClassName( "input", "group_id", form ).value; - console.log( form, group_id ); self.invoker.invoke( "/groups/load_users", "GET", { "group_id": group_id }, function ( result ) { @@ -2189,6 +2237,7 @@ Wiki.prototype.clear_pulldowns = function ( ephemeral_only ) { continue; result.pulldown.shutdown(); + result.pulldown = null; } } } @@ -2443,6 +2492,7 @@ Wiki.prototype.toggle_editor_changes = function ( event, editor ) { var existing_div = getElement( pulldown_id ); if ( existing_div ) { existing_div.pulldown.shutdown(); + existing_div.pulldown = null; return; } @@ -2475,6 +2525,7 @@ Wiki.prototype.toggle_editor_options = function ( event, editor ) { var existing_div = getElement( pulldown_id ); if ( existing_div ) { existing_div.pulldown.shutdown(); + existing_div.pulldown = null; return; } @@ -3306,6 +3357,149 @@ File_link_pulldown.prototype.shutdown = function () { disconnectAll( this.right_justify_radio ); } + +function Suggest_pulldown( wiki, notebook_id, invoker, editor, anchor, search_text, key_press_node ) { + anchor.pulldown = this; + this.anchor = anchor; + this.previous_search_text = ""; + + Pulldown.call( this, wiki, notebook_id, "suggest_" + editor.id, anchor, editor.iframe ); + + this.invoker = invoker; + this.editor = editor; + this.update_suggestions( search_text ); + + var self = this; + this.key_handler = connect( key_press_node, "onkeydown", function ( event ) { self.key_pressed( event ); } ); + + Pulldown.prototype.update_position.call( this ); +} + +Suggest_pulldown.prototype = new function () { this.prototype = Pulldown.prototype; }; +Suggest_pulldown.prototype.constructor = Suggest_pulldown; + +Suggest_pulldown.prototype.update_suggestions = function ( search_text ) { + // if the search text hasn't changed since last time, bail + if ( this.previous_search_text == search_text ) + return; + + // if there is no search text, hide the pulldown and bail + if ( !search_text ) { + addElementClass( this.div, "invisible" ); + return; + } + + var self = this; + this.previous_search_text = search_text; + + this.invoker.invoke( "/notebooks/search_titles", "GET", { + "notebook_id": this.notebook_id, + "search_text": search_text + }, + function( result ) { self.display_suggestions( result, search_text ); } + ); +} + +Suggest_pulldown.prototype.display_suggestions = function ( result, search_text ) { + if ( result.notes.length == 0 ) { + addElementClass( this.div, "invisible" ); + return; + } + + removeElementClass( this.div, "invisible" ); + var results_list = createDOM( "div" ); + var self = this; + + function connect_link( suggest_link, note ) { + connect( suggest_link, "onclick", function ( event ) { self.suggestion_selected( event, note ); } ); + } + + for ( var i in result.notes ) { + var note = result.notes[ i ]; + if ( !note.title ) continue; + + var suggest_link = createDOM( "a", { "href": "#", "class": "pulldown_link" } ); + suggest_link.innerHTML = note.summary; + suggest_link.note = note; + + appendChildNodes( results_list, createDOM( "div", { "class": "suggestion" }, suggest_link ) ); + connect_link( suggest_link, note ); + } + + replaceChildNodes( this.div, results_list ); +} + +Suggest_pulldown.prototype.suggestion_selected = function ( event, note ) { + event.stop(); + + signal( this, "suggestion_selected", note ); +} + +Suggest_pulldown.prototype.key_pressed = function ( event ) { + // an invisible Suggest_pulldown shouldn't grab keypresses + if ( hasElementClass( this.div, "invisible" ) ) + return; + + var code = event.key().code; + + // up arrow: move up to the previous suggestion + if ( code == 38 ) { + var selected = getFirstElementByTagAndClassName( "div", "selected_suggestion", this.div ); + + // if something is selected and there's a previous suggestion in the list, move the selection up + if ( selected && selected.previousSibling ) { + removeElementClass( selected, "selected_suggestion" ); + addElementClass( selected.previousSibling, "selected_suggestion" ); + // otherwise, hide the Suggest_pulldown + } else { + addElementClass( this.div, "invisible" ); + } + // down arrow: move down to the previous suggestion + } else if ( code == 40 ) { + var selected = getFirstElementByTagAndClassName( "div", "selected_suggestion", this.div ); + + // if something is selected and there's a next suggestion in the list, move the selection down + if ( selected ) { + if ( selected.nextSibling ) { + removeElementClass( selected, "selected_suggestion" ); + addElementClass( selected.nextSibling, "selected_suggestion" ); + } + // if nothing is selected yet, then just select the first link + } else { + var suggest_link = getFirstElementByTagAndClassName( "a", "pulldown_link", this.div ); + addElementClass( suggest_link.parentNode, "selected_suggestion" ); + } + // enter: select current suggestion + } else if ( code == 13 ) { + var selected = getFirstElementByTagAndClassName( "div", "selected_suggestion", this.div ); + var suggest_link = getFirstElementByTagAndClassName( "a", "pulldown_link", selected ); + + if ( selected ) + this.suggestion_selected( event, suggest_link.note ); + // escape: hide the suggestions + } else if ( code == 27 ) { + addElementClass( this.div, "invisible" ); + // otherwise, not a key this method handles + } else { + return; + } + + event.stop(); +} + +Suggest_pulldown.prototype.update_position = function ( anchor, relative_to ) { + Pulldown.prototype.update_position.call( this, anchor, relative_to ); +} + +Suggest_pulldown.prototype.shutdown = function () { + Pulldown.prototype.shutdown.call( this ); + + this.anchor.pulldown = null; + disconnectAll( this ); + disconnect( this.key_handler ); +} + + function Note_tree( wiki, notebook_id, invoker ) { this.wiki = wiki; this.notebook_id = notebook_id;