Replaced autocompleting username with a much more efficient algorithm and caching system
authorDan
Fri, 12 Oct 2007 14:41:51 -0400
changeset 184 d74ff822acc9
parent 183 91127e62f38f
child 186 b796208d050d
Replaced autocompleting username with a much more efficient algorithm and caching system
ajax.php
includes/clientside/static/acl.js
includes/clientside/static/autocomplete.js
includes/clientside/static/autofill.js
includes/clientside/static/enano-lib-basic.js
includes/functions.php
includes/template.php
--- a/ajax.php	Tue Oct 09 16:14:55 2007 -0400
+++ b/ajax.php	Fri Oct 12 14:41:51 2007 -0400
@@ -33,35 +33,50 @@
     define('ENANO_ROOT', dirname($filename));
     require(ENANO_ROOT.'/includes/functions.php');
     require(ENANO_ROOT.'/includes/dbal.php');
+    require(ENANO_ROOT.'/includes/json.php');
     $db = new mysql();
     $db->connect();
     
-    // should be connected now
+    // result is sent using JSON
+    $json = new Services_JSON(SERVICES_JSON_LOOSE_TYPE);
+    $return = Array(
+        'mode' => 'success',
+        'users_real' => Array()
+      );
+    
+    // should be connected to the DB now
     $name = (isset($_GET['name'])) ? $db->escape($_GET['name']) : false;
     if ( !$name )
     {
-      die('userlist = new Array(); errorstring=\'Invalid URI\'');
+      $return = array(
+        'mode' => 'error',
+        'error' => 'Invalid URI'
+      );
+      die( $json->encode($return) );
     }
-    $q = $db->sql_query('SELECT username,user_id FROM '.table_prefix.'users WHERE lcase(username) LIKE lcase(\'%'.$name.'%\');');
+    $allowanon = ( isset($_GET['allowanon']) && $_GET['allowanon'] == '1' ) ? '' : ' AND user_id > 1';
+    $q = $db->sql_query('SELECT username FROM '.table_prefix.'users WHERE lcase(username) LIKE lcase(\'%'.$name.'%\')' . $allowanon . ' ORDER BY username ASC;');
     if ( !$q )
     {
-      die('userlist = new Array(); errorstring=\'MySQL error selecting username data: '.addslashes(mysql_error()).'\'');
+      $return = array(
+        'mode' => 'error',
+        'error' => 'MySQL error selecting username data: '.addslashes(mysql_error())
+      );
+      die( $json->encode($return) );
     }
-    if($db->numrows() < 1)
-    {
-      die('userlist = new Array(); errorstring=\'No usernames found\';');
-    }
-    echo 'var errorstring = false; userlist = new Array();';
     $i = 0;
     while($r = $db->fetchrow())
     {
-      echo "userlist[$i] = '".addslashes($r['username'])."'; ";
+      $return['users_real'][] = $r['username'];
       $i++;
     }
     $db->free_result();
     
     // all done! :-)
     $db->close();
+    
+    echo $json->encode( $return );
+    
     exit;
   }
  
--- a/includes/clientside/static/acl.js	Tue Oct 09 16:14:55 2007 -0400
+++ b/includes/clientside/static/acl.js	Fri Oct 12 14:41:51 2007 -0400
@@ -128,7 +128,7 @@
   usrsel = document.createElement('input');
   usrsel.type = 'text';
   usrsel.name = 'username';
-  usrsel.onkeyup = function() { ajaxUserNameComplete(this); };
+  usrsel.onkeyup = function() { new AutofillUsername(this, undefined, true); };
   usrsel.id = 'userfield_' + aclManagerID;
   try {
     usrsel.setAttribute("autocomplete","off");
--- a/includes/clientside/static/autocomplete.js	Tue Oct 09 16:14:55 2007 -0400
+++ b/includes/clientside/static/autocomplete.js	Fri Oct 12 14:41:51 2007 -0400
@@ -160,7 +160,24 @@
         thediv.id = id;
         unObj.onblur = function() { destroyUsernameDropdowns(); }
         
-        eval(ajax.responseText);
+        var response = String(ajax.responseText) + ' ';
+        if ( response.substr(0,1) != '{' )
+        {
+          new messagebox(MB_OK|MB_ICONSTOP, 'Invalid response', 'Invalid or unexpected JSON response from server:<pre>' + ajax.responseText + '</pre>');
+          return false;
+        }
+        
+        response = parseJSON(response);
+        var errorstring = false;
+        if ( response.mode == 'error' )
+        {
+          errorstring = response.error;
+        }
+        else
+        {
+          var userlist = response.users_real;
+        }
+        
         if(errorstring)
         {
           html = '<span style="color: #555; padding: 4px;">'+errorstring+'</span>';
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/includes/clientside/static/autofill.js	Fri Oct 12 14:41:51 2007 -0400
@@ -0,0 +1,512 @@
+/**
+ * Javascript auto-completion for form fields.
+ */
+ 
+var af_current = false;
+ 
+function AutofillUsername(parent, event, allowanon)
+{
+  // if this is IE, use the old code
+  if ( IE )
+  {
+    ajaxUserNameComplete(parent);
+    return false;
+  }
+  if ( parent.afobj )
+  {
+    parent.afobj.go();
+    return true;
+  }
+  
+  parent.autocomplete = 'off';
+  parent.setAttribute('autocomplete', 'off');
+  
+  this.repeat = false;
+  this.event = event;
+  this.box_id = false;
+  this.boxes = new Array();
+  this.state = false;
+  this.allowanon = ( allowanon ) ? true : false;
+  
+  if ( !parent.id )
+    parent.id = 'afuser_' + Math.floor(Math.random() * 1000000);
+  
+  this.field_id = parent.id;
+  
+  // constants
+  this.KEY_UP    = 38;
+  this.KEY_DOWN  = 40;
+  this.KEY_ESC   = 27;
+  this.KEY_TAB   = 9;
+  this.KEY_ENTER = 13;
+  
+  // response cache
+  this.responses = new Object();
+  
+  // ajax placeholder
+  this.process_dataset = function(resp_json)
+  {
+    // window.console.info('Processing the following dataset.');
+    // window.console.debug(resp_json);
+    var autofill = this;
+    
+    if ( typeof(autofill.event) == 'object' )
+    {
+      if ( autofill.event.keyCode )
+      {
+        if ( autofill.event.keyCode == autofill.KEY_ENTER && autofill.boxes.length < 1 && !autofill.box_id )
+        {
+          // user hit enter after accepting a suggestion - submit the form
+          var frm = findParentForm($(autofill.field_id).object);
+          frm._af_acting = false;
+          frm.submit();
+          // window.console.info('Submitting form');
+          return false;
+        }
+        if ( autofill.event.keyCode == autofill.KEY_UP || autofill.event.keyCode == autofill.KEY_DOWN || autofill.event.keyCode == autofill.KEY_ESC || autofill.event.keyCode == autofill.KEY_TAB || autofill.event.keyCode == autofill.KEY_ENTER )
+        {
+          autofill.keyhandler();
+          // window.console.info('Control key detected, called keyhandler and exiting');
+          return true;
+        }
+      }
+    }
+    
+    if ( this.box_id )
+    {
+      this.destroy();
+      // window.console.info('already have a box open - destroying and exiting');
+      //return false;
+    }
+    
+    var users = new Array();
+    for ( var i = 0; i < resp_json.users_real.length; i++ )
+    {
+      try
+      {
+        var user = resp_json.users_real[i].toLowerCase();
+        var inp  = $(autofill.field_id).object.value;
+        inp = inp.toLowerCase();
+        if ( user.indexOf(inp) > -1 )
+        {
+          users.push(resp_json.users_real[i]);
+        }
+      }
+      catch(e)
+      {
+        users.push(resp_json.users_real[i]);
+      }
+    }
+
+    // This was used ONLY for debugging the DOM and list logic    
+    // resp_json.users = resp_json.users_real;
+    
+    // construct table
+    var div = document.createElement('div');
+    div.className = 'tblholder';
+    div.style.clip = 'rect(0px,auto,auto,0px)';
+    div.style.maxHeight = '200px';
+    div.style.overflow = 'auto';
+    div.style.zIndex = '9999';
+    var table = document.createElement('table');
+    table.border = '0';
+    table.cellSpacing = '1';
+    table.cellPadding = '3';
+    
+    var tr = document.createElement('tr');
+    var th = document.createElement('th');
+    th.appendChild(document.createTextNode('Username suggestions'));
+    tr.appendChild(th);
+    table.appendChild(tr);
+    
+    if ( users.length < 1 )
+    {
+      var tr = document.createElement('tr');
+      var td = document.createElement('td');
+      td.className = 'row1';
+      td.appendChild(document.createTextNode('No suggestions'));
+      td.afobj = autofill;
+      tr.appendChild(td);
+      table.appendChild(tr);
+    }
+    else
+      
+      for ( var i = 0; i < users.length; i++ )
+      {
+        var user = users[i];
+        var tr = document.createElement('tr');
+        var td = document.createElement('td');
+        td.className = ( i == 0 ) ? 'row2' : 'row1';
+        td.appendChild(document.createTextNode(user));
+        td.afobj = autofill;
+        td.style.cursor = 'pointer';
+        td.onclick = function()
+        {
+          this.afobj.set(this.firstChild.nodeValue);
+        }
+        tr.appendChild(td);
+        table.appendChild(tr);
+      }
+      
+    // Finalize div
+    var tb_top    = $(autofill.field_id).Top();
+    var tb_height = $(autofill.field_id).Height();
+    var af_top    = tb_top + tb_height - 9;
+    var tb_left   = $(autofill.field_id).Left();
+    var af_left   = tb_left;
+    
+    div.style.position = 'absolute';
+    div.style.left = af_left + 'px';
+    div.style.top  = af_top  + 'px';
+    div.style.width = '200px';
+    div.style.fontSize = '7pt';
+    div.style.fontFamily = 'Trebuchet MS, arial, helvetica, sans-serif';
+    div.id = 'afuserdrop_' + Math.floor(Math.random() * 1000000);
+    div.appendChild(table);
+    
+    autofill.boxes.push(div.id);
+    autofill.box_id = div.id;
+    if ( users.length > 0 )
+      autofill.state = users[0];
+    
+    var body = document.getElementsByTagName('body')[0];
+    body.appendChild(div);
+    
+    autofill.repeat = true;
+  }
+  
+  // perform ajax call
+  this.fetch_and_process = function()
+  {
+    af_current = this;
+    var processResponse = function()
+    {
+      if ( ajax.readyState == 4 )
+      {
+        var afobj = af_current;
+        af_current = false;
+        // parse the JSON response
+        var response = String(ajax.responseText) + ' ';
+        if ( response.substr(0,1) != '{' )
+        {
+          new messagebox(MB_OK|MB_ICONSTOP, 'Invalid response', 'Invalid or unexpected JSON response from server:<pre>' + ajax.responseText + '</pre>');
+          return false;
+        }
+        if ( $(afobj.field_id).object.value.length < 3 )
+          return false;
+        var resp_json = parseJSON(response);
+        var resp_code = $(afobj.field_id).object.value.toLowerCase().substr(0, 3);
+        afobj.responses[resp_code] = resp_json;
+        afobj.process_dataset(resp_json);
+      }
+    }
+    var usernamefragment = ajaxEscape($(this.field_id).object.value);
+    ajaxGet(stdAjaxPrefix + '&_mode=fillusername&name=' + usernamefragment + '&allowanon=' + ( this.allowanon ? '1' : '0' ), processResponse);
+  }
+  
+  this.go = function()
+  {
+    if ( document.getElementById(this.field_id).value.length < 3 )
+    {
+      this.destroy();
+      return false;
+    }
+    
+    if ( af_current )
+      return false;
+    
+    var resp_code = $(this.field_id).object.value.toLowerCase().substr(0, 3);
+    if ( this.responses.length < 1 || ! this.responses[ resp_code ] )
+    {
+      // window.console.info('Cannot find dataset ' + resp_code + ' in cache, sending AJAX request');
+      this.fetch_and_process();
+    }
+    else
+    {
+      // window.console.info('Using cached dataset: ' + resp_code);
+      var resp_json = this.responses[ resp_code ];
+      this.process_dataset(resp_json);
+    }
+    document.getElementById(this.field_id).onkeyup = function(event)
+    {
+      this.afobj.event = event;
+      this.afobj.go();
+    }
+    document.getElementById(this.field_id).onkeydown = function(event)
+    {
+      var form = findParentForm(this);
+      if ( typeof(event) != 'object' )
+        var event = window.event;
+      if ( typeof(event) == 'object' )
+      {
+        if ( event.keyCode == this.afobj.KEY_ENTER && this.afobj.boxes.length < 1 && !this.afobj.box_id )
+        {
+          // user hit enter after accepting a suggestion - submit the form
+          form._af_acting = false;
+          return true;
+        }
+      }
+      form._af_acting = true;
+    }
+  }
+  
+  this.keyhandler = function()
+  {
+    var key = this.event.keyCode;
+    if ( key == this.KEY_ENTER && !this.repeat )
+    {
+      var form = findParentForm($(this.field_id).object);
+        form._af_acting = false;
+      return true;
+    }
+    switch(key)
+    {
+      case this.KEY_UP:
+        this.focus_up();
+        break;
+      case this.KEY_DOWN:
+        this.focus_down();
+        break;
+      case this.KEY_ESC:
+        this.destroy();
+        break;
+      case this.KEY_TAB:
+        this.destroy();
+        break;
+      case this.KEY_ENTER:
+        this.set();
+        break;
+    }
+    
+    var form = findParentForm($(this.field_id).object);
+      form._af_acting = false;
+  }
+  
+  this.get_state_td = function()
+  {
+    var div = document.getElementById(this.box_id);
+    if ( !div )
+      return false;
+    if ( !this.state )
+      return false;
+    var table = div.firstChild;
+    for ( var i = 1; i < table.childNodes.length; i++ )
+    {
+      // the table is DOM-constructed so no cruddy HTML hacks :-)
+      var child = table.childNodes[i];
+      var tn = child.firstChild.firstChild;
+      if ( tn.nodeValue == this.state )
+        return child.firstChild;
+    }
+    return false;
+  }
+  
+  this.focus_down = function()
+  {
+    var state_td = this.get_state_td();
+    if ( !state_td )
+      return false;
+    if ( state_td.parentNode.nextSibling )
+    {
+      // Ooh boy, DOM stuff can be so complicated...
+      // <tr>  -->  <tr>
+      // <td>       <td>
+      // user       user
+      
+      var newstate = state_td.parentNode.nextSibling.firstChild.firstChild.nodeValue;
+      if ( !newstate )
+        return false;
+      this.state = newstate;
+      state_td.className = 'row1';
+      state_td.parentNode.nextSibling.firstChild.className = 'row2';
+      
+      // Exception - automatically scroll around if the item is off-screen
+      var height = $(this.box_id).Height();
+      var top = $(this.box_id).object.scrollTop;
+      var scroll_bottom = height + top;
+      
+      var td_top = $(state_td.parentNode.nextSibling.firstChild).Top() - $(this.box_id).Top();
+      var td_height = $(state_td.parentNode.nextSibling.firstChild).Height();
+      var td_bottom = td_top + td_height;
+      
+      if ( td_bottom > scroll_bottom )
+      {
+        var scrollY = td_top - height + 2*td_height - 7;
+        // window.console.debug(scrollY);
+        $(this.box_id).object.scrollTop = scrollY;
+        /*
+        var newtd = state_td.parentNode.nextSibling.firstChild;
+        var a = document.createElement('a');
+        var id = 'autofill' + Math.floor(Math.random() * 100000);
+        a.name = id;
+        a.id = id;
+        newtd.appendChild(a);
+        window.location.hash = '#' + id;
+        */
+        
+        // In firefox, scrolling like that makes the field get unfocused
+        $(this.field_id).object.focus();
+      }
+    }
+    else
+    {
+      return false;
+    }
+  }
+  
+  this.focus_up = function()
+  {
+    var state_td = this.get_state_td();
+    if ( !state_td )
+      return false;
+    if ( state_td.parentNode.previousSibling && state_td.parentNode.previousSibling.firstChild.tagName != 'TH' )
+    {
+      // Ooh boy, DOM stuff can be so complicated...
+      // <tr>  <--  <tr>
+      // <td>       <td>
+      // user       user
+      
+      var newstate = state_td.parentNode.previousSibling.firstChild.firstChild.nodeValue;
+      if ( !newstate )
+      {
+        return false;
+      }
+      this.state = newstate;
+      state_td.className = 'row1';
+      state_td.parentNode.previousSibling.firstChild.className = 'row2';
+      
+      // Exception - automatically scroll around if the item is off-screen
+      var top = $(this.box_id).object.scrollTop;
+      
+      var td_top = $(state_td.parentNode.previousSibling.firstChild).Top() - $(this.box_id).Top();
+      
+      if ( td_top < top )
+      {
+        $(this.box_id).object.scrollTop = td_top - 10;
+        /*
+        var newtd = state_td.parentNode.previousSibling.firstChild;
+        var a = document.createElement('a');
+        var id = 'autofill' + Math.floor(Math.random() * 100000);
+        a.name = id;
+        a.id = id;
+        newtd.appendChild(a);
+        window.location.hash = '#' + id;
+        */
+        
+        // In firefox, scrolling like that makes the field get unfocused
+        $(this.field_id).object.focus();
+      }
+    }
+    else
+    {
+      $(this.box_id).object.scrollTop = 0;
+      return false;
+    }
+  }
+  
+  this.destroy = function()
+  {
+    this.repeat = false;
+    var body = document.getElementsByTagName('body')[0];
+    var div = document.getElementById(this.box_id);
+    if ( !div )
+      return false;
+    setTimeout('var body = document.getElementsByTagName("body")[0]; body.removeChild(document.getElementById("'+div.id+'"));', 20);
+    // hackish workaround for divs that stick around past their welcoming period
+    for ( var i = 0; i < this.boxes.length; i++ )
+    {
+      var div = document.getElementById(this.boxes[i]);
+      if ( div )
+        setTimeout('var body = document.getElementsByTagName("body")[0]; var div = document.getElementById("'+div.id+'"); if ( div ) body.removeChild(div);', 20);
+      delete(this.boxes[i]);
+    }
+    this.box_id = false;
+    this.state = false;
+  }
+  
+  this.set = function(val)
+  {
+    var ta = document.getElementById(this.field_id);
+    if ( val )
+      ta.value = val;
+    else if ( this.state )
+      ta.value = this.state;
+    this.destroy();
+  }
+  
+  this.sleep = function()
+  {
+    if ( this.box_id )
+    {
+      var div = document.getElementById(this.box_id);
+      div.style.display = 'none';
+    }
+    var el = $(this.field_id).object;
+    var fr = findParentForm(el);
+    el._af_acting = false;
+  }
+  
+  this.wake = function()
+  {
+    if ( this.box_id )
+    {
+      var div = document.getElementById(this.box_id);
+      div.style.display = 'block';
+    }
+  }
+  
+  parent.onblur = function()
+  {
+    af_current = this.afobj;
+    window.setTimeout('if ( af_current ) af_current.sleep(); af_current = false;', 50);
+  }
+  
+  parent.onfocus = function()
+  {
+    af_current = this.afobj;
+    window.setTimeout('if ( af_current ) af_current.wake(); af_current = false;', 50);
+  }
+  
+  parent.afobj = this;
+  var frm = findParentForm(parent);
+  if ( frm.onsubmit )
+  {
+    frm.orig_onsubmit = frm.onsubmit;
+    frm.onsubmit = function(e)
+    {
+      if ( this._af_acting )
+        return false;
+      this.orig_onsubmit(e);
+    }
+  }
+  else
+  {
+    frm.onsubmit = function()
+    {
+      if ( this._af_acting )
+        return false;
+    }
+  }
+  
+  if ( parent.value.length < 3 )
+  {
+    this.destroy();
+    return false;
+  }
+}
+
+function findParentForm(o)
+{
+  if ( o.tagName == 'FORM' )
+    return o;
+  while(true)
+  {
+    o = o.parentNode;
+    if ( !o )
+      return false;
+    if ( o.tagName == 'FORM' )
+      return o;
+  }
+  return false;
+}
+
--- a/includes/clientside/static/enano-lib-basic.js	Tue Oct 09 16:14:55 2007 -0400
+++ b/includes/clientside/static/enano-lib-basic.js	Fri Oct 12 14:41:51 2007 -0400
@@ -264,6 +264,7 @@
   'admin-menu.js',
   'ajax.js',
   'autocomplete.js',
+  'autofill.js',
   'base64.js',
   'dropdown.js',
   'faders.js',
--- a/includes/functions.php	Tue Oct 09 16:14:55 2007 -0400
+++ b/includes/functions.php	Fri Oct 12 14:41:51 2007 -0400
@@ -2804,7 +2804,7 @@
   }
   
   // Optimize (but don't obfuscate) Javascript
-  preg_match_all('/<script([ ]+.*?)?>(.*?)<\/script>/is', $html, $jscript);
+  preg_match_all('/<script([ ]+.*?)?>(.*?)(\]\]>)?<\/script>/is', $html, $jscript);
   
   // list of Javascript reserved words - from about.com
   $reserved_words = array('abstract', 'as', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'continue', 'const', 'debugger', 'default', 'delete', 'do',
--- a/includes/template.php	Tue Oct 09 16:14:55 2007 -0400
+++ b/includes/template.php	Fri Oct 12 14:41:51 2007 -0400
@@ -1411,7 +1411,7 @@
   function username_field($name, $value = false)
   {
     $randomid = md5( time() . microtime() . mt_rand() );
-    $text = '<input name="'.$name.'" onkeyup="ajaxUserNameComplete(this)" autocomplete="off" type="text" size="30" id="userfield_'.$randomid.'"';
+    $text = '<input name="'.$name.'" onkeyup="new AutofillUsername(this);" autocomplete="off" type="text" size="30" id="userfield_'.$randomid.'"';
     if($value) $text .= ' value="'.$value.'"';
     $text .= ' />';
     return $text;