[bugzilla-gnome-org-extensions] 4.4 migration: Add splinter.js being a cat of most js files



commit e9137618d095cba3f95cfd6804232fd667998ada
Author: Krzesimir Nowak <qdlacz gmail com>
Date:   Tue Nov 18 12:50:05 2014 +0100

    4.4 migration: Add splinter.js being a cat of most js files
    
    Created with a throw-away modification of flattener.py and then
    hand-edited here and there.

 web/splinter.js | 2686 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 2686 insertions(+), 0 deletions(-)
---
diff --git a/web/splinter.js b/web/splinter.js
new file mode 100644
index 0000000..3923e0c
--- /dev/null
+++ b/web/splinter.js
@@ -0,0 +1,2686 @@
+/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
+// Splinter - patch review add-on for Bugzilla
+// By Owen Taylor <otaylor fishsoup net>
+// Copyright 2009, Red Hat, Inc.
+// Licensed under MPL 1.1 or later, or GPL 2 or later
+// http://git.fishsoup.net/cgit/splinter
+
+if (!console) {
+    var console = {};
+    console.log = function() {};
+}
+
+//
+// MODULE: Utils
+//
+
+var Utils = {};
+
+Utils.assert = function(condition) {
+    if (!condition)
+        throw new Error("Assertion failed");
+};
+
+Utils.assertNotReached = function() {
+    throw new Error("Assertion failed: should not be reached");
+};
+
+Utils.strip = function(string) {
+    return /^\s*([\s\S]*?)\s*$/.exec(string)[1];
+};
+
+Utils.lstrip = function(string) {
+    return /^\s*([\s\S]*)$/.exec(string)[1];
+};
+
+Utils.rstrip = function(string) {
+    return /^([\s\S]*?)\s*$/.exec(string)[1];
+};
+
+Utils.formatDate = function(date, now) {
+    if (now == null)
+        now = new Date();
+    var daysAgo = (now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000);
+    if (daysAgo < 0 && now.getDate() != date.getDate())
+        return date.toLocaleDateString();
+    else if (daysAgo < 1 && now.getDate() == date.getDate())
+        return date.toLocaleTimeString();
+    else if (daysAgo < 7 && now.getDay() != date.getDay())
+        return ['Sun', 'Mon','Tue','Wed','Thu','Fri','Sat'][date.getDay()] + " " + date.toLocaleTimeString();
+    else
+        return date.toLocaleDateString();
+};
+
+//
+// MODULE: Bug
+//
+
+var Bug = {};
+
+// Until 2009-04, Bugzilla would use symbolic abbrevations for timezones in the XML output.
+// Afterwords it was switched to a UTC offset. We handle some of the more likely to be
+// encountered symbolic timezeones. Anything else is just handled as if it was UTC.
+// See: https://bugzilla.mozilla.org/show_bug.cgi?id=487865
+Bug.TIMEZONES = {
+    CEST: 200,
+    CET:  100,
+    BST:  100,
+    GMT:  000,
+    UTC:  000,
+    EDT: -400,
+    EST: -500,
+    CDT: -500,
+    CST: -600,
+    MDT: -600,
+    MST: -700,
+    PDT: -700,
+    PST: -800
+};
+
+Bug.parseDate = function(d) {
+    var m = /^\s*(\d+)-(\d+)-(\d+)\s+(\d+):(\d+)(?::(\d+))?\s+(?:([A-Z]{3,})|([-+]\d{3,}))\s*$/.exec(d);
+    if (!m)
+        return null;
+
+    var year = parseInt(m[1], 10);
+    var month = parseInt(m[2] - 1, 10);
+    var day = parseInt(m[3], 10);
+    var hour = parseInt(m[4], 10);
+    var minute = parseInt(m[5], 10);
+    var second = m[6] ? parseInt(m[6], 10) : 0;
+
+    var tzoffset = 0;
+    if (m[7]) {
+        if (m[7] in Bug.TIMEZONES)
+            tzoffset = Bug.TIMEZONES[m[7]];
+    } else {
+        tzoffset = parseInt(m[8], 10);
+    }
+
+    var unadjustedDate = new Date(Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5]));
+
+    // 430 => 4:30. Easier to do this computation for only positive offsets
+    var sign = tzoffset < 0 ? -1 : 1;
+    tzoffset *= sign;
+    var adjustmentHours = Math.floor(tzoffset/100);
+    var adjustmentMinutes = tzoffset - adjustmentHours * 100;
+
+    return new Date(unadjustedDate.getTime() -
+                    sign * adjustmentHours * 3600000 -
+                    sign * adjustmentMinutes * 60000);
+};
+
+Bug._formatWho = function(name, email) {
+    if (name && email)
+        return name + " <" + email + ">";
+    else if (name)
+        return name;
+    else
+        return email;
+};
+
+Bug.Attachment = function(bug, id) {
+    this._init(bug, id);
+};
+
+Bug.Attachment.prototype = {
+    _init : function(bug, id) {
+        this.bug = bug;
+        this.id = id;
+    }
+};
+
+Bug.Comment = function(bug) {
+    this._init(bug);
+};
+
+Bug.Comment.prototype = {
+    _init : function(bug) {
+        this.bug = bug;
+    },
+
+    getWho : function() {
+        return Bug._formatWho(this.whoName, this.whoEmail);
+    }
+};
+
+Bug.Bug = function() {
+    this._init();
+};
+
+Bug.Bug.prototype = {
+    _init : function() {
+        this.attachments = [];
+        this.comments = [];
+    },
+
+    getAttachment : function(attachmentId) {
+        for (i = 0; i < this.attachments.length; i++) {
+            var attachment = theBug.attachments[i];
+            if (attachment.id == attachmentId)
+                return attachment;
+            }
+
+        return null;
+    },
+
+    getReporter : function() {
+        return Bug._formatWho(this.reporterName, this.reporterEmail);
+    }
+};
+
+// In the browser environment we use JQuery to parse the DOM tree
+// for the XML document for the bug
+Bug.Bug.fromDOM = function(xml) {
+    var bug = new Bug.Bug();
+
+    $(xml).children('bugzilla').children('bug').each(function() {
+        bug.id = parseInt($(this).children('bug_id').text());
+        bug.token = $(this).children('token').text();
+        bug.shortDesc = Utils.strip($(this).children('short_desc').text());
+        bug.creationDate = Bug.parseDate($(this).children('creation_ts').text());
+
+        $(this).children('reporter').each(function() {
+            bug.reporterEmail = Utils.strip($(this).text());
+            bug.reporterName = Utils.strip($(this).attr('name'));
+        });
+        $(this).children('long_desc').each(function() {
+            var comment = new Bug.Comment(bug);
+
+            $(this).children('who').each(function() {
+                comment.whoEmail = Utils.strip($(this).text());
+                comment.whoName = Utils.strip($(this).attr('name'));
+            });
+            comment.date = Bug.parseDate($(this).children('bug_when').text());
+            comment.text = $(this).children('thetext').text();
+
+            bug.comments.push(comment);
+        });
+        $(this).children('attachment').each(function() {
+            var attachid = parseInt($(this).children('attachid').text());
+            var attachment = new Bug.Attachment(bug, attachid);
+
+            attachment.description = Utils.strip($(this).children('desc').text());
+            attachment.filename = Utils.strip($(this).children('filename').text());
+            attachment.date = Bug.parseDate($(this).children('date').text());
+            attachment.status = Utils.strip($(this).children('status').text());
+            if (attachment.status == "")
+                attachment.status = null;
+            attachment.token = Utils.strip($(this).children('token').text());
+            if (attachment.token == "")
+                attachment.token = null;
+            attachment.isPatch = $(this).attr('ispatch') == "1";
+            attachment.isObsolete = $(this).attr('isobsolete') == "1";
+            attachment.isPrivate = $(this).attr('isprivate') == "1";
+
+            bug.attachments.push(attachment);
+        });
+    });
+
+    return bug;
+};
+
+//
+// MODULE: Dialog
+//
+
+var Dialog = {};
+
+/* This is a simple "lightboxed" modal dialog. The only reason I wrote it was
+ * so that the the "Cancel" button for a review wouldn't put up a:
+ *
+ * 'Really discard your changes?' [ OK ] [ Cancel ]
+ *
+ * dialog with Cancel meaning the opposite thing as the first Cancel - that's
+ * what you'd get with window.confirm(). Maybe it has other uses.
+ *
+ * Usage is:
+ *
+ *  var dialog = new Dialog(<prompt>, <button_label1>, <callback1>)
+ *  dialog.show();
+ *  dialog.focus(<button_label1>)
+ */
+
+Dialog.Dialog = function() {
+    this._init.apply(this, arguments);
+};
+
+Dialog.Dialog.prototype = {
+    _init: function(prompt) {
+        var q = $("<div id='modalContainer' style='display: none;'>"
+                  + "<div id='modalBackground' style='display: none;'></div>"
+                  + "<table>"
+                  + "<tr><td>"
+                  + "<div id='dialog'>"
+                  + "<div id='dialogText'></div>"
+                  + "<div id='dialogButtons'></div>"
+                  + "<div class='clear'></div>"
+                  + "</div>"
+                  + "</td></tr>"
+                  + "</table>"
+                  + "</div>")
+                      .find("#dialogText").text(prompt).end()
+                      .appendTo(document.body);
+
+        this.div = q.get(0);
+
+        if (arguments.length % 2 != 1)
+            throw new Error("Must be an even number of label/callback pairs");
+
+        for (var i = 1; i < arguments.length; i += 2) {
+            this.addButton(arguments[i], arguments[i + 1]);
+        }
+
+        var me = this;
+        this._keypress = function(e) {
+            if (e.keyCode == 27)
+                me.destroy();
+        };
+        $("body").keypress(this._keypress);
+    },
+
+    addButton: function(label, callback) {
+        var me = this;
+        $("<input type='button' />")
+            .val(label)
+            .click(function() {
+                       me.destroy();
+                       callback();
+                   })
+            .appendTo($(this.div).find("#dialogButtons"));
+    },
+
+    destroy: function() {
+        $(this.div).remove();
+        $("body").unbind('keypress', this._keypress);
+    },
+
+    focus: function(label) {
+        $(this.div).find('input[value=' + label + ']').focus();
+    },
+
+    show: function() {
+        $(this.div).show();
+        $(this.div).find("#modalBackground").fadeIn(250);
+    }
+};
+
+//
+// MODULE: Patch
+//
+
+var Patch = {};
+
+// A patch is stored as:
+// Patch ::= File *
+// File ::= Hunk *
+// Hunk ::= Line *
+//
+// The lines of a hunk are the lines of the two-column display of the hunk
+// So, e.g., the unified diff hunk:
+//
+// @@ -4,8 +4,7
+//  import time
+// -from gettext import ngettext
+//  from threading import Thread
+// +
+//  import gobject
+//  import gtk
+// -import gc
+// -import sys
+// +from gettext import ngettext
+//
+// Is represented as:
+//
+//    4 import time                      4 import time
+// -  5 from gettext import ngettext
+//    6 from threading import Thread     5 from threading import Thread
+//                                     + 6
+//    7 import gobject                   7 import gobject
+//    8 import gtk                       8 import gtk
+// !  9 import gc                      ! 9 from gettext import ngettext
+// ! 10 import sys                     !
+//   11                                 10
+//
+// Conceptually the hunk is made up of context lines - lines that are unchanged
+// by the patch and "segments" - series of lines that are changed by the patch
+// Each line is stored as an array:
+//
+//  [old_text, new_text, flags]
+//
+// old_text or new_text can be null (but not both). Flags are:
+Patch.ADDED         = 1 << 0; // Part of a pure addition segment
+Patch.REMOVED       = 1 << 1; // Part of a pure removal segment
+Patch.CHANGED       = 1 << 2; // Part of some other segmnet
+Patch.NEW_NONEWLINE = 1 << 3; // Old line doesn't end with \n
+Patch.OLD_NONEWLINE = 1 << 4; // New line doesn't end with \n
+
+Patch.Hunk = function(oldStart, oldCount, newStart, newCount, functionLine, text) {
+    this._init(oldStart, oldCount, newStart, newCount, functionLine, text);
+};
+
+Patch.Hunk.prototype = {
+    _init : function(oldStart, oldCount, newStart, newCount, functionLine, text) {
+        var rawlines = text.split("\n");
+        if (rawlines.length > 0 && Utils.strip(rawlines[rawlines.length - 1]) == "")
+            rawlines.pop(); // Remove trailing element from final \n
+
+        this.oldStart = oldStart;
+        this.oldCount = oldCount;
+        this.newStart = newStart;
+        this.newCount = newCount;
+        this.functionLine = Utils.strip(functionLine);
+        this.comment = null;
+
+        var lines = [];
+        var totalOld = 0;
+        var totalNew = 0;
+
+        var currentStart = -1;
+        var currentOldCount = 0;
+        var currentNewCount = 0;
+
+        // A segment is a series of lines added/removed/changed with no intervening
+        // unchanged lines. We make the classification of Patch.ADDED/Patch.REMOVED/Patch.CHANGED
+        // in the flags for the entire segment
+        function startSegment() {
+            if (currentStart < 0) {
+                currentStart = lines.length;
+            }
+        }
+
+        function endSegment() {
+            if (currentStart >= 0) {
+                if (currentOldCount > 0 && currentNewCount > 0) {
+                    for (var j = currentStart; j < lines.length; j++) {
+                        lines[j][2] &= ~(Patch.ADDED | Patch.REMOVED);
+                        lines[j][2] |= Patch.CHANGED;
+                    }
+                }
+
+                currentStart = -1;
+                currentOldCount = 0;
+                currentNewCount = 0;
+            }
+        }
+
+        for (var i = 0; i < rawlines.length; i++) {
+            var line = rawlines[i];
+            var op = line.substr(0, 1);
+            var strippedLine = line.substring(1);
+            var noNewLine = 0;
+            if (i + 1 < rawlines.length && rawlines[i + 1].substr(0, 1) == '\\') {
+                noNewLine = op == '-' ? Patch.OLD_NONEWLINE : Patch.NEW_NONEWLINE;
+            }
+
+            if (op == ' ') {
+                endSegment();
+                totalOld++;
+                totalNew++;
+                lines.push([strippedLine, strippedLine, 0]);
+            } else if (op == '-') {
+                totalOld++;
+                startSegment();
+                lines.push([strippedLine, null, Patch.REMOVED | noNewLine]);
+                currentOldCount++;
+            } else if (op == '+') {
+                totalNew++;
+                startSegment();
+                if (currentStart + currentNewCount >= lines.length) {
+                    lines.push([null, strippedLine, Patch.ADDED | noNewLine]);
+                } else {
+                    lines[currentStart + currentNewCount][1] = strippedLine;
+                    lines[currentStart + currentNewCount][2] |= Patch.ADDED | noNewLine;
+                }
+                currentNewCount++;
+            } else if (op == '\\') {
+                // Handled with preceding line
+            } else {
+                // Junk in the patch - hope the patch got line wrapped and just ignoring
+                // it produces something meaningful. (For a patch displayer, anyways.
+                // would be bad for applying the patch.)
+                // Utils.assertNotReached();
+            }
+        }
+
+        // git mail-formatted patches end with --\n<git version> like a signature
+        // This is troublesome since it looks like a subtraction at the end
+        // of last hunk of the last file. Handle this specifically rather than
+        // generically stripping excess lines to be kind to hand-edited patches
+        if (totalOld > oldCount &&
+            lines[lines.length - 1][1] == null &&
+            lines[lines.length - 1][0].substr(0, 1) == '-')
+        {
+            lines.pop();
+            currentOldCount--;
+            if (currentOldCount == 0 && currentNewCount == 0)
+                currentStart = -1;
+        }
+
+        endSegment();
+
+        this.lines = lines;
+    },
+
+    iterate : function(cb) {
+        var oldLine = this.oldStart;
+        var newLine = this.newStart;
+        for (var i = 0; i < this.lines.length; i++) {
+            var line = this.lines[i];
+            cb(this.location + i, oldLine, line[0], newLine, line[1], line[2], line);
+            if (line[0] != null)
+                oldLine++;
+            if (line[1] != null)
+                newLine++;
+        }
+    }
+};
+
+Patch.File = function(filename, status, hunks) {
+    this._init(filename, status, hunks);
+};
+
+Patch.File.prototype = {
+    _init : function(filename, status, hunks) {
+        this.filename = filename;
+        this.status = status;
+        this.hunks = hunks;
+
+        var l = 0;
+        for (var i = 0; i < this.hunks.length; i++) {
+            var hunk = this.hunks[i];
+            hunk.location = l;
+            l += hunk.lines.length;
+        }
+    },
+
+    // A "location" is just a linear index into the lines of the patch in this file
+    getLocation : function(oldLine, newLine) {
+        for (var i = 0; i < this.hunks.length; i++) {
+            var hunk = this.hunks[i];
+            if (oldLine != null && hunk.oldStart > oldLine)
+                continue;
+            if (newLine != null && hunk.newStart > newLine)
+                continue;
+
+            if ((oldLine != null && oldLine < hunk.oldStart + hunk.oldCount) ||
+                (newLine != null && newLine < hunk.newStart + hunk.newCount)) {
+                var location = -1;
+                hunk.iterate(function(loc, oldl, oldText, newl, newText, flags) {
+                                 if ((oldLine == null || oldl == oldLine) &&
+                                     (newLine == null || newl == newLine))
+                                     location = loc;
+                             });
+
+                if (location != -1)
+                    return location;
+            }
+        }
+
+        throw "Bad oldLine,newLine: " + oldLine + "," + newLine;
+    },
+
+    getHunk : function(location) {
+        for (var i = 0; i < this.hunks.length; i++) {
+            var hunk = this.hunks[i];
+            if (location >= hunk.location && location < hunk.location + hunk.lines.length)
+                return hunk;
+        }
+
+        throw "Bad location: " + location;
+    },
+
+    toString : function() {
+        return "Patch.File(" + this.filename + ")";
+    }
+};
+
+Patch._cleanIntro = function(intro) {
+    var m;
+
+    intro = Utils.strip(intro);
+
+    // Git: remove leading 'From <commit_id> <date'
+    m = /^From\s+[a-f0-9]{40}.*\n/.exec(intro);
+    if (m)
+        intro = intro.substr(m.index + m[0].length);
+
+    // Git: remove 'diff --stat' output from the end
+    m = /^---\n(?:^\s.*\n)+\s+\d+\s+files changed.*\n?(?!.)/m.exec(intro);
+    if (m)
+        intro = intro.substr(0, m.index);
+
+    return intro;
+};
+
+// Matches the start unified diffs for a file as produced by different version control tools
+Patch.FILE_START_RE = /^(?:(?:Index|index|===|RCS|diff).*\n)*---[ \t]*(\S+).*\n\+\+\+[ 
\t]*(\S+).*\n(?=@@)/mg;
+
+// Hunk start: @@ -23,12 +30,11 @@
+// Followed by: lines beginning with [ +\-]
+Patch.HUNK_RE = /^@@[ \t]+-(\d+),(\d+)[ \t]+\+(\d+),(\d+)[ \t]+@@(.*)\n((?:[ +\\-].*\n)*)/mg;
+
+Patch.Patch = function(text) {
+    this._init(text);
+};
+
+Patch.Patch.prototype = {
+    // cf. parsing in Review.Review.parse()
+    _init : function(text) {
+        // Canonicalize newlines to simplify the following
+        if (/\r/.test(text))
+            text = text.replace(/(\r\n|\r|\n)/g, "\n");
+
+        this.files = [];
+
+        var m = Patch.FILE_START_RE.exec(text);
+        if (m != null)
+            this.intro = Patch._cleanIntro(text.substring(0, m.index));
+        else
+            throw "Not a patch";
+
+        while (m != null) {
+            // git and hg show a diff between a/foo/bar.c and b/foo/bar.c
+            // or between a/foo/bar.c and /dev/null for removals and the
+            // reverse for additions.
+            var filename;
+            var status = undefined;
+
+            if (/^a\//.test(m[1]) && /^b\//.test(m[2])) {
+                filename = m[1].substring(2);
+                status = Patch.CHANGED;
+            } else if (/^a\//.test(m[1]) && /^\/dev\/null/.test(m[2])) {
+                filename = m[1].substring(2);
+                status = Patch.REMOVED;
+            } else if (/^\/dev\/null/.test(m[1]) && /^b\//.test(m[2])) {
+                filename = m[2].substring(2);
+                status = Patch.ADDED;
+            } else {
+                filename = m[1];
+            }
+
+            var hunks = [];
+            var pos = Patch.FILE_START_RE.lastIndex;
+            while (true) {
+                Patch.HUNK_RE.lastIndex = pos;
+                var m2 = Patch.HUNK_RE.exec(text);
+                if (m2 == null || m2.index != pos)
+                    break;
+
+                pos = Patch.HUNK_RE.lastIndex;
+                var oldStart = parseInt(m2[1]);
+                var oldCount = parseInt(m2[2]);
+                var newStart = parseInt(m2[3]);
+                var newCount = parseInt(m2[4]);
+
+                hunks.push(new Patch.Hunk(oldStart, oldCount, newStart, newCount, m2[5], m2[6]));
+            }
+
+            if (status === undefined) {
+                // For non-Hg/Git we use assume patch was generated non-zero context
+                // and just look at the patch to detect added/removed. Bzr actually
+                // says added/removed in the diff, but SVN/CVS don't
+                if (hunks.length == 1 && hunks[0].oldCount == 0)
+                    status = Patch.ADDED;
+                else if (hunks.length == 1 && hunks[0].newCount == 0)
+                    status = Patch.REMOVED;
+                else
+                    status = Patch.CHANGED;
+            }
+
+            this.files.push(new Patch.File(filename, status, hunks));
+
+            Patch.FILE_START_RE.lastIndex = pos;
+            m = Patch.FILE_START_RE.exec(text);
+        }
+    },
+
+    getFile : function(filename) {
+        for (var i = 0; i < this.files.length; i++) {
+            if (this.files[i].filename == filename)
+                return this.files[i];
+        }
+
+        return null;
+    }
+};
+
+//
+// MODULE: Review
+//
+
+var Review = {};
+
+Review._removeFromArray = function(a, element) {
+    for (var i = 0; i < a.length; i++) {
+        if (a[i] === element) {
+            a.splice(i, 1);
+            return;
+        }
+    }
+};
+
+Review.Comment = function(file, location, type, comment) {
+    this._init(file, location, type, comment);
+};
+
+Review.Comment.prototype = {
+    _init : function(file, location, type, comment) {
+        this.file = file;
+        this.type = type;
+        this.location = location;
+        this.comment = comment;
+    },
+
+    getHunk : function() {
+        return this.file.patchFile.getHunk(this.location);
+    },
+
+    getInReplyTo : function() {
+        var hunk = this.getHunk();
+        var line = hunk.lines[this.location - hunk.location];
+        for (var i = 0; i < line.reviewComments.length; i++) {
+            var comment = line.reviewComments[0];
+            if (comment === this)
+                return null;
+            if (comment.type == this.type)
+                return comment;
+        }
+
+        return null;
+    },
+
+    remove : function() {
+        var hunk = this.getHunk();
+        var line = hunk.lines[this.location - hunk.location];
+        Review._removeFromArray(this.file.comments, this);
+        Review._removeFromArray(line.reviewComments, this);
+    }
+};
+
+Review._noNewLine = function(flags, flag) {
+    return ((flags & flag) != 0) ? "\n\ No newline at end of file" : "";
+};
+
+Review._lineInSegment = function(line) {
+    return (line[2] & (Patch.ADDED | Patch.REMOVED | Patch.CHANGED)) != 0;
+};
+
+Review._compareSegmentLines = function(a, b) {
+    var op1 = a[0];
+    var op2 = b[0];
+     if (op1 == op2)
+        return 0;
+    else if (op1 == ' ')
+        return -1;
+    else if (op2 == ' ')
+        return 1;
+    else
+        return op1 == '-' ? -1 : 1;
+};
+
+Review.File = function(review, patchFile) {
+    this._init(review, patchFile);
+};
+
+Review.File.prototype = {
+    _init : function(review, patchFile) {
+        this.review = review;
+        this.patchFile = patchFile;
+        this.comments = [];
+    },
+
+    addComment : function(location, type, comment) {
+        var hunk = this.patchFile.getHunk(location);
+        var line = hunk.lines[location - hunk.location];
+        comment = new Review.Comment(this, location, type, comment);
+        if (line.reviewComments == null)
+            line.reviewComments = [];
+        line.reviewComments.push(comment);
+        for (var i = 0; i <= this.comments.length; i++) {
+            if (i == this.comments.length ||
+                this.comments[i].location > location ||
+                (this.comments[i].location == location && this.comments[i].type > type)) {
+                this.comments.splice(i, 0, comment);
+                break;
+            } else if (this.comments[i].location == location &&
+                       this.comments[i].type == type) {
+                throw "Two comments at the same location";
+            }
+        }
+
+        return comment;
+    },
+
+    getComment : function(location, type) {
+        for (var i = 0; i < this.comments.length; i++)
+            if (this.comments[i].location == location &&
+                this.comments[i].type == type)
+                return this.comments[i];
+
+        return null;
+    },
+
+    toString : function() {
+        var str = '::: ';
+        str += this.patchFile.filename;
+        str += '\n';
+        var first = true;
+
+        for (var i = 0; i < this.comments.length; i++) {
+            if (first)
+                first = false;
+            else
+                str += '\n';
+            var comment = this.comments[i];
+            var hunk = comment.getHunk();
+
+            // Find the range of lines we might want to show. That's everything in the
+            // same segment as the commented line, plus up two two lines of non-comment
+            // diff before.
+
+            var contextFirst = comment.location - hunk.location;
+            if (Review._lineInSegment(hunk.lines[contextFirst])) {
+                while (contextFirst > 0 && Review._lineInSegment(hunk.lines[contextFirst - 1]))
+                    contextFirst--;
+            }
+
+            var j;
+            for (j = 0; j < 2; j++)
+                if (contextFirst > 0 && !Review._lineInSegment(hunk.lines[contextFirst - 1]))
+                    contextFirst--;
+
+            // Now get the diff lines (' ', '-', '+' for that range of lines)
+
+            var patchOldStart = null;
+            var patchNewStart = null;
+            var patchOldLines = 0;
+            var patchNewLines = 0;
+            var unchangedLines = 0;
+            var patchLines = [];
+
+            function addOldLine(oldLine) {
+                if (patchOldLines == 0)
+                    patchOldStart = oldLine;
+                patchOldLines++;
+            }
+
+            function addNewLine(newLine) {
+                if (patchNewLines == 0)
+                    patchNewStart = newLine;
+                patchNewLines++;
+            }
+
+            hunk.iterate(function(loc, oldLine, oldText, newLine, newText, flags) {
+                             if (loc >= hunk.location + contextFirst && loc <= comment.location) {
+                                 if ((flags & (Patch.ADDED | Patch.REMOVED | Patch.CHANGED)) == 0) {
+                                     patchLines.push(' ' + oldText + Review._noNewLine(flags, 
Patch.OLD_NONEWLINE | Patch.NEW_NONEWLINE));
+                                     addOldLine(oldLine);
+                                     addNewLine(newLine);
+                                     unchangedLines++;
+                                 } else {
+                                     if ((comment.type == Patch.REMOVED || comment.type == Patch.CHANGED) && 
oldText != null) {
+                                         patchLines.push('-' + oldText +Review._noNewLine(flags, 
Patch.OLD_NONEWLINE));
+                                         addOldLine(oldLine);
+                                     }
+                                     if ((comment.type == Patch.ADDED || comment.type == Patch.CHANGED) && 
newText != null) {
+                                         patchLines.push('+' + newText + Review._noNewLine(flags, 
Patch.NEW_NONEWLINE));
+                                         addNewLine(newLine);
+                                     }
+                                 }
+                             }
+                         });
+
+            // Sort them into global order ' ', '-', '+'
+            patchLines.sort(Review._compareSegmentLines);
+
+            // Completely blank context isn't useful so remove it; however if we are commenting
+            // on blank lines at the start of a segment, we have to leave something or things break
+            while (patchLines.length > 1 && patchLines[0].match(/^\s*$/)) {
+                patchLines.shift();
+                patchOldStart++;
+                patchNewStart++;
+                patchOldLines--;
+                patchNewLines--;
+                unchangedLines--;
+            }
+
+            if (comment.type == Patch.CHANGED) {
+                // For a CHANGED comment, we have to show the the start of the hunk - but to save
+                // in length we can trim unchanged context before it
+
+                if (patchOldLines + patchNewLines - unchangedLines > 5) {
+                    var toRemove = Math.min(unchangedLines, patchOldLines + patchNewLines - unchangedLines - 
5);
+                    patchLines.splice(0, toRemove);
+                    patchOldStart += toRemove;
+                    patchNewStart += toRemove;
+                    patchOldLines -= toRemove;
+                    patchNewLines -= toRemove;
+                    unchangedLines -= toRemove;
+                }
+
+                str += '@@ -' + patchOldStart + ',' + patchOldLines + ' +' + patchNewStart + ',' + 
patchNewLines + ' @@\n';
+
+                // We will use up to 8 lines more:
+                //  4 old lines or 3 old lines and a "... <N> more ... " line
+                //  4 new lines or 3 new lines and a "... <N> more ... " line
+
+                var patchRemovals = patchOldLines - unchangedLines;
+                var showPatchRemovals = patchRemovals > 4 ? 3 : patchRemovals;
+                var patchAdditions = patchNewLines - unchangedLines;
+                var showPatchAdditions = patchAdditions > 4 ? 3 : patchAdditions;
+
+                j = 0;
+                while (j < unchangedLines + showPatchRemovals) {
+                    str += patchLines[j];
+                    str += "\n";
+                    j++;
+                }
+                if (showPatchRemovals < patchRemovals) {
+                    str += "... ";
+                    str += patchRemovals - showPatchRemovals;
+                    str += " more ...\n";
+                    j += patchRemovals - showPatchRemovals;
+                }
+                while (j < unchangedLines + patchRemovals + showPatchAdditions) {
+                    str += patchLines[j];
+                    str += "\n";
+                    j++;
+                }
+                if (showPatchAdditions < patchAdditions) {
+                    str += "... ";
+                    str += patchAdditions - showPatchAdditions;
+                    str += " more ...\n";
+                    j += patchAdditions - showPatchAdditions;
+                }
+            } else {
+                // We limit Patch.ADDED/Patch.REMOVED comments strictly to 3 lines after the header
+                if (patchOldLines + patchNewLines - unchangedLines > 3) {
+                    var toRemove =  patchOldLines + patchNewLines - unchangedLines - 3;
+                    patchLines.splice(0, toRemove);
+                    patchOldStart += toRemove;
+                    patchNewStart += toRemove;
+                    patchOldLines -= toRemove;
+                    patchNewLines -= toRemove;
+                }
+
+                if (comment.type == Patch.REMOVED)
+                    str += '@@ -' + patchOldStart + ',' + patchOldLines + ' @@\n';
+                else
+                    str += '@@ +' + patchNewStart + ',' + patchNewLines + ' @@\n';
+                str += patchLines.join("\n");
+                str += "\n";
+            }
+            str += "\n";
+            str += comment.comment;
+            str += "\n";
+        }
+
+        return str;
+    }
+};
+
+Review.Review = function(patch, who, date) {
+    this._init(patch, who, date);
+};
+
+// Indicates start of review comments about a file
+// ::: foo/bar.c
+Review.FILE_START_RE = /^:::[ \t]+(\S+)[ \t]*\n/mg;
+
+
+// This is like Patch.HUNK_RE for the starting line, but differs in that it
+// includes trailing lines that are not patch lines up to the next hunk or file
+// (the trailing lines will be split out as the coment.)
+//
+// Hunk start: @@ -23,12 +30,11 @@
+// Followed by: lines that don't start with @@ or :::
+Review.HUNK_RE = /^@@[ \t]+(?:-(\d+),(\d+)[ \t]+)?(?:\+(\d+),(\d+)[ \t]+)?@@.*\n((?:(?!@@|:::).*\n?)*)/mg;
+
+Review.Review.prototype = {
+    _init : function(patch, who, date) {
+        this.date = null;
+        this.patch = patch;
+        this.who = who;
+        this.date = date;
+        this.intro = null;
+        this.files = [];
+
+        for (var i = 0; i < patch.files.length; i++) {
+            this.files.push(new Review.File(this, patch.files[i]));
+        }
+    },
+
+    // cf. parsing in Patch.Patch._init()
+    parse : function(text) {
+       Review.FILE_START_RE.lastIndex = 0;
+        var m = Review.FILE_START_RE.exec(text);
+
+        var intro;
+        if (m != null) {
+            this.setIntro(text.substr(0, m.index));
+        } else{
+            this.setIntro(text);
+            return;
+        }
+
+        while (m != null) {
+            var filename = m[1];
+            var file = this.getFile(filename);
+            if (file == null)
+                throw "Review.Review refers to filename '" + filename + "' not in reviewed Patch.";
+
+            var pos = Review.FILE_START_RE.lastIndex;
+
+            while (true) {
+                Review.HUNK_RE.lastIndex = pos;
+                var m2 = Review.HUNK_RE.exec(text);
+                if (m2 == null || m2.index != pos)
+                    break;
+
+                pos = Review.HUNK_RE.lastIndex;
+
+                var oldStart, oldCount, newStart, newCount;
+                if (m2[1]) {
+                    oldStart = parseInt(m2[1]);
+                    oldCount = parseInt(m2[2]);
+                } else {
+                    oldStart = oldCount = null;
+                }
+
+                if (m2[3]) {
+                    newStart = parseInt(m2[3]);
+                    newCount = parseInt(m2[4]);
+                } else {
+                    newStart = newCount = null;
+                }
+
+                var type;
+                if (oldStart != null && newStart != null)
+                    type = Patch.CHANGED;
+                else if (oldStart != null)
+                    type = Patch.REMOVED;
+                else if (newStart != null)
+                    type = Patch.ADDED;
+                else
+                    throw "Either old or new line numbers must be given";
+
+                var oldLine = oldStart;
+                var newLine = newStart;
+
+                var rawlines = m2[5].split("\n");
+                if (rawlines.length > 0 && rawlines[rawlines.length - 1].match('^/s+$'))
+                    rawlines.pop(); // Remove trailing element from final \n
+
+                var commentText = null;
+
+                var lastSegmentOld = 0;
+                var lastSegmentNew = 0;
+                for (var i = 0; i < rawlines.length; i++) {
+                    var line = rawlines[i];
+                    var count = 1;
+                    if (i < rawlines.length - 1 && rawlines[i + 1].match(/^... \d+\s+/)) {
+                        var m3 = /^\.\.\.\s+(\d+)\s+/.exec(rawlines[i + 1]);
+                        count += parseInt(m3[1]);
+                        i += 1;
+                    }
+                    // The check for /^$/ is because if Bugzilla is line-wrapping it also
+                    // strips completely whitespace lines
+                    if (line.match(/^ /) || line.match(/^$/)) {
+                        oldLine += count;
+                        newLine += count;
+                        lastSegmentOld = 0;
+                        lastSegmentNew = 0;
+                    } else if (line.match(/^-/)) {
+                        oldLine += count;
+                        lastSegmentOld += count;
+                    } else if (line.match(/^\+/)) {
+                        newLine += count;
+                        lastSegmentNew += count;
+                    } else if (line.match(/^\\/)) {
+                        // '\ No newline at end of file' - ignore
+                    } else {
+                        // Ignore assumming it's a result of line-wrapping
+                        // https://bugzilla.mozilla.org/show_bug.cgi?id=509152
+                        console.log("WARNING: Bad content in hunk: " + line);
+                    }
+
+                    if ((oldStart == null || oldLine == oldStart + oldCount) &&
+                        (newStart == null || newLine == newStart + newCount)) {
+                        commentText = rawlines.slice(i + 1).join("\n");
+                        break;
+                    }
+                }
+
+                if (commentText == null) {
+                    console.log("WARNING: No comment found in hunk");
+                    commentText = "";
+                }
+
+
+                var location;
+                if (type == Patch.CHANGED) {
+                    if (lastSegmentOld >= lastSegmentNew)
+                        oldLine--;
+                    if (lastSegmentOld <= lastSegmentNew)
+                        newLine--;
+                    location = file.patchFile.getLocation(oldLine, newLine);
+                } else if (type == Patch.REMOVED) {
+                    oldLine--;
+                    location = file.patchFile.getLocation(oldLine, null);
+                } else if (type == Patch.ADDED) {
+                    newLine--;
+                    location = file.patchFile.getLocation(null, newLine);
+                }
+                file.addComment(location, type, Utils.strip(commentText));
+            }
+
+            Review.FILE_START_RE.lastIndex = pos;
+            m = Review.FILE_START_RE.exec(text);
+        }
+    },
+
+    setIntro : function(intro) {
+        intro = Utils.strip(intro);
+        this.intro = intro != "" ? intro : null;
+    },
+
+    getFile : function(filename) {
+        for (var i = 0; i < this.files.length; i++) {
+            if (this.files[i].patchFile.filename == filename)
+                return this.files[i];
+        }
+
+        return null;
+    },
+
+    // Making toString() serialize to our seriaization format is maybe a bit sketchy
+    // But the serialization format is designed to be human readable so it works
+    // pretty well.
+    toString : function() {
+        var str = '';
+        if (this.intro != null) {
+            str += Utils.strip(this.intro);
+            str += '\n';
+        }
+
+       var first = this.intro == null;
+        for (var i = 0; i < this.files.length; i++) {
+            var file = this.files[i];
+            if (file.comments.length > 0) {
+               if (first)
+                    first = false;
+               else
+                    str += '\n';
+                str += file.toString();
+            }
+        }
+
+        return str;
+    }
+};
+
+//
+// MODULE: ReviewStorage
+//
+
+var ReviewStorage = {};
+
+/* The ReviewStorage 'interface' has the following methods:
+ *
+ *  listReviews()
+ *    Returns an array of objects with the following properties:
+ *      bugId
+ *      bugShortDesc
+ *      attachmentId
+ *      attachmentDescription
+ *      creationTime
+ *      modificationTime
+ *      isDraft
+ *  loadDraft(bug, attachment, patch)
+ *  saveDraft(bug, attachment, review)
+ *  draftPublished(bug, attachment)
+ */
+
+ReviewStorage.LocalReviewStorage = function() {
+    this._init();
+};
+
+ReviewStorage.LocalReviewStorage.available = function() {
+    // The try is a workaround for
+    //   https://bugzilla.mozilla.org/show_bug.cgi?id=517778
+    // where if cookies are disabled or set to ask, then the first attempt
+    // to access the localStorage property throws a security error.
+    try {
+        return 'localStorage' in window && window.localStorage != null;
+    } catch (e) {
+        return false;
+    }
+};
+
+ReviewStorage.LocalReviewStorage.prototype = {
+    _init : function() {
+        var reviewInfosText = localStorage.splinterReviews;
+        if (reviewInfosText == null)
+            this._reviewInfos = [];
+        else
+            this._reviewInfos = JSON.parse(reviewInfosText);
+    },
+
+    listReviews : function() {
+        return this._reviewInfos;
+    },
+
+    _reviewPropertyName : function(bug, attachment) {
+        return 'splinterReview_' + bug.id + '_' + attachment.id;
+    },
+
+    loadDraft : function(bug, attachment, patch) {
+        var propertyName = this._reviewPropertyName(bug, attachment);
+        var reviewText = localStorage[propertyName];
+        if (reviewText != null) {
+            var review = new Review.Review(patch);
+            review.parse(reviewText);
+            return review;
+        } else {
+            return null;
+        }
+    },
+
+    _findReview : function(bug, attachment) {
+        for (var i = 0 ; i < this._reviewInfos.length; i++)
+            if (this._reviewInfos[i].bugId == bug.id && this._reviewInfos[i].attachmentId == attachment.id)
+                return i;
+
+        return -1;
+    },
+
+    _updateOrCreateReviewInfo : function(bug, attachment, props) {
+        var reviewIndex = this._findReview(bug, attachment);
+        var reviewInfo;
+
+        var nowTime = Date.now();
+        if (reviewIndex >= 0) {
+            reviewInfo = this._reviewInfos[reviewIndex];
+            this._reviewInfos.splice(reviewIndex, 1);
+        } else {
+            reviewInfo = {
+                bugId: bug.id,
+                bugShortDesc: bug.shortDesc,
+                attachmentId: attachment.id,
+                attachmentDescription: attachment.description,
+                creationTime: nowTime
+            };
+        }
+
+        reviewInfo.modificationTime = nowTime;
+        for (var prop in props)
+            reviewInfo[prop] = props[prop];
+
+        this._reviewInfos.push(reviewInfo);
+        localStorage.splinterReviews = JSON.stringify(this._reviewInfos);
+    },
+
+    _deleteReviewInfo : function(bug, attachment) {
+        var reviewIndex = this._findReview(bug, attachment);
+        if (reviewIndex >= 0) {
+            this._reviewInfos.splice(reviewIndex, 1);
+            localStorage.splinterReviews = JSON.stringify(this._reviewInfos);
+        }
+    },
+
+    saveDraft : function(bug, attachment, review) {
+        var propertyName = this._reviewPropertyName(bug, attachment);
+
+        this._updateOrCreateReviewInfo(bug, attachment, { isDraft: true });
+        localStorage[propertyName] = "" + review;
+    },
+
+    deleteDraft : function(bug, attachment, review) {
+        var propertyName = this._reviewPropertyName(bug, attachment);
+
+        this._deleteReviewInfo(bug, attachment);
+        delete localStorage[propertyName];
+    },
+
+    draftPublished : function(bug, attachment) {
+        var propertyName = this._reviewPropertyName(bug, attachment);
+
+        this._updateOrCreateReviewInfo(bug, attachment, { isDraft: false });
+        delete localStorage[propertyName];
+    }
+};
+
+//
+// MODULE: XmlRpc
+//
+
+var XmlRpc = {};
+
+// This is a reasonably accurate implementation of the XML-RPC specification, except
+// for the data types that aren't implemented. Places where parsing isn't fully
+// validating:
+//
+//  * Element children of elements that are supposed to have only text content
+//     are ignored.
+//  * Trailing junk on integers and doubles is ignored
+//  * integer elements that are out of 32-bit range are accepted
+
+XmlRpc._appendValue = function(doc, parent, value) {
+    var valueElement = doc.createElement('value');
+    parent.appendChild(valueElement);
+
+    var element;
+    switch (typeof(value)) {
+    case 'boolean':
+        element = doc.createElement('boolean');
+        element.appendChild(doc.createTextNode(value ? '1' : '0'));
+        break;
+    case 'object':
+        if (value instanceof Date) {
+            throw new Error("Date values not yet implemented");
+        } else if (value instanceof Array) {
+            throw new Error("Array values not yet implemented");
+        } else {
+            element = doc.createElement('struct');
+            for (var i in value) {
+                var memberElement = doc.createElement('member');
+                var nameElement = doc.createElement('name');
+                nameElement.appendChild(doc.createTextNode(i));
+                memberElement.appendChild(nameElement);
+                var vElement = doc.createElement('value');
+                XmlRpc._appendValue(doc, vElement, value[i]);
+                memberElement.appendChild(vElement);
+                element.appendChild(memberElement);
+            }
+        }
+        break;
+    case 'number':
+        if (Math.round(value) == value &&
+            value >= -0x8000000 && value <= 0x7fffffff)
+            element = doc.createElement('int');
+        else
+            element = doc.createElement('double');
+        element.appendChild(doc.createTextNode(value.toString()));
+        break;
+    case 'string':
+        element = doc.createElement('string');
+        element.appendChild(doc.createTextNode(value));
+        break;
+    default:
+        throw new Error("Don't know how to handle value of type: " + typeof(value));
+    }
+
+    valueElement.appendChild(element);
+};
+
+XmlRpc._appendParam = function(doc, paramsElement, param) {
+    var paramElement = doc.createElement('param');
+    XmlRpc._appendValue(doc, paramElement, param);
+    paramsElement.appendChild(paramElement);
+};
+
+XmlRpc.ParseError = function(message) {
+    this.message = message;
+};
+
+XmlRpc.ParseError.prototype = {
+    toString: function() {
+        return "XmlRpc.ParseError: " + this.message;
+    }
+};
+
+XmlRpc._parseValue = function(valueElement) {
+    var text;
+    var value;
+
+    if (valueElement.firstChild == null || valueElement.firstChild.nextChild != null)
+        throw new XmlRpc.ParseError("<value/> doesn't have a single child");
+
+    var element = valueElement.firstChild;
+
+    switch (element.tagName) {
+    case 'boolean':
+        text = Utils.strip(element.textContent);
+        if (text == '0')
+            value = false;
+        else if (text == '1')
+            value = true;
+        else
+            throw new XmlRpc.ParseError("<boolean/> should be 0 or 1");
+        break;
+    case 'double':
+        text = Utils.strip(element.textContent);
+        value = parseFloat(text);
+        if (isNaN(value))
+            throw new XmlRpc.ParseError("<double/> doesn't contain a floating point number");
+        break;
+    case 'int':
+    case 'i4':
+        text = Utils.strip(element.textContent);
+        value = parseInt(text);
+        if (isNaN(value))
+            throw new XmlRpc.ParseError("<i4/> doesn't contain an integer");
+        break;
+    case 'struct':
+        value = new Object();
+        var member = element.firstChild;
+        while (member){
+            if (member.tagName != 'member')
+                throw new XmlRpc.ParseError("<struct/> has childeren other than <member/>");
+
+            var nameElement = member.firstChild;
+            if (nameElement == null || nameElement.tagName != 'name')
+                throw new XmlRpc.ParseError("<member/> doesn't have <name/> as the first element");
+
+            var name = nameElement.textContent;
+
+            var valueElement = nameElement.nextSibling;
+            if (valueElement == null || valueElement.tagName != 'value')
+                throw new XmlRpc.ParseError("<member/> doesn't have <value/> as the second element");
+
+            value[name] = XmlRpc._parseValue(valueElement);
+
+            if (valueElement.nextSibling != null)
+                throw new XmlRpc.ParseError("<member/> has too many children");
+
+            member = member.nextSibling;
+        }
+        break;
+    case 'string':
+        value = Utils.strip(element.textContent);
+        break;
+    case 'array':
+    case 'base64':
+    case 'dateTime.iso8601':
+        throw new XmlRpc.ParseError("Support for <" + element.tagName + "/> not yet implemented");
+    default:
+        throw new XmlRpc.ParseError("Unknown value element <" + element.tagName + "/>");
+    }
+
+    return value;
+};
+
+XmlRpc._handleSuccess = function(options, xml) {
+    try {
+        var root = xml.documentElement;
+        if (root.tagName != 'methodResponse')
+            throw new XmlRpc.ParseError("Root isn't <methodResponse/>");
+
+        if (root.firstChild.tagName == 'params' &&
+            root.firstChild.nextSibling == null) {
+
+            var param = root.firstChild.firstChild;
+            if (param == null ||
+                param.tagName != 'param' ||
+                param.nextSibling != null)
+                throw new XmlRpc.ParseError("<params/> element in response should have <param/> child");
+
+            var value = param.firstChild;
+            if (value == null ||
+                value.tagName != 'value' ||
+                value.nextSibling != null)
+                throw new XmlRpc.ParseError("<param/> element in response doesn't have a single value as 
child");
+
+            options.success(XmlRpc._parseValue(value));
+
+        } else if (root.firstChild.tagName == 'fault' &&
+                   root.firstChild.nextSibling == null) {
+
+            var value = root.firstChild.firstChild;
+            if (value == null ||
+                value.tagName != 'value' ||
+                value.nextSibling != null)
+                throw new XmlRpc.ParseError("<fault/> element in response should have <value/> child");
+
+            var struct = value.firstChild;
+            if (struct == null ||
+                struct.tagName != 'struct')
+                throw new XmlRpc.ParseError("<value/> element in <fault/> should have <struct/> child");
+
+            var faultStruct = XmlRpc._parseValue(value);
+
+            var faultCode = faultStruct.faultCode;
+            var faultString = faultStruct.faultString;
+
+            //  XMLRPC::Lite gives faultCodes like 'Client' at times,
+            //  so we don't check for integer, though the spec says
+            //  the faultCode should always be an integer
+            if (faultCode == null || typeof(faultString) != 'string')
+                throw new XmlRpc.ParseError("fault structure should contain an [integer] faultCode and 
string faultString");
+
+            options.fault(faultCode, faultString);
+
+        } else {
+            throw new XmlRpc.ParseError("Bad content of <methodResponse/>");
+        }
+
+    } catch (e) {
+        if (e instanceof XmlRpc.ParseError)
+            options.error(e.message);
+        else
+            throw e;
+    }
+};
+
+XmlRpc.call = function(options) {
+    var doc = document.implementation.createDocument(null, "methodCall", null);
+    var methodNameElement = doc.createElement("methodName");
+    methodNameElement.appendChild(doc.createTextNode(options.name));
+    doc.documentElement.appendChild(methodNameElement);
+    var paramsElement = doc.createElement("params");
+    doc.documentElement.appendChild(paramsElement);
+
+    if (options.params instanceof Array) {
+        for (var i = 0; i < params.length; i++) {
+            XmlRpc._appendParam(doc, paramsElement, options.params[i]);
+        }
+    } else if (options.params != null) {
+        XmlRpc._appendParam(doc, paramsElement, options.params);
+    }
+
+    $.ajax({
+               type: 'POST',
+               url: options.url,
+               contentType: 'text/xml',
+               dataType: 'xml',
+               data: (new XMLSerializer()).serializeToString(doc),
+               error: function(xmlHttpRequest, textStatus, errorThrown) {
+                   options.error(textStatus);
+               },
+               success: function(xml) {
+                   XmlRpc._handleSuccess(options, xml);
+               }
+           });
+};
+
+//
+// MODULE: Main
+//
+
+var reviewStorage;
+var attachmentId;
+var theBug;
+var theAttachment;
+var thePatch;
+var theReview;
+
+var reviewers = {};
+
+var navigationLinks = {};
+
+var updateHaveDraftTimeoutId;
+var saveDraftTimeoutId;
+var saveDraftNoticeTimeoutId;
+var savingDraft = false;
+
+var currentEditComment;
+
+var ADD_COMMENT_SUCCESS = /<title>\s*Bug[\S\s]*processed\s*<\/title>/;
+var UPDATE_ATTACHMENT_SUCCESS = /<title>\s*Changes\s+Submitted/;
+
+function doneLoading() {
+    $("#loading").hide();
+    $("#helpLink").attr("href", configHelp);
+    $("#credits").show();
+}
+
+function displayError(msg) {
+    $("<p></p>")
+        .text(msg)
+        .appendTo("#error");
+    $("#error").show();
+    doneLoading();
+}
+
+function updateAttachmentStatus(attachment, newStatus, success, failure) {
+    var data = {
+        action: 'update',
+        id: attachment.id,
+        description: attachment.description,
+        filename: attachment.filename,
+        ispatch: attachment.isPatch ? 1 : 0,
+        isobsolete: attachment.isObsolete ? 1 : 0,
+        isprivate: attachment.isPrivate ? 1 : 0,
+        'attachments.status': newStatus
+    };
+
+    if (attachment.token)
+        data.token = attachment.token;
+
+    $.ajax({
+               data: data,
+               dataType: 'text',
+               error: function(xmlHttpRequest, textStatus, errorThrown) {
+                   failure();
+               },
+               success: function(data, textStatus) {
+                   if (data.search(UPDATE_ATTACHMENT_SUCCESS) != -1) {
+                       success();
+                   } else {
+                       failure();
+                   }
+               },
+               type: 'POST',
+               url: "/attachment.cgi"
+           });
+}
+
+function addComment(bug, comment, success, failure) {
+    var data = {
+        id: bug.id,
+        comment: comment
+    };
+
+    if (bug.token)
+        data.token = bug.token;
+
+    $.ajax({
+               data: data,
+               dataType: 'text',
+               error: function(xmlHttpRequest, textStatus, errorThrown) {
+                   failure();
+               },
+               success: function(data, textStatus) {
+                   if (data.search(ADD_COMMENT_SUCCESS) != -1) {
+                       success();
+                   } else {
+                       failure();
+                   }
+               },
+               type: 'POST',
+               url: "/process_bug.cgi"
+           });
+}
+
+function publishReview() {
+    saveComment();
+    theReview.setIntro($("#myComment").val());
+
+    var newStatus = null;
+    if (theAttachment.status && $("#attachmentStatus").val() != theAttachment.status) {
+        newStatus = $("#attachmentStatus").val();
+    }
+
+    function success() {
+        if (reviewStorage)
+            reviewStorage.draftPublished(theBug, theAttachment);
+        document.location = newPageUrl(theBug.id);
+    }
+
+    if (configHaveExtension) {
+        var params = {
+            attachment_id: theAttachment.id,
+            review: theReview.toString()
+        };
+
+        if (newStatus != null)
+            params['attachment_status'] = newStatus;
+
+        XmlRpc.call({
+                        url: '/xmlrpc.cgi',
+                        name: 'Splinter.publish_review',
+                        params: params,
+                        error: function(message) {
+                            displayError("Failed to publish review: " + message);
+                        },
+                        fault: function(faultCode, faultString) {
+                            displayError("Failed to publish review: " + faultString);
+                        },
+                        success: function(result) {
+                            success();
+                        }
+                    });
+    } else {
+        var comment = "Review of attachment " + attachmentId + ":\n\n" + theReview;
+        addComment(theBug, comment,
+                   function(detail) {
+                       if (newStatus)
+                           updateAttachmentStatus(theAttachment, newStatus,
+                                                  success,
+                                                  function() {
+                                                      displayError("Published review; patch status could not 
be updated.");
+                                                  });
+                       else
+                           success();
+                   },
+                   function(detail) {
+                       displayError("Failed to publish review.");
+                   });
+    }
+}
+
+function doDiscardReview() {
+    if (theAttachment.status)
+        $("#attachmentStatus").val(theAttachment.status);
+
+    $("#myComment").val("");
+    $("#emptyCommentNotice").show();
+
+    for (var i = 0; i  < theReview.files.length; i++) {
+        while (theReview.files[i].comments.length > 0)
+            theReview.files[i].comments[0].remove();
+    }
+    updateMyPatchComments();
+
+    updateHaveDraft();
+    saveDraft();
+}
+
+function discardReview() {
+    var dialog = new Dialog.Dialog("Really discard your changes?",
+                                   'Continue', function() {},
+                                   'Discard', doDiscardReview);
+    dialog.show();
+    dialog.focus('Continue');
+}
+
+function haveDraft() {
+    if (theAttachment.status && $("#attachmentStatus").val() != theAttachment.status)
+        return true;
+
+    if ($("#myComment").val().search(/\S/) >= 0)
+        return true;
+
+    for (var i = 0; i  < theReview.files.length; i++) {
+        if (theReview.files[i].comments.length > 0)
+            return true;
+    }
+
+    return false;
+}
+
+function updateHaveDraft() {
+    clearTimeout(updateHaveDraftTimeoutId);
+    updateHaveDraftTimeoutId = null;
+
+    if (haveDraft()) {
+        $("#publishButton").removeAttr('disabled');
+        $("#cancelButton").removeAttr('disabled');
+        $("#haveDraftNotice").show();
+    } else {
+        $("#publishButton").attr('disabled', 1);
+        $("#cancelButton").attr('disabled', 1);
+        $("#haveDraftNotice").hide();
+    }
+}
+
+function queueUpdateHaveDraft() {
+    if (updateHaveDraftTimeoutId == null)
+        updateHaveDraftTimeoutId = setTimeout(updateHaveDraft, 0);
+}
+
+function hideSaveDraftNotice() {
+    clearTimeout(saveDraftNoticeTimeoutId);
+    saveDraftNoticeTimeoutId = null;
+    $("#saveDraftNotice").hide();
+}
+
+function saveDraft() {
+    if (reviewStorage == null)
+        return;
+
+    clearTimeout(saveDraftTimeoutId);
+    saveDraftTimeoutId = null;
+
+    savingDraft = true;
+    $("#saveDraftNotice")
+        .text("Saving Draft...")
+        .show();
+    clearTimeout(saveDraftNoticeTimeoutId);
+    setTimeout(hideSaveDraftNotice, 3000);
+
+    if (currentEditComment) {
+        currentEditComment.comment = Utils.strip($("#commentEditor textarea").val());
+        // Messy, we don't want the empty comment in the saved draft, so remove it and
+        // then add it back.
+        if (!currentEditComment.comment)
+            currentEditComment.remove();
+    }
+
+    theReview.setIntro($("#myComment").val());
+
+    var draftSaved = false;
+    if (haveDraft()) {
+        reviewStorage.saveDraft(theBug, theAttachment, theReview);
+        draftSaved = true;
+    } else {
+        reviewStorage.deleteDraft(theBug, theAttachment, theReview);
+    }
+
+    if (currentEditComment && !currentEditComment.comment) {
+        currentEditComment = currentEditComment.file.addComment(currentEditComment.location,
+                                                                currentEditComment.type,
+                                                                "");
+    }
+
+    savingDraft = false;
+    if (draftSaved)
+        $("#saveDraftNotice")
+            .text("Saved Draft");
+    else
+        hideSaveDraftNotice();
+}
+
+function queueSaveDraft() {
+    if (saveDraftTimeoutId == null)
+        saveDraftTimeoutId = setTimeout(saveDraft, 10000);
+}
+
+function flushSaveDraft() {
+    if (saveDraftTimeoutId != null)
+        saveDraft();
+}
+
+function getQueryParams() {
+    var query = window.location.search.substring(1);
+    if (query == null || query == "")
+        return {};
+
+    var components = query.split(/&/);
+
+    var params = {};
+    var i;
+    for (i = 0; i < components.length; i++) {
+        var component = components[i];
+        var m = component.match(/([^=]+)=(.*)/);
+        if (m)
+            params[m[1]] = decodeURIComponent(m[2]);
+    }
+
+    return params;
+}
+
+function ensureCommentArea(row) {
+    var file = $(row).data('patchFile');
+    var colSpan = file.status == Patch.CHANGED ? 5 : 2;
+    if (!row.nextSibling || row.nextSibling.className != "comment-area")
+        $("<tr class='comment-area'><td colSpan='" + colSpan + "'>"
+          + "</td></tr>")
+            .insertAfter(row);
+
+    return row.nextSibling.firstChild;
+}
+
+function getTypeClass(type) {
+    switch (type) {
+    case Patch.ADDED:
+        return "comment-added";
+    case Patch.REMOVED:
+        return "comment-removed";
+    case Patch.CHANGED:
+        return "comment-changed";
+    }
+
+    return null;
+}
+
+function getSeparatorClass(type) {
+    switch (type) {
+    case Patch.ADDED:
+        return "comment-separator-added";
+    case Patch.REMOVED:
+        return "comment-separator-removed";
+    }
+
+    return null;
+}
+
+function getReviewerClass(review) {
+    var reviewerIndex;
+    if (review == theReview)
+        reviewerIndex = 0;
+    else
+        reviewerIndex = (reviewers[review.who] - 1) % 5 + 1;
+
+    return "reviewer-" + reviewerIndex;
+}
+
+function addCommentDisplay(commentArea, comment) {
+    var review = comment.file.review;
+
+    var separatorClass = getSeparatorClass(comment.type);
+    if (separatorClass)
+        $("<div></div>")
+            .addClass(separatorClass)
+            .addClass(getReviewerClass(review))
+            .appendTo(commentArea);
+
+    var q = $("<div class='comment'>"
+      + "<div class='comment-frame'>"
+      + "<div class='reviewer-box'>"
+      + "<div class='comment-text'></div>"
+      + "</div>"
+      + "</div>"
+      + "</div>")
+        .find(".comment-text").preWrapLines(comment.comment).end()
+        .addClass(getTypeClass(comment.type))
+        .addClass(getReviewerClass(review))
+        .appendTo(commentArea)
+        .dblclick(function() {
+                      saveComment();
+                      insertCommentEditor(commentArea,
+                                          comment.file.patchFile, comment.location, comment.type);
+                  });
+
+    if (review != theReview) {
+        $("<div class='review-info'>"
+          + "<div class='reviewer'></div><div class='review-date'></div>"
+          + "<div class='review-info-bottom'></div>"
+          + "</div>")
+            .find(".reviewer").text(review.who).end()
+            .find(".review-date").text(Utils.formatDate(review.date)).end()
+            .appendTo(q.find(".reviewer-box"));
+    }
+
+    comment.div = q.get(0);
+}
+
+function saveComment() {
+    var comment = currentEditComment;
+    if (!comment)
+        return;
+
+    var commentEditor = $("#commentEditor").get(0);
+    var commentArea = commentEditor.parentNode;
+    var reviewFile = comment.file;
+
+    var hunk = comment.getHunk();
+    var line = hunk.lines[comment.location - hunk.location];
+
+    var value = Utils.strip($(commentEditor).find("textarea").val());
+    if (value != "") {
+        comment.comment = value;
+        addCommentDisplay(commentArea, comment);
+    } else {
+        comment.remove();
+    }
+
+    if (line.reviewComments.length > 0) {
+        $("#commentEditor").remove();
+        $("#commentEditorSeparator").remove();
+    } else {
+        $(commentArea).parent().remove();
+    }
+
+    currentEditComment = null;
+    saveDraft();
+    queueUpdateHaveDraft();
+}
+
+function cancelComment(previousText) {
+    $("#commentEditor textarea").val(previousText);
+    saveComment();
+}
+
+function deleteComment() {
+    $("#commentEditor textarea").val("");
+    saveComment();
+}
+
+function insertCommentEditor(commentArea, file, location, type) {
+    saveComment();
+
+    var reviewFile = theReview.getFile(file.filename);
+    var comment = reviewFile.getComment(location, type);
+    if (!comment) {
+        comment = reviewFile.addComment(location, type, "");
+        queueUpdateHaveDraft();
+    }
+
+    var previousText = comment.comment;
+
+    var typeClass = getTypeClass(type);
+    var separatorClass = getSeparatorClass(type);
+
+    if (separatorClass)
+        $(commentArea).find(".reviewer-0." + separatorClass).remove();
+    $(commentArea).find(".reviewer-0." + typeClass).remove();
+
+    if (separatorClass)
+        $("<div class='commentEditorSeparator'></div>")
+            .addClass(separatorClass)
+            .appendTo(commentArea);
+    $("<div id='commentEditor'>"
+      + "<div id='commentEditorInner'>"
+      + "<div id='commentTextFrame'>"
+      + "<textarea></textarea>"
+      + "</div>"
+      + "<div id='commentEditorLeftButtons'>"
+      + "<input id='commentCancel' type='button' value='Cancel' />"
+      + "</div>"
+      + "<div id='commentEditorRightButtons'>"
+      + "<input id='commentSave' type='button'value='Save' />"
+      + "</div>"
+      + "<div class='clear'></div>"
+      + "</div>"
+      + "</div>")
+        .addClass(typeClass)
+        .find("#commentSave").click(saveComment).end()
+        .find("#commentCancel").click(function() {
+                                          cancelComment(previousText);
+                                      }).end()
+        .appendTo(commentArea)
+        .find('textarea')
+            .val(previousText)
+            .keypress(function(e) {
+                          if (e.which == 13 && e.ctrlKey)
+                              saveComment();
+                          else
+                              queueSaveDraft();
+                      })
+            .focus(function() {
+                       $("#commentEditor").addClass('focused');
+                   })
+            .blur(function() {
+                      $("#commentEditor").removeClass('focused');
+                  })
+            .each(function() { this.focus(); });
+
+    if (previousText)
+        $("<input id='commentDelete' type='button' value='Delete' />")
+            .click(deleteComment)
+            .appendTo($("#commentEditorLeftButtons"));
+
+    currentEditComment = comment;
+}
+
+function insertCommentForRow(clickRow, clickType) {
+    var file = $(clickRow).data('patchFile');
+    var clickLocation = $(clickRow).data('patchLocation');
+
+    var row = clickRow;
+    var location = clickLocation;
+    var type = clickType;
+
+    saveComment();
+    var commentArea = ensureCommentArea(row);
+    insertCommentEditor(commentArea, file, location, type);
+}
+
+function EL(element, cls, text) {
+    var e = document.createElement(element);
+    if (text != null)
+        e.appendChild(document.createTextNode(text));
+    if (cls)
+        e.className = cls;
+
+    return e;
+}
+
+function getElementPosition(element) {
+    var left = element.offsetLeft;
+    var top = element.offsetTop;
+    var parent = element.offsetParent;
+    while (parent && parent != document.body) {
+        left += parent.offsetLeft;
+        top += parent.offsetTop;
+        parent = parent.offsetParent;
+    }
+
+    return [left, top];
+}
+
+function scrollToElement(element) {
+    var windowHeight;
+    if ('innerHeight' in window) // Not IE
+        windowHeight = window.innerHeight;
+    else // IE
+        windowHeight = document.documentElement.clientHeight;
+    var pos = getElementPosition(element);
+    var yCenter = pos[1] + element.offsetHeight / 2;
+    window.scrollTo(0, yCenter - windowHeight / 2);
+}
+
+function onRowDblClick(e) {
+    var file = $(this).data('patchFile');
+
+    if (file.status == Patch.CHANGED) {
+        var pos = getElementPosition(this);
+        var delta = e.pageX - (pos[0] + this.offsetWidth/2);
+        var type;
+        if (delta < - 20)
+        type = Patch.REMOVED;
+        else if (delta < 20)
+        type = Patch.CHANGED;
+        else
+            type = Patch.ADDED;
+    } else {
+        type = file.status;
+    }
+
+    insertCommentForRow(this, type);
+}
+
+function appendPatchTable(type, maxLine, parentDiv) {
+    var q = $("<table class='file-table'><colgroup></colgroup>"
+              + "</table>").appendTo(parentDiv);
+    var colQ = q.find("colgroup");
+    if (type != Patch.ADDED) {
+        colQ.append("<col class='line-number-column' span='1'></col>");
+        colQ.append("<col class='old-column' span='1'></col>");
+    }
+    if (type == Patch.CHANGED) {
+        colQ.append("<col class='middle-column' span='1'></col>");
+    }
+    if (type != Patch.REMOVED) {
+        colQ.append("<col class='line-number-column' span='1'></col>");
+        colQ.append("<col class='new-column' span='1'></col");
+    }
+
+    if (type == Patch.CHANGED)
+        q.addClass("file-table-changed");
+
+    if (maxLine >= 1000)
+        q.addClass("file-table-wide-numbers");
+
+    q.append("<tbody></tbody>");
+    return q.find("tbody").get(0);
+}
+
+function appendPatchHunk(file, hunk, tableType, includeComments, clickable, tbody, filter) {
+    hunk.iterate(function(loc, oldLine, oldText, newLine, newText, flags, line) {
+                     if (filter && !filter(loc))
+                         return;
+
+                     var tr = document.createElement("tr");
+
+                     var oldStyle = "";
+                     var newStyle = "";
+                     if ((flags & Patch.CHANGED) != 0)
+                         oldStyle = newStyle = "changed-line";
+                     else if ((flags & Patch.REMOVED) != 0)
+                         oldStyle = "removed-line";
+                     else if ((flags & Patch.ADDED) != 0)
+                         newStyle = "added-line";
+
+                     if (tableType != Patch.ADDED) {
+                         if (oldText != null) {
+                             tr.appendChild(EL("td", "line-number", oldLine.toString()));
+                             tr.appendChild(EL("td", "old-line " + oldStyle,
+                                               oldText != "" ? oldText : "\u00a0"));
+                             oldLine++;
+                         } else {
+                             tr.appendChild(EL("td", "line-number"));
+                             tr.appendChild(EL("td", "old-line"));
+                         }
+                     }
+
+                     if (tableType == Patch.CHANGED)
+                         tr.appendChild(EL("td", "line-middle"));
+
+                     if (tableType != Patch.REMOVED) {
+                         if (newText != null) {
+                             tr.appendChild(EL("td", "line-number", newLine.toString()));
+                             tr.appendChild(EL("td", "new-line " + newStyle,
+                                               newText != "" ? newText : "\u00a0"));
+                             newLine++;
+                         } else if (tableType == Patch.CHANGED) {
+                             tr.appendChild(EL("td", "line-number"));
+                             tr.appendChild(EL("td", "new-line"));
+                         }
+                     }
+
+                     if (clickable){
+                         $(tr).data('patchFile', file);
+                         $(tr).data('patchLocation', loc);
+                         $(tr).dblclick(onRowDblClick);
+                     }
+
+                     tbody.appendChild(tr);
+
+                     if (includeComments && line.reviewComments != null)
+                         for (var k = 0; k < line.reviewComments.length; k++) {
+                             var commentArea = ensureCommentArea(tr);
+                             addCommentDisplay(commentArea, line.reviewComments[k]);
+                         }
+                 });
+}
+
+function addPatchFile(file) {
+    var fileDiv = $("<div class='file'></div>").appendTo("#files").get(0);
+    file.div = fileDiv;
+
+    var statusString;
+    switch (file.status) {
+    case Patch.ADDED:
+        statusString = " (new file)";
+       break;
+    case Patch.REMOVED:
+        statusString = " (removed)";
+       break;
+    case Patch.CHANGED:
+        statusString = "";
+       break;
+    }
+
+    $("<div class='file-label'>"
+      + "<span class='file-label-name'></span>"
+      + "<span class='file-label-status'></span>"
+      + "</div/>")
+        .find(".file-label-name").text(file.filename).end()
+        .find(".file-label-status").text(statusString).end()
+        .appendTo(fileDiv);
+
+    var lastHunk = file.hunks[file.hunks.length -1];
+    var lastLine = Math.max(lastHunk.oldStart + lastHunk.oldCount- 1,
+                            lastHunk.newStart + lastHunk.newCount- 1);
+
+    var tbody = appendPatchTable(file.status, lastLine, fileDiv);
+
+    for (var i = 0; i  < file.hunks.length; i++) {
+        var hunk = file.hunks[i];
+        if (hunk.oldStart > 1) {
+            var hunkHeader = EL("tr", "hunk-header");
+            tbody.appendChild(hunkHeader);
+            hunkHeader.appendChild(EL("td")); // line number column
+            var hunkCell = EL("td", "hunk-cell",
+                              hunk.functionLine ? hunk.functionLine : "\u00a0");
+            hunkCell.colSpan = file.status == Patch.CHANGED ? 4 : 1;
+            hunkHeader.appendChild(hunkCell);
+        }
+
+        appendPatchHunk(file, hunk, file.status, true, true, tbody);
+    }
+}
+
+function appendReviewComment(comment, parentDiv) {
+    var commentDiv = EL("div", "review-patch-comment");
+    $(commentDiv).click(function() {
+                            showPatchFile(comment.file.patchFile);
+                            if (comment.file.review == theReview) {
+                                // Immediately start editing the comment again
+                                var commentArea = $(comment.div).parents(".comment-area").find("td").get(0);
+                                insertCommentEditor(commentArea,
+                                                    comment.file.patchFile, comment.location, comment.type);
+                                scrollToElement($("#commentEditor").get(0));
+                            } else {
+                                // Just scroll to the comment, don't start a reply yet
+                                scrollToElement(comment.div);
+                            }
+                        });
+
+    var inReplyTo = comment.getInReplyTo();
+    if (inReplyTo) {
+        $("<div>"
+          + "<div class='reviewer-box'>"
+          + "</div>"
+          + "</div>")
+            .addClass(getReviewerClass(inReplyTo.file.review))
+            .find(".reviewer-box").preWrapLines(inReplyTo.comment).end()
+            .appendTo(commentDiv);
+
+        $("<div class='review-patch-comment-text'></div>")
+            .preWrapLines(comment.comment)
+            .appendTo(commentDiv);
+    } else {
+        var hunk = comment.getHunk();
+
+        var lastLine = Math.max(hunk.oldStart + hunk.oldCount- 1,
+                                hunk.newStart + hunk.newCount- 1);
+        var tbody = appendPatchTable(comment.type, lastLine, commentDiv);
+
+        appendPatchHunk(comment.file.patchFile, hunk, comment.type, false, false, tbody,
+                        function(loc) {
+                            return (loc <= comment.location && comment.location - loc < 3);
+                        });
+        $("<tr>"
+          + "<td></td>"
+          + "<td class='review-patch-comment-text'></td>"
+          + "</tr>")
+            .find('.review-patch-comment-text').preWrapLines(comment.comment).end()
+            .appendTo(tbody);
+    }
+
+    parentDiv.appendChild(commentDiv);
+}
+
+function appendReviewComments(review, parentDiv) {
+    for (var i = 0; i < review.files.length; i++) {
+        var file = review.files[i];
+
+        if (file.comments.length == 0)
+            continue;
+
+        parentDiv.appendChild(EL("div", "review-patch-file", file.patchFile.filename));
+        var firstComment = true;
+        for (var j = 0; j < file.comments.length; j++) {
+            if (firstComment)
+                firstComment = false;
+            else
+                parentDiv.appendChild(EL("div", "review-patch-comment-separator"));
+
+            appendReviewComment(file.comments[j], parentDiv);
+        }
+    }
+}
+
+function updateMyPatchComments() {
+    appendReviewComments(theReview, $("#myPatchComments").empty().get(0));
+    if ($("#myPatchComments").children().size() > 0)
+        $("#myPatchComments").show();
+    else
+        $("#myPatchComments").hide();
+}
+
+function selectNavigationLink(identifier) {
+    $(".navigation-link").removeClass("navigation-link-selected");
+    $(navigationLinks[identifier]).addClass("navigation-link-selected");
+}
+
+function addNavigationLink(identifier, title, callback, selected) {
+    if ($("#navigation").children().size() > 0)
+        $("#navigation").append(" | ");
+
+    var q = $("<a class='navigation-link' href='javascript:void(0)'></a>")
+        .text(title)
+        .appendTo("#navigation")
+        .click(function() {
+                   if (!$(this).hasClass("navigation-link-selected")) {
+                       callback();
+                   }
+               });
+
+    if (selected)
+        q.addClass("navigation-link-selected");
+
+    navigationLinks[identifier] = q.get(0);
+}
+
+function showOverview() {
+    selectNavigationLink('__OVERVIEW__');
+    $("#overview").show();
+    $(".file").hide();
+    updateMyPatchComments();
+}
+
+function addOverviewNavigationLink() {
+    addNavigationLink('__OVERVIEW__', "Overview", showOverview, true);
+}
+
+function showPatchFile(file) {
+    selectNavigationLink(file.filename);
+    $("#overview").hide();
+    $(".file").hide();
+    if (file.div)
+        $(file.div).show();
+    else
+        addPatchFile(file);
+}
+
+function addFileNavigationLink(file) {
+    var basename = file.filename.replace(/.*\//, "");
+    addNavigationLink(file.filename, basename, function() {
+        showPatchFile(file);
+    });
+}
+
+var REVIEW_RE = /^\s*review\s+of\s+attachment\s+(\d+)\s*:\s*/i;
+
+function start(xml) {
+    var i;
+
+    document.title = "Attachment " + theAttachment.id + " - " + theAttachment.description + " - Patch 
Review";
+
+    doneLoading();
+    $("#attachmentInfo").show();
+    $("#navigation").show();
+    $("#overview").show();
+    $("#files").show();
+
+    $("#subtitle").text("Attachment " + theAttachment.id + " - " + theAttachment.description);
+    $("<a></a>")
+        .text("Bug " + theBug.id)
+        .attr('href', newPageUrl(theBug.id))
+        .attr('title', theBug.shortDesc)
+        .click(flushSaveDraft)
+        .appendTo("#information");
+
+    for (i = 0; i < configAttachmentStatuses.length; i++) {
+        $("<option></option") .text(configAttachmentStatuses[i])
+        .appendTo($("#attachmentStatus")); }
+
+    if (theAttachment.status != null)
+        $("#attachmentStatus")
+            .val(theAttachment.status)
+            .change(queueUpdateHaveDraft);
+    else
+        $("#attachmentStatusSpan").hide();
+
+    if (thePatch.intro)
+        $("#patchIntro").preWrapLines(thePatch.intro);
+    else
+        $("#patchIntro").hide();
+
+    addOverviewNavigationLink();
+    for (i = 0; i < thePatch.files.length; i++)
+        addFileNavigationLink(thePatch.files[i]);
+
+    $("<div id='haveDraftNotice'style='display: none;'>Draft</div>"
+      + "<div class='clear'></div>").appendTo("#navigation");
+
+    var numReviewers = 0;
+    for (i = 0; i < theBug.comments.length; i++) {
+        var comment = theBug.comments[i];
+        var m = REVIEW_RE.exec(comment.text);
+
+        if (m && parseInt(m[1]) == attachmentId) {
+            var review = new Review.Review(thePatch, comment.getWho(), comment.date);
+            review.parse(comment.text.substr(m[0].length));
+
+            var reviewerIndex;
+            if (review.who in reviewers)
+                reviewerIndex = reviewers[review.who];
+            else {
+                reviewerIndex = ++numReviewers;
+                reviewers[review.who] = reviewerIndex;
+            }
+
+            var q = $("<div class='review'>"
+              + "<div class='reviewer-box'>"
+              + "<div class='reviewer'></div><div class='review-date'></div>"
+              + "<div class='review-info-bottom'></div>"
+              + "<div class='review-intro'></div>"
+              + "</div>"
+              + "</div>")
+                .addClass(getReviewerClass(review))
+                .find(".reviewer").text(review.who).end()
+                .find(".review-date").text(Utils.formatDate(review.date)).end()
+                .find(".review-intro").preWrapLines(review.intro? review.intro : "").end()
+                .appendTo("#oldReviews");
+
+            $("#oldReviews").show();
+
+            appendReviewComments(review, q.find('.reviewer-box').get(0));
+        }
+    }
+
+    // We load the saved draft or create a new reeview *after* inserting the existing reviews
+    // so that the ordering comes out right.
+
+    if (reviewStorage) {
+        theReview = reviewStorage.loadDraft(theBug, theAttachment, thePatch);
+        if (theReview) {
+            var storedReviews = reviewStorage.listReviews();
+            $("#restored").show();
+            for (i = 0; i < storedReviews.length; i++) {
+                if (storedReviews[i].bugId == theBug.id &&
+                    storedReviews[i].attachmentId == theAttachment.id)
+                    $("#restoredLastModified").text(Utils.formatDate(new 
Date(storedReviews[i].modificationTime)));
+            }
+        }
+    }
+
+    if (!theReview)
+        theReview = new Review.Review(thePatch);
+
+    if (theReview.intro)
+        $("#emptyCommentNotice").hide();
+
+    $("#myComment")
+        .val(theReview.intro ? theReview.intro : "")
+        .focus(function() {
+                   $("#emptyCommentNotice").hide();
+               })
+        .blur(function() {
+                  if ($(this).val().search(/\S/) < 0)
+                      $("#emptyCommentNotice").show();
+              })
+        .keypress(function() {
+                      queueSaveDraft();
+                      queueUpdateHaveDraft();
+                  });
+
+    updateMyPatchComments();
+
+    queueUpdateHaveDraft();
+
+    $("#publishButton").click(publishReview);
+    $("#cancelButton").click(discardReview);
+}
+
+function gotBug(xml) {
+    theBug = Bug.Bug.fromDOM(xml);
+
+    showNote();
+
+    if (attachmentId != null) {
+        theAttachment = theBug.getAttachment(attachmentId);
+        if (theAttachment == null)
+            displayError("Attachment " + attachmentId + " is not an attachment to bug " + theBug.id);
+        else if (!theAttachment.isPatch) {
+            displayError("Attachment " + attachmentId + " is not a patch");
+            theAttachment = null;
+        }
+    }
+
+    if (theAttachment == null)
+        showChooseAttachment();
+    else if (thePatch != null)
+        start();
+}
+
+function gotAttachment(text) {
+    thePatch = new Patch.Patch(text);
+    if (theAttachment != null)
+        start();
+}
+
+function isDigits(str) {
+    return str.match(/^[0-9]+$/);
+}
+
+function newPageUrl(newBugId, newAttachmentId) {
+    var newUrl = configBase;
+    if (newBugId != null) {
+        newUrl += (newUrl.indexOf("?") < 0) ? "?" : "&";
+        newUrl += "bug=" + escape("" + newBugId);
+        if (newAttachmentId != null)
+            newUrl += "&attachment=" + escape("" + newAttachmentId);
+    }
+
+    return newUrl;
+}
+
+function showNote() {
+    if (configNote)
+        $("#note")
+            .text(configNote)
+            .show();
+}
+
+function showEnterBug() {
+    showNote();
+
+    $("#enterBugGo").click(function() {
+                               var newBugId = Utils.strip($("#enterBugInput").val());
+                               document.location = newPageUrl(newBugId);
+                           });
+    doneLoading();
+    $("#enterBug").show();
+
+    if (!reviewStorage)
+        return;
+
+    var storedReviews = reviewStorage.listReviews();
+    if (storedReviews.length == 0)
+        return;
+
+    $("#chooseReview").show();
+
+    for (var i = storedReviews.length - 1; i >= 0; i--) {
+        var reviewInfo = storedReviews[i];
+        var href = newPageUrl(reviewInfo.bugId, reviewInfo.attachmentId);
+        var modificationDate = Utils.formatDate(new Date(reviewInfo.modificationTime));
+
+        var extra = reviewInfo.isDraft ? "(draft)" : "";
+
+        $("<tr>"
+          + "<td class='review-bug'>Bug <span></span></td>"
+          + "<td class='review-attachment'><a></a></td>"
+          + "<td class='review-desc'><a></a></td>"
+          + "<td class='review-modification'></td>"
+          + "<td class='review-extra'></td>"
+          + "</tr>")
+            .addClass(reviewInfo.isDraft ? "review-draft" : "")
+            .find(".review-bug span").text(reviewInfo.bugId).end()
+            .find(".review-attachment a")
+                .attr("href", href)
+                .text("Attachment " + reviewInfo.attachmentId).end()
+            .find(".review-desc a")
+                .attr("href", href)
+                .text(reviewInfo.attachmentDescription).end()
+            .find(".review-modification").text(modificationDate).end()
+            .find(".review-extra").text(extra).end()
+            .appendTo("#chooseReview tbody");
+    }
+}
+
+function showChooseAttachment() {
+    $("#bugId").text(theBug.id);
+    $("#bugShortDesc").text(theBug.shortDesc);
+    $("#bugReporter").text(theBug.getReporter());
+    $("#bugCreationDate").text(Utils.formatDate(theBug.creationDate));
+
+    $("#bugInfo").show();
+
+    document.title = "Bug " + theBug.id + " - " + theBug.shortDesc + " - Patch Review";
+    $("#originalBugLink").attr('href', configBugzillaUrl + "show_bug.cgi?id=" + theBug.id);
+
+    $("#allReviewsLink").attr('href', configBase);
+
+    var drafts = {};
+    var published = {};
+    if (reviewStorage) {
+        var storedReviews = reviewStorage.listReviews();
+        for (var j = 0; j < storedReviews.length; j++) {
+            var reviewInfo = storedReviews[j];
+            if (reviewInfo.bugId == theBug.id) {
+                if (reviewInfo.isDraft)
+                    drafts[reviewInfo.attachmentId] = 1;
+                else
+                    published[reviewInfo.attachmentId] = 1;
+            }
+        }
+    }
+
+    for (var i = 0; i < theBug.attachments.length; i++) {
+        var attachment = theBug.attachments[i];
+
+        if (!attachment.isPatch)
+            continue;
+
+        var href = newPageUrl(theBug.id, attachment.id);
+
+        var date = Utils.formatDate(attachment.date);
+        var status = (attachment.status && attachment.status != 'none') ? attachment.status : '';
+
+        var obsoleteClass = attachment.isObsolete ? "attachment-obsolete" : '';
+        var draftClass = attachment.id in drafts ? "attachment-draft" : '';
+
+        var extra = '';
+        if (attachment.id in drafts)
+            extra = '(draft)';
+        else if (attachment.id in published)
+            extra = '(published)';
+
+        $("<tr>"
+          + "<td class='attachment-id'><a></a></td>"
+          + "<td class='attachment-desc'><a></a></td>"
+          + "<td class='attachment-date'></td>"
+          + "<td class='attachment-status'></td>"
+          + "<td class='attachment-extra'></td>"
+          + "</tr>")
+            .addClass(obsoleteClass)
+            .addClass(draftClass)
+            .find(".attachment-id a")
+                .attr("href", href)
+                .text(attachment.id).end()
+            .find(".attachment-desc a")
+                .attr("href", href)
+                .text(attachment.description).end()
+            .find(".attachment-date").text(date).end()
+            .find(".attachment-status").text(status).end()
+            .find(".attachment-extra").text(extra).end()
+            .appendTo("#chooseAttachment tbody");
+    }
+
+    doneLoading();
+    $("#chooseAttachment").show();
+}
+
+
+// This is basically a workaround for IE which doesn't treat \n as a
+// line-break in white-space: pre, but only \r\n; we could normalize
+// line endings, but we take an alternate approach of just putting
+// each line into a separate div. We omit a trailing empty line
+// after the last line break.
+var LINE_RE = /(?!$)([^\r\n]*)(?:\r\n|\r|\n|$)/g;
+
+jQuery.fn.preWrapLines = function(text) {
+    return this.each(function() {
+        while ((m = LINE_RE.exec(text)) != null) {
+            var div = document.createElement("div");
+            div.className = "pre-wrap";
+            div.appendChild(document.createTextNode(m[1].length == 0 ? " " : m[1]));
+            this.appendChild(div);
+        }
+    });
+};
+
+function init() {
+    var params = getQueryParams();
+    var bugId;
+
+    if (ReviewStorage.LocalReviewStorage.available())
+        reviewStorage = new ReviewStorage.LocalReviewStorage();
+
+    if (params.bug)
+        bugId = isDigits(params.bug) ? parseInt(params.bug) : NaN;
+
+    if (bugId === undefined || isNaN(bugId)) {
+        if (bugId !== undefined)
+            displayError("Bug ID '" + params.bug + "' is not valid");
+        showEnterBug();
+        return;
+    } else {
+        $.ajax({
+                   type: 'GET',
+                   dataType: 'xml',
+                   url: '/show_bug.cgi',
+                   data: {
+                       id: bugId,
+                       ctype: 'xml',
+                       excludefield: 'attachmentdata'
+                   },
+                   success: gotBug,
+                   error: function() {
+                       displayError("Failed to retrieve bug " + bugId);
+                       showEnterBug();
+                   }
+               });
+    }
+
+    if (params.attachment) {
+        attachmentId = isDigits(params.attachment) ? parseInt(params.attachment) : NaN;
+    }
+    if (attachmentId === undefined || isNaN(attachmentId)) {
+        if (attachmentId !== undefined) {
+            displayError("Attachment ID '" + params.bug + "' is not valid");
+            attachmentId = undefined;
+        }
+    } else {
+        $.ajax({
+                   type: 'GET',
+                   dataType: 'text',
+                   url: '/attachment.cgi',
+                   data: {
+                       id: attachmentId
+                   },
+                   success: gotAttachment,
+                   error: function(a, b, c) {
+                       displayError("Failed to retrieve attachment " + attachmentId);
+                   }
+               });
+    }
+}



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]