Commits

Anonymous committed ee14d1e

#417: add an autocomplete for the nosy list.

Comments (0)

Files changed (6)

+"""
+This module provides two helper functions used by the Javascript autocomplete
+of the nosy list:
+  1) a simple state machine to parse the tables of the
+     experts index and turn them in a JSON object;
+  2) a function to get the list of developers as a JSON object;
+"""
+
+import urllib
+try:
+    import json
+except ImportError:
+    import simplejson as json
+
+url = 'http://hg.python.org/devguide/raw-file/default/experts.rst'
+
+# possible states
+no_table = 0  # not parsing a table
+table_header = 1  # parsing the header
+table_content = 2  # parsing the content
+table_end = 3  # reached the end of the table
+
+def experts_as_json():
+    """
+    Parse the tables of the experts index and turn them into a JSON object.
+    """
+    data = {}
+    table_state = no_table
+
+    try:
+        page = urllib.urlopen(url)
+    except Exception:
+        # if something goes wrong just return an empty JSON object
+        return '{}'
+
+    for line in page:
+        columns = [column.strip() for column in line.split('  ', 1)]
+        # all the tables have 2 columns (some entries might not have experts,
+        # so we just skip them)
+        if len(columns) != 2:
+            continue
+        first, second = columns
+        # check if we found a table separator
+        if set(first) == set(second) == set('='):
+            table_state += 1
+            if table_state == table_end:
+                table_state = no_table
+            continue
+        if table_state == table_header:
+            # create a dict for the category (e.g. 'Modules', 'Interest areas')
+            category = first
+            data[category] = {}
+        if table_state == table_content:
+            # add to the category dict the entries for that category
+            # (e.g.module names) and the list of experts
+            # if the entry is empty the names belong to the previous entry
+            entry = first or entry
+            names = (name.strip(' *') for name in second.split(','))
+            names = ','.join(name for name in names if '(inactive)' not in name)
+            if not first:
+                data[category][entry] += names
+            else:
+                data[category][entry] = names
+    return json.dumps(data, separators=(',',':'))
+
+
+def devs_as_json(cls):
+    """
+    Generate a JSON object that contains the username and realname of all
+    the committers.
+    """
+    users = []
+    for user in cls.filter(None, {'iscommitter': 1}):
+        username = user.username.plain()
+        realname = user.realname.plain(unchecked=1)
+        if not realname:
+            continue
+        users.append([username, realname])
+    return json.dumps(users, separators=(',',':'))
+
+
+def init(instance):
+    instance.registerUtil('experts_as_json', experts_as_json)
+    instance.registerUtil('devs_as_json', devs_as_json)
 </title>
 
 <metal:slot fill-slot="more-javascript">
-<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js"></script>
+<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script>
+<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.15/jquery-ui.js"></script>
 <script type="text/javascript" src="@@file/issue.item.js"></script>
+<link rel="stylesheet" type="text/css"  href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/themes/smoothness/jquery-ui.css" />
 </metal:slot>
 
 <tal:block metal:fill-slot="body_title">
         }
     });
 })
+
+
+$(document).ready(function() {
+    /* Add an autocomplete to the nosy list that searches the term in two lists:
+         1) the list of developers (both the user and the real name);
+         2) the list of experts in the devguide;
+       See also the "categories" and "multiple values" examples at
+       http://jqueryui.com/demos/autocomplete/. */
+
+    if ($("input[name=nosy]").length == 0) {
+        // if we can't find the nosy <input>, the user can't edit the nosy
+        // so there's no need to load the autocomplete
+        return;
+    }
+
+    // create a custom widget to group the entries in categories
+    $.widget("custom.catcomplete", $.ui.autocomplete, {
+        _renderMenu: function(ul, items) {
+            var self = this, current_category = "";
+            // loop through the items, adding a <li> when a new category is
+            // found, and then render the item in the <ul>
+            $.each(items, function(index, item) {
+                if (item.category != current_category) {
+                    ul.append("<li class='ui-autocomplete-category'>" + item.category + "</li>");
+                    current_category = item.category;
+                }
+                self._renderItem(ul, item);
+            });
+        }
+    });
+
+    function split(val) {
+        return val.split(/\s*,\s*/);
+    }
+    function extract_last(term) {
+        return split(term).pop();
+    }
+    function unix_time() {
+        return Math.floor(new Date().getTime() / 1000);
+    }
+    function is_expired(time_str) {
+        // check if the cached file is older than 1 day
+        return ((unix_time() - parseInt(time_str)) > 24*60*60);
+    }
+
+    // this will be called once we have retrieved the data
+    function add_autocomplete(data) {
+        $("input[name=nosy]")
+            // don't navigate away from the field on tab when selecting an item
+            .bind("keydown", function(event) {
+                if (event.keyCode === $.ui.keyCode.TAB &&
+                        $(this).data("autocomplete").menu.active) {
+                    event.preventDefault();
+                }
+            })
+            .catcomplete({
+                minLength: 2, // this doesn't seem to work
+                delay: 0,
+                source: function(request, response) {
+                    // delegate back to autocomplete, but extract the last term
+                    response($.ui.autocomplete.filter(
+                        data, extract_last(request.term)));
+                },
+                focus: function() {
+                    // prevent value inserted on focus
+                    return false;
+                },
+                select: function(event, ui) {
+                    var usernames = split(this.value);
+                    // remove the current input
+                    usernames.pop();
+                    // add the selected item
+                    $.each(split(ui.item.value), function(i, username) {
+                        // check if any of the usernames are already there
+                        if ($.inArray(username, usernames) == -1)
+                            usernames.push(username);
+                    });
+                    // add placeholder to get the comma at the end
+                    usernames.push("");
+                    this.value = usernames.join(",") ;
+                    return false;
+                }
+            });
+    }
+
+
+    // check if we have HTML5 storage available
+    try {
+        var supports_html5_storage = !!localStorage.getItem;
+    } catch(e) {
+        var supports_html5_storage = false;
+    }
+
+    // this object receives the entries for the devs and experts and
+    // when it has both it calls add_autocomplete
+    var data = {
+        devs: null,
+        experts: null,
+        add: function(data, type) {
+            // type is either 'devs' or 'experts'
+            this[type] = data;
+            if (this.devs && this.experts)
+                add_autocomplete(this.devs.concat(this.experts))
+        }
+    };
+
+    /* Note: instead of using a nested structure like:
+       {"Platform": {"plat1": "name1,name2", "plat2": "name3,name4", ...},
+        "Module": {"mod1": "name1,name2", "mod2": "name3,name4", ...},
+        ...}
+       (i.e. the same format sent by the server), we have to change it and
+       repeat the category for each entry, because the autocomplete wants a
+       flat structure like:
+       [{label: "plat1: name1,name2", value: "name1,name2", category: "Platform"},
+        {label: "plat2: name3,name4", value: "name3,name4", category: "Platform"},
+        {label: "mod1: name1,name2", value: "name1,name2", category: "Module"},
+        {label: "mod2: name3,name4", value: "name3,name4", category: "Module"},
+        ...].
+       Passing a nested structure to ui.autocomplete.filter() and attempt
+       further parsing in _renderMenu doesn't seem to work.
+    */
+    function get_json(file, callback) {
+        // Get the JSON from either the HTML5 storage or the server.
+        //   file is either 'devs' or 'experts',
+        //   the callback is called once the json is retrieved
+        var json;
+        if (supports_html5_storage &&
+                ((json = localStorage[file]) != null) &&
+                !is_expired(localStorage[file+'time'])) {
+            // if we have HTML5 storage and already cached the JSON, use it
+            callback(JSON.parse(json), file);
+        }
+        else {
+            // if we don't have HTML5 storage or the cache is empty, request
+            // the JSON to the server
+            $.getJSON('user?@template='+file, function(rawdata) {
+                var objects = []; // array of objs with label, value, category
+                if (file == 'devs') {
+                    // save devs as 'Name Surname (user.name)'
+                    $.each(rawdata, function(index, names) {
+                        objects.push({label: names[1] + ' (' + names[0] + ')',
+                                      value: names[0], category: 'Developer'});
+                    });
+                }
+                else {
+                    // save experts as e.g. 'modname: user1,user2'
+                    $.each(rawdata, function(category, entries) {
+                        $.each(entries, function(entry, names) {
+                            objects.push({label: entry + ': ' + names,
+                                          value: names, category: category});
+                        });
+                    });
+                }
+                // cache the objects if we have HTML5 storage
+                if (supports_html5_storage) {
+                    localStorage[file] = JSON.stringify(objects);
+                    localStorage[file+'time'] = unix_time();
+                }
+                callback(objects, file);
+            });
+        }
+    }
+
+    // request the JSON.  This will get it from the HTML5 storage if it's there
+    // or request it to the server if it's not,  The JSON will be passed to the
+    // data object, that will wait to get both the files before calling the
+    // add_autocomplete function.
+    get_json('experts', data.add);
+    get_json('devs', data.add);
+});
 .calendar_display .today {
   background-color: #afafaf;
 }
+
+.ui-autocomplete-category {
+    font-weight: bold;
+    padding: 0 .2em;
+    line-height: 1.2;
+}
+
+.ui-autocomplete {
+    font-size: 75% !important;
+    max-height: 25em;
+    max-width: 20em;
+    overflow: auto;
+}
+<tal:block tal:condition="context/is_view_ok"
+           content="python:utils.devs_as_json(context)">
+    [["username1","Real Name1"],["username2", "Real Name2"],...]
+</tal:block>

html/user.experts.html

+<tal:block content="python:utils.experts_as_json()">
+{"Platform":{"platname":"name1,name2",...},
+ "Module":{"modname":"name1,name2",...},
+ ...}
+</tal:block>