[mb-commits] branch, beta, updated. Merge remote-tracking branch 'bitmap/knockout-2.2.0-upgrade' into beta Upgrad...

MusicBrainz Git Server <[email protected]>
Newsgroups gmane.comp.audio.musicbrainz.cvs
Message-ID <E1TTZxb-0005nX-P0@wiley>
The branch, beta has been updated
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=c5ccae2d806a22dfdd81d6206f8c41441a336769 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=6adb200d24e881ea378024ec157e246899f8423c (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=08ea6ce9c82e3d436eb70eb94abda77f54054292 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=fd647f86b613b72bc49b92c98ea31636198f347c (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=684cc3e815c33a43c0b21e9238e2599a484b585c (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=ac01ec72b245c38b340366dd51a7c9d6f29c8d51 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=a9a9cd279e10e4a3f4d28673ca2e5ef85d1b0fa0 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=c3075e51689a2cf3ab04d65366797ca9255ce626 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=63041d75886c85863c37cc1ddd60c393dfebd529 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=0aa7752ed2ee645974f0318e091358350341434d (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=8393ef47433de047a7eee98eef531139888e42d5 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=49aa509da1ebb8b837b4b3cafd5aa14e775a5a8f (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=ae8998748bd0e70e23e7ef07b3b63e1f8c8bb17a (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=070070138957f693768b57a7a1ce28f388ab8de9 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=231fbd6576b11b06d0523efd04944cb339b2d666 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=a3297c80d5d70091a595bfc4a480be9c254a2234 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=e8ab6a5159a38465f46f4722486c8bacb8cd911b (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=b89cf93efaf0678881d43af27b76f97f073495c8 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=3d66a69744bc014f3e063c8e718f12669705d675 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=ffc2aafea8dac4f24fb62c7ed043da9c351c7173 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=0650b786f211914d9b01f66a30ed7f4dab6ebfa6 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=2b57f54d138f3e6d16bcaa8b207efaf213a0a573 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=90461ef717ee409ca872fccf44b68d815e794bc4 (commit)
       via  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=ab6011c012d1277e9f2634cbd05f9693b2aa42d9 (commit)
      from  http://git.musicbrainz.org/gitweb/?p=musicbrainz-server/core.git;a=commit;h=0daca74fc9bfbbeca8aa185e9c217efa8e0cf97b (commit)

Summary of changes:
 .../Server/Controller/RelationshipEditor.pm        |   16 +-
 root/edit/index.tt                                 |    6 +-
 root/release/edit_relationships.tt                 |  126 +-
 root/scripts/text_strings.tt                       |    3 +-
 root/static/lib/knockout/knockout-latest.debug.js  | 1264 +++++++++++---------
 .../lib/knockout/knockout.mapping-latest.debug.js  |  716 -----------
 root/static/lib/libraries.txt                      |   10 +-
 root/static/relationship-editor.js.manifest        |    1 -
 root/static/scripts/common/multiselect.js          |   20 +-
 root/static/scripts/relationship-editor/Dialog.js  |  436 ++++----
 root/static/scripts/relationship-editor/Entity.js  |  209 ++--
 root/static/scripts/relationship-editor/Fields.js  |  169 ++--
 .../scripts/relationship-editor/Relationship.js    |  393 +++----
 .../relationship-editor/RelationshipEditor.js      |   59 +-
 root/static/scripts/relationship-editor/Util.js    |  108 +--
 root/static/scripts/tests/RelationshipEditor.js    |  390 +++---
 root/static/scripts/tests/all.html                 |    1 -
 root/static/styles/relationship-editor.css         |    1 +
 root/static/styles/widgets.css                     |    6 +
 root/work/edit_form.tt                             |    6 +-
 20 files changed, 1561 insertions(+), 2379 deletions(-)
 delete mode 100644 root/static/lib/knockout/knockout.mapping-latest.debug.js

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit c5ccae2d806a22dfdd81d6206f8c41441a336769
Merge: 6adb200 49aa509
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 15:01:59 2012 +0000

    Merge remote-tracking branch 'bitmap/knockout-2.2.0-upgrade' into beta
    
    * bitmap/knockout-2.2.0-upgrade:
      Upgrade knockout.js to 2.2.0

diff --cc root/static/scripts/relationship-editor/Dialog.js
index 766bae0,f412855..078e8cc
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@@ -316,20 -289,8 +315,8 @@@ var Dialog = UI.Dialog = 
      showAttributesHelp: ko.observable(false),
      showLinkTypeHelp: ko.observable(false),
  
-     relationship: (function() {
-         var value = ko.observable(null);
- 
-         return ko.computed({
-             read: value,
-             write: function(newValue) {
-                 var oldValue = value();
-                 if (oldValue !== newValue) value(newValue);
-             }
-         });
-     }()),
- 
      init: function() {
 -        var entity = [RE.Entity({type: "artist"}), RE.Entity({type: "recording"})];
 +        var self = this, entity = [RE.Entity({type: "artist"}), RE.Entity({type: "recording"})];
  
          // this is used as an "empty" state when the dialog is hidden, so that
          // none of the bindings error out.

commit 6adb200d24e881ea378024ec157e246899f8423c
Merge: 08ea6ce 0aa7752
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 15:01:25 2012 +0000

    Merge remote-tracking branch 'mineo/add-vote-button-for-review' into beta
    
    * mineo/add-vote-button-for-review:
      Do not show 'and vote' on the note/vote submit button for closed edits


commit 08ea6ce9c82e3d436eb70eb94abda77f54054292
Merge: fd647f8 8393ef4
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:58:45 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5538' into beta
    
    * bitmap/mbs-5538:
      MBS-5538: Pressing enter in work add adds/removes ISWC instead of submitting


commit fd647f86b613b72bc49b92c98ea31636198f347c
Merge: 684cc3e e8ab6a5
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:57:10 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5521' into beta
    
    * bitmap/mbs-5521:
      MBS-5521: Date placeholders in the relationship editor are not translated


commit 684cc3e815c33a43c0b21e9238e2599a484b585c
Merge: ac01ec7 231fbd6
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:56:40 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5438-change-batch-tools-ui' into beta
    
    * bitmap/mbs-5438-change-batch-tools-ui:
      Rearrange the rel editor's batch ui


commit ac01ec72b245c38b340366dd51a7c9d6f29c8d51
Merge: a9a9cd2 0700701
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:56:12 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5529' into beta
    
    * bitmap/mbs-5529:
      Relationship editor could partially submit edits


commit a9a9cd279e10e4a3f4d28673ca2e5ef85d1b0fa0
Merge: c3075e5 ae89987
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:55:45 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5480' into beta
    
    * bitmap/mbs-5480:
      MBS-5480: limit the size of the autocomplete menu

diff --cc root/static/scripts/relationship-editor/Dialog.js
index 40c27d3,414968e..766bae0
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@@ -209,7 -209,15 +209,9 @@@ ko.bindingHandlers.autocomplete = (func
          $("#target-type").val(type).trigger("change");
      }
  
 -    function closeOnEnter(event) {
 -        if (event.keyCode == 13 && !Dialog.relationship.peek().hasErrors.peek() &&
 -            !event.isDefaultPrevented())
 -                Dialog.instance.peek().accept();
 -    }
 -
      function changeTarget(event, data) {
+         if (!data.gid) return;
+ 
          // XXX release groups' numeric "type" conflicts with the entity type
          data.type = _.isNumber(data.type) ? "release_group" : (data.type || Dialog.target.type);
  

commit c3075e51689a2cf3ab04d65366797ca9255ce626
Merge: 63041d7 b89cf93
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:55:01 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5476' into beta
    
    * bitmap/mbs-5476:
      Extraneous semicolon
      MBS-5476 / MBS-5445: Misc. fixes pressing <enter> in dialog


commit 63041d75886c85863c37cc1ddd60c393dfebd529
Merge: 0daca74 a3297c8
Author: Oliver Charles <[email protected]>
Date:   Wed Oct 31 14:54:17 2012 +0000

    Merge remote-tracking branch 'bitmap/mbs-5389' into beta
    
    * bitmap/mbs-5389:
      Fix open edits link in the rel editor dialog
      Fix the "Create a new work" link not displaying
      Avoid re-rendering the dialog attrs
      Correctly handle relating a recording on the release to another recording on the release (MBS-5389)


commit 0aa7752ed2ee645974f0318e091358350341434d
Author: Wieland Hoffmann <[email protected]>
Date:   Sat Oct 27 15:45:36 2012 +0200

    Do not show 'and vote' on the note/vote submit button for closed edits

diff --git a/root/edit/index.tt b/root/edit/index.tt
index 9bb2f8c..57d66c6 100644
--- a/root/edit/index.tt
+++ b/root/edit/index.tt
@@ -78,8 +78,10 @@
 
             <h2>[% l('Edit notes') %]</h2>
             [% INCLUDE 'edit/notes.tt' %]
-            [%- IF edit.editor_may_add_note(c.user) || edit.editor_may_vote -%]
-                [%- form_submit(l('Add note and vote')) -%]
+            [%- IF edit.editor_may_vote(c.user) -%]
+                    [%- form_submit(l('Add note and vote')) -%]
+            [%- ELSIF edit.editor_may_add_note(c.user) -%]
+                    [%- form_submit(l('Add note')) -%]
             [%- END -%]
         </form>
     </div>

commit 8393ef47433de047a7eee98eef531139888e42d5
Author: Michael Wiencek <[email protected]>
Date:   Tue Oct 30 18:29:00 2012 +0000

    MBS-5538: Pressing enter in work add adds/removes ISWC instead of submitting

diff --git a/root/work/edit_form.tt b/root/work/edit_form.tt
index ea741ab..446f126 100644
--- a/root/work/edit_form.tt
+++ b/root/work/edit_form.tt
@@ -1,4 +1,4 @@
-<form action="[% c.req.uri %]" method="post">
+<form action="[% c.req.uri %]" method="post" class="edit-work">
   [%- USE r = FormRenderer(form) -%]
 
   <div class="half-width">
@@ -23,3 +23,7 @@
   </div>
 
 </form>
+
+<script type="text/javascript">
+    MB.utility.setDefaultAction ('form.edit-work', 'button.submit.positive');
+</script>

commit 49aa509da1ebb8b837b4b3cafd5aa14e775a5a8f
Author: Michael Wiencek <[email protected]>
Date:   Tue Oct 30 02:07:16 2012 +0000

    Upgrade knockout.js to 2.2.0
    
    Also make use of the improved css binding, and remove unneeded code.

diff --git a/root/release/edit_relationships.tt b/root/release/edit_relationships.tt
index 296cd5e..44c999a 100644
--- a/root/release/edit_relationships.tt
+++ b/root/release/edit_relationships.tt
@@ -276,12 +276,9 @@
   </tr>
 </script>
 
-<script type="text/html" id="template.link.phrase"><span class="link-phrase"
-        data-bind="text: linkPhrase($parents[1]), css: {
-             'error-field': hasErrors,
-             'rel-add':     action() == 'add',
-             'rel-edit':    action() == 'edit',
-             'rel-remove':  action() == 'remove'}"></span></script>
+<script type="text/html" id="template.link.phrase"><!--
+  --><span class="link-phrase" data-bind="text: linkPhrase($parents[1]), css: css()"></span><!--
+--></script>
 
 <script type="text/html" id="template.relationship">
   <div class="ar">
diff --git a/root/static/lib/knockout/knockout-latest.debug.js b/root/static/lib/knockout/knockout-latest.debug.js
index 29dc24b..61c2a0e 100644
--- a/root/static/lib/knockout/knockout-latest.debug.js
+++ b/root/static/lib/knockout/knockout-latest.debug.js
@@ -1,4 +1,4 @@
-// Knockout JavaScript library v2.1.0
+// Knockout JavaScript library v2.2.0
 // (c) Steven Sanderson - http://knockoutjs.com/
 // License: MIT (http://www.opensource.org/licenses/mit-license.php)
 
@@ -37,7 +37,7 @@ ko.exportSymbol = function(koPath, object) {
 ko.exportProperty = function(owner, publicName, object) {
   owner[publicName] = object;
 };
-ko.version = "2.1.0";
+ko.version = "2.2.0";
 
 ko.exportSymbol('version', ko.version);
 ko.utils = new (function () {
@@ -58,6 +58,9 @@ ko.utils = new (function () {
     var eventsThatMustBeRegisteredUsingAttachEvent = { 'propertychange': true }; // Workaround for an IE9 issue - https://github.com/SteveSanderson/knockout/issues/406
 
     // Detect IE versions for bug workarounds (uses IE conditionals, not UA string, for robustness)
+    // Note that, since IE 10 does not support conditional comments, the following logic only detects IE < 10.
+    // Currently this is by design, since IE 10+ behaves correctly when treated as a standard browser.
+    // If there is a future need to detect specific versions of IE10+, we will amend this.
     var ieVersion = (function() {
         var version = 3, div = document.createElement('div'), iElems = div.getElementsByTagName('i');
 
@@ -168,12 +171,19 @@ ko.utils = new (function () {
 
             var container = document.createElement('div');
             for (var i = 0, j = nodesArray.length; i < j; i++) {
-                ko.cleanNode(nodesArray[i]);
-                container.appendChild(nodesArray[i]);
+                container.appendChild(ko.cleanNode(nodesArray[i]));
             }
             return container;
         },
 
+        cloneNodes: function (nodesArray, shouldCleanNodes) {
+            for (var i = 0, j = nodesArray.length, newNodesArray = []; i < j; i++) {
+                var clonedNode = nodesArray[i].cloneNode(true);
+                newNodesArray.push(shouldCleanNodes ? ko.cleanNode(clonedNode) : clonedNode);
+            }
+            return newNodesArray;
+        },
+
         setDomNodeChildren: function (domNode, childNodes) {
             ko.utils.emptyDomNode(domNode);
             if (childNodes) {
@@ -225,17 +235,6 @@ ko.utils = new (function () {
             return string.substring(0, startsWith.length) === startsWith;
         },
 
-        buildEvalWithinScopeFunction: function (expression, scopeLevels) {
-            // Build the source for a function that evaluates "expression"
-            // For each scope variable, add an extra level of "with" nesting
-            // Example result: with(sc[1]) { with(sc[0]) { return (expression) } }
-            var functionBody = "return (" + expression + ")";
-            for (var i = 0; i < scopeLevels; i++) {
-                functionBody = "with(sc[" + i + "]) { " + functionBody + " } ";
-            }
-            return new Function("sc", functionBody);
-        },
-
         domNodeIsContainedBy: function (node, containedByNode) {
             if (containedByNode.compareDocumentPosition)
                 return (containedByNode.compareDocumentPosition(node) & 16) == 16;
@@ -321,6 +320,10 @@ ko.utils = new (function () {
             return ko.isObservable(value) ? value() : value;
         },
 
+        peekObservable: function (value) {
+            return ko.isObservable(value) ? value.peek() : value;
+        },
+
         toggleDomNodeCssClass: function (node, classNames, shouldHaveClass) {
             if (classNames) {
                 var cssClassNameRegex = /[\w-]+/g,
@@ -344,17 +347,21 @@ ko.utils = new (function () {
             if ((value === null) || (value === undefined))
                 value = "";
 
-            // We need there to be exactly one child: a text node.
-            // If there are no children, more than one, or if it's not a text node,
-            // we'll clear everything and create a single text node.
-            var innerTextNode = ko.virtualElements.firstChild(element);
-            if (!innerTextNode || innerTextNode.nodeType != 3 || ko.virtualElements.nextSibling(innerTextNode)) {
-                ko.virtualElements.setDomNodeChildren(element, [document.createTextNode(value)]);
+            if (element.nodeType === 3) {
+                element.data = value;
             } else {
-                innerTextNode.data = value;
-            }
+                // We need there to be exactly one child: a text node.
+                // If there are no children, more than one, or if it's not a text node,
+                // we'll clear everything and create a single text node.
+                var innerTextNode = ko.virtualElements.firstChild(element);
+                if (!innerTextNode || innerTextNode.nodeType != 3 || ko.virtualElements.nextSibling(innerTextNode)) {
+                    ko.virtualElements.setDomNodeChildren(element, [document.createTextNode(value)]);
+                } else {
+                    innerTextNode.data = value;
+                }
 
-            ko.utils.forceRefresh(element);
+                ko.utils.forceRefresh(element);
+            }
         },
 
         setElementName: function(element, name) {
@@ -496,6 +503,7 @@ ko.exportSymbol('utils.arrayRemoveItem', ko.utils.arrayRemoveItem);
 ko.exportSymbol('utils.extend', ko.utils.extend);
 ko.exportSymbol('utils.fieldsIncludedWithJsonPost', ko.utils.fieldsIncludedWithJsonPost);
 ko.exportSymbol('utils.getFormFields', ko.utils.getFormFields);
+ko.exportSymbol('utils.peekObservable', ko.utils.peekObservable);
 ko.exportSymbol('utils.postJson', ko.utils.postJson);
 ko.exportSymbol('utils.parseJson', ko.utils.parseJson);
 ko.exportSymbol('utils.registerEventHandler', ko.utils.registerEventHandler);
@@ -536,7 +544,7 @@ ko.utils.domData = new (function () {
         },
         getAll: function (node, createIfNotFound) {
             var dataStoreKey = node[dataStoreKeyExpandoPropertyName];
-            var hasExistingDataStore = dataStoreKey && (dataStoreKey !== "null");
+            var hasExistingDataStore = dataStoreKey && (dataStoreKey !== "null") && dataStore[dataStoreKey];
             if (!hasExistingDataStore) {
                 if (!createIfNotFound)
                     return undefined;
@@ -550,7 +558,9 @@ ko.utils.domData = new (function () {
             if (dataStoreKey) {
                 delete dataStore[dataStoreKey];
                 node[dataStoreKeyExpandoPropertyName] = null;
+                return true; // Exposing "did clean" flag purely so specs can infer whether things have been cleaned up as intended
             }
+            return false;
         }
     }
 })();
@@ -638,6 +648,7 @@ ko.utils.domNodeDisposal = new (function () {
                         cleanSingleNode(descendants[i]);
                 }
             }
+            return node;
         },
 
         removeNode : function(node) {
@@ -952,10 +963,10 @@ ko.dependencyDetection = (function () {
             }
         },
 
-        ignore: function(callback, callbackTarget) {
+        ignore: function(callback, callbackTarget, callbackArgs) {
             try {
                 _frames.push(null);
-                callback.call(callbackTarget);
+                return callback.apply(callbackTarget, callbackArgs || []);
             } finally {
                 _frames.pop();
             }
@@ -1167,41 +1178,20 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
         if (!readFunction)
             readFunction = options["read"];
     }
-    // By here, "options" is always non-null
     if (typeof readFunction != "function")
         throw new Error("Pass a function that returns the value of the ko.computed");
 
-    var writeFunction = options["write"];
-    if (!evaluatorFunctionTarget)
-        evaluatorFunctionTarget = options["owner"];
+    function addSubscriptionToDependency(subscribable) {
+        _subscriptionsToDependencies.push(subscribable.subscribe(evaluatePossiblyAsync));
+    }
 
-    var _subscriptionsToDependencies = [];
     function disposeAllSubscriptionsToDependencies() {
         ko.utils.arrayForEach(_subscriptionsToDependencies, function (subscription) {
             subscription.dispose();
         });
         _subscriptionsToDependencies = [];
     }
-    var dispose = disposeAllSubscriptionsToDependencies;
-
-    // Build "disposeWhenNodeIsRemoved" and "disposeWhenNodeIsRemovedCallback" option values
-    // (Note: "disposeWhenNodeIsRemoved" option both proactively disposes as soon as the node is removed using ko.removeNode(),
-    // plus adds a "disposeWhen" callback that, on each evaluation, disposes if the node was removed by some other means.)
-    var disposeWhenNodeIsRemoved = (typeof options["disposeWhenNodeIsRemoved"] == "object") ? options["disposeWhenNodeIsRemoved"] : null;
-    var disposeWhen = options["disposeWhen"] || function() { return false; };
-    if (disposeWhenNodeIsRemoved) {
-        dispose = function() {
-            ko.utils.domNodeDisposal.removeDisposeCallback(disposeWhenNodeIsRemoved, arguments.callee);
-            disposeAllSubscriptionsToDependencies();
-        };
-        ko.utils.domNodeDisposal.addDisposeCallback(disposeWhenNodeIsRemoved, dispose);
-        var existingDisposeWhenFunction = disposeWhen;
-        disposeWhen = function () {
-            return !ko.utils.domNodeIsAttachedToDocument(disposeWhenNodeIsRemoved) || existingDisposeWhenFunction();
-        }
-    }
 
-    var evaluationTimeoutInstance = null;
     function evaluatePossiblyAsync() {
         var throttleEvaluationTimeout = dependentObservable['throttleEvaluation'];
         if (throttleEvaluationTimeout && throttleEvaluationTimeout >= 0) {
@@ -1239,7 +1229,7 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
                 if ((inOld = ko.utils.arrayIndexOf(disposalCandidates, subscribable)) >= 0)
                     disposalCandidates[inOld] = undefined; // Don't want to dispose this subscription, as it's still being used
                 else
-                    _subscriptionsToDependencies.push(subscribable.subscribe(evaluatePossiblyAsync)); // Brand new subscription - add it
+                    addSubscriptionToDependency(subscribable); // Brand new subscription - add it
             });
 
             var newValue = readFunction.call(evaluatorFunctionTarget);
@@ -1260,7 +1250,8 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
 
         dependentObservable["notifySubscribers"](_latestValue);
         _isBeingEvaluated = false;
-
+        if (!_subscriptionsToDependencies.length)
+            dispose();
     }
 
     function dependentObservable() {
@@ -1271,6 +1262,7 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
             } else {
                 throw new Error("Cannot write a value to a ko.computed unless you specify a 'write' option. If you wish to read the current value, don't pass any parameters.");
             }
+            return this; // Permits chained assignments
         } else {
             // Reading the value
             if (!_hasBeenEvaluated)
@@ -1280,26 +1272,61 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction
         }
     }
 
-    dependentObservable.peek = function () {
+    function peek() {
         if (!_hasBeenEvaluated)
             evaluateImmediate();
         return _latestValue;
     }
 
+    function isActive() {
+        return !_hasBeenEvaluated || _subscriptionsToDependencies.length > 0;
+    }
+
+    // By here, "options" is always non-null
+    var writeFunction = options["write"],
+        disposeWhenNodeIsRemoved = options["disposeWhenNodeIsRemoved"] || options.disposeWhenNodeIsRemoved || null,
+        disposeWhen = options["disposeWhen"] || options.disposeWhen || function() { return false; },
+        dispose = disposeAllSubscriptionsToDependencies,
+        _subscriptionsToDependencies = [],
+        evaluationTimeoutInstance = null;
+
+    if (!evaluatorFunctionTarget)
+        evaluatorFunctionTarget = options["owner"];
+
+    dependentObservable.peek = peek;
     dependentObservable.getDependenciesCount = function () { return _subscriptionsToDependencies.length; };
     dependentObservable.hasWriteFunction = typeof options["write"] === "function";
     dependentObservable.dispose = function () { dispose(); };
+    dependentObservable.isActive = isActive;
 
     ko.subscribable.call(dependentObservable);
     ko.utils.extend(dependentObservable, ko.dependentObservable['fn']);
 
-    if (options['deferEvaluation'] !== true)
-        evaluateImmediate();
-
     ko.exportProperty(dependentObservable, 'peek', dependentObservable.peek);
     ko.exportProperty(dependentObservable, 'dispose', dependentObservable.dispose);
+    ko.exportProperty(dependentObservable, 'isActive', dependentObservable.isActive);
     ko.exportProperty(dependentObservable, 'getDependenciesCount', dependentObservable.getDependenciesCount);
 
+    // Evaluate, unless deferEvaluation is true
+    if (options['deferEvaluation'] !== true)
+        evaluateImmediate();
+
+    // Build "disposeWhenNodeIsRemoved" and "disposeWhenNodeIsRemovedCallback" option values.
+    // But skip if isActive is false (there will never be any dependencies to dispose).
+    // (Note: "disposeWhenNodeIsRemoved" option both proactively disposes as soon as the node is removed using ko.removeNode(),
+    // plus adds a "disposeWhen" callback that, on each evaluation, disposes if the node was removed by some other means.)
+    if (disposeWhenNodeIsRemoved && isActive()) {
+        dispose = function() {
+            ko.utils.domNodeDisposal.removeDisposeCallback(disposeWhenNodeIsRemoved, arguments.callee);
+            disposeAllSubscriptionsToDependencies();
+        };
+        ko.utils.domNodeDisposal.addDisposeCallback(disposeWhenNodeIsRemoved, dispose);
+        var existingDisposeWhenFunction = disposeWhen;
+        disposeWhen = function () {
+            return !ko.utils.domNodeIsAttachedToDocument(disposeWhenNodeIsRemoved) || existingDisposeWhenFunction();
+        }
+    }
+
     return dependentObservable;
 };
 
@@ -1473,9 +1500,12 @@ ko.exportSymbol('selectExtensions.readValue', ko.selectExtensions.readValue);
 ko.exportSymbol('selectExtensions.writeValue', ko.selectExtensions.writeValue);
 ko.expressionRewriting = (function () {
     var restoreCapturedTokensRegex = /\@ko_token_(\d+)\@/g;
-    var javaScriptAssignmentTarget = /^[\_$a-z][\_$a-z0-9]*(\[.*?\])*(\.[\_$a-z][\_$a-z0-9]*(\[.*?\])*)*$/i;
     var javaScriptReservedWords = ["true", "false"];
 
+    // Matches something that can be assigned to--either an isolated identifier or something ending with a property accessor
+    // This is designed to be simple and avoid false negatives, but could produce false positives (e.g., a+b.c).
+    var javaScriptAssignmentTarget = /^(?:[$_a-z][$\w]*|(.+)(\.\s*[$_a-z][$\w]*|\[.+\]))$/i;
+
     function restoreTokens(string, tokens) {
         var prevValue = null;
         while (string != prevValue) { // Keep restoring tokens until it no longer makes a difference (they may be nested)
@@ -1487,10 +1517,11 @@ ko.expressionRewriting = (function () {
         return string;
     }
 
-    function isWriteableValue(expression) {
+    function getWriteableValue(expression) {
         if (ko.utils.arrayIndexOf(javaScriptReservedWords, ko.utils.stringTrim(expression).toLowerCase()) >= 0)
             return false;
-        return expression.match(javaScriptAssignmentTarget) !== null;
+        var match = expression.match(javaScriptAssignmentTarget);
+        return match === null ? false : match[1] ? ('Object(' + match[1] + ')' + match[2]) : expression;
     }
 
     function ensureQuoted(key) {
@@ -1610,7 +1641,7 @@ ko.expressionRewriting = (function () {
                     resultStrings.push(":");
                     resultStrings.push(val);
 
-                    if (isWriteableValue(ko.utils.stringTrim(val))) {
+                    if (val = getWriteableValue(ko.utils.stringTrim(val))) {
                         if (propertyAccessorResultStrings.length > 0)
                             propertyAccessorResultStrings.push(", ");
                         propertyAccessorResultStrings.push(quotedKey + " : function(__ko_value) { " + val + " = __ko_value; }");
@@ -1650,7 +1681,7 @@ ko.expressionRewriting = (function () {
                 var propWriters = allBindingsAccessor()['_ko_property_writers'];
                 if (propWriters && propWriters[key])
                     propWriters[key](value);
-            } else if (!checkIfDifferent || property() !== value) {
+            } else if (!checkIfDifferent || property.peek() !== value) {
                 property(value);
             }
         }
@@ -1679,7 +1710,7 @@ ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.ex
     // So, use node.text where available, and node.nodeValue elsewhere
     var commentNodesHaveTextProperty = document.createComment("test").text === "<!--test-->";
 
-    var startCommentRegex = commentNodesHaveTextProperty ? /^<!--\s*ko\s+(.*\:.*)\s*-->$/ : /^\s*ko\s+(.*\:.*)\s*$/;
+    var startCommentRegex = commentNodesHaveTextProperty ? /^<!--\s*ko(?:\s+(.+\s*\:[\s\S]*))?\s*-->$/ : /^\s*ko(?:\s+(.+\s*\:[\s\S]*))?\s*$/;
     var endCommentRegex =   commentNodesHaveTextProperty ? /^<!--\s*\/ko\s*-->$/ : /^\s*\/ko\s*$/;
     var htmlTagsWithOptionallyClosingChildren = { 'ul': true, 'ol': true };
 
@@ -1785,7 +1816,9 @@ ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.ex
         },
 
         insertAfter: function(containerNode, nodeToInsert, insertAfterNode) {
-            if (!isStartComment(containerNode)) {
+            if (!insertAfterNode) {
+                ko.virtualElements.prepend(containerNode, nodeToInsert);
+            } else if (!isStartComment(containerNode)) {
                 // Insert after insertion point
                 if (insertAfterNode.nextSibling)
                     containerNode.insertBefore(nodeToInsert, insertAfterNode.nextSibling);
@@ -1874,7 +1907,7 @@ ko.exportSymbol('virtualElements.setDomNodeChildren', ko.virtualElements.setDomN
 
         'getBindings': function(node, bindingContext) {
             var bindingsString = this['getBindingsString'](node, bindingContext);
-            return bindingsString ? this['parseBindingsString'](bindingsString, bindingContext) : null;
+            return bindingsString ? this['parseBindingsString'](bindingsString, bindingContext, node) : null;
         },
 
         // The following function is only used internally by this default provider.
@@ -1889,12 +1922,10 @@ ko.exportSymbol('virtualElements.setDomNodeChildren', ko.virtualElements.setDomN
 
         // The following function is only used internally by this default provider.
         // It's not part of the interface definition for a general binding provider.
-        'parseBindingsString': function(bindingsString, bindingContext) {
+        'parseBindingsString': function(bindingsString, bindingContext, node) {
             try {
-                var viewModel = bindingContext['$data'],
-                    scopes = (typeof viewModel == 'object' && viewModel != null) ? [viewModel, bindingContext] : [bindingContext],
-                    bindingFunction = createBindingsStringEvaluatorViaCache(bindingsString, scopes.length, this.bindingCache);
-                return bindingFunction(scopes);
+                var bindingFunction = createBindingsStringEvaluatorViaCache(bindingsString, this.bindingCache);
+                return bindingFunction(bindingContext, node);
             } catch (ex) {
                 throw new Error("Unable to parse bindings.\nMessage: " + ex + ";\nBindings value: " + bindingsString);
             }
@@ -1903,15 +1934,19 @@ ko.exportSymbol('virtualElements.setDomNodeChildren', ko.virtualElements.setDomN
 
     ko.bindingProvider['instance'] = new ko.bindingProvider();
 
-    function createBindingsStringEvaluatorViaCache(bindingsString, scopesCount, cache) {
-        var cacheKey = scopesCount + '_' + bindingsString;
+    function createBindingsStringEvaluatorViaCache(bindingsString, cache) {
+        var cacheKey = bindingsString;
         return cache[cacheKey]
-            || (cache[cacheKey] = createBindingsStringEvaluator(bindingsString, scopesCount));
+            || (cache[cacheKey] = createBindingsStringEvaluator(bindingsString));
     }
 
-    function createBindingsStringEvaluator(bindingsString, scopesCount) {
-        var rewrittenBindings = " { " + ko.expressionRewriting.preProcessBindings(bindingsString) + " } ";
-        return ko.utils.buildEvalWithinScopeFunction(rewrittenBindings, scopesCount);
+    function createBindingsStringEvaluator(bindingsString) {
+        // Build the source for a function that evaluates "expression"
+        // For each scope variable, add an extra level of "with" nesting
+        // Example result: with(sc1) { with(sc0) { return (expression) } }
+        var rewrittenBindings = ko.expressionRewriting.preProcessBindings(bindingsString),
+            functionBody = "with($context){with($data||{}){return{" + rewrittenBindings + "}}}";
+        return new Function("$context", "$element", functionBody);
     }
 })();
 
@@ -1919,7 +1954,7 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider);
 (function () {
     ko.bindingHandlers = {};
 
-    ko.bindingContext = function(dataItem, parentBindingContext) {
+    ko.bindingContext = function(dataItem, parentBindingContext, dataItemAlias) {
         if (parentBindingContext) {
             ko.utils.extend(this, parentBindingContext); // Inherit $root and any custom properties
             this['$parentContext'] = parentBindingContext;
@@ -1935,9 +1970,11 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider);
             this['ko'] = ko;
         }
         this['$data'] = dataItem;
+        if (dataItemAlias)
+            this[dataItemAlias] = dataItem;
     }
-    ko.bindingContext.prototype['createChildContext'] = function (dataItem) {
-        return new ko.bindingContext(dataItem, this);
+    ko.bindingContext.prototype['createChildContext'] = function (dataItem, dataItemAlias) {
+        return new ko.bindingContext(dataItem, this, dataItemAlias);
     };
     ko.bindingContext.prototype['extend'] = function(properties) {
         var clone = ko.utils.extend(new ko.bindingContext(), this);
@@ -2020,7 +2057,7 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider);
                     ko.storedBindingContextForNode(node, bindingContextInstance);
 
                 // Use evaluatedBindings if given, otherwise fall back on asking the bindings provider to give us some bindings
-                var evaluatedBindings = (typeof bindings == "function") ? bindings() : bindings;
+                var evaluatedBindings = (typeof bindings == "function") ? bindings(bindingContextInstance, node) : bindings;
                 parsedBindings = evaluatedBindings || ko.bindingProvider['instance']['getBindings'](node, bindingContextInstance);
 
                 if (parsedBindings) {
@@ -2060,7 +2097,7 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider);
                 }
             },
             null,
-            { 'disposeWhenNodeIsRemoved' : node }
+            { disposeWhenNodeIsRemoved : node }
         );
 
         return {
@@ -2120,10 +2157,128 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider);
     ko.exportSymbol('contextFor', ko.contextFor);
     ko.exportSymbol('dataFor', ko.dataFor);
 })();
+var attrHtmlToJavascriptMap = { 'class': 'className', 'for': 'htmlFor' };
+ko.bindingHandlers['attr'] = {
+    'update': function(element, valueAccessor, allBindingsAccessor) {
+        var value = ko.utils.unwrapObservable(valueAccessor()) || {};
+        for (var attrName in value) {
+            if (typeof attrName == "string") {
+                var attrValue = ko.utils.unwrapObservable(value[attrName]);
+
+                // To cover cases like "attr: { checked:someProp }", we want to remove the attribute entirely
+                // when someProp is a "no value"-like value (strictly null, false, or undefined)
+                // (because the absence of the "checked" attr is how to mark an element as not checked, etc.)
+                var toRemove = (attrValue === false) || (attrValue === null) || (attrValue === undefined);
+                if (toRemove)
+                    element.removeAttribute(attrName);
+
+                // In IE <= 7 and IE8 Quirks Mode, you have to use the Javascript property name instead of the
+                // HTML attribute name for certain attributes. IE8 Standards Mode supports the correct behavior,
+                // but instead of figuring out the mode, we'll just set the attribute through the Javascript
+                // property for IE <= 8.
+                if (ko.utils.ieVersion <= 8 && attrName in attrHtmlToJavascriptMap) {
+                    attrName = attrHtmlToJavascriptMap[attrName];
+                    if (toRemove)
+                        element.removeAttribute(attrName);
+                    else
+                        element[attrName] = attrValue;
+                } else if (!toRemove) {
+                    element.setAttribute(attrName, attrValue.toString());
+                }
+
+                // Treat "name" specially - although you can think of it as an attribute, it also needs
+                // special handling on older versions of IE (https://github.com/SteveSanderson/knockout/pull/333)
+                // Deliberately being case-sensitive here because XHTML would regard "Name" as a different thing
+                // entirely, and there's no strong reason to allow for such casing in HTML.
+                if (attrName === "name") {
+                    ko.utils.setElementName(element, toRemove ? "" : attrValue.toString());
+                }
+            }
+        }
+    }
+};
+ko.bindingHandlers['checked'] = {
+    'init': function (element, valueAccessor, allBindingsAccessor) {
+        var updateHandler = function() {
+            var valueToWrite;
+            if (element.type == "checkbox") {
+                valueToWrite = element.checked;
+            } else if ((element.type == "radio") && (element.checked)) {
+                valueToWrite = element.value;
+            } else {
+                return; // "checked" binding only responds to checkboxes and selected radio buttons
+            }
+
+            var modelValue = valueAccessor(), unwrappedValue = ko.utils.unwrapObservable(modelValue);
+            if ((element.type == "checkbox") && (unwrappedValue instanceof Array)) {
+                // For checkboxes bound to an array, we add/remove the checkbox value to that array
+                // This works for both observable and non-observable arrays
+                var existingEntryIndex = ko.utils.arrayIndexOf(unwrappedValue, element.value);
+                if (element.checked && (existingEntryIndex < 0))
+                    modelValue.push(element.value);
+                else if ((!element.checked) && (existingEntryIndex >= 0))
+                    modelValue.splice(existingEntryIndex, 1);
+            } else {
+                ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'checked', valueToWrite, true);
+            }
+        };
+        ko.utils.registerEventHandler(element, "click", updateHandler);
+
+        // IE 6 won't allow radio buttons to be selected unless they have a name
+        if ((element.type == "radio") && !element.name)
+            ko.bindingHandlers['uniqueName']['init'](element, function() { return true });
+    },
+    'update': function (element, valueAccessor) {
+        var value = ko.utils.unwrapObservable(valueAccessor());
+
+        if (element.type == "checkbox") {
+            if (value instanceof Array) {
+                // When bound to an array, the checkbox being checked represents its value being present in that array
+                element.checked = ko.utils.arrayIndexOf(value, element.value) >= 0;
+            } else {
+                // When bound to anything other value (not an array), the checkbox being checked represents the value being trueish
+                element.checked = value;
+            }
+        } else if (element.type == "radio") {
+            element.checked = (element.value == value);
+        }
+    }
+};
+var classesWrittenByBindingKey = '__ko__cssValue';
+ko.bindingHandlers['css'] = {
+    'update': function (element, valueAccessor) {
+        var value = ko.utils.unwrapObservable(valueAccessor());
+        if (typeof value == "object") {
+            for (var className in value) {
+                var shouldHaveClass = ko.utils.unwrapObservable(value[className]);
+                ko.utils.toggleDomNodeCssClass(element, className, shouldHaveClass);
+            }
+        } else {
+            value = String(value || ''); // Make sure we don't try to store or set a non-string value
+            ko.utils.toggleDomNodeCssClass(element, element[classesWrittenByBindingKey], false);
+            element[classesWrittenByBindingKey] = value;
+            ko.utils.toggleDomNodeCssClass(element, value, true);
+        }
+    }
+};
+ko.bindingHandlers['enable'] = {
+    'update': function (element, valueAccessor) {
+        var value = ko.utils.unwrapObservable(valueAccessor());
+        if (value && element.disabled)
+            element.removeAttribute("disabled");
+        else if ((!value) && (!element.disabled))
+            element.disabled = true;
+    }
+};
+
+ko.bindingHandlers['disable'] = {
+    'update': function (element, valueAccessor) {
+        ko.bindingHandlers['enable']['update'](element, function() { return !ko.utils.unwrapObservable(valueAccessor()) });
+    }
+};
 // For certain common events (currently just 'click'), allow a simplified data-binding syntax
 // e.g. click:handler instead of the usual full-length event:{click:handler}
-var eventHandlersWithShortcuts = ['click'];
-ko.utils.arrayForEach(eventHandlersWithShortcuts, function(eventName) {
+function makeEventHandlerShortcut(eventName) {
     ko.bindingHandlers[eventName] = {
         'init': function(element, valueAccessor, allBindingsAccessor, viewModel) {
             var newValueAccessor = function () {
@@ -2134,8 +2289,7 @@ ko.utils.arrayForEach(eventHandlersWithShortcuts, function(eventName) {
             return ko.bindingHandlers['event']['init'].call(this, element, newValueAccessor, allBindingsAccessor, viewModel);
         }
     }
-});
-
+}
 
 ko.bindingHandlers['event'] = {
     'init' : function (element, valueAccessor, allBindingsAccessor, viewModel) {
@@ -2177,140 +2331,145 @@ ko.bindingHandlers['event'] = {
         }
     }
 };
+// "foreach: someExpression" is equivalent to "template: { foreach: someExpression }"
+// "foreach: { data: someExpression, afterAdd: myfn }" is equivalent to "template: { foreach: someExpression, afterAdd: myfn }"
+ko.bindingHandlers['foreach'] = {
+    makeTemplateValueAccessor: function(valueAccessor) {
+        return function() {
+            var modelValue = valueAccessor(),
+                unwrappedValue = ko.utils.peekObservable(modelValue);    // Unwrap without setting a dependency here
 
-ko.bindingHandlers['submit'] = {
-    'init': function (element, valueAccessor, allBindingsAccessor, viewModel) {
-        if (typeof valueAccessor() != "function")
-            throw new Error("The value for a submit binding must be a function");
-        ko.utils.registerEventHandler(element, "submit", function (event) {
-            var handlerReturnValue;
-            var value = valueAccessor();
-            try { handlerReturnValue = value.call(viewModel, element); }
-            finally {
-                if (handlerReturnValue !== true) { // Normally we want to prevent default action. Developer can override this be explicitly returning true.
-                    if (event.preventDefault)
-                        event.preventDefault();
-                    else
-                        event.returnValue = false;
-                }
-            }
-        });
-    }
-};
+            // If unwrappedValue is the array, pass in the wrapped value on its own
+            // The value will be unwrapped and tracked within the template binding
+            // (See https://github.com/SteveSanderson/knockout/issues/523)
+            if ((!unwrappedValue) || typeof unwrappedValue.length == "number")
+                return { 'foreach': modelValue, 'templateEngine': ko.nativeTemplateEngine.instance };
 
-ko.bindingHandlers['visible'] = {
-    'update': function (element, valueAccessor) {
-        var value = ko.utils.unwrapObservable(valueAccessor());
-        var isCurrentlyVisible = !(element.style.display == "none");
-        if (value && !isCurrentlyVisible)
-            element.style.display = "";
-        else if ((!value) && isCurrentlyVisible)
-            element.style.display = "none";
+            // If unwrappedValue.data is the array, preserve all relevant options and unwrap again value so we get updates
+            ko.utils.unwrapObservable(modelValue);
+            return {
+                'foreach': unwrappedValue['data'],
+                'as': unwrappedValue['as'],
+                'includeDestroyed': unwrappedValue['includeDestroyed'],
+                'afterAdd': unwrappedValue['afterAdd'],
+                'beforeRemove': unwrappedValue['beforeRemove'],
+                'afterRender': unwrappedValue['afterRender'],
+                'beforeMove': unwrappedValue['beforeMove'],
+                'afterMove': unwrappedValue['afterMove'],
+                'templateEngine': ko.nativeTemplateEngine.instance
+            };
+        };
+    },
+    'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
+        return ko.bindingHandlers['template']['init'](element, ko.bindingHandlers['foreach'].makeTemplateValueAccessor(valueAccessor));
+    },
+    'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
+        return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['foreach'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext);
     }
-}
+};
+ko.expressionRewriting.bindingRewriteValidators['foreach'] = false; // Can't rewrite control flow bindings
+ko.virtualElements.allowedBindings['foreach'] = true;
+var hasfocusUpdatingProperty = '__ko_hasfocusUpdating';
+ko.bindingHandlers['hasfocus'] = {
+    'init': function(element, valueAccessor, allBindingsAccessor) {
+        var handleElementFocusChange = function(isFocused) {
+            // Where possible, ignore which event was raised and determine focus state using activeElement,
+            // as this avoids phantom focus/blur events raised when changing tabs in modern browsers.
+            // However, not all KO-targeted browsers (Firefox 2) support activeElement. For those browsers,
+            // prevent a loss of focus when changing tabs/windows by setting a flag that prevents hasfocus
+            // from calling 'blur()' on the element when it loses focus.
+            // Discussion at https://github.com/SteveSanderson/knockout/pull/352
+            element[hasfocusUpdatingProperty] = true;
+            var ownerDoc = element.ownerDocument;
+            if ("activeElement" in ownerDoc) {
+                isFocused = (ownerDoc.activeElement === element);
+            }
+            var modelValue = valueAccessor();
+            ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'hasfocus', isFocused, true);
+            element[hasfocusUpdatingProperty] = false;
+        };
+        var handleElementFocusIn = handleElementFocusChange.bind(null, true);
+        var handleElementFocusOut = handleElementFocusChange.bind(null, false);
 
-ko.bindingHandlers['enable'] = {
-    'update': function (element, valueAccessor) {
+        ko.utils.registerEventHandler(element, "focus", handleElementFocusIn);
+        ko.utils.registerEventHandler(element, "focusin", handleElementFocusIn); // For IE
+        ko.utils.registerEventHandler(element, "blur",  handleElementFocusOut);
+        ko.utils.registerEventHandler(element, "focusout",  handleElementFocusOut); // For IE
+    },
+    'update': function(element, valueAccessor) {
         var value = ko.utils.unwrapObservable(valueAccessor());
-        if (value && element.disabled)
-            element.removeAttribute("disabled");
-        else if ((!value) && (!element.disabled))
-            element.disabled = true;
+        if (!element[hasfocusUpdatingProperty]) {
+            value ? element.focus() : element.blur();
+            ko.dependencyDetection.ignore(ko.utils.triggerEvent, null, [element, value ? "focusin" : "focusout"]); // For IE, which doesn't reliably fire "focus" or "blur" events synchronously
+        }
     }
 };
-
-ko.bindingHandlers['disable'] = {
+ko.bindingHandlers['html'] = {
+    'init': function() {
+        // Prevent binding on the dynamically-injected HTML (as developers are unlikely to expect that, and it has security implications)
+        return { 'controlsDescendantBindings': true };
+    },
     'update': function (element, valueAccessor) {
-        ko.bindingHandlers['enable']['update'](element, function() { return !ko.utils.unwrapObservable(valueAccessor()) });
+        // setHtml will unwrap the value if needed
+        ko.utils.setHtml(element, valueAccessor());
     }
 };
+var withIfDomDataKey = '__ko_withIfBindingData';
+// Makes a binding like with or if
+function makeWithIfBinding(bindingKey, isWith, isNot, makeContextCallback) {
+    ko.bindingHandlers[bindingKey] = {
+        'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
+            ko.utils.domData.set(element, withIfDomDataKey, {});
+            return { 'controlsDescendantBindings': true };
+        },
+        'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
+            var withIfData = ko.utils.domData.get(element, withIfDomDataKey),
+                dataValue = ko.utils.unwrapObservable(valueAccessor()),
+                shouldDisplay = !isNot !== !dataValue, // equivalent to isNot ? !dataValue : !!dataValue
+                isFirstRender = !withIfData.savedNodes,
+                needsRefresh = isFirstRender || isWith || (shouldDisplay !== withIfData.didDisplayOnLastUpdate);
 
-function ensureDropdownSelectionIsConsistentWithModelValue(element, modelValue, preferModelValue) {
-    if (preferModelValue) {
-        if (modelValue !== ko.selectExtensions.readValue(element))
-            ko.selectExtensions.writeValue(element, modelValue);
-    }
-
-    // No matter which direction we're syncing in, we want the end result to be equality between dropdown value and model value.
-    // If they aren't equal, either we prefer the dropdown value, or the model value couldn't be represented, so either way,
-    // change the model value to match the dropdown.
-    if (modelValue !== ko.selectExtensions.readValue(element))
-        ko.utils.triggerEvent(element, "change");
-};
-
-ko.bindingHandlers['value'] = {
-    'init': function (element, valueAccessor, allBindingsAccessor) {
-        // Always catch "change" event; possibly other events too if asked
-        var eventsToCatch = ["change"];
-        var requestedEventsToCatch = allBindingsAccessor()["valueUpdate"];
-        if (requestedEventsToCatch) {
-            if (typeof requestedEventsToCatch == "string") // Allow both individual event names, and arrays of event names
-                requestedEventsToCatch = [requestedEventsToCatch];
-            ko.utils.arrayPushAll(eventsToCatch, requestedEventsToCatch);
-            eventsToCatch = ko.utils.arrayGetDistinctValues(eventsToCatch);
-        }
-
-        var valueUpdateHandler = function() {
-            var modelValue = valueAccessor();
-            var elementValue = ko.selectExtensions.readValue(element);
-            ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'value', elementValue, /* checkIfDifferent: */ true);
-        }
+            if (needsRefresh) {
+                if (isFirstRender) {
+                    withIfData.savedNodes = ko.utils.cloneNodes(ko.virtualElements.childNodes(element), true /* shouldCleanNodes */);
+                }
 
-        // Workaround for https://github.com/SteveSanderson/knockout/issues/122
-        // IE doesn't fire "change" events on textboxes if the user selects a value from its autocomplete list
-        var ieAutoCompleteHackNeeded = ko.utils.ieVersion && element.tagName.toLowerCase() == "input" && element.type == "text"
-                                       && element.autocomplete != "off" && (!element.form || element.form.autocomplete != "off");
-        if (ieAutoCompleteHackNeeded && ko.utils.arrayIndexOf(eventsToCatch, "propertychange") == -1) {
-            var propertyChangedFired = false;
-            ko.utils.registerEventHandler(element, "propertychange", function () { propertyChangedFired = true });
-            ko.utils.registerEventHandler(element, "blur", function() {
-                if (propertyChangedFired) {
-                    propertyChangedFired = false;
-                    valueUpdateHandler();
+                if (shouldDisplay) {
+                    if (!isFirstRender) {
+                        ko.virtualElements.setDomNodeChildren(element, ko.utils.cloneNodes(withIfData.savedNodes));
+                    }
+                    ko.applyBindingsToDescendants(makeContextCallback ? makeContextCallback(bindingContext, dataValue) : bindingContext, element);
+                } else {
+                    ko.virtualElements.emptyNode(element);
                 }
-            });
-        }
 
-        ko.utils.arrayForEach(eventsToCatch, function(eventName) {
-            // The syntax "after<eventname>" means "run the handler asynchronously after the event"
-            // This is useful, for example, to catch "keydown" events after the browser has updated the control
-            // (otherwise, ko.selectExtensions.readValue(this) will receive the control's value *before* the key event)
-            var handler = valueUpdateHandler;
-            if (ko.utils.stringStartsWith(eventName, "after")) {
-                handler = function() { setTimeout(valueUpdateHandler, 0) };
-                eventName = eventName.substring("after".length);
+                withIfData.didDisplayOnLastUpdate = shouldDisplay;
             }
-            ko.utils.registerEventHandler(element, eventName, handler);
-        });
-    },
-    'update': function (element, valueAccessor) {
-        var valueIsSelectOption = ko.utils.tagNameLower(element) === "select";
-        var newValue = ko.utils.unwrapObservable(valueAccessor());
-        var elementValue = ko.selectExtensions.readValue(element);
-        var valueHasChanged = (newValue != elementValue);
-
-        // JavaScript's 0 == "" behavious is unfortunate here as it prevents writing 0 to an empty text box (loose equality suggests the values are the same).
-        // We don't want to do a strict equality comparison as that is more confusing for developers in certain cases, so we specifically special case 0 != "" here.
-        if ((newValue === 0) && (elementValue !== 0) && (elementValue !== "0"))
-            valueHasChanged = true;
-
-        if (valueHasChanged) {
-            var applyValueAction = function () { ko.selectExtensions.writeValue(element, newValue); };
-            applyValueAction();
-
-            // Workaround for IE6 bug: It won't reliably apply values to SELECT nodes during the same execution thread
-            // right after you've changed the set of OPTION nodes on it. So for that node type, we'll schedule a second thread
-            // to apply the value as well.
-            var alsoApplyAsynchronously = valueIsSelectOption;
-            if (alsoApplyAsynchronously)
-                setTimeout(applyValueAction, 0);
         }
+    };
+    ko.expressionRewriting.bindingRewriteValidators[bindingKey] = false; // Can't rewrite control flow bindings
+    ko.virtualElements.allowedBindings[bindingKey] = true;
+}
 
-        // If you try to set a model value that can't be represented in an already-populated dropdown, reject that change,
-        // because you're not allowed to have a model value that disagrees with a visible UI selection.
-        if (valueIsSelectOption && (element.length > 0))
-            ensureDropdownSelectionIsConsistentWithModelValue(element, newValue, /* preferModelValue */ false);
+// Construct the actual binding handlers
+makeWithIfBinding('if');
+makeWithIfBinding('ifnot', false /* isWith */, true /* isNot */);
+makeWithIfBinding('with', true /* isWith */, false /* isNot */,
+    function(bindingContext, dataValue) {
+        return bindingContext['createChildContext'](dataValue);
+    }
+);
+function ensureDropdownSelectionIsConsistentWithModelValue(element, modelValue, preferModelValue) {
+    if (preferModelValue) {
+        if (modelValue !== ko.selectExtensions.readValue(element))
+            ko.selectExtensions.writeValue(element, modelValue);
     }
+
+    // No matter which direction we're syncing in, we want the end result to be equality between dropdown value and model value.
+    // If they aren't equal, either we prefer the dropdown value, or the model value couldn't be represented, so either way,
+    // change the model value to match the dropdown.
+    if (modelValue !== ko.selectExtensions.readValue(element))
+        ko.dependencyDetection.ignore(ko.utils.triggerEvent, null, [element, "change"]);
 };
 
 ko.bindingHandlers['options'] = {
@@ -2395,7 +2554,7 @@ ko.bindingHandlers['options'] = {
                 // Ensure consistency between model value and selected option.
                 // If the dropdown is being populated for the first time here (or was otherwise previously empty),
                 // the dropdown selection state is meaningless, so we preserve the model value.
-                ensureDropdownSelectionIsConsistentWithModelValue(element, ko.utils.unwrapObservable(allBindings['value']), /* preferModelValue */ true);
+                ensureDropdownSelectionIsConsistentWithModelValue(element, ko.utils.peekObservable(allBindings['value']), /* preferModelValue */ true);
             }
 
             // Workaround for IE9 bug
@@ -2404,7 +2563,6 @@ ko.bindingHandlers['options'] = {
     }
 };
 ko.bindingHandlers['options'].optionValueDomDataKey = '__ko.optionValueDomData__';
-
 ko.bindingHandlers['selectedOptions'] = {
     'init': function (element, valueAccessor, allBindingsAccessor) {
         ko.utils.registerEventHandler(element, "change", function () {
@@ -2429,43 +2587,6 @@ ko.bindingHandlers['selectedOptions'] = {
         }
     }
 };
-
-ko.bindingHandlers['text'] = {
-    'update': function (element, valueAccessor) {
-        ko.utils.setTextContent(element, valueAccessor());
-    }
-};
-ko.virtualElements.allowedBindings['text'] = true;
-
-ko.bindingHandlers['html'] = {
-    'init': function() {
-        // Prevent binding on the dynamically-injected HTML (as developers are unlikely to expect that, and it has security implications)
-        return { 'controlsDescendantBindings': true };
-    },
-    'update': function (element, valueAccessor) {
-        // setHtml will unwrap the value if needed
-        ko.utils.setHtml(element, valueAccessor());
-    }
-};
-
-var classesWrittenByBindingKey = '__ko__cssValue';
-ko.bindingHandlers['css'] = {
-    'update': function (element, valueAccessor) {
-        var value = ko.utils.unwrapObservable(valueAccessor());
-        if (typeof value == "object") {
-            for (var className in value) {
-                var shouldHaveClass = ko.utils.unwrapObservable(value[className]);
-                ko.utils.toggleDomNodeCssClass(element, className, shouldHaveClass);
-            }
-        } else {
-            value = String(value || ''); // Make sure we don't try to store or set a non-string value
-            ko.utils.toggleDomNodeCssClass(element, element[classesWrittenByBindingKey], false);
-            element[classesWrittenByBindingKey] = value;
-            ko.utils.toggleDomNodeCssClass(element, value, true);
-        }
-    }
-};
-
 ko.bindingHandlers['style'] = {
     'update': function (element, valueAccessor) {
         var value = ko.utils.unwrapObservable(valueAccessor() || {});
@@ -2477,7 +2598,31 @@ ko.bindingHandlers['style'] = {
         }
     }
 };
-
+ko.bindingHandlers['submit'] = {
+    'init': function (element, valueAccessor, allBindingsAccessor, viewModel) {
+        if (typeof valueAccessor() != "function")
+            throw new Error("The value for a submit binding must be a function");
+        ko.utils.registerEventHandler(element, "submit", function (event) {
+            var handlerReturnValue;
+            var value = valueAccessor();
+            try { handlerReturnValue = value.call(viewModel, element); }
+            finally {
+                if (handlerReturnValue !== true) { // Normally we want to prevent default action. Developer can override this be explicitly returning true.
+                    if (event.preventDefault)
+                        event.preventDefault();
+                    else
+                        event.returnValue = false;
+                }
+            }
+        });
+    }
+};
+ko.bindingHandlers['text'] = {
+    'update': function (element, valueAccessor) {
+        ko.utils.setTextContent(element, valueAccessor());
+    }
+};
+ko.virtualElements.allowedBindings['text'] = true;
 ko.bindingHandlers['uniqueName'] = {
     'init': function (element, valueAccessor) {
         if (valueAccessor()) {
@@ -2487,190 +2632,92 @@ ko.bindingHandlers['uniqueName'] = {
     }
 };
 ko.bindingHandlers['uniqueName'].currentIndex = 0;
-
-ko.bindingHandlers['checked'] = {
+ko.bindingHandlers['value'] = {
     'init': function (element, valueAccessor, allBindingsAccessor) {
-        var updateHandler = function() {
-            var valueToWrite;
-            if (element.type == "checkbox") {
-                valueToWrite = element.checked;
-            } else if ((element.type == "radio") && (element.checked)) {
-                valueToWrite = element.value;
-            } else {
-                return; // "checked" binding only responds to checkboxes and selected radio buttons
-            }
+        // Always catch "change" event; possibly other events too if asked
+        var eventsToCatch = ["change"];
+        var requestedEventsToCatch = allBindingsAccessor()["valueUpdate"];
+        var propertyChangedFired = false;
+        if (requestedEventsToCatch) {
+            if (typeof requestedEventsToCatch == "string") // Allow both individual event names, and arrays of event names
+                requestedEventsToCatch = [requestedEventsToCatch];
+            ko.utils.arrayPushAll(eventsToCatch, requestedEventsToCatch);
+            eventsToCatch = ko.utils.arrayGetDistinctValues(eventsToCatch);
+        }
 
+        var valueUpdateHandler = function() {
+            propertyChangedFired = false;
             var modelValue = valueAccessor();
-            if ((element.type == "checkbox") && (ko.utils.unwrapObservable(modelValue) instanceof Array)) {
-                // For checkboxes bound to an array, we add/remove the checkbox value to that array
-                // This works for both observable and non-observable arrays
-                var existingEntryIndex = ko.utils.arrayIndexOf(ko.utils.unwrapObservable(modelValue), element.value);
-                if (element.checked && (existingEntryIndex < 0))
-                    modelValue.push(element.value);
-                else if ((!element.checked) && (existingEntryIndex >= 0))
-                    modelValue.splice(existingEntryIndex, 1);
-            } else {
-                ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'checked', valueToWrite, true);
-            }
-        };
-        ko.utils.registerEventHandler(element, "click", updateHandler);
-
-        // IE 6 won't allow radio buttons to be selected unless they have a name
-        if ((element.type == "radio") && !element.name)
-            ko.bindingHandlers['uniqueName']['init'](element, function() { return true });
-    },
-    'update': function (element, valueAccessor) {
-        var value = ko.utils.unwrapObservable(valueAccessor());
-
-        if (element.type == "checkbox") {
-            if (value instanceof Array) {
-                // When bound to an array, the checkbox being checked represents its value being present in that array
-                element.checked = ko.utils.arrayIndexOf(value, element.value) >= 0;
-            } else {
-                // When bound to anything other value (not an array), the checkbox being checked represents the value being trueish
-                element.checked = value;
-            }
-        } else if (element.type == "radio") {
-            element.checked = (element.value == value);
+            var elementValue = ko.selectExtensions.readValue(element);
+            ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'value', elementValue);
         }
-    }
-};
 
-var attrHtmlToJavascriptMap = { 'class': 'className', 'for': 'htmlFor' };
-ko.bindingHandlers['attr'] = {
-    'update': function(element, valueAccessor, allBindingsAccessor) {
-        var value = ko.utils.unwrapObservable(valueAccessor()) || {};
-        for (var attrName in value) {
-            if (typeof attrName == "string") {
-                var attrValue = ko.utils.unwrapObservable(value[attrName]);
-
-                // To cover cases like "attr: { checked:someProp }", we want to remove the attribute entirely
-                // when someProp is a "no value"-like value (strictly null, false, or undefined)
-                // (because the absence of the "checked" attr is how to mark an element as not checked, etc.)
-                var toRemove = (attrValue === false) || (attrValue === null) || (attrValue === undefined);
-                if (toRemove)
-                    element.removeAttribute(attrName);
-
-                // In IE <= 7 and IE8 Quirks Mode, you have to use the Javascript property name instead of the
-                // HTML attribute name for certain attributes. IE8 Standards Mode supports the correct behavior,
-                // but instead of figuring out the mode, we'll just set the attribute through the Javascript
-                // property for IE <= 8.
-                if (ko.utils.ieVersion <= 8 && attrName in attrHtmlToJavascriptMap) {
-                    attrName = attrHtmlToJavascriptMap[attrName];
-                    if (toRemove)
-                        element.removeAttribute(attrName);
-                    else
-                        element[attrName] = attrValue;
-                } else if (!toRemove) {
-                    element.setAttribute(attrName, attrValue.toString());
-                }
-
-                // Treat "name" specially - although you can think of it as an attribute, it also needs
-                // special handling on older versions of IE (https://github.com/SteveSanderson/knockout/pull/333)
-                // Deliberately being case-sensitive here because XHTML would regard "Name" as a different thing
-                // entirely, and there's no strong reason to allow for such casing in HTML.
-                if (attrName === "name") {
-                    ko.utils.setElementName(element, toRemove ? "" : attrValue.toString());
+        // Workaround for https://github.com/SteveSanderson/knockout/issues/122
+        // IE doesn't fire "change" events on textboxes if the user selects a value from its autocomplete list
+        var ieAutoCompleteHackNeeded = ko.utils.ieVersion && element.tagName.toLowerCase() == "input" && element.type == "text"
+                                       && element.autocomplete != "off" && (!element.form || element.form.autocomplete != "off");
+        if (ieAutoCompleteHackNeeded && ko.utils.arrayIndexOf(eventsToCatch, "propertychange") == -1) {
+            ko.utils.registerEventHandler(element, "propertychange", function () { propertyChangedFired = true });
+            ko.utils.registerEventHandler(element, "blur", function() {
+                if (propertyChangedFired) {
+                    valueUpdateHandler();
                 }
-            }
+            });
         }
-    }
-};
 
-ko.bindingHandlers['hasfocus'] = {
-    'init': function(element, valueAccessor, allBindingsAccessor) {
-        var writeValue = function(valueToWrite) {
-            var modelValue = valueAccessor();
-            ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'hasfocus', valueToWrite, true);
-        };
-        ko.utils.registerEventHandler(element, "focus", function() { writeValue(true) });
-        ko.utils.registerEventHandler(element, "focusin", function() { writeValue(true) }); // For IE
-        ko.utils.registerEventHandler(element, "blur",  function() { writeValue(false) });
-        ko.utils.registerEventHandler(element, "focusout",  function() { writeValue(false) }); // For IE
+        ko.utils.arrayForEach(eventsToCatch, function(eventName) {
+            // The syntax "after<eventname>" means "run the handler asynchronously after the event"
+            // This is useful, for example, to catch "keydown" events after the browser has updated the control
+            // (otherwise, ko.selectExtensions.readValue(this) will receive the control's value *before* the key event)
+            var handler = valueUpdateHandler;
+            if (ko.utils.stringStartsWith(eventName, "after")) {
+                handler = function() { setTimeout(valueUpdateHandler, 0) };
+                eventName = eventName.substring("after".length);
+            }
+            ko.utils.registerEventHandler(element, eventName, handler);
+        });
     },
-    'update': function(element, valueAccessor) {
-        var value = ko.utils.unwrapObservable(valueAccessor());
-        value ? element.focus() : element.blur();
-        ko.utils.triggerEvent(element, value ? "focusin" : "focusout"); // For IE, which doesn't reliably fire "focus" or "blur" events synchronously
-    }
-};
+    'update': function (element, valueAccessor) {
+        var valueIsSelectOption = ko.utils.tagNameLower(element) === "select";
+        var newValue = ko.utils.unwrapObservable(valueAccessor());
+        var elementValue = ko.selectExtensions.readValue(element);
+        var valueHasChanged = (newValue != elementValue);
 
-// "with: someExpression" is equivalent to "template: { if: someExpression, data: someExpression }"
-ko.bindingHandlers['with'] = {
-    makeTemplateValueAccessor: function(valueAccessor) {
-        return function() { var value = valueAccessor(); return { 'if': value, 'data': value, 'templateEngine': ko.nativeTemplateEngine.instance } };
-    },
-    'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['init'](element, ko.bindingHandlers['with'].makeTemplateValueAccessor(valueAccessor));
-    },
-    'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['with'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext);
-    }
-};
-ko.expressionRewriting.bindingRewriteValidators['with'] = false; // Can't rewrite control flow bindings
-ko.virtualElements.allowedBindings['with'] = true;
+        // JavaScript's 0 == "" behavious is unfortunate here as it prevents writing 0 to an empty text box (loose equality suggests the values are the same).
+        // We don't want to do a strict equality comparison as that is more confusing for developers in certain cases, so we specifically special case 0 != "" here.
+        if ((newValue === 0) && (elementValue !== 0) && (elementValue !== "0"))
+            valueHasChanged = true;
 
-// "if: someExpression" is equivalent to "template: { if: someExpression }"
-ko.bindingHandlers['if'] = {
-    makeTemplateValueAccessor: function(valueAccessor) {
-        return function() { return { 'if': valueAccessor(), 'templateEngine': ko.nativeTemplateEngine.instance } };
-    },
-    'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['init'](element, ko.bindingHandlers['if'].makeTemplateValueAccessor(valueAccessor));
-    },
-    'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['if'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext);
-    }
-};
-ko.expressionRewriting.bindingRewriteValidators['if'] = false; // Can't rewrite control flow bindings
-ko.virtualElements.allowedBindings['if'] = true;
+        if (valueHasChanged) {
+            var applyValueAction = function () { ko.selectExtensions.writeValue(element, newValue); };
+            applyValueAction();
 
-// "ifnot: someExpression" is equivalent to "template: { ifnot: someExpression }"
-ko.bindingHandlers['ifnot'] = {
-    makeTemplateValueAccessor: function(valueAccessor) {
-        return function() { return { 'ifnot': valueAccessor(), 'templateEngine': ko.nativeTemplateEngine.instance } };
-    },
-    'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['init'](element, ko.bindingHandlers['ifnot'].makeTemplateValueAccessor(valueAccessor));
-    },
-    'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['ifnot'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext);
+            // Workaround for IE6 bug: It won't reliably apply values to SELECT nodes during the same execution thread
+            // right after you've changed the set of OPTION nodes on it. So for that node type, we'll schedule a second thread
+            // to apply the value as well.
+            var alsoApplyAsynchronously = valueIsSelectOption;
+            if (alsoApplyAsynchronously)
+                setTimeout(applyValueAction, 0);
+        }
+
+        // If you try to set a model value that can't be represented in an already-populated dropdown, reject that change,
+        // because you're not allowed to have a model value that disagrees with a visible UI selection.
+        if (valueIsSelectOption && (element.length > 0))
+            ensureDropdownSelectionIsConsistentWithModelValue(element, newValue, /* preferModelValue */ false);
     }
 };
-ko.expressionRewriting.bindingRewriteValidators['ifnot'] = false; // Can't rewrite control flow bindings
-ko.virtualElements.allowedBindings['ifnot'] = true;
-
-// "foreach: someExpression" is equivalent to "template: { foreach: someExpression }"
-// "foreach: { data: someExpression, afterAdd: myfn }" is equivalent to "template: { foreach: someExpression, afterAdd: myfn }"
-ko.bindingHandlers['foreach'] = {
-    makeTemplateValueAccessor: function(valueAccessor) {
-        return function() {
-            var bindingValue = ko.utils.unwrapObservable(valueAccessor());
-
-            // If bindingValue is the array, just pass it on its own
-            if ((!bindingValue) || typeof bindingValue.length == "number")
-                return { 'foreach': bindingValue, 'templateEngine': ko.nativeTemplateEngine.instance };
-
-            // If bindingValue.data is the array, preserve all relevant options
-            return {
-                'foreach': bindingValue['data'],
-                'includeDestroyed': bindingValue['includeDestroyed'],
-                'afterAdd': bindingValue['afterAdd'],
-                'beforeRemove': bindingValue['beforeRemove'],
-                'afterRender': bindingValue['afterRender'],
-                'templateEngine': ko.nativeTemplateEngine.instance
-            };
-        };
-    },
-    'init': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['init'](element, ko.bindingHandlers['foreach'].makeTemplateValueAccessor(valueAccessor));
-    },
-    'update': function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-        return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['foreach'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext);
+ko.bindingHandlers['visible'] = {
+    'update': function (element, valueAccessor) {
+        var value = ko.utils.unwrapObservable(valueAccessor());
+        var isCurrentlyVisible = !(element.style.display == "none");
+        if (value && !isCurrentlyVisible)
+            element.style.display = "";
+        else if ((!value) && isCurrentlyVisible)
+            element.style.display = "none";
     }
 };
-ko.expressionRewriting.bindingRewriteValidators['foreach'] = false; // Can't rewrite control flow bindings
-ko.virtualElements.allowedBindings['foreach'] = true;
+// 'click' is just a shorthand for the usual full-length event:{click:handler}
+makeEventHandlerShortcut('click');
 // If you want to make a custom template engine,
 //
 // [1] Inherit from this class (like ko.nativeTemplateEngine does)
@@ -2774,7 +2821,7 @@ ko.templateRewriting = (function () {
         // anonymous function, even though Opera's built-in debugger can evaluate it anyway. No other browser requires this
         // extra indirection.
         var applyBindingsToNextSiblingScript =
-            "ko.__tr_ambtns(function(){return(function(){return{" + rewrittenDataBindAttributeValue + "} })()})";
+            "ko.__tr_ambtns(function($context,$element){return(function(){return{ " + rewrittenDataBindAttributeValue + " } })()})";
         return templateEngine['createJavaScriptEvaluatorBlock'](applyBindingsToNextSiblingScript) + tagToRetain;
     }
 
@@ -2973,7 +3020,7 @@ ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextS
         if (haveAddedNodesToParent) {
             activateBindingsOnContinuousNodeArray(renderedNodesArray, bindingContext);
             if (options['afterRender'])
-                options['afterRender'](renderedNodesArray, bindingContext['$data']);
+                ko.dependencyDetection.ignore(options['afterRender'], null, [renderedNodesArray, bindingContext['$data']]);
         }
 
         return renderedNodesArray;
@@ -3008,7 +3055,7 @@ ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextS
                     }
                 },
                 null,
-                { 'disposeWhen': whenToDispose, 'disposeWhenNodeIsRemoved': activelyDisposeWhenNodeIsRemoved }
+                { disposeWhen: whenToDispose, disposeWhenNodeIsRemoved: activelyDisposeWhenNodeIsRemoved }
             );
         } else {
             // We don't yet have a DOM node to evaluate, so use a memo and render the template later when there is a DOM node
@@ -3026,7 +3073,7 @@ ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextS
         // This will be called by setDomNodeChildrenFromArrayMapping to get the nodes to add to targetNode
         var executeTemplateForArrayItem = function (arrayValue, index) {
             // Support selecting template as a function of the data being rendered
-            arrayItemContext = parentBindingContext['createChildContext'](ko.utils.unwrapObservable(arrayValue));
+            arrayItemContext = parentBindingContext['createChildContext'](ko.utils.unwrapObservable(arrayValue), options['as']);
             arrayItemContext['$index'] = index;
             var templateName = typeof(template) == 'function' ? template(arrayValue, arrayItemContext) : template;
             return executeTemplate(null, "ignoreTargetNode", templateName, arrayItemContext, options);
@@ -3049,17 +3096,19 @@ ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextS
                 return options['includeDestroyed'] || item === undefined || item === null || !ko.utils.unwrapObservable(item['_destroy']);
             });
 
-            ko.utils.setDomNodeChildrenFromArrayMapping(targetNode, filteredArray, executeTemplateForArrayItem, options, activateBindingsCallback);
+            // Call setDomNodeChildrenFromArrayMapping, ignoring any observables unwrapped within (most likely from a callback function).
+            // If the array items are observables, though, they will be unwrapped in executeTemplateForArrayItem and managed within setDomNodeChildrenFromArrayMapping.
+            ko.dependencyDetection.ignore(ko.utils.setDomNodeChildrenFromArrayMapping, null, [targetNode, filteredArray, executeTemplateForArrayItem, options, activateBindingsCallback]);
 
-        }, null, { 'disposeWhenNodeIsRemoved': targetNode });
+        }, null, { disposeWhenNodeIsRemoved: targetNode });
     };
 
-    var templateSubscriptionDomDataKey = '__ko__templateSubscriptionDomDataKey__';
-    function disposeOldSubscriptionAndStoreNewOne(element, newSubscription) {
-        var oldSubscription = ko.utils.domData.get(element, templateSubscriptionDomDataKey);
-        if (oldSubscription && (typeof(oldSubscription.dispose) == 'function'))
-            oldSubscription.dispose();
-        ko.utils.domData.set(element, templateSubscriptionDomDataKey, newSubscription);
+    var templateComputedDomDataKey = '__ko__templateComputedDomDataKey__';
+    function disposeOldComputedAndStoreNewOne(element, newComputed) {
+        var oldComputed = ko.utils.domData.get(element, templateComputedDomDataKey);
+        if (oldComputed && (typeof(oldComputed.dispose) == 'function'))
+            oldComputed.dispose();
+        ko.utils.domData.set(element, templateComputedDomDataKey, (newComputed && newComputed.isActive()) ? newComputed : undefined);
     }
 
     ko.bindingHandlers['template'] = {
@@ -3075,41 +3124,41 @@ ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextS
             return { 'controlsDescendantBindings': true };
         },
         'update': function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
-            var bindingValue = ko.utils.unwrapObservable(valueAccessor());
-            var templateName;
-            var shouldDisplay = true;
+            var templateName = ko.utils.unwrapObservable(valueAccessor()),
+                options = {},
+                shouldDisplay = true,
+                dataValue,
+                templateComputed = null;
 
-            if (typeof bindingValue == "string") {
-                templateName = bindingValue;
-            } else {
-                templateName = bindingValue['name'];
+            if (typeof templateName != "string") {
+                options = templateName;
+                templateName = options['name'];
 
                 // Support "if"/"ifnot" conditions
-                if ('if' in bindingValue)
-                    shouldDisplay = shouldDisplay && ko.utils.unwrapObservable(bindingValue['if']);
-                if ('ifnot' in bindingValue)
-                    shouldDisplay = shouldDisplay && !ko.utils.unwrapObservable(bindingValue['ifnot']);
-            }
+                if ('if' in options)
+                    shouldDisplay = ko.utils.unwrapObservable(options['if']);
+                if (shouldDisplay && 'ifnot' in options)
+                    shouldDisplay = !ko.utils.unwrapObservable(options['ifnot']);
 
-            var templateSubscription = null;
+                dataValue = ko.utils.unwrapObservable(options['data']);
+            }
 
-            if ((typeof bindingValue === 'object') && ('foreach' in bindingValue)) { // Note: can't use 'in' operator on strings
+            if ('foreach' in options) {
                 // Render once for each data point (treating data set as empty if shouldDisplay==false)
-                var dataArray = (shouldDisplay && bindingValue['foreach']) || [];
-                templateSubscription = ko.renderTemplateForEach(templateName || element, dataArray, /* options: */ bindingValue, element, bindingContext);
+                var dataArray = (shouldDisplay && options['foreach']) || [];
+                templateComputed = ko.renderTemplateForEach(templateName || element, dataArray, options, element, bindingContext);
+            } else if (!shouldDisplay) {
+                ko.virtualElements.emptyNode(element);
             } else {
-                if (shouldDisplay) {
-                    // Render once for this single data point (or use the viewModel if no data was provided)
-                    var innerBindingContext = (typeof bindingValue == 'object') && ('data' in bindingValue)
-                        ? bindingContext['createChildContext'](ko.utils.unwrapObservable(bindingValue['data'])) // Given an explitit 'data' value, we create a child binding context for it
-                        : bindingContext;                                                                       // Given no explicit 'data' value, we retain the same binding context
-                    templateSubscription = ko.renderTemplate(templateName || element, innerBindingContext, /* options: */ bindingValue, element);
-                } else
-                    ko.virtualElements.emptyNode(element);
+                // Render once for this single data point (or use the viewModel if no data was provided)
+                var innerBindingContext = ('data' in options) ?
+                    bindingContext['createChildContext'](dataValue, options['as']) :  // Given an explitit 'data' value, we create a child binding context for it
+                    bindingContext;                                                        // Given no explicit 'data' value, we retain the same binding context
+                templateComputed = ko.renderTemplate(templateName || element, innerBindingContext, options, element);
             }
 
-            // It only makes sense to have a single template subscription per element (otherwise which one should have its output displayed?)
-            disposeOldSubscriptionAndStoreNewOne(element, templateSubscription);
+            // It only makes sense to have a single template computed per element (otherwise which one should have its output displayed?)
+            disposeOldComputedAndStoreNewOne(element, templateComputed);
         }
     };
 
@@ -3131,85 +3180,95 @@ ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextS
 ko.exportSymbol('setTemplateEngine', ko.setTemplateEngine);
 ko.exportSymbol('renderTemplate', ko.renderTemplate);
 
-(function () {
+ko.utils.compareArrays = (function () {
+    var statusNotInOld = 'added', statusNotInNew = 'deleted';
+
     // Simple calculation based on Levenshtein distance.
-    function calculateEditDistanceMatrix(oldArray, newArray, maxAllowedDistance) {
-        var distances = [];
-        for (var i = 0; i <= newArray.length; i++)
-            distances[i] = [];
-
-        // Top row - transform old array into empty array via deletions
-        for (var i = 0, j = Math.min(oldArray.length, maxAllowedDistance); i <= j; i++)
-            distances[0][i] = i;
-
-        // Left row - transform empty array into new array via additions
-        for (var i = 1, j = Math.min(newArray.length, maxAllowedDistance); i <= j; i++) {
-            distances[i][0] = i;
-        }
-
-        // Fill out the body of the array
-        var oldIndex, oldIndexMax = oldArray.length, newIndex, newIndexMax = newArray.length;
-        var distanceViaAddition, distanceViaDeletion;
-        for (oldIndex = 1; oldIndex <= oldIndexMax; oldIndex++) {
-            var newIndexMinForRow = Math.max(1, oldIndex - maxAllowedDistance);
-            var newIndexMaxForRow = Math.min(newIndexMax, oldIndex + maxAllowedDistance);
-            for (newIndex = newIndexMinForRow; newIndex <= newIndexMaxForRow; newIndex++) {
-                if (oldArray[oldIndex - 1] === newArray[newIndex - 1])
-                    distances[newIndex][oldIndex] = distances[newIndex - 1][oldIndex - 1];
+    function compareArrays(oldArray, newArray, dontLimitMoves) {
+        oldArray = oldArray || [];
+        newArray = newArray || [];
+
+        if (oldArray.length <= newArray.length)
+            return compareSmallArrayToBigArray(oldArray, newArray, statusNotInOld, statusNotInNew, dontLimitMoves);
+        else
+            return compareSmallArrayToBigArray(newArray, oldArray, statusNotInNew, statusNotInOld, dontLimitMoves);
+    }
+
+    function compareSmallArrayToBigArray(smlArray, bigArray, statusNotInSml, statusNotInBig, dontLimitMoves) {
+        var myMin = Math.min,
+            myMax = Math.max,
+            editDistanceMatrix = [],
+            smlIndex, smlIndexMax = smlArray.length,
+            bigIndex, bigIndexMax = bigArray.length,
+            compareRange = (bigIndexMax - smlIndexMax) || 1,
+            maxDistance = smlIndexMax + bigIndexMax + 1,
+            thisRow, lastRow,
+            bigIndexMaxForRow, bigIndexMinForRow;
+
+        for (smlIndex = 0; smlIndex <= smlIndexMax; smlIndex++) {
+            lastRow = thisRow;
+            editDistanceMatrix.push(thisRow = []);
+            bigIndexMaxForRow = myMin(bigIndexMax, smlIndex + compareRange);
+            bigIndexMinForRow = myMax(0, smlIndex - 1);
+            for (bigIndex = bigIndexMinForRow; bigIndex <= bigIndexMaxForRow; bigIndex++) {
+                if (!bigIndex)
+                    thisRow[bigIndex] = smlIndex + 1;
+                else if (!smlIndex)  // Top row - transform empty array into new array via additions
+                    thisRow[bigIndex] = bigIndex + 1;
+                else if (smlArray[smlIndex - 1] === bigArray[bigIndex - 1])
+                    thisRow[bigIndex] = lastRow[bigIndex - 1];                  // copy value (no edit)
                 else {
-                    var northDistance = distances[newIndex - 1][oldIndex] === undefined ? Number.MAX_VALUE : distances[newIndex - 1][oldIndex] + 1;
-                    var westDistance = distances[newIndex][oldIndex - 1] === undefined ? Number.MAX_VALUE : distances[newIndex][oldIndex - 1] + 1;
-                    distances[newIndex][oldIndex] = Math.min(northDistance, westDistance);
+                    var northDistance = lastRow[bigIndex] || maxDistance;       // not in big (deletion)
+                    var westDistance = thisRow[bigIndex - 1] || maxDistance;    // not in small (addition)
+                    thisRow[bigIndex] = myMin(northDistance, westDistance) + 1;
                 }
             }
         }
 
-        return distances;
-    }
-
-    function findEditScriptFromEditDistanceMatrix(editDistanceMatrix, oldArray, newArray) {
-        var oldIndex = oldArray.length;
-        var newIndex = newArray.length;
-        var editScript = [];
-        var maxDistance = editDistanceMatrix[newIndex][oldIndex];
-        if (maxDistance === undefined)
-            return null; // maxAllowedDistance must be too small
-        while ((oldIndex > 0) || (newIndex > 0)) {
-            var me = editDistanceMatrix[newIndex][oldIndex];
-            var distanceViaAdd = (newIndex > 0) ? editDistanceMatrix[newIndex - 1][oldIndex] : maxDistance + 1;
-            var distanceViaDelete = (oldIndex > 0) ? editDistanceMatrix[newIndex][oldIndex - 1] : maxDistance + 1;
-            var distanceViaRetain = (newIndex > 0) && (oldIndex > 0) ? editDistanceMatrix[newIndex - 1][oldIndex - 1] : maxDistance + 1;
-            if ((distanceViaAdd === undefined) || (distanceViaAdd < me - 1)) distanceViaAdd = maxDistance + 1;
-            if ((distanceViaDelete === undefined) || (distanceViaDelete < me - 1)) distanceViaDelete = maxDistance + 1;
-            if (distanceViaRetain < me - 1) distanceViaRetain = maxDistance + 1;
-
-            if ((distanceViaAdd <= distanceViaDelete) && (distanceViaAdd < distanceViaRetain)) {
-                editScript.push({ status: "added", value: newArray[newIndex - 1] });
-                newIndex--;
-            } else if ((distanceViaDelete < distanceViaAdd) && (distanceViaDelete < distanceViaRetain)) {
-                editScript.push({ status: "deleted", value: oldArray[oldIndex - 1] });
-                oldIndex--;
+        var editScript = [], meMinusOne, notInSml = [], notInBig = [];
+        for (smlIndex = smlIndexMax, bigIndex = bigIndexMax; smlIndex || bigIndex;) {
+            meMinusOne = editDistanceMatrix[smlIndex][bigIndex] - 1;
+            if (bigIndex && meMinusOne === editDistanceMatrix[smlIndex][bigIndex-1]) {
+                notInSml.push(editScript[editScript.length] = {     // added
+                    'status': statusNotInSml,
+                    'value': bigArray[--bigIndex],
+                    'index': bigIndex });
+            } else if (smlIndex && meMinusOne === editDistanceMatrix[smlIndex - 1][bigIndex]) {
+                notInBig.push(editScript[editScript.length] = {     // deleted
+                    'status': statusNotInBig,
+                    'value': smlArray[--smlIndex],
+                    'index': smlIndex });
             } else {
-                editScript.push({ status: "retained", value: oldArray[oldIndex - 1] });
-                newIndex--;
-                oldIndex--;
+                editScript.push({
+                    'status': "retained",
+                    'value': bigArray[--bigIndex] });
+                --smlIndex;
+            }
+        }
+
+        if (notInSml.length && notInBig.length) {
+            // Set a limit on the number of consecutive non-matching comparisons; having it a multiple of
+            // smlIndexMax keeps the time complexity of this algorithm linear.
+            var limitFailedCompares = smlIndexMax * 10, failedCompares,
+                a, d, notInSmlItem, notInBigItem;
+            // Go through the items that have been added and deleted and try to find matches between them.
+            for (failedCompares = a = 0; (dontLimitMoves || failedCompares < limitFailedCompares) && (notInSmlItem = notInSml[a]); a++) {
+                for (d = 0; notInBigItem = notInBig[d]; d++) {
+                    if (notInSmlItem['value'] === notInBigItem['value']) {
+                        notInSmlItem['moved'] = notInBigItem['index'];
+                        notInBigItem['moved'] = notInSmlItem['index'];
+                        notInBig.splice(d,1);       // This item is marked as moved; so remove it from notInBig list
+                        failedCompares = d = 0;     // Reset failed compares count because we're checking for consecutive failures
+                        break;
+                    }
+                }
+                failedCompares += d;
             }
         }
         return editScript.reverse();
     }
 
-    ko.utils.compareArrays = function (oldArray, newArray, maxEditsToConsider) {
-        if (maxEditsToConsider === undefined) {
-            return ko.utils.compareArrays(oldArray, newArray, 1)                 // First consider likely case where there is at most one edit (very fast)
-                || ko.utils.compareArrays(oldArray, newArray, 10)                // If that fails, account for a fair number of changes while still being fast
-                || ko.utils.compareArrays(oldArray, newArray, Number.MAX_VALUE); // Ultimately give the right answer, even though it may take a long time
-        } else {
-            oldArray = oldArray || [];
-            newArray = newArray || [];
-            var editDistanceMatrix = calculateEditDistanceMatrix(oldArray, newArray, maxEditsToConsider);
-            return findEditScriptFromEditDistanceMatrix(editDistanceMatrix, oldArray, newArray);
-        }
-    };
+    return compareArrays;
 })();
 
 ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays);
@@ -3225,8 +3284,8 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays);
     // "callbackAfterAddingNodes" will be invoked after any "mapping"-generated nodes are inserted into the container node
     // You can use this, for example, to activate bindings on those nodes.
 
-    function fixUpNodesToBeRemoved(contiguousNodeArray) {
-        // Before deleting or replacing a set of nodes that were previously outputted by the "map" function, we have to reconcile
+    function fixUpNodesToBeMovedOrRemoved(contiguousNodeArray) {
+        // Before moving, deleting, or replacing a set of nodes that were previously outputted by the "map" function, we have to reconcile
         // them against what is in the DOM right now. It may be that some of the nodes have already been removed from the document,
         // or that new nodes might have been inserted in the middle, for example by a binding. Also, there may previously have been
         // leading comment nodes (created by rewritten string-based templates) that have since been removed during binding.
@@ -3258,6 +3317,7 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays);
             // (The following line replaces the contents of contiguousNodeArray with newContiguousSet)
             Array.prototype.splice.apply(contiguousNodeArray, [0, contiguousNodeArray.length].concat(newContiguousSet));
         }
+        return contiguousNodeArray;
     }
 
     function mapNodeAndRefreshWhenChanged(containerNode, mapping, valueToMap, callbackAfterAddingNodes, index) {
@@ -3268,18 +3328,17 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays);
 
             // On subsequent evaluations, just replace the previously-inserted DOM nodes
             if (mappedNodes.length > 0) {
-                fixUpNodesToBeRemoved(mappedNodes);
-                ko.utils.replaceDomNodes(mappedNodes, newMappedNodes);
+                ko.utils.replaceDomNodes(fixUpNodesToBeMovedOrRemoved(mappedNodes), newMappedNodes);
                 if (callbackAfterAddingNodes)
-                    callbackAfterAddingNodes(valueToMap, newMappedNodes);
+                    ko.dependencyDetection.ignore(callbackAfterAddingNodes, null, [valueToMap, newMappedNodes, index]);
             }
 
             // Replace the contents of the mappedNodes array, thereby updating the record
             // of which nodes would be deleted if valueToMap was itself later removed
             mappedNodes.splice(0, mappedNodes.length);
             ko.utils.arrayPushAll(mappedNodes, newMappedNodes);
-        }, null, { 'disposeWhenNodeIsRemoved': containerNode, 'disposeWhen': function() { return (mappedNodes.length == 0) || !ko.utils.domNodeIsAttachedToDocument(mappedNodes[0]) } });
-        return { mappedNodes : mappedNodes, dependentObservable : dependentObservable };
+        }, null, { disposeWhenNodeIsRemoved: containerNode, disposeWhen: function() { return (mappedNodes.length == 0) || !ko.utils.domNodeIsAttachedToDocument(mappedNodes[0]) } });
+        return { mappedNodes : mappedNodes, dependentObservable : (dependentObservable.isActive() ? dependentObservable : undefined) };
     }
 
     var lastMappingResultDomDataKey = "setDomNodeChildrenFromArrayMapping_lastMappingResult";
@@ -3296,96 +3355,113 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays);
         // Build the new mapping result
         var newMappingResult = [];
         var lastMappingResultIndex = 0;
-        var nodesToDelete = [];
         var newMappingResultIndex = 0;
-        var nodesAdded = [];
-        var insertAfterNode = null;
-        for (var i = 0, j = editScript.length; i < j; i++) {
-            switch (editScript[i].status) {
-                case "retained":
-                    // Just keep the information - don't touch the nodes
-                    var dataToRetain = lastMappingResult[lastMappingResultIndex];
-                    dataToRetain.indexObservable(newMappingResultIndex);
-                    newMappingResultIndex = newMappingResult.push(dataToRetain);
-                    if (dataToRetain.domNodes.length > 0)
-                        insertAfterNode = dataToRetain.domNodes[dataToRetain.domNodes.length - 1];
-                    lastMappingResultIndex++;
-                    break;
 
-                case "deleted":
-                    // Stop tracking changes to the mapping for these nodes
-                    lastMappingResult[lastMappingResultIndex].dependentObservable.dispose();
-
-                    // Queue these nodes for later removal
-                    fixUpNodesToBeRemoved(lastMappingResult[lastMappingResultIndex].domNodes);
-                    ko.utils.arrayForEach(lastMappingResult[lastMappingResultIndex].domNodes, function (node) {
-                        nodesToDelete.push({
-                          element: node,
-                          index: i,
-                          value: editScript[i].value
+        var nodesToDelete = [];
+        var itemsToProcess = [];
+        var itemsForBeforeRemoveCallbacks = [];
+        var itemsForMoveCallbacks = [];
+        var itemsForAfterAddCallbacks = [];
+        var mapData;
+
+        function itemMovedOrRetained(editScriptIndex, oldPosition) {
+            mapData = lastMappingResult[oldPosition];
+            if (newMappingResultIndex !== oldPosition)
+                itemsForMoveCallbacks[editScriptIndex] = mapData;
+            // Since updating the index might change the nodes, do so before calling fixUpNodesToBeMovedOrRemoved
+            mapData.indexObservable(newMappingResultIndex++);
+            fixUpNodesToBeMovedOrRemoved(mapData.mappedNodes);
+            newMappingResult.push(mapData);
+            itemsToProcess.push(mapData);
+        }
+
+        function callCallback(callback, items) {
+            if (callback) {
+                for (var i = 0, n = items.length; i < n; i++) {
+                    if (items[i]) {
+                        ko.utils.arrayForEach(items[i].mappedNodes, function(node) {
+                            callback(node, i, items[i].arrayEntry);
                         });
-                        insertAfterNode = node;
-                    });
+                    }
+                }
+            }
+        }
+
+        for (var i = 0, editScriptItem, movedIndex; editScriptItem = editScript[i]; i++) {
+            movedIndex = editScriptItem['moved'];
+            switch (editScriptItem['status']) {
+                case "deleted":
+                    if (movedIndex === undefined) {
+                        mapData = lastMappingResult[lastMappingResultIndex];
+
+                        // Stop tracking changes to the mapping for these nodes
+                        if (mapData.dependentObservable)
+                            mapData.dependentObservable.dispose();
+
+                        // Queue these nodes for later removal
+                        nodesToDelete.push.apply(nodesToDelete, fixUpNodesToBeMovedOrRemoved(mapData.mappedNodes));
+                        if (options['beforeRemove']) {
+                            itemsForBeforeRemoveCallbacks[i] = mapData;
+                            itemsToProcess.push(mapData);
+                        }
+                    }
                     lastMappingResultIndex++;
                     break;
 
+                case "retained":
+                    itemMovedOrRetained(i, lastMappingResultIndex++);
+                    break;
+
                 case "added":
-                    var valueToMap = editScript[i].value;
-                    var indexObservable = ko.observable(newMappingResultIndex);
-                    var mapData = mapNodeAndRefreshWhenChanged(domNode, mapping, valueToMap, callbackAfterAddingNodes, indexObservable);
-                    var mappedNodes = mapData.mappedNodes;
-
-                    // On the first evaluation, insert the nodes at the current insertion point
-                    newMappingResultIndex = newMappingResult.push({
-                        arrayEntry: editScript[i].value,
-                        domNodes: mappedNodes,
-                        dependentObservable: mapData.dependentObservable,
-                        indexObservable: indexObservable
-                    });
-                    for (var nodeIndex = 0, nodeIndexMax = mappedNodes.length; nodeIndex < nodeIndexMax; nodeIndex++) {
-                        var node = mappedNodes[nodeIndex];
-                        nodesAdded.push({
-                          element: node,
-                          index: i,
-                          value: editScript[i].value
-                        });
-                        if (insertAfterNode == null) {
-                            // Insert "node" (the newly-created node) as domNode's first child
-                            ko.virtualElements.prepend(domNode, node);
-                        } else {
-                            // Insert "node" into "domNode" immediately after "insertAfterNode"
-                            ko.virtualElements.insertAfter(domNode, node, insertAfterNode);
-                        }
-                        insertAfterNode = node;
+                    if (movedIndex !== undefined) {
+                        itemMovedOrRetained(i, movedIndex);
+                    } else {
+                        mapData = { arrayEntry: editScriptItem['value'], indexObservable: ko.observable(newMappingResultIndex++) };
+                        newMappingResult.push(mapData);
+                        itemsToProcess.push(mapData);
+                        if (!isFirstExecution)
+                            itemsForAfterAddCallbacks[i] = mapData;
                     }
-                    if (callbackAfterAddingNodes)
-                        callbackAfterAddingNodes(valueToMap, mappedNodes, indexObservable);
                     break;
             }
         }
 
-        ko.utils.arrayForEach(nodesToDelete, function (node) { ko.cleanNode(node.element) });
+        // Call beforeMove first before any changes have been made to the DOM
+        callCallback(options['beforeMove'], itemsForMoveCallbacks);
 
-        var invokedBeforeRemoveCallback = false;
-        if (!isFirstExecution) {
-            if (options['afterAdd']) {
-                for (var i = 0; i < nodesAdded.length; i++)
-                    options['afterAdd'](nodesAdded[i].element, nodesAdded[i].index, nodesAdded[i].value);
-            }
-            if (options['beforeRemove']) {
-                for (var i = 0; i < nodesToDelete.length; i++)
-                    options['beforeRemove'](nodesToDelete[i].element, nodesToDelete[i].index, nodesToDelete[i].value);
-                invokedBeforeRemoveCallback = true;
+        // Next remove nodes for deleted items (or just clean if there's a beforeRemove callback)
+        ko.utils.arrayForEach(nodesToDelete, options['beforeRemove'] ? ko.cleanNode : ko.removeNode);
+
+        // Next add/reorder the remaining items (will include deleted items if there's a beforeRemove callback)
+        for (var i = 0, nextNode = ko.virtualElements.firstChild(domNode), lastNode, node; mapData = itemsToProcess[i]; i++) {
+            // Get nodes for newly added items
+            if (!mapData.mappedNodes)
+                ko.utils.extend(mapData, mapNodeAndRefreshWhenChanged(domNode, mapping, mapData.arrayEntry, callbackAfterAddingNodes, mapData.indexObservable));
+
+            // Put nodes in the right place if they aren't there already
+            for (var j = 0; node = mapData.mappedNodes[j]; nextNode = node.nextSibling, lastNode = node, j++) {
+                if (node !== nextNode)
+                    ko.virtualElements.insertAfter(domNode, node, lastNode);
             }
-        }
-        if (!invokedBeforeRemoveCallback && nodesToDelete.length) {
-            for (var i = 0; i < nodesToDelete.length; i++) {
-                var element = nodesToDelete[i].element;
-                if (element.parentNode)
-                    element.parentNode.removeChild(element);
+
+            // Run the callbacks for newly added nodes (for example, to apply bindings, etc.)
+            if (!mapData.initialized && callbackAfterAddingNodes) {
+                callbackAfterAddingNodes(mapData.arrayEntry, mapData.mappedNodes, mapData.indexObservable);
+                mapData.initialized = true;
             }
         }
 
+        // If there's a beforeRemove callback, call it after reordering.
+        // Note that we assume that the beforeRemove callback will usually be used to remove the nodes using
+        // some sort of animation, which is why we first reorder the nodes that will be removed. If the
+        // callback instead removes the nodes right away, it would be more efficient to skip reordering them.
+        // Perhaps we'll make that change in the future if this scenario becomes more common.
+        callCallback(options['beforeRemove'], itemsForBeforeRemoveCallbacks);
+
+        // Finally call afterMove and afterAdd callbacks
+        callCallback(options['afterMove'], itemsForMoveCallbacks);
+        callCallback(options['afterAdd'], itemsForAfterAddCallbacks);
+
         // Store a copy of the array items we just considered so we can difference it next time
         ko.utils.domData.set(domNode, lastMappingResultDomDataKey, newMappingResult);
     }
diff --git a/root/static/lib/libraries.txt b/root/static/lib/libraries.txt
index 9e99edf..1f7fdbd 100644
--- a/root/static/lib/libraries.txt
+++ b/root/static/lib/libraries.txt
@@ -94,7 +94,7 @@ url:         http://tablesorter.com
 
 library:     Knockout
 license:     MIT
-version:     GIT, 2012-07-23
+version:     2.2.0
 author:      Steven Sanderson
 url:         http://knockoutjs.com
 
diff --git a/root/static/scripts/relationship-editor/Dialog.js b/root/static/scripts/relationship-editor/Dialog.js
index f2d6c69..f412855 100644
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@ -283,38 +283,29 @@ var Dialog = UI.Dialog = {
     mode: ko.observable(""),
     loading: ko.observable(false),
     batchWorksError: ko.observable(false),
-    backward: ko.observable(false),
 
     showAutocomplete: ko.observable(false),
     showCreateWorkLink: ko.observable(false),
     showAttributesHelp: ko.observable(false),
     showLinkTypeHelp: ko.observable(false),
 
-    relationship: (function() {
-        var value = ko.observable(null);
-
-        return ko.computed({
-            read: value,
-            write: function(newValue) {
-                var oldValue = value();
-                if (oldValue !== newValue) value(newValue);
-            }
-        });
-    }()),
-
     init: function() {
         var entity = [RE.Entity({type: "artist"}), RE.Entity({type: "recording"})];
 
         // this is used as an "empty" state when the dialog is hidden, so that
         // none of the bindings error out.
         this.emptyRelationship = RE.Relationship({entity: entity});
+        this.relationship = ko.observable(this.emptyRelationship);
 
-        this.backward(true);
-        this.relationship(this.emptyRelationship);
+        this.backward = ko.observable(true);
         this.sourceField = ko.observable(null);
         this.targetField = ko.observable(null);
         this.source = entity[1];
 
+        this.linkTypeDescription = ko.computed(function() {
+            return (Util.typeInfo(Dialog.relationship().link_type()) || {}).descr || "";
+        });
+
         ko.computed(function() {
             var relationship = Dialog.relationship(),
                 entity0 = relationship.entity[0],
@@ -404,17 +395,6 @@ var Dialog = UI.Dialog = {
         this.showAttributesHelp(!this.showAttributesHelp());
     },
 
-    linkTypeDescription: ko.computed({
-        read: function() {
-            var info = Util.typeInfo(Dialog.relationship().link_type());
-            return info ? info.descr : "";
-        },
-        // at the time this is declared, Dialog.relationship() is not set,
-        // and can't be because relationships require the link type info from
-        // the server. so defer this.
-        deferEvaluation: true
-    }),
-
     changeDirection: function() {
         var relationship = this.relationship.peek(),
             entity0 = relationship.entity[0].peek(),
diff --git a/root/static/scripts/relationship-editor/Relationship.js b/root/static/scripts/relationship-editor/Relationship.js
index 779d770..549b810 100644
--- a/root/static/scripts/relationship-editor/Relationship.js
+++ b/root/static/scripts/relationship-editor/Relationship.js
@@ -282,6 +282,12 @@ Relationship.prototype.openEdits = function() {
     );
 };
 
+
+Relationship.prototype.css = function() {
+    var action = this.action();
+    return _.trim((this.hasErrors() ? "error-field" : "") + " " + (action ? "rel-" + action : ""));
+};
+
 return RE;
 
 }(MB.RelationshipEditor || {}));

commit ae8998748bd0e70e23e7ef07b3b63e1f8c8bb17a
Author: Michael Wiencek <[email protected]>
Date:   Mon Oct 29 20:10:55 2012 +0000

    MBS-5480: limit the size of the autocomplete menu

diff --git a/root/static/scripts/relationship-editor/Dialog.js b/root/static/scripts/relationship-editor/Dialog.js
index f2d6c69..414968e 100644
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@ -216,6 +216,8 @@ ko.bindingHandlers.autocomplete = (function() {
     }
 
     function changeTarget(event, data) {
+        if (!data.gid) return;
+
         // XXX release groups' numeric "type" conflicts with the entity type
         data.type = _.isNumber(data.type) ? "release_group" : (data.type || Dialog.target.type);
 
@@ -257,7 +259,6 @@ ko.bindingHandlers.autocomplete = (function() {
 
             Dialog.autocomplete = MB.Control.EntityAutocomplete({
                 inputs: $autocomplete,
-                position: {collision: "fit"},
                 entity: Dialog.target.type,
                 setEntity: setEntity
             });
diff --git a/root/static/styles/widgets.css b/root/static/styles/widgets.css
index da3d82e..feba02a 100644
--- a/root/static/styles/widgets.css
+++ b/root/static/styles/widgets.css
@@ -334,3 +334,9 @@ ul.favicons li img {
 #recaptcha_widget_div {
     display: inline-block;
 }
+
+ul.ui-autocomplete.ui-menu {
+    max-height: 300px !important;
+    overflow-y: auto !important;
+    overflow-x: hidden !important;
+}

commit 070070138957f693768b57a7a1ce28f388ab8de9
Author: Michael Wiencek <[email protected]>
Date:   Mon Oct 29 01:45:00 2012 +0000

    Relationship editor could partially submit edits

diff --git a/lib/MusicBrainz/Server/Controller/RelationshipEditor.pm b/lib/MusicBrainz/Server/Controller/RelationshipEditor.pm
index 259b0ab..4bcf4cf 100644
--- a/lib/MusicBrainz/Server/Controller/RelationshipEditor.pm
+++ b/lib/MusicBrainz/Server/Controller/RelationshipEditor.pm
@@ -34,7 +34,9 @@ sub base : Path('/relationship-editor') Args(0) Edit RequireAuth {
             }
         }
         if ($form->submitted_and_valid($c->req->body_parameters)) {
-            $self->submit_edits($c, $form);
+            $c->model('MB')->with_transaction(sub {
+                $self->submit_edits($c, $form);
+            });
             $c->res->body(encode_json({message => 'OK'}));
         } else {
             $c->res->status(400);
@@ -199,13 +201,11 @@ sub remove_relationship {
     my $id = $field->field('id')->value;
     my $relationship = $c->stash->{loaded_relationships}->{$types}->{$id};
 
-    $c->model('MB')->with_transaction(sub {
-        $self->_insert_edit(
-            $c, $form,
-            edit_type => $EDIT_RELATIONSHIP_DELETE,
-            relationship => $relationship,
-        );
-    });
+    $self->_insert_edit(
+        $c, $form,
+        edit_type => $EDIT_RELATIONSHIP_DELETE,
+        relationship => $relationship,
+    );
 }
 
 sub add_relationship {
diff --git a/root/scripts/text_strings.tt b/root/scripts/text_strings.tt
index ef1f9aa..5efc125 100644
--- a/root/scripts/text_strings.tt
+++ b/root/scripts/text_strings.tt
@@ -116,6 +116,7 @@
         AddNote: '[% l('Add Note') | js %]',
         DeleteNote: '[% l('Delete Note') | js %]',
         ConfirmSwap: '[% l('This tracklist has artist credits with information that will be lost if you swap artist credits with track titles. This cannot be undone. Do you wish to continue?') | js %]',
-        ConfirmNavigation: '[% l('All of your changes will be lost if you leave this page.') | js %]'
+        ConfirmNavigation: '[% l('All of your changes will be lost if you leave this page.') | js %]',
+        SubmissionError: '[% l('We\'re very sorry, an error occurred during submission. The server may be under heavy load; please wait a moment and try again.') | js %]'
     });
 })(MB);
diff --git a/root/static/scripts/relationship-editor/RelationshipEditor.js b/root/static/scripts/relationship-editor/RelationshipEditor.js
index 291109f..fd818f1 100644
--- a/root/static/scripts/relationship-editor/RelationshipEditor.js
+++ b/root/static/scripts/relationship-editor/RelationshipEditor.js
@@ -101,12 +101,12 @@ RE.releaseViewModel = {
             .success(function() {
                 window.location.replace("/release/" + self.GID);
             })
-            .error(function(jqXHR, statusText) {
+            .error(function(jqXHR) {
                 try {
                     self.handlerErrors(JSON.parse(jqXHR.responseText), changed);
                 } catch(e) {
                     self.submissionLoading(false);
-                    self.submissionError(statusText);
+                    self.submissionError(MB.text.SubmissionError);
                 }
                 if (beforeUnload) window.onbeforeunload = beforeUnload;
             });

commit 231fbd6576b11b06d0523efd04944cb339b2d666
Author: Michael Wiencek <[email protected]>
Date:   Sun Oct 28 03:15:47 2012 +0000

    Rearrange the rel editor's batch ui

diff --git a/root/release/edit_relationships.tt b/root/release/edit_relationships.tt
index 296cd5e..0bdbcc7 100644
--- a/root/release/edit_relationships.tt
+++ b/root/release/edit_relationships.tt
@@ -327,9 +327,9 @@
 
       <p>[% l('To <span class="rel-edit">edit</span> a relationship, click on its name (e.g. "composer").') %]</p>
 
-      <h2>[% l('Track Relationships') %]</h2>
+      <p>[% l('To use the batch tools, select some recordings or works using the checkboxes.') %]</p>
 
-      <h3>[% l('Tools:') %]</h3>
+      <h2>[% l('Track Relationships') %]</h2>
 
       [%# These are here to make sure the strings get added to the message catalog.
           The strings are later obtained via /ws/js/plurals. %]
@@ -337,38 +337,40 @@
       <!-- [% ln('{n} recording selected', '{n} recordings selected', 0) %]
            [% ln('{n} work selected', '{n} works selected', 0) %] -->
 
-      <div>
-        <div style="float: left;" data-bind="with: checkboxes">
-          <a id="batch-recording" class="btn" data-bind="css: {disabled: recordingCount() == 0}">
-            <img src="/static/images/icons/add.png" class="bottom"/>
-            [% l('Batch-add a relationship to recordings') %]
-          </a>
-          <!-- ko text: recordingMessage --><!-- /ko --><br/>
-          <a id="batch-work" class="btn" data-bind="css: {disabled: workCount() == 0}">
-            <img src="/static/images/icons/add.png" class="bottom"/>
-            [% l('Batch-add a relationship to works') %]
-          </a>
-          <!-- ko text: workMessage --><!-- /ko --><br/>
-          <a id="batch-create-works" class="btn" data-bind="css: {disabled: recordingCount() == 0}">
-            <img src="/static/images/icons/add.png" class="bottom"/>
-            [% l('Batch-create new works') %]
-          </a>
-          <!-- ko text: recordingMessage --><!-- /ko -->
-        </div>
-        <div class="documentation">
-          <div class="bubble">
-            [% l('To use these tools, select some recordings or works using the checkboxes.') %]
-          </div>
-        </div>
-      </div>
+      <table id="batch-tools" data-bind="with: checkboxes">
+        <tr>
+          <td>
+            <a id="batch-recording" class="btn" data-bind="css: {disabled: recordingCount() == 0}">
+              <img src="/static/images/icons/add.png" class="bottom"/>
+              [% l('Batch-add a relationship to recordings') %]
+            </a>
+          </td>
+          <td>
+            <a id="batch-create-works" class="btn" data-bind="css: {disabled: recordingCount() == 0}">
+              <img src="/static/images/icons/add.png" class="bottom"/>
+              [% l('Batch-create new works') %]
+            </a>
+          </td>
+          <td>
+            <a id="batch-work" class="btn" data-bind="css: {disabled: workCount() == 0}">
+              <img src="/static/images/icons/add.png" class="bottom"/>
+              [% l('Batch-add a relationship to works') %]
+            </a>
+          </td>
+        </tr>
+      </table>
 
       <table class="tbl" id="tracklist">
-        <thead>
+        <thead data-bind="with: checkboxes">
           <tr>
             <th class="pos t">#</th>
-            <th class="recordings">[% l('Recording') %]</th>
-            <th style="border-left: none;">&#160;</th>
-            <th class="works">[% l('Related Works') %]</th>
+            <th class="recordings">
+              [% l('Recording') %] <!-- ko text: recordingMessage --><!-- /ko -->
+            </th>
+            <th style="border-left: none;"></th>
+            <th class="works">
+              [% l('Related Works') %] <!-- ko text: workMessage --><!-- /ko -->
+            </th>
           </tr>
         </thead>
         <tbody data-bind="foreach: media">
diff --git a/root/static/styles/relationship-editor.css b/root/static/styles/relationship-editor.css
index e7c4109..0a39d2f 100644
--- a/root/static/styles/relationship-editor.css
+++ b/root/static/styles/relationship-editor.css
@@ -1,5 +1,6 @@
 #content.rel-editor #tracklist td {vertical-align: top;}
 #content.rel-editor #tracklist td.midcol {vertical-align: middle; font-size: 0.9em;}
+#content.rel-editor #batch-tools {width: 100%; margin: 0.8em;}
 #content.rel-editor div.ars {padding: 0; margin: 0.5em 0; margin-left: 1.5em;}
 #content.rel-editor span.link-phrase {color: #000; cursor: pointer; text-decoration: none;}
 #content.rel-editor span.link-phrase:hover {background: #FD9;}

commit a3297c80d5d70091a595bfc4a480be9c254a2234
Author: Michael Wiencek <[email protected]>
Date:   Sun Oct 28 02:02:05 2012 +0000

    Fix open edits link in the rel editor dialog

diff --git a/root/release/edit_relationships.tt b/root/release/edit_relationships.tt
index 6bc1c1b..296cd5e 100644
--- a/root/release/edit_relationships.tt
+++ b/root/release/edit_relationships.tt
@@ -16,7 +16,7 @@
       <!-- ko if: relationship() && relationship().edits_pending -->
       <p class="msg warning">
         [% l('Warning: This relationship has pending edits.
-              <a target="_blank" data-bind="attr: {href: relationship().openEdits}">Click here</a>
+              <a target="_blank" data-bind="attr: {href: relationship().openEdits()}">Click here</a>
               to view these edits and make sure they do not conflict with your own.') %]
       </p>
       <!-- /ko -->
diff --git a/root/static/scripts/relationship-editor/Relationship.js b/root/static/scripts/relationship-editor/Relationship.js
index c6123c8..779d770 100644
--- a/root/static/scripts/relationship-editor/Relationship.js
+++ b/root/static/scripts/relationship-editor/Relationship.js
@@ -261,9 +261,9 @@ Relationship.prototype.isDuplicate = function(other) {
 };
 
 
-Relationship.prototype.buildOpenEdits = function() {
-    var entity0 = this.original_fields.entity[0],
-        entity1 = this.original_fields.entity[1];
+Relationship.prototype.openEdits = function() {
+    var entity0 = RE.Entity(this.original_fields.entity[0]),
+        entity1 = RE.Entity(this.original_fields.entity[1]);
 
     return _.sprintf(
         '/search/edits?auto_edit_filter=&order=desc&negation=0&combinator=and' +

commit e8ab6a5159a38465f46f4722486c8bacb8cd911b
Author: Michael Wiencek <[email protected]>
Date:   Sun Oct 28 01:15:34 2012 +0000

    MBS-5521: Date placeholders in the relationship editor are not translated

diff --git a/root/release/edit_relationships.tt b/root/release/edit_relationships.tt
index 6bc1c1b..0d2ad52 100644
--- a/root/release/edit_relationships.tt
+++ b/root/release/edit_relationships.tt
@@ -129,29 +129,29 @@
         <!-- /ko -->
         <tr>
           <td class="section">[% l('Begin date:') %]</td>
-          <td data-bind="with: relationship().period">
-               <input data-bind="value: begin_date().year, valueUpdate: 'afterkeydown'" maxlength="4" placeholder="YYYY" size="4"/>-[%-
-            -%]<input data-bind="value: begin_date().month, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="MM" size="2"/>-[%-
-            -%]<input data-bind="value: begin_date().day, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="DD" size="2"/>
-               <div class="error" data-bind="text: begin_date.error"></div>
+          <td class="partial-date">
+               <input data-bind="value: relationship().period.begin_date().year, valueUpdate: 'afterkeydown'" maxlength="4" placeholder="[% l('YYYY') %]" size="4"/>-[%-
+            -%]<input data-bind="value: relationship().period.begin_date().month, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="[% l('MM') %]" size="2"/>-[%-
+            -%]<input data-bind="value: relationship().period.begin_date().day, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="[% l('DD') %]" size="2"/>
+               <div class="error" data-bind="text: relationship().period.begin_date.error"></div>
           </td>
         </tr>
         <tr>
           <td class="section">[% l('End date:') %]</td>
-          <td data-bind="with: relationship().period">
-               <input data-bind="value: end_date().year, valueUpdate: 'afterkeydown'" maxlength="4" placeholder="YYYY" size="4"/>-[%-
-            -%]<input data-bind="value: end_date().month, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="MM" size="2"/>-[%-
-            -%]<input data-bind="value: end_date().day, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="DD" size="2"/>
-               <div class="error" data-bind="text: end_date.error"></div>
+          <td class="partial-date">
+               <input data-bind="value: relationship().period.end_date().year, valueUpdate: 'afterkeydown'" maxlength="4" placeholder="[% l('YYYY') %]" size="4"/>-[%-
+            -%]<input data-bind="value: relationship().period.end_date().month, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="[% l('MM') %]" size="2"/>-[%-
+            -%]<input data-bind="value: relationship().period.end_date().day, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="[% l('DD') %]" size="2"/>
+               <div class="error" data-bind="text: relationship().period.end_date.error"></div>
           </td>
         </tr>
         <tr>
-          <td colspan="2" data-bind="with: relationship().period">
+          <td colspan="2">
             <label>
-              <input type="checkbox" data-bind="checked: ended"/>
+              <input type="checkbox" data-bind="checked: relationship().period.ended"/>
               [% l('This relationship has ended.') %]
             </label>
-            <div class="error" data-bind="text: ended.error"></div>
+            <div class="error" data-bind="text: relationship().period.ended.error"></div>
           </td>
         </tr>
         <!-- ko if: loading -->

commit b89cf93efaf0678881d43af27b76f97f073495c8
Author: Michael Wiencek <[email protected]>
Date:   Sat Oct 27 06:58:29 2012 +0000

    Extraneous semicolon

diff --git a/root/static/scripts/common/multiselect.js b/root/static/scripts/common/multiselect.js
index 2292eaf..3ab9759 100644
--- a/root/static/scripts/common/multiselect.js
+++ b/root/static/scripts/common/multiselect.js
@@ -193,7 +193,7 @@ var multiselect = function(input, placeholder, cacheKey) {
     .on("keydown", function(event) {
         if (event.keyCode == 13) { // enter
             if (self.hoverOption) {
-                self.select(self.hoverOption);;
+                self.select(self.hoverOption);
                 killEvent(event);
             }
         } else if (event.keyCode == 38) { // up

commit 3d66a69744bc014f3e063c8e718f12669705d675
Merge: ffc2aaf 0650b78
Author: Michael Wiencek <[email protected]>
Date:   Sat Oct 27 06:28:44 2012 +0000

    Merge branch 'mbs-5389' into mbs-5476


commit ffc2aafea8dac4f24fb62c7ed043da9c351c7173
Author: Michael Wiencek <[email protected]>
Date:   Sat Oct 27 06:23:44 2012 +0000

    MBS-5476 / MBS-5445: Misc. fixes pressing <enter> in dialog
    
    MBS-5476: Pressing enter in empty fields selects instruments / vocals
    
    MBS-5445: All the widgets forms should submit when hitting <Enter> in a
    field

diff --git a/root/static/scripts/common/multiselect.js b/root/static/scripts/common/multiselect.js
index dec19a3..2292eaf 100644
--- a/root/static/scripts/common/multiselect.js
+++ b/root/static/scripts/common/multiselect.js
@@ -192,18 +192,17 @@ var multiselect = function(input, placeholder, cacheKey) {
     })
     .on("keydown", function(event) {
         if (event.keyCode == 13) { // enter
-            if (self.hoverOption)
-                self.select(self.hoverOption);
-
+            if (self.hoverOption) {
+                self.select(self.hoverOption);;
+                killEvent(event);
+            }
         } else if (event.keyCode == 38) { // up
-            if (self.activeOption()) return;
-
             // opera skips these when tabbing, so focus them explicitly.
-            if (self.items.lastChild) {
+            if (!self.activeOption() && self.items.lastChild) {
                 self.hide();
                 self.items.lastChild.focus();
-            } else return;
-
+                killEvent(event);
+            }
         } else if (event.keyCode == 40) { // down
             self.show();
 
@@ -214,9 +213,9 @@ var multiselect = function(input, placeholder, cacheKey) {
                 var option = self.activeOption() || self.firstOption();
                 option ? self.activateOption(option) : self.hide();
             }, 1);
-        } else return;
 
-        killEvent(event);
+            killEvent(event);
+        }
     })
     .on("input", function(event) {
         self.lookup(this.value);
@@ -293,7 +292,6 @@ multiselect.prototype.activateOption = function(option, focus) {
     if (option === this.hoverOption) return;
 
     this.hoverOption && (this.hoverOption.className = "");
-    this.hoverOption = null;
     this.hoverOption = option;
     option.className = "hover";
 };
@@ -358,7 +356,7 @@ multiselect.prototype.matchesTerm = function(option) {
 }
 
 multiselect.prototype.lookup = function(term) {
-    var self = this, first = true;
+    var self = this, first = Boolean(term);
     this.term = term.toLowerCase();
     if (!this.term) this.hide();
 
diff --git a/root/static/scripts/relationship-editor/Dialog.js b/root/static/scripts/relationship-editor/Dialog.js
index a839928..83f6480 100644
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@ -209,12 +209,6 @@ ko.bindingHandlers.autocomplete = (function() {
         $("#target-type").val(type).trigger("change");
     }
 
-    function closeOnEnter(event) {
-        if (event.keyCode == 13 && !Dialog.relationship.peek().hasErrors.peek() &&
-            !event.isDefaultPrevented())
-                Dialog.instance.peek().accept();
-    }
-
     function changeTarget(event, data) {
         // XXX release groups' numeric "type" conflicts with the entity type
         data.type = _.isNumber(data.type) ? "release_group" : (data.type || Dialog.target.type);
@@ -251,6 +245,14 @@ ko.bindingHandlers.autocomplete = (function() {
         }
     }
 
+    // In Opera 10, when the keydown event on the autocomplete bubbles up to the
+    // dialog, isDefaultPrevented returns false even though here it returns true.
+    // Other browsers work fine.
+    function stopEnter(event) {
+        if (event.keyCode == 13 && event.isDefaultPrevented())
+            event.stopPropagation();
+    }
+
     return {
         init: function(element) {
             var $autocomplete = Dialog.$autocomplete = $(element);
@@ -265,8 +267,8 @@ ko.bindingHandlers.autocomplete = (function() {
             $autocomplete
                 .on("lookup-performed", changeTarget)
                 .find("input.name")
-                    .on("keyup focus click", showRecentEntities)
-                    .on("keydown", closeOnEnter);
+                    .on("keydown", stopEnter)
+                    .on("keyup focus click", showRecentEntities);
 
             setAutocompleteEntity(Dialog.target, Dialog.mode() != "edit");
 
@@ -278,6 +280,29 @@ ko.bindingHandlers.autocomplete = (function() {
 }());
 
 
+var BaseDialog = (function() {
+    var inputRegex = /^input|button|select$/;
+
+    function submit(event) {
+        if (event.keyCode == 13 && this.canSubmit() && !event.isDefaultPrevented() &&
+                inputRegex.test(event.target.nodeName.toLowerCase()))
+            this.accept();
+    }
+
+    function cancel(event) {
+        if (event.keyCode == 13) {
+            event.preventDefault();
+            this.hide();
+        }
+    }
+
+    return function(options) {
+        options.$dialog.on("keydown", _.bind(submit, options))
+            .find("button.negative").on("keydown", _.bind(cancel, options));
+    };
+}());
+
+
 var Dialog = UI.Dialog = {
     MB: MB,
     mode: ko.observable(""),
@@ -303,7 +328,7 @@ var Dialog = UI.Dialog = {
     }()),
 
     init: function() {
-        var entity = [RE.Entity({type: "artist"}), RE.Entity({type: "recording"})];
+        var self = this, entity = [RE.Entity({type: "artist"}), RE.Entity({type: "recording"})];
 
         // this is used as an "empty" state when the dialog is hidden, so that
         // none of the bindings error out.
@@ -334,7 +359,16 @@ var Dialog = UI.Dialog = {
         });
 
         this.$overlay = $("#overlay");
-        this.$dialog =  $("#dialog");
+        this.$dialog = $("#dialog");
+
+        BaseDialog({
+            $dialog: this.$dialog,
+            canSubmit: function() {
+                return !self.relationship.peek().hasErrors.peek();
+            },
+            accept: function() {self.instance.peek().accept()},
+            hide: function() {self.instance.peek().hide()}
+        });
 
         Dialog.instance = ko.observable(this);
         ko.applyBindings(this, this.$dialog[0]);
@@ -676,11 +710,16 @@ var WorkDialog = UI.WorkDialog = {
     editNote: ko.observable(""),
 
     init: function() {
-        var self = this, $dialog = $("#new-work-dialog")
-            .on("keydown", "#work-name", function(event) {
-                if (event.keyCode == 13 && self.name.peek() && !self.loading.peek())
-                    self.accept();
-            });
+        var self = this, $dialog = $("#new-work-dialog");
+
+        BaseDialog({
+            $dialog: $dialog,
+            canSubmit: function() {
+                return self.name.peek() && !self.loading.peek();
+            },
+            accept: this.accept,
+            hide: this.hide
+        });
 
         ko.applyBindings(this, $dialog[0]);
     },

commit 0650b786f211914d9b01f66a30ed7f4dab6ebfa6
Author: Michael Wiencek <[email protected]>
Date:   Sat Oct 27 05:33:38 2012 +0000

    Fix the "Create a new work" link not displaying

diff --git a/root/static/scripts/relationship-editor/Dialog.js b/root/static/scripts/relationship-editor/Dialog.js
index a839928..f2d6c69 100644
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@ -341,8 +341,7 @@ var Dialog = UI.Dialog = {
     },
 
     show: function(options) {
-        var dlg = Dialog, relationship = dlg.relationship.peek(),
-            notBatchWorks = dlg.mode.peek() != "batch.create.works";
+        var dlg = Dialog, notBatchWorks = dlg.mode.peek() != "batch.create.works";
 
         dlg.source = options.source;
         dlg.relationship(options.relationship);
@@ -353,7 +352,7 @@ var Dialog = UI.Dialog = {
         dlg.instance(this);
 
         dlg.showAutocomplete(notBatchWorks);
-        dlg.showCreateWorkLink(relationship.type == "recording-work" && notBatchWorks);
+        dlg.showCreateWorkLink(options.relationship.type == "recording-work" && notBatchWorks);
 
         dlg.$overlay.show();
         // prevents the page from jumping. these will be adjusted in positionDialog.

commit 2b57f54d138f3e6d16bcaa8b207efaf213a0a573
Author: Michael Wiencek <[email protected]>
Date:   Sat Oct 27 02:20:35 2012 +0000

    Avoid re-rendering the dialog attrs

diff --git a/root/static/scripts/relationship-editor/Dialog.js b/root/static/scripts/relationship-editor/Dialog.js
index 1466f18..a839928 100644
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@ -483,7 +483,7 @@ function positionDialog($dialog, posx, posy) {
 Dialog.attrs = (function() {
 
     var Attribute = function(relationship, attr, info) {
-        this.value = relationship.attrs()[attr.name];
+        this.value = relationship.attrs.peek()[attr.name];
         this.data = attr;
         this.min = info[0];
         this.max = info[1];

commit 90461ef717ee409ca872fccf44b68d815e794bc4
Merge: ab6011c 69906c8
Author: Michael Wiencek <[email protected]>
Date:   Tue Oct 23 23:10:28 2012 +0000

    Merge branch 'beta' of git://git.musicbrainz.org/musicbrainz-server into mbs-5389


commit ab6011c012d1277e9f2634cbd05f9693b2aa42d9
Author: Michael Wiencek <[email protected]>
Date:   Tue Oct 23 23:07:25 2012 +0000

    Correctly handle relating a recording on the release to another recording on the release (MBS-5389)
    
    Re-works a lot of code so that relationships no longer have a "source" or "target."
    
    Removes the knockout mapping plugin, which created more problems than it solved.

diff --git a/root/release/edit_relationships.tt b/root/release/edit_relationships.tt
index c2ffb6b..6bc1c1b 100644
--- a/root/release/edit_relationships.tt
+++ b/root/release/edit_relationships.tt
@@ -51,9 +51,10 @@
       <tbody>
         <!-- ko if: mode().match(/^batch/) == null -->
         <tr>
-          <td class="section" data-bind="text: MB.text.Entity[relationship().source.type] + ':'"></td>
+          <td class="section" data-bind="text: MB.text.Entity[sourceField()().type] + ':'"></td>
           <td>
-            <span data-bind="html: relationship().source.rendering"></span>
+            <span data-bind="html: sourceField()().rendering"></span>
+            <div class="error" data-bind="text: sourceField().error"></div>
           </td>
         </tr>
         <!-- /ko -->
@@ -61,7 +62,7 @@
           <td class="section">[% l('Type') %]:</td>
           <td>
             <select id="link-type" data-bind="linkType: relationship, value: relationship().link_type"></select>
-            <!-- ko template: {"if": relationship().source.type == relationship().target().type, afterRender: resize} -->
+            <!-- ko template: {"if": sourceField()().type == targetField()().type, afterRender: resize} -->
             <button data-bind="click: changeDirection, text: MB.text.ChangeDirection"></button>
             <!-- /ko -->
             (<a href="#" data-bind="click: toggleLinkTypeHelp">[% l('help') %]</a>)
@@ -73,7 +74,7 @@
         <tr>
           <td class="section">
             <select id="target-type" data-bind="targetType: $data,
-              disable: mode() == 'edit' || relationship().type() == 'recording-work'"></select>
+              disable: mode() == 'edit' || relationship().type == 'recording-work'"></select>
           </td>
           <td>
             <span id="autocomplete" class="autocomplete" data-bind="autocomplete: relationship">
@@ -82,7 +83,7 @@
               <input type="hidden" class="id"/>
               <input type="hidden" class="gid"/>
             </span>
-            <div class="error" data-bind="text: relationship().target.error"></div>
+            <div class="error" data-bind="text: targetField().error"></div>
           </td>
         </tr>
         <!-- /ko -->
@@ -102,13 +103,13 @@
           <td id="batch-work-lang"></td>
         </tr>
         <!-- /ko -->
-        <!-- ko template: {"if": attributes().length > 0, afterRender: resize} -->
+        <!-- ko template: {"if": attrs().length > 0, afterRender: resize} -->
         <tr>
           <td class="section">
             [% l('Attributes:') %]<br/>
             (<a href="#" data-bind="click: toggleAttributesHelp">[% l('help') %]</a>)
           </td>
-          <td data-bind="foreach: attributes">
+          <td data-bind="foreach: attrs">
             <div>
               <label>
               <!-- ko if: type == 'boolean' -->
@@ -120,7 +121,7 @@
                 <select data-bind="selectAttribute: $data"></select>
               <!-- /ko -->
               </label>
-              <div class="error" data-bind="text: $root.relationship.peek().attributes.peek()[$data.data.name].error"></div>
+              <div class="error" data-bind="text: value.error"></div>
               <div class="ar-descr" data-bind="html: data.descr, visible: $root.showAttributesHelp"></div>
             </div>
           </td>
@@ -128,7 +129,7 @@
         <!-- /ko -->
         <tr>
           <td class="section">[% l('Begin date:') %]</td>
-          <td data-bind="with: relationship">
+          <td data-bind="with: relationship().period">
                <input data-bind="value: begin_date().year, valueUpdate: 'afterkeydown'" maxlength="4" placeholder="YYYY" size="4"/>-[%-
             -%]<input data-bind="value: begin_date().month, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="MM" size="2"/>-[%-
             -%]<input data-bind="value: begin_date().day, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="DD" size="2"/>
@@ -137,7 +138,7 @@
         </tr>
         <tr>
           <td class="section">[% l('End date:') %]</td>
-          <td data-bind="with: relationship">
+          <td data-bind="with: relationship().period">
                <input data-bind="value: end_date().year, valueUpdate: 'afterkeydown'" maxlength="4" placeholder="YYYY" size="4"/>-[%-
             -%]<input data-bind="value: end_date().month, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="MM" size="2"/>-[%-
             -%]<input data-bind="value: end_date().day, valueUpdate: 'afterkeydown'" maxlength="2" placeholder="DD" size="2"/>
@@ -145,7 +146,7 @@
           </td>
         </tr>
         <tr>
-          <td colspan="2" data-bind="with: relationship">
+          <td colspan="2" data-bind="with: relationship().period">
             <label>
               <input type="checkbox" data-bind="checked: ended"/>
               [% l('This relationship has ended.') %]
@@ -276,17 +277,17 @@
 </script>
 
 <script type="text/html" id="template.link.phrase"><span class="link-phrase"
-        data-bind="text: linkPhrase, css: {
+        data-bind="text: linkPhrase($parents[1]), css: {
              'error-field': hasErrors,
              'rel-add':     action() == 'add',
              'rel-edit':    action() == 'edit',
              'rel-remove':  action() == 'remove'}"></span></script>
 
 <script type="text/html" id="template.relationship">
-  <div class="ar" data-bind="with: $data">
+  <div class="ar">
     <span class="remove-button">&#215;</span>
     <!-- ko template: {name: 'template.link.phrase', data: $data} --><!-- /ko -->:
-    <span class="entity" data-bind="html: target().rendering, css: {'rel-edit': edits_pending}"></span>
+    <span class="entity" data-bind="html: target($parents[0]).rendering, css: {'rel-edit': edits_pending}"></span>
     <!-- ko if: dateRendering -->
       (<!-- ko text: dateRendering --><!-- /ko -->)
     <!-- /ko -->
@@ -295,8 +296,8 @@
 
 <script type="text/html" id="template.relationship.performance">
   <div class="ar">
-    <!-- ko with: target --><input type="checkbox"/><!-- /ko -->
-    <span class="entity" data-bind="html: target().rendering, css: {'rel-edit': edits_pending}"></span>
+    <!-- ko with: entity[1] --><input type="checkbox"/><!-- /ko -->
+    <span class="entity" data-bind="html: entity[1]().rendering, css: {'rel-edit': edits_pending}"></span>
     (<!-- ko template: {name: 'template.link.phrase', data: $data} --><!-- /ko -->)
     <!-- ko if: dateRendering -->
       (<!-- ko text: dateRendering --><!-- /ko -->)
@@ -306,7 +307,7 @@
       <div data-bind="html: $root.RE.UI.loadingIndicator"></div>
     <!-- /ko -->
     <!-- ko if: !loadingWork() -->
-      <!-- ko template: {name: 'template.relationships', data: target} --><!-- /ko -->
+      <!-- ko template: {name: 'template.relationships', data: entity[1]} --><!-- /ko -->
     <!-- /ko -->
     <!-- /ko -->
   </div>
diff --git a/root/static/lib/knockout/knockout.mapping-latest.debug.js b/root/static/lib/knockout/knockout.mapping-latest.debug.js
deleted file mode 100644
index 4bee64e..0000000
--- a/root/static/lib/knockout/knockout.mapping-latest.debug.js
+++ /dev/null
@@ -1,716 +0,0 @@
-// Knockout Mapping plugin v2.2.3
-// (c) 2012 Steven Sanderson, Roy Jacobs - http://knockoutjs.com/
-// License: MIT (http://www.opensource.org/licenses/mit-license.php)
-
-(function (factory) {
-	// Module systems magic dance.
-
-	if (typeof require === "function" && typeof exports === "object" && typeof module === "object") {
-		// CommonJS or Node: hard-coded dependency on "knockout"
-		factory(require("knockout"), exports);
-	} else if (typeof define === "function" && define["amd"]) {
-		// AMD anonymous module with hard-coded dependency on "knockout"
-		define(["knockout", "exports"], factory);
-	} else {
-		// <script> tag: use the global `ko` object, attaching a `mapping` property
-		factory(ko, ko.mapping = {});
-	}
-}(function (ko, exports) {
-	var DEBUG=true;
-	var mappingProperty = "__ko_mapping__";
-	var realKoDependentObservable = ko.dependentObservable;
-	var mappingNesting = 0;
-	var dependentObservables;
-	var visitedObjects;
-
-	var _defaultOptions = {
-		include: ["_destroy"],
-		ignore: [],
-		copy: []
-	};
-	var defaultOptions = _defaultOptions;
-
-	function extendObject(destination, source) {
-		for (var key in source) {
-			if (source.hasOwnProperty(key) && source[key]) {
-				if (key && destination[key] && !(exports.getType(destination[key]) === "array")) {
-					extendObject(destination[key], source[key]);
-				} else {
-					destination[key] = source[key];
-				}
-			}
-		}
-	}
-
-	function merge(obj1, obj2) {
-		var merged = {};
-		extendObject(merged, obj1);
-		extendObject(merged, obj2);
-
-		return merged;
-	}
-
-	exports.isMapped = function (viewModel) {
-		var unwrapped = ko.utils.unwrapObservable(viewModel);
-		return unwrapped && unwrapped[mappingProperty];
-	}
-
-	exports.fromJS = function (jsObject /*, inputOptions, target*/ ) {
-		if (arguments.length == 0) throw new Error("When calling ko.fromJS, pass the object you want to convert.");
-
-		// When mapping is completed, even with an exception, reset the nesting level
-		window.setTimeout(function () {
-			mappingNesting = 0;
-		}, 0);
-
-		if (!mappingNesting++) {
-			dependentObservables = [];
-			visitedObjects = new objectLookup();
-		}
-
-		var options;
-		var target;
-
-		if (arguments.length == 2) {
-			if (arguments[1][mappingProperty]) {
-				target = arguments[1];
-			} else {
-				options = arguments[1];
-			}
-		}
-		if (arguments.length == 3) {
-			options = arguments[1];
-			target = arguments[2];
-		}
-
-		if (target) {
-			options = merge(options, target[mappingProperty]);
-		}
-		options = fillOptions(options);
-
-		var result = updateViewModel(target, jsObject, options);
-		if (target) {
-			result = target;
-		}
-
-		// Evaluate any dependent observables that were proxied.
-		// Do this in a timeout to defer execution. Basically, any user code that explicitly looks up the DO will perform the first evaluation. Otherwise,
-		// it will be done by this code.
-		if (!--mappingNesting) {
-			window.setTimeout(function () {
-				while (dependentObservables.length) {
-					var DO = dependentObservables.pop();
-					if (DO) DO();
-				}
-			}, 0);
-		}
-
-		// Save any new mapping options in the view model, so that updateFromJS can use them later.
-		result[mappingProperty] = merge(result[mappingProperty], options);
-
-		return result;
-	};
-
-	exports.fromJSON = function (jsonString /*, options, target*/ ) {
-		var parsed = ko.utils.parseJson(jsonString);
-		arguments[0] = parsed;
-		return exports.fromJS.apply(this, arguments);
-	};
-
-	exports.updateFromJS = function (viewModel) {
-		throw new Error("ko.mapping.updateFromJS, use ko.mapping.fromJS instead. Please note that the order of parameters is different!");
-	};
-
-	exports.updateFromJSON = function (viewModel) {
-		throw new Error("ko.mapping.updateFromJSON, use ko.mapping.fromJSON instead. Please note that the order of parameters is different!");
-	};
-
-	exports.toJS = function (rootObject, options) {
-		if (!defaultOptions) exports.resetDefaultOptions();
-
-		if (arguments.length == 0) throw new Error("When calling ko.mapping.toJS, pass the object you want to convert.");
-		if (exports.getType(defaultOptions.ignore) !== "array") throw new Error("ko.mapping.defaultOptions().ignore should be an array.");
-		if (exports.getType(defaultOptions.include) !== "array") throw new Error("ko.mapping.defaultOptions().include should be an array.");
-		if (exports.getType(defaultOptions.copy) !== "array") throw new Error("ko.mapping.defaultOptions().copy should be an array.");
-
-		// Merge in the options used in fromJS
-		options = fillOptions(options, rootObject[mappingProperty]);
-
-		// We just unwrap everything at every level in the object graph
-		return exports.visitModel(rootObject, function (x) {
-			return ko.utils.unwrapObservable(x)
-		}, options);
-	};
-
-	exports.toJSON = function (rootObject, options) {
-		var plainJavaScriptObject = exports.toJS(rootObject, options);
-		return ko.utils.stringifyJson(plainJavaScriptObject);
-	};
-
-	exports.defaultOptions = function () {
-		if (arguments.length > 0) {
-			defaultOptions = arguments[0];
-		} else {
-			return defaultOptions;
-		}
-	};
-
-	exports.resetDefaultOptions = function () {
-		defaultOptions = {
-			include: _defaultOptions.include.slice(0),
-			ignore: _defaultOptions.ignore.slice(0),
-			copy: _defaultOptions.copy.slice(0)
-		};
-	};
-
-	exports.getType = function(x) {
-		if ((x) && (typeof (x) === "object")) {
-			if (x.constructor == (new Date).constructor) return "date";
-			if (Object.prototype.toString.call(x) === "[object Array]") return "array";
-		}
-		return typeof x;
-	}
-
-	function fillOptions(options, otherOptions) {
-		options = options || {};
-
-		// Is there only a root-level mapping present?
-		if ((options.create instanceof Function) || (options.update instanceof Function) || (options.key instanceof Function) || (options.arrayChanged instanceof Function)) {
-			options = {
-				"": options
-			};
-		}
-
-		if (otherOptions) {
-			options.ignore = mergeArrays(otherOptions.ignore, options.ignore);
-			options.include = mergeArrays(otherOptions.include, options.include);
-			options.copy = mergeArrays(otherOptions.copy, options.copy);
-		}
-		options.ignore = mergeArrays(options.ignore, defaultOptions.ignore);
-		options.include = mergeArrays(options.include, defaultOptions.include);
-		options.copy = mergeArrays(options.copy, defaultOptions.copy);
-
-		options.mappedProperties = options.mappedProperties || {};
-		return options;
-	}
-
-	function mergeArrays(a, b) {
-		if (exports.getType(a) !== "array") {
-			if (exports.getType(a) === "undefined") a = [];
-			else a = [a];
-		}
-		if (exports.getType(b) !== "array") {
-			if (exports.getType(b) === "undefined") b = [];
-			else b = [b];
-		}
-
-		return ko.utils.arrayGetDistinctValues(a.concat(b));
-	}
-
-	// When using a 'create' callback, we proxy the dependent observable so that it doesn't immediately evaluate on creation.
-	// The reason is that the dependent observables in the user-specified callback may contain references to properties that have not been mapped yet.
-	function withProxyDependentObservable(dependentObservables, callback) {
-		var localDO = ko.dependentObservable;
-		ko.dependentObservable = function (read, owner, options) {
-			options = options || {};
-
-			if (read && typeof read == "object") { // mirrors condition in knockout implementation of DO's
-				options = read;
-			}
-
-			var realDeferEvaluation = options.deferEvaluation;
-
-			var isRemoved = false;
-
-			// We wrap the original dependent observable so that we can remove it from the 'dependentObservables' list we need to evaluate after mapping has
-			// completed if the user already evaluated the DO themselves in the meantime.
-			var wrap = function (DO) {
-				var wrapped = realKoDependentObservable({
-					read: function () {
-						if (!isRemoved) {
-							ko.utils.arrayRemoveItem(dependentObservables, DO);
-							isRemoved = true;
-						}
-						return DO.apply(DO, arguments);
-					},
-					write: function (val) {
-						return DO(val);
-					},
-					deferEvaluation: true
-				});
-				if(DEBUG) wrapped._wrapper = true;
-				return wrapped;
-			};
-			
-			options.deferEvaluation = true; // will either set for just options, or both read/options.
-			var realDependentObservable = new realKoDependentObservable(read, owner, options);
-
-			if (!realDeferEvaluation) {
-				realDependentObservable = wrap(realDependentObservable);
-				dependentObservables.push(realDependentObservable);
-			}
-
-			return realDependentObservable;
-		}
-		ko.dependentObservable.fn = realKoDependentObservable.fn;
-		ko.computed = ko.dependentObservable;
-		var result = callback();
-		ko.dependentObservable = localDO;
-		ko.computed = ko.dependentObservable;
-		return result;
-	}
-
-	function updateViewModel(mappedRootObject, rootObject, options, parentName, parent, parentPropertyName) {
-		var isArray = exports.getType(ko.utils.unwrapObservable(rootObject)) === "array";
-
-		parentPropertyName = parentPropertyName || "";
-
-		// If this object was already mapped previously, take the options from there and merge them with our existing ones.
-		if (exports.isMapped(mappedRootObject)) {
-			var previousMapping = ko.utils.unwrapObservable(mappedRootObject)[mappingProperty];
-			options = merge(previousMapping, options);
-		}
-
-		var callbackParams = {
-			data: rootObject,
-			parent: parent
-		};
-
-		var hasCreateCallback = function () {
-			return options[parentName] && options[parentName].create instanceof Function;
-		};
-
-		var createCallback = function (data) {
-			return withProxyDependentObservable(dependentObservables, function () {
-				return options[parentName].create({
-					data: data || callbackParams.data,
-					parent: callbackParams.parent
-				});
-			});
-		};
-
-		var hasUpdateCallback = function () {
-			return options[parentName] && options[parentName].update instanceof Function;
-		};
-
-		var updateCallback = function (obj, data) {
-			var params = {
-				data: data || callbackParams.data,
-				parent: callbackParams.parent,
-				target: ko.utils.unwrapObservable(obj)
-			};
-
-			if (ko.isWriteableObservable(obj)) {
-				params.observable = obj;
-			}
-
-			return options[parentName].update(params);
-		}
-
-		var alreadyMapped = visitedObjects.get(rootObject);
-		if (alreadyMapped) {
-			return alreadyMapped;
-		}
-
-		parentName = parentName || "";
-
-		if (!isArray) {
-			// For atomic types, do a direct update on the observable
-			if (!canHaveProperties(rootObject)) {
-				switch (exports.getType(rootObject)) {
-				case "function":
-					if (hasUpdateCallback()) {
-						if (ko.isWriteableObservable(rootObject)) {
-							rootObject(updateCallback(rootObject));
-							mappedRootObject = rootObject;
-						} else {
-							mappedRootObject = updateCallback(rootObject);
-						}
-					} else {
-						mappedRootObject = rootObject;
-					}
-					break;
-				default:
-					if (ko.isWriteableObservable(mappedRootObject)) {
-						if (hasUpdateCallback()) {
-							mappedRootObject(updateCallback(mappedRootObject));
-						} else {
-							mappedRootObject(ko.utils.unwrapObservable(rootObject));
-						}
-					} else {
-						if (hasCreateCallback()) {
-							mappedRootObject = createCallback();
-						} else {
-							mappedRootObject = ko.observable(ko.utils.unwrapObservable(rootObject));
-						}
-
-						if (hasUpdateCallback()) {
-							mappedRootObject(updateCallback(mappedRootObject));
-						}
-					}
-					break;
-				}
-
-			} else {
-				mappedRootObject = ko.utils.unwrapObservable(mappedRootObject);
-				if (!mappedRootObject) {
-					if (hasCreateCallback()) {
-						var result = createCallback();
-
-						if (hasUpdateCallback()) {
-							result = updateCallback(result);
-						}
-
-						return result;
-					} else {
-						if (hasUpdateCallback()) {
-							return updateCallback(result);
-						}
-
-						mappedRootObject = {};
-					}
-				}
-
-				if (hasUpdateCallback()) {
-					mappedRootObject = updateCallback(mappedRootObject);
-				}
-
-				visitedObjects.save(rootObject, mappedRootObject);
-
-				// For non-atomic types, visit all properties and update recursively
-				visitPropertiesOrArrayEntries(rootObject, function (indexer) {
-					var fullPropertyName = parentPropertyName.length ? parentPropertyName + "." + indexer : indexer;
-
-					if (ko.utils.arrayIndexOf(options.ignore, fullPropertyName) != -1) {
-						return;
-					}
-
-					if (ko.utils.arrayIndexOf(options.copy, fullPropertyName) != -1) {
-						mappedRootObject[indexer] = rootObject[indexer];
-						return;
-					}
-
-					// In case we are adding an already mapped property, fill it with the previously mapped property value to prevent recursion.
-					// If this is a property that was generated by fromJS, we should use the options specified there
-					var prevMappedProperty = visitedObjects.get(rootObject[indexer]);
-					var value = prevMappedProperty || updateViewModel(mappedRootObject[indexer], rootObject[indexer], options, indexer, mappedRootObject, fullPropertyName);
-
-					if (ko.isWriteableObservable(mappedRootObject[indexer])) {
-						mappedRootObject[indexer](ko.utils.unwrapObservable(value));
-					} else {
-						mappedRootObject[indexer] = value;
-					}
-
-					options.mappedProperties[fullPropertyName] = true;
-				});
-			}
-		} else {
-			var changes = [];
-
-			var hasKeyCallback = false;
-			var keyCallback = function (x) {
-				return x;
-			}
-			if (options[parentName] && options[parentName].key) {
-				keyCallback = options[parentName].key;
-				hasKeyCallback = true;
-			}
-
-			if (!ko.isObservable(mappedRootObject)) {
-				// When creating the new observable array, also add a bunch of utility functions that take the 'key' of the array items into account.
-				mappedRootObject = ko.observableArray([]);
-
-				mappedRootObject.mappedRemove = function (valueOrPredicate) {
-					var predicate = typeof valueOrPredicate == "function" ? valueOrPredicate : function (value) {
-							return value === keyCallback(valueOrPredicate);
-						};
-					return mappedRootObject.remove(function (item) {
-						return predicate(keyCallback(item));
-					});
-				}
-
-				mappedRootObject.mappedRemoveAll = function (arrayOfValues) {
-					var arrayOfKeys = filterArrayByKey(arrayOfValues, keyCallback);
-					return mappedRootObject.remove(function (item) {
-						return ko.utils.arrayIndexOf(arrayOfKeys, keyCallback(item)) != -1;
-					});
-				}
-
-				mappedRootObject.mappedDestroy = function (valueOrPredicate) {
-					var predicate = typeof valueOrPredicate == "function" ? valueOrPredicate : function (value) {
-							return value === keyCallback(valueOrPredicate);
-						};
-					return mappedRootObject.destroy(function (item) {
-						return predicate(keyCallback(item));
-					});
-				}
-
-				mappedRootObject.mappedDestroyAll = function (arrayOfValues) {
-					var arrayOfKeys = filterArrayByKey(arrayOfValues, keyCallback);
-					return mappedRootObject.destroy(function (item) {
-						return ko.utils.arrayIndexOf(arrayOfKeys, keyCallback(item)) != -1;
-					});
-				}
-
-				mappedRootObject.mappedIndexOf = function (item) {
-					var keys = filterArrayByKey(mappedRootObject(), keyCallback);
-					var key = keyCallback(item);
-					return ko.utils.arrayIndexOf(keys, key);
-				}
-
-				mappedRootObject.mappedCreate = function (value) {
-					if (mappedRootObject.mappedIndexOf(value) !== -1) {
-						throw new Error("There already is an object with the key that you specified.");
-					}
-
-					var item = hasCreateCallback() ? createCallback(value) : value;
-					if (hasUpdateCallback()) {
-						var newValue = updateCallback(item, value);
-						if (ko.isWriteableObservable(item)) {
-							item(newValue);
-						} else {
-							item = newValue;
-						}
-					}
-					mappedRootObject.push(item);
-					return item;
-				}
-			}
-
-			var currentArrayKeys = filterArrayByKey(ko.utils.unwrapObservable(mappedRootObject), keyCallback).sort();
-			var newArrayKeys = filterArrayByKey(rootObject, keyCallback);
-			if (hasKeyCallback) newArrayKeys.sort();
-			var editScript = ko.utils.compareArrays(currentArrayKeys, newArrayKeys);
-
-			var ignoreIndexOf = {};
-			
-			var i, j;
-
-			var unwrappedRootObject = ko.utils.unwrapObservable(rootObject);
-			var itemsByKey = {};
-			var optimizedKeys = true;
-			for (i = 0, j = unwrappedRootObject.length; i < j; i++) {
-				var key = keyCallback(unwrappedRootObject[i]);
-				if (key === undefined || key instanceof Object) {
-					optimizedKeys = false;
-					break;
-				}
-				itemsByKey[key] = unwrappedRootObject[i];
-			}
-
-			var newContents = [];
-			for (i = 0, j = editScript.length; i < j; i++) {
-				var key = editScript[i];
-				var mappedItem;
-				var fullPropertyName = parentPropertyName + "[" + i + "]";
-				switch (key.status) {
-				case "added":
-					var item = optimizedKeys ? itemsByKey[key.value] : getItemByKey(ko.utils.unwrapObservable(rootObject), key.value, keyCallback);
-					mappedItem = updateViewModel(undefined, item, options, parentName, mappedRootObject, fullPropertyName);
-					if(!hasCreateCallback()) {
-						mappedItem = ko.utils.unwrapObservable(mappedItem);
-					}
-
-					var index = ignorableIndexOf(ko.utils.unwrapObservable(rootObject), item, ignoreIndexOf);
-					newContents[index] = mappedItem;
-					ignoreIndexOf[index] = true;
-					break;
-				case "retained":
-					var item = optimizedKeys ? itemsByKey[key.value] : getItemByKey(ko.utils.unwrapObservable(rootObject), key.value, keyCallback);
-					mappedItem = getItemByKey(mappedRootObject, key.value, keyCallback);
-					updateViewModel(mappedItem, item, options, parentName, mappedRootObject, fullPropertyName);
-
-					var index = ignorableIndexOf(ko.utils.unwrapObservable(rootObject), item, ignoreIndexOf);
-					newContents[index] = mappedItem;
-					ignoreIndexOf[index] = true;
-					break;
-				case "deleted":
-					mappedItem = getItemByKey(mappedRootObject, key.value, keyCallback);
-					break;
-				}
-
-				changes.push({
-					event: key.status,
-					item: mappedItem
-				});
-			}
-
-			mappedRootObject(newContents);
-
-			if (options[parentName] && options[parentName].arrayChanged) {
-				ko.utils.arrayForEach(changes, function (change) {
-					options[parentName].arrayChanged(change.event, change.item);
-				});
-			}
-		}
-
-		return mappedRootObject;
-	}
-
-	function ignorableIndexOf(array, item, ignoreIndices) {
-		for (var i = 0, j = array.length; i < j; i++) {
-			if (ignoreIndices[i] === true) continue;
-			if (array[i] === item) return i;
-		}
-		return null;
-	}
-
-	function mapKey(item, callback) {
-		var mappedItem;
-		if (callback) mappedItem = callback(item);
-		if (exports.getType(mappedItem) === "undefined") mappedItem = item;
-
-		return ko.utils.unwrapObservable(mappedItem);
-	}
-
-	function getItemByKey(array, key, callback) {
-		array = ko.utils.unwrapObservable(array);
-		for (var i = 0, j = array.length; i < j; i++) {
-			var item = array[i];
-			if (mapKey(item, callback) === key) return item;
-		}
-
-		throw new Error("When calling ko.update*, the key '" + key + "' was not found!");
-	}
-
-	function filterArrayByKey(array, callback) {
-		return ko.utils.arrayMap(ko.utils.unwrapObservable(array), function (item) {
-			if (callback) {
-				return mapKey(item, callback);
-			} else {
-				return item;
-			}
-		});
-	}
-
-	function visitPropertiesOrArrayEntries(rootObject, visitorCallback) {
-		if (exports.getType(rootObject) === "array") {
-			for (var i = 0; i < rootObject.length; i++)
-			visitorCallback(i);
-		} else {
-			for (var propertyName in rootObject)
-			visitorCallback(propertyName);
-		}
-	};
-
-	function canHaveProperties(object) {
-		var type = exports.getType(object);
-		return ((type === "object") || (type === "array")) && (object !== null);
-	}
-
-	// Based on the parentName, this creates a fully classified name of a property
-
-	function getPropertyName(parentName, parent, indexer) {
-		var propertyName = parentName || "";
-		if (exports.getType(parent) === "array") {
-			if (parentName) {
-				propertyName += "[" + indexer + "]";
-			}
-		} else {
-			if (parentName) {
-				propertyName += ".";
-			}
-			propertyName += indexer;
-		}
-		return propertyName;
-	}
-
-	exports.visitModel = function (rootObject, callback, options) {
-		options = options || {};
-		options.visitedObjects = options.visitedObjects || new objectLookup();
-
-		var mappedRootObject;
-		var unwrappedRootObject = ko.utils.unwrapObservable(rootObject);
-
-		if (!canHaveProperties(unwrappedRootObject)) {
-			return callback(rootObject, options.parentName);
-		} else {
-			options = fillOptions(options, unwrappedRootObject[mappingProperty]);
-
-			// Only do a callback, but ignore the results
-			callback(rootObject, options.parentName);
-			mappedRootObject = exports.getType(unwrappedRootObject) === "array" ? [] : {};
-		}
-
-		options.visitedObjects.save(rootObject, mappedRootObject);
-
-		var parentName = options.parentName;
-		visitPropertiesOrArrayEntries(unwrappedRootObject, function (indexer) {
-			if (options.ignore && ko.utils.arrayIndexOf(options.ignore, indexer) != -1) return;
-
-			var propertyValue = unwrappedRootObject[indexer];
-			options.parentName = getPropertyName(parentName, unwrappedRootObject, indexer);
-
-			// If we don't want to explicitly copy the unmapped property...
-			if (ko.utils.arrayIndexOf(options.copy, indexer) === -1) {
-				// ...find out if it's a property we want to explicitly include
-				if (ko.utils.arrayIndexOf(options.include, indexer) === -1) {
-					// The mapped properties object contains all the properties that were part of the original object.
-					// If a property does not exist, and it is not because it is part of an array (e.g. "myProp[3]"), then it should not be unmapped.
-					if (unwrappedRootObject[mappingProperty] && unwrappedRootObject[mappingProperty].mappedProperties && !unwrappedRootObject[mappingProperty].mappedProperties[indexer] && !(exports.getType(unwrappedRootObject) === "array")) {
-						return;
-					}
-				}
-			}
-
-			var outputProperty;
-			switch (exports.getType(ko.utils.unwrapObservable(propertyValue))) {
-			case "object":
-			case "array":
-			case "undefined":
-				var previouslyMappedValue = options.visitedObjects.get(propertyValue);
-				mappedRootObject[indexer] = (exports.getType(previouslyMappedValue) !== "undefined") ? previouslyMappedValue : exports.visitModel(propertyValue, callback);
-				break;
-			default:
-				mappedRootObject[indexer] = callback(propertyValue, options.parentName);
-			}
-		});
-
-		return mappedRootObject;
-	}
-
-	function simpleObjectLookup() {
-		var keys = [];
-		var values = [];
-		this.save = function (key, value) {
-			var existingIndex = ko.utils.arrayIndexOf(keys, key);
-			if (existingIndex >= 0) values[existingIndex] = value;
-			else {
-				keys.push(key);
-				values.push(value);
-			}
-		};
-		this.get = function (key) {
-			var existingIndex = ko.utils.arrayIndexOf(keys, key);
-			var value = (existingIndex >= 0) ? values[existingIndex] : undefined;
-			return value;
-		};
-	};
-	
-	function objectLookup() {
-		var buckets = {};
-		
-		var findBucket = function(key) {
-			var bucketKey;
-			try {
-				bucketKey = JSON.stringify(key);
-			}
-			catch (e) {
-				bucketKey = "$$$";
-			}
-			var bucket = buckets[bucketKey];
-			if (bucket === undefined) {
-				bucket = new simpleObjectLookup();
-				buckets[bucketKey] = bucket;
-			}
-			return bucket;
-		};
-		
-		this.save = function (key, value) {
-			findBucket(key).save(key, value);
-		};
-		this.get = function (key) {
-			return findBucket(key).get(key);
-		};
-	};
-}));
diff --git a/root/static/lib/libraries.txt b/root/static/lib/libraries.txt
index 4a89c8d..9e99edf 100644
--- a/root/static/lib/libraries.txt
+++ b/root/static/lib/libraries.txt
@@ -98,14 +98,6 @@ version:     GIT, 2012-07-23
 author:      Steven Sanderson
 url:         http://knockoutjs.com
 
--- knockout/knockout.mapping-latest.debug.js --
-
-library:     Knockout mapping
-license:     MIT
-version:     v2.2.3
-author:      Steven Sanderson, Roy Jacobs
-url:         http://knockoutjs.com/documentation/plugins-mapping.html
-
 -- underscore/underscore.js --
 
 library:     Underscore
diff --git a/root/static/relationship-editor.js.manifest b/root/static/relationship-editor.js.manifest
index 054ec77..af25747 100644
--- a/root/static/relationship-editor.js.manifest
+++ b/root/static/relationship-editor.js.manifest
@@ -1,4 +1,3 @@
 lib/json/
 lib/knockout/knockout-latest.debug.js
-lib/knockout/knockout.mapping-latest.debug.js
 scripts/relationship-editor/
diff --git a/root/static/scripts/relationship-editor/Dialog.js b/root/static/scripts/relationship-editor/Dialog.js
index 3868364..1466f18 100644
--- a/root/static/scripts/relationship-editor/Dialog.js
+++ b/root/static/scripts/relationship-editor/Dialog.js
@@ -21,6 +21,13 @@ MB.RelationshipEditor = (function(RE) {
 
 var UI = RE.UI = RE.UI || {}, Util = RE.Util = RE.Util || {}, $w = $(window);
 
+var allowedRelations = {
+    recording:     ["artist", "label", "recording", "release"],
+    work:          ["artist", "label", "work"],
+    release:       ["artist", "label", "recording", "release"],
+    release_group: ["artist", "release_group"]
+};
+
 // For select attributes and the link type field, we use a custom binding handler
 // for performance reasons (the instrument tree is huge, for example). We also
 // need to support the unaccented instrument names. the builtin options binding
@@ -46,7 +53,7 @@ ko.bindingHandlers.selectAttribute = (function() {
 
     var getOptions = _.memoize(function(attr) {
         var doc = document.createDocumentFragment();
-        build(attr.relationship.attributes(), attr.data, 0, doc);
+        build(Dialog.relationship().attrs(), attr.data, 0, doc);
         return doc;
 
     }, function(attr) {return attr.data.name});
@@ -79,7 +86,7 @@ ko.bindingHandlers.selectAttribute = (function() {
 
 ko.bindingHandlers.linkType = (function() {
 
-    var previousType, previousDirection;
+    var previousType, previousDirection, getOptions;
 
     function build(root, indent, backward, doc) {
         var phrase = backward ? root.reverse_phrase : root.phrase;
@@ -104,7 +111,7 @@ ko.bindingHandlers.linkType = (function() {
         });
     };
 
-    var getOptions = _.memoize(function(type, backward) {
+    getOptions = _.memoize(function(type, backward) {
         var doc = document.createDocumentFragment();
 
         $.each(Util.typeInfoByEntities(type), function(i, root) {
@@ -115,11 +122,11 @@ ko.bindingHandlers.linkType = (function() {
 
     return {
         update: function(element) {
-            var relationship = Dialog.relationship(), type = relationship.type(),
-                backward = relationship.backward();
+            var relationship = Dialog.relationship(), type = relationship.type,
+                backward = Dialog.backward();
 
             if (type != previousType || backward != previousDirection) {
-                var doc = getOptions(relationship.type(), backward).cloneNode(true);
+                var doc = getOptions(type, backward).cloneNode(true);
 
                 $(element).empty().append(doc).val(relationship.link_type());
                 Dialog.resize();
@@ -134,23 +141,27 @@ ko.bindingHandlers.linkType = (function() {
 
 ko.bindingHandlers.targetType = (function() {
 
-    var allowedRelations = {
-        recording:     ["artist", "label", "recording", "release"],
-        work:          ["artist", "label", "work"],
-        release:       ["artist", "label", "recording", "release"],
-        release_group: ["artist", "release_group"]
-    };
-
     function change() {
         var mode = Dialog.mode();
         if (!(mode == "add" || /^batch\.(recording|work)$/.test(mode))) return;
 
-        var relationship = Dialog.relationship.peek(), ac = Dialog.autocomplete,
-            name = relationship.target().name(), target = Util.tempEntity(this.value);
+        var ac = Dialog.autocomplete, relationship = Dialog.relationship.peek(),
+            newTarget = RE.Entity({type: this.value, name: Dialog.target.name}),
+            obj = relationship.toJS();
+
+        obj.entity[Dialog.target.gid == obj.entity[0].gid ? 0 : 1] = newTarget;
 
-        // reset the current target entity
-        target.name(name);
-        relationship.target(target);
+        // detect when the entity order needs to be reversed.
+        // e.g. switching from artist-recording to recording-release.
+
+        var types = [obj.entity[0].type, obj.entity[1].type],
+            type = types.join("-"), reverseType = types.reverse().join("-");
+
+        if (!Util.typeInfoByEntities(type) && Util.typeInfoByEntities(reverseType))
+            obj.entity.reverse();
+
+        Dialog.relationship(RE.Relationship(obj));
+        relationship.remove();
 
         if (ac) {
             ac.clear();
@@ -162,14 +173,14 @@ ko.bindingHandlers.targetType = (function() {
     return {
         init: function(element) {
             var $element = $(element).change(change), relationship = Dialog.relationship(),
-                types = (relationship.type.peek() == "recording-work")
-                    ? ["work"] : allowedRelations[relationship.source.type];
+                types = (relationship.type == "recording-work")
+                    ? ["work"] : allowedRelations[Dialog.source.type];
 
             $element.empty();
             $.each(types, function(i, type) {
                 $element.append($("<option></option>").val(type).text(MB.text.Entity[type]));
             });
-            $element.val(relationship.target.peek().type);
+            $element.val(Dialog.target.type);
         }
     };
 }());
@@ -177,11 +188,11 @@ ko.bindingHandlers.targetType = (function() {
 
 function setAutocompleteEntity(entity, nameOnly) {
     var $ac = Dialog.$autocomplete, ac = Dialog.autocomplete,
-        $name = $ac.find("input.name"), name = entity.name.peek();
+        $name = $ac.find("input.name");
 
-    ac.term = name;
+    ac.term = entity.name;
     ac.selectedItem = null;
-    $name.removeClass("error lookup-performed").val(name);
+    $name.removeClass("error lookup-performed").val(entity.name);
 
     if (nameOnly === false) {
         ac.currentSelection = null;
@@ -205,16 +216,23 @@ ko.bindingHandlers.autocomplete = (function() {
     }
 
     function changeTarget(event, data) {
-        var target = Dialog.relationship.peek().target, recent, dup;
-        data.type = target.peek().type;
+        // XXX release groups' numeric "type" conflicts with the entity type
+        data.type = _.isNumber(data.type) ? "release_group" : (data.type || Dialog.target.type);
+
+        if (allowedRelations[Dialog.source.type].indexOf(data.type) == -1 &&
+            !(Dialog.source.type == "recording" && data.type == "work")) {
+            Dialog.autocomplete.clear();
+            return;
+        }
 
         // Add/move to the top of the recent entities menu.
-        recent = recentEntities[data.type] = recentEntities[data.type] || [];
-        dup = _.where(recent, {gid: data.gid})[0];
+        var recent = recentEntities[data.type] = recentEntities[data.type] || [],
+            dup = _.where(recent, {gid: data.gid})[0];
+
         dup && recent.splice(recent.indexOf(dup), 1);
         recent.unshift(data);
 
-        target(RE.Entity(data));
+        Dialog.targetField.peek()(RE.Entity(data));
     }
 
     function showRecentEntities(event) {
@@ -222,7 +240,7 @@ ko.bindingHandlers.autocomplete = (function() {
             (event.type == "keyup" && !_.contains([8, 40], event.keyCode)))
             return;
 
-        var recent = recentEntities[Dialog.relationship.peek().target.peek().type],
+        var recent = recentEntities[Dialog.target.type],
             ac = Dialog.autocomplete.autocomplete;
 
         if (!this.value && recent && recent.length && !ac.menu.active) {
@@ -235,13 +253,12 @@ ko.bindingHandlers.autocomplete = (function() {
 
     return {
         init: function(element) {
-            var $autocomplete = Dialog.$autocomplete = $(element),
-                target = Dialog.relationship.peek().target.peek();
+            var $autocomplete = Dialog.$autocomplete = $(element);
 
             Dialog.autocomplete = MB.Control.EntityAutocomplete({
                 inputs: $autocomplete,
                 position: {collision: "fit"},
-                entity: target.type,
+                entity: Dialog.target.type,
                 setEntity: setEntity
             });
 
@@ -251,7 +268,7 @@ ko.bindingHandlers.autocomplete = (function() {
                     .on("keyup focus click", showRecentEntities)
                     .on("keydown", closeOnEnter);
 
-            setAutocompleteEntity(target, Dialog.mode() != "edit");
+            setAutocompleteEntity(Dialog.target, Dialog.mode() != "edit");
 
             ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
                 $autocomplete.autocomplete("destroy");
@@ -266,6 +283,7 @@ var Dialog = UI.Dialog = {
     mode: ko.observable(""),
     loading: ko.observable(false),
     batchWorksError: ko.observable(false),
+    backward: ko.observable(false),
 
     showAutocomplete: ko.observable(false),
     showCreateWorkLink: ko.observable(false),
@@ -279,28 +297,41 @@ var Dialog = UI.Dialog = {
             read: value,
             write: function(newValue) {
                 var oldValue = value();
-
-                if (oldValue !== newValue) {
-                    // if we cancelled an add dialog, the temporary relationship
-                    // must be deleted.
-                    if (oldValue && !oldValue.visible) oldValue.remove();
-
-                    value(newValue);
-                }
+                if (oldValue !== newValue) value(newValue);
             }
         });
     }()),
 
     init: function() {
+        var entity = [RE.Entity({type: "artist"}), RE.Entity({type: "recording"})];
+
         // this is used as an "empty" state when the dialog is hidden, so that
         // none of the bindings error out.
-        this.emptyRelationship = RE.Relationship({
-            source: Util.tempEntity("recording"),
-            target: Util.tempEntity("artist"),
-            backward: true
-        }, false);
+        this.emptyRelationship = RE.Relationship({entity: entity});
 
+        this.backward(true);
         this.relationship(this.emptyRelationship);
+        this.sourceField = ko.observable(null);
+        this.targetField = ko.observable(null);
+        this.source = entity[1];
+
+        ko.computed(function() {
+            var relationship = Dialog.relationship(),
+                entity0 = relationship.entity[0],
+                entity1 = relationship.entity[1],
+                backward = (Dialog.source === entity1());
+
+            if (backward) {
+                Dialog.sourceField(entity1);
+                Dialog.targetField(entity0);
+            } else {
+                Dialog.sourceField(entity0);
+                Dialog.targetField(entity1);
+            }
+
+            Dialog.target = Dialog.targetField.peek()();
+            Dialog.backward(backward);
+        });
 
         this.$overlay = $("#overlay");
         this.$dialog =  $("#dialog");
@@ -309,27 +340,26 @@ var Dialog = UI.Dialog = {
         ko.applyBindings(this, this.$dialog[0]);
     },
 
-    show: function(posx, posy) {
-        var dlg = Dialog;
+    show: function(options) {
+        var dlg = Dialog, relationship = dlg.relationship.peek(),
+            notBatchWorks = dlg.mode.peek() != "batch.create.works";
+
+        dlg.source = options.source;
+        dlg.relationship(options.relationship);
 
-        dlg.posx = posx;
-        dlg.posy = posy;
         // important: objects down the prototype chain should set "this" when
         // calling show. the template uses instance to decide which accept and
         // hide methods to execute.
         dlg.instance(this);
 
-        var relationship = dlg.relationship.peek(),
-            notBatchWorks = dlg.mode.peek() != "batch.create.works";
-
-        dlg.showAutocomplete(relationship.target().type != "url" && notBatchWorks);
-        dlg.showCreateWorkLink(relationship.type() == "recording-work" && notBatchWorks);
+        dlg.showAutocomplete(notBatchWorks);
+        dlg.showCreateWorkLink(relationship.type == "recording-work" && notBatchWorks);
 
         dlg.$overlay.show();
         // prevents the page from jumping. these will be adjusted in positionDialog.
         dlg.$dialog.css({top: $w.scrollTop(), left: $w.scrollLeft()}).show();
 
-        positionDialog(dlg.$dialog, posx, posy);
+        positionDialog(dlg.$dialog, options.posx, options.posy);
         $("#link-type").focus();
     },
 
@@ -344,6 +374,7 @@ var Dialog = UI.Dialog = {
         if ($.isFunction(callback)) callback.call(dlg);
 
         dlg.showAutocomplete(false);
+        dlg.source = dlg.emptyRelationship.entity[1].peek();
         dlg.relationship(dlg.emptyRelationship);
     },
 
@@ -354,11 +385,11 @@ var Dialog = UI.Dialog = {
         WorkDialog.show(function(work) {
             var target = RE.Entity(work, "work");
             setAutocompleteEntity(target, false);
-            Dialog.relationship.peek().target(target);
+            Dialog.targetField.peek()(target);
 
         }, event.pageX, event.pageY);
 
-        WorkDialog.name(Dialog.relationship.peek().source.name.peek());
+        WorkDialog.name(Dialog.source.name);
         $("#work-name").focus();
     },
 
@@ -386,19 +417,18 @@ var Dialog = UI.Dialog = {
     }),
 
     changeDirection: function() {
-        var backward = this.relationship().backward;
-        backward(!backward());
+        var relationship = this.relationship.peek(),
+            entity0 = relationship.entity[0].peek(),
+            entity1 = relationship.entity[1].peek();
+
+        relationship.entity[0](entity1);
+        relationship.entity[1](entity0);
         this.resize();
     },
 
     toggleLinkTypeHelp: function() {
-        var newValue = !this.showLinkTypeHelp.peek();
-        this.showLinkTypeHelp(newValue);
-
-        if (newValue)
-            _.defer(function() {
-                $("#link-type").parent().find("div.ar-descr a").attr("target", "_blank");
-            });
+        this.showLinkTypeHelp(!this.showLinkTypeHelp.peek());
+        $("#link-type").parent().find("div.ar-descr a").attr("target", "_blank");
     },
 
     resize: function() {
@@ -418,7 +448,7 @@ function resizeDialog($dialog) {
         if ($div.is(":visible")) $hidden = $hidden.add($div.hide());
     });
 
-    $d.css("width", "").css("width", $d[0].offsetWidth + 1);
+    $d.css("width", "").css("width", $d[0].offsetWidth + 2);
     $hidden.show();
 }
 
@@ -450,21 +480,22 @@ function positionDialog($dialog, posx, posy) {
 }
 
 
-Dialog.attributes = (function() {
-    var value = ko.observable([]), build, sub;
+Dialog.attrs = (function() {
 
-    var Attribute = function(attr, info) {
-        this.relationship = Dialog.relationship();
-        this.value = this.relationship.attributes()[attr.name];
+    var Attribute = function(relationship, attr, info) {
+        this.value = relationship.attrs()[attr.name];
         this.data = attr;
         this.min = info[0];
         this.max = info[1];
         this.type = attr.children ? "select" : "boolean";
-    }
+    };
+
+    return ko.computed({read: function() {
+        var relationship = Dialog.relationship(), attrs = [], id,
+            linkType = relationship.link_type(),
+            typeInfo = Util.typeInfo(linkType);
 
-    build = function(linkType) {
-        var attributes = [], typeInfo = Util.typeInfo(linkType), id;
-        if (!typeInfo) return attributes;
+        if (!typeInfo) return attrs;
 
         var allowedAttrs = typeInfo.attrs ? MB.utility.keys(typeInfo.attrs) : [];
 
@@ -473,82 +504,56 @@ Dialog.attributes = (function() {
         });
 
         for (var i = 0; id = allowedAttrs[i]; i++)
-            attributes.push(new Attribute(Util.attrInfo(id), typeInfo.attrs[id]));
+            attrs.push(new Attribute(relationship, Util.attrInfo(id), typeInfo.attrs[id]));
 
-        value(attributes);
-    };
+        return attrs;
+    }, deferEvaluation: true});
 
-    Dialog.relationship.subscribe(function(relationship) {
-        if (sub) sub.dispose();
-        build(relationship.link_type());
-        sub = relationship.link_type.subscribe(build);
-    });
-    return value;
 }());
 
 
 UI.AddDialog = MB.utility.beget(Dialog);
 
-UI.AddDialog.show = function(options, posx, posy) {
-    var target = options.target, source = options.source,
-        relationship = RE.Relationship({
-            source: source, target: target, action: "add"}, false);
-
-    if (target.type == "work") target.name(source.name.peek());
-
+UI.AddDialog.show = function(options) {
+    options.relationship = RE.Relationship({entity: options.entity, action: "add"});
     this.mode(options.mode || "add");
-    this.relationship(relationship);
-    Dialog.show.call(this, posx, posy);
-}
+    Dialog.show.call(this, options);
+};
 
 UI.AddDialog.accept = function() {
     var relationship = this.relationship();
 
     if (!relationship.hasErrors()) {
-        if (!relationship.source.mergeRelationship(relationship)) {
+        if (!Dialog.source.mergeRelationship(relationship))
             relationship.show();
-        }
         Dialog.hide();
     }
 };
 
+UI.AddDialog.hide = function(cancel) {
+    Dialog.hide(function() {
+        this.relationship.peek().remove();
+    });
+};
 
-UI.EditDialog = MB.utility.beget(Dialog);
 
-UI.EditDialog.show = function(relationship, posx, posy) {
-    var dlg = Dialog, target = relationship.target.peek(), name = target.name.peek();
+UI.EditDialog = MB.utility.beget(Dialog);
 
-    dlg.mode("edit");
+UI.EditDialog.show = function(options) {
+    Dialog.mode("edit");
 
     // originalRelationship is a copy of the relationship when the dialog was
     // opened, i.e. before the user edits it. if they cancel the dialog, this is
     // what gets copied back to revert their changes.
-    dlg.originalRelationship = ko.mapping.toJS(relationship);
-
-    // because the target is excluded from the Relationship mapping options, we
-    // have to save the target as well.
-    dlg.originalTarget = target;
-
-    dlg.relationship(relationship);
-    dlg.show.call(this, posx, posy);
+    Dialog.originalRelationship = options.relationship.toJS();
+    Dialog.show.call(this, options);
 };
 
 UI.EditDialog.hide = function(cancel) {
     Dialog.hide(function() {
-        if (cancel !== false) {
-            var relationship = this.relationship.peek();
-            ko.mapping.fromJS(this.originalRelationship, relationship);
-            var observable = relationship.target, target = observable.peek();
-
-            if (target !== this.originalTarget) {
-
-                RE.Entity.isInstance(this.originalTarget)
-                    ? observable(this.originalTarget)
-                    : ko.mapping.fromJS(this.originalTarget, target);
-            }
-        }
+        if (cancel !== false)
+            this.relationship.peek().fromJS(this.originalRelationship);
         delete Dialog.originalRelationship;
-        delete Dialog.originalTarget;
     });
 };
 
@@ -557,65 +562,49 @@ UI.EditDialog.accept = function() {
 
     if (!relationship.hasErrors()) {
         delete Dialog.originalRelationship;
-
         UI.EditDialog.hide(false);
     }
 };
 
 
-var BatchRelationshipDialog = MB.utility.beget(UI.AddDialog);
-
-BatchRelationshipDialog.accept = function(callback) {
-    var relationship = Dialog.relationship();
-    if (relationship.hasErrors()) return;
-
-    var model = ko.mapping.toJS(relationship),
-        hasCallback = $.isFunction(callback),
-        targets = Dialog.targets;
-
-    model.target = relationship.target();
-
-    Util.callbackQueue(targets, function(target) {
-        model.source = target;
-        delete model.id;
-
-        if (!hasCallback || callback(model))
-            RE.Relationship(model, true, true);
-    });
+UI.BatchRelationshipDialog = MB.utility.beget(UI.AddDialog);
 
-    Dialog.hide();
-};
+UI.BatchRelationshipDialog.show = function(targets) {
+    Dialog.targets = targets;
 
-
-UI.BatchRecordingRelationshipDialog = MB.utility.beget(BatchRelationshipDialog);
-
-UI.BatchRecordingRelationshipDialog.show = function() {
-    Dialog.targets = UI.checkedRecordings();
-    if (Dialog.targets.length > 0) {
+    if (targets.length > 0) {
+        var source = targets[0];
 
         UI.AddDialog.show.call(this, {
-            source: Util.tempEntity("recording"), target: Util.tempEntity("artist"),
-            mode: "batch.recording"
+            entity: [RE.Entity({type: "artist"}), source],
+            source: source,
+            mode: "batch." + source.type
         });
     }
 };
 
+UI.BatchRelationshipDialog.accept = function(callback) {
+    var relationship = Dialog.relationship.peek(),
+        model = relationship.toJS(), hasCallback = $.isFunction(callback),
+        src = Dialog.backward.peek() ? 1 : 0;
 
-UI.BatchWorkRelationshipDialog = MB.utility.beget(BatchRelationshipDialog);
+    Util.callbackQueue(Dialog.targets, function(source) {
+        model.entity[src] = source;
+        delete model.id;
 
-UI.BatchWorkRelationshipDialog.show = function() {
-    Dialog.targets = UI.checkedWorks();
-    if (Dialog.targets.length > 0) {
+        if (!hasCallback || callback(model)) {
+            var newRelationship = RE.Relationship(model);
 
-        UI.AddDialog.show.call(this, {
-            source: Util.tempEntity("work"), target: Util.tempEntity("artist"),
-            mode: "batch.work"
-        });
-    }
+            if (!source.mergeRelationship(newRelationship))
+                newRelationship.show();
+        }
+    });
+
+    UI.AddDialog.hide();
 };
 
 
-UI.BatchCreateWorksDialog = MB.utility.beget(BatchRelationshipDialog);
+UI.BatchCreateWorksDialog = MB.utility.beget(UI.BatchRelationshipDialog);
 
 UI.BatchCreateWorksDialog.show = function() {
     Dialog.targets = _.filter(UI.checkedRecordings(), function(obj) {
@@ -623,13 +612,19 @@ UI.BatchCreateWorksDialog.show = function() {
     });
 
     if (Dialog.targets.length > 0) {
-        var source = Util.tempEntity("recording"), target = Util.tempEntity("work");
+        var source = Dialog.targets[0], target = RE.Entity({type: "work"});
 
         // the user can't edit the target in this dialog, but the gid of the
         // temporary target entity has to be set to something valid, so that
-        // validation passes and the dialog can be okay'd.
+        // validation passes and the dialog can be okay'd. we don't want to pass
+        // the gid to RE.Entity either, or else the entity will be cached.
         target.gid = "00000000-0000-0000-0000-000000000000";
-        UI.AddDialog.show.call(this, {source: source, target: target, mode: "batch.create.works"});
+
+        UI.AddDialog.show.call(this, {
+            entity: [source, target],
+            source: source,
+            mode: "batch.create.works"
+        });
     }
 };
 
@@ -637,18 +632,15 @@ UI.BatchCreateWorksDialog.accept = function() {
     Dialog.loading(true);
 
     var type_id = $("#batch-work-type > select").val(),
-        language_id = $("#batch-work-lang > select").val(), works = [];
+        language_id = $("#batch-work-lang > select").val(), works;
 
-    _.each(Dialog.targets, function(obj) {
-        works.push({
-            name: obj.name.peek(), comment: "",
-            type: type_id, language: language_id
-        });
+    works = _.map(Dialog.targets, function(obj) {
+        return {name: obj.name, comment: "", type: type_id, language: language_id};
     });
 
     function success(data) {
-        BatchRelationshipDialog.accept.call(this, function(obj) {
-            obj.target = RE.Entity(data.works.shift(), "work");
+        UI.BatchRelationshipDialog.accept.call(this, function(obj) {
+            obj.entity[1] = RE.Entity(data.works.shift(), "work");
             if (data.works.length == 0) Dialog.loading(false);
             return true;
         });
@@ -664,7 +656,8 @@ UI.BatchCreateWorksDialog.accept = function() {
 
 UI.BatchCreateWorksDialog.hide = function() {
     Dialog.hide(function() {
-        Dialog.batchWorksError(false);
+        this.batchWorksError(false);
+        this.relationship.peek().remove();
     });
 };
 
diff --git a/root/static/scripts/relationship-editor/Entity.js b/root/static/scripts/relationship-editor/Entity.js
index 9bbc874..60da231 100644
--- a/root/static/scripts/relationship-editor/Entity.js
+++ b/root/static/scripts/relationship-editor/Entity.js
@@ -19,183 +19,132 @@
 
 MB.RelationshipEditor = (function(RE) {
 
-var Entity, Source, Artist, Label, Recording, Release, ReleaseGroup, Work, URL,
-    entities, mapping, cache = {};
-
-mapping = {
-    copy:    ["type", "gid", "id", "artistCredit", "number", "position", "length"],
-    ignore:  ["refcount", "relationships", "artist", "artists", "label", "value", "isrcs", "artist_credit"],
-    include: ["type", "gid", "id", "name", "sortname", "newWork", "comment", "work_type", "work_language"]
-};
-
 // represents a core entitiy, either existing or newly-created.
 // Entity is private - modules use RE.Entity to find or create an entity.
 
-Entity = function() {};
-
-Entity.prototype.init = function() {
-    this.refcount = 0;
-    this.name = ko.observable("");
-
-    this.rendering = ko.computed({
-        read: renderEntity,
-        owner: this,
-        deferEvaluation: true
-    });
-};
-
-Entity.prototype.render = function(name, options) {
-    options = $.extend({
-        href: "/" + this.type + "/" + this.gid,
-        target: "_blank"
-    }, options);
-    return MB.html.a(options, name);
-};
+var Entity = function() {};
 
-function renderEntity() {
-    return this.render(this.name());
-}
+Entity.prototype.init = function(obj) {
+    obj = obj || {};
+    this.gid = obj.gid;
+    this.id = obj.id;
+    this.name = obj.name || "";
+    this.relationships = ko.observableArray([]);
 
-Entity.prototype.remove = function() {
-    if (--this.refcount == 0) delete cache[this.gid];
+    var options = {href: "/" + this.type + "/" + this.gid, target: "_blank"};
+    if (obj.sortname) options.title = obj.sortname;
+    this.rendering = MB.html.a(options, this.name);
 };
 
-Source = function() {};
-
-Source.prototype = new Entity;
-
-Source.prototype.init = function() {
-    Entity.prototype.init.call(this);
-    this.relationships = ko.observableArray([]);
+Entity.prototype.toJS = function() {
+    return {gid: this.gid, type: this.type};
 };
 
 // searches this entity's relationships for potential duplicate "rel"
 // if it is a duplicate, remove and merge it
 
-Source.prototype.mergeRelationship = function(rel) {
-    var relationships = rel.type.peek() == "recording-work"
-            ? this.performanceRelationships() : this.relationships(),
-        obj = ko.mapping.toJS(rel);
-
-    delete obj.id;
-    delete obj.action;
-
-    // XXX figure out a faster/nicer way to merge relationship attributes
-    var attrs = $.extend({}, obj.attributes),
-        attrNames = MB.utility.keys(obj.attributes), name, value;
+Entity.prototype.mergeRelationship = function(rel) {
+    var relationships = (rel.type == "recording-work")
+            ? this.performanceRelationships() : this.relationships();
 
     for (var i = 0; i < relationships.length; i++) {
         var other = relationships[i];
 
         if (rel !== other && rel.isDuplicate(other)) {
 
-            obj.attributes = {};
-            for (var i = 0; name = attrNames[i]; i++) {
-                value = obj.attributes[name] = attrs[name];
+            var obj = rel.toJS(), attrs = $.extend({}, obj.attrs);
+            obj.attrs = {};
+            delete obj.id;
+            delete obj.action;
 
-                if (!value || ($.isArray(value) && !value.length))
-                    obj.attributes[name] = other.attributes.peek()[name].peek();
-            }
+            _.each(attrs, function(value, name) {
+                obj.attrs[name] = !value || ($.isArray(value) && !value.length)
+                    ? other.attrs.peek()[name].peek() : value;
+            });
 
-            // Merge the dates here, otherwise ko.mapping.fromJS would overwrite
-            // them with whatever is in obj.begin_date and obj.end_date.
-            other.begin_date(RE.Util.mergeDates(rel.begin_date, other.begin_date));
-            other.end_date(RE.Util.mergeDates(rel.end_date, other.end_date));
-            delete obj.begin_date;
-            delete obj.end_date;
+            obj.period.begin_date = RE.Util.mergeDates(rel.period.begin_date, other.period.begin_date);
+            obj.period.end_date = RE.Util.mergeDates(rel.period.end_date, other.period.end_date);
 
-            ko.mapping.fromJS(obj, other);
+            other.fromJS(obj);
             rel.remove();
+
             return true;
         }
     }
     return false;
 };
 
-Artist = function() {
-    this.init();
-    this.sortname = ko.observable("");
-
-    this.rendering = ko.computed({
-        read: renderArtist,
-        owner: this,
-        deferEvaluation: true
-    });
+Artist = function(obj) {
+    obj = obj || {};
+    this.init(obj);
+    this.sortname = ko.observable(obj.sortname);
 };
 
-function renderArtist() {
-    return this.render(this.name(), {title: this.sortname()});
-}
+Artist.prototype = new Entity;
+Artist.prototype.type = "artist";
 
-Label = function() {
-    this.init();
+Label = function(obj) {
+    this.init(obj);
 };
 
-Recording = function() {
-    this.init();
+Label.prototype = new Entity;
+Label.prototype.type = "label";
+
+Recording = function(obj) {
+    obj = obj || {};
+    this.init(obj);
+    this.number = obj.number;
+    this.position = obj.position;
+    this.length = obj.length;
+    this.artistCredit = obj.artistCredit;
     this.performanceRelationships = ko.observableArray([]);
 };
 
-Release = function() {
-    this.init();
-};
-
-ReleaseGroup = function() {
-    this.init();
-};
+Recording.prototype = new Entity;
+Recording.prototype.type = "recording";
 
-Work = function() {
-    this.init();
-    this.performanceRefcount = 0;
-    this.comment = ko.observable("");
-    this.work_type = ko.observable(null);
-    this.work_language = ko.observable(null);
+Release = function(obj) {
+    this.init(obj);
 };
 
-URL = function() {
-    this.init();
+Release.prototype = new Entity;
+Release.prototype.type = "release";
 
-    this.rendering = ko.computed({
-        read: renderURL,
-        owner: this,
-        deferEvaluation: true
-    });
+ReleaseGroup = function(obj) {
+    this.init(obj);
 };
 
-function renderURL() {
-    var name = _.prune(this.name(), 50);
-    return this.render(name, {href: this.name()});
-}
+ReleaseGroup.prototype = new Entity;
+ReleaseGroup.prototype.type = "release_group";
 
-Artist.prototype = new Entity;
-Label.prototype = new Entity;
-Recording.prototype = new Source;
-Release.prototype = new Source;
-ReleaseGroup.prototype = new Source;
-Work.prototype = new Source;
-URL.prototype = new Entity;
-
-entities = {
-    artist:        Artist,
-    label:         Label,
-    recording:     Recording,
-    release:       Release,
-    release_group: ReleaseGroup,
-    work:          Work,
-    url:           URL
+Work = function(obj) {
+    obj = obj || {};
+    this.init(obj);
+    this.performanceCount = 0;
+    this.comment = ko.observable(obj.comment || "");
+    this.work_type = ko.observable(obj.work_type || null);
+    this.work_language = ko.observable(obj.work_language || null);
 };
 
-RE.Entity = function(obj, type) {
-    if (obj instanceof Entity) return obj;
-    obj.type = obj.type || type;
+Work.prototype = new Entity;
+Work.prototype.type = "work";
 
-    var ent;
-    if ((ent = cache[obj.gid]) === undefined)
-        ent = cache[obj.gid] = new entities[obj.type];
+RE.Entity = (function() {
+    var entities = {
+        artist:        Artist,
+        label:         Label,
+        recording:     Recording,
+        release:       Release,
+        release_group: ReleaseGroup,
+        work:          Work
+    }, cache = {};
 
-    ko.mapping.fromJS(obj, mapping, ent);
-    return ent;
-};
+    return function(obj, type) {
+        if (obj instanceof Entity) return obj;
+        if (obj.gid) return cache[obj.gid] || (cache[obj.gid] = new entities[type || obj.type](obj));
+        return new entities[type || obj.type](obj);
+    };
+}());
 
 RE.Entity.isInstance = function(obj) {
     return obj instanceof Entity;
diff --git a/root/static/scripts/relationship-editor/Fields.js b/root/static/scripts/relationship-editor/Fields.js
index 27dcfeb..f32aa47 100644
--- a/root/static/scripts/relationship-editor/Fields.js
+++ b/root/static/scripts/relationship-editor/Fields.js
@@ -33,23 +33,19 @@ var validationHandlers = {
         }
     },
 
-    begin_date: function(field, value, relationship) {
-        validateDatePeriod(field, relationship.end_date, validateDate(field, value));
+    "period.begin_date": function(field, value, relationship) {
+        validateDatePeriod(field, relationship.period.end_date, validateDate(field, value));
     },
 
-    end_date: function(field, value, relationship) {
-        validateDatePeriod(relationship.begin_date, field, null, validateDate(field, value));
+    "period.end_date": function(field, value, relationship) {
+        validateDatePeriod(relationship.period.begin_date, field, null, validateDate(field, value));
     },
 
-    ended: function(field, value) {
+    "period.ended": function(field, value) {
         _.isBoolean(value) ? field.error("") : field.error(MB.text.InvalidValue);
     },
 
-    backward: function(field, value) {
-        field.error(_.isBoolean(value) ? "" : MB.text.InvalidValue);
-    },
-
-    attributes: function(field, value, relationship) {
+    attrs: function(field, value, relationship) {
         var linkType = relationship.link_type(), typeInfo = Util.typeInfo(linkType);
         if (!typeInfo) return;
 
@@ -79,12 +75,17 @@ var validationHandlers = {
         });
     },
 
-    target: function(field, value) {
+    "entity.0": function(field, value) {
         field.error(Util.isMBID(value.gid) ? "" : MB.text.RequiredField);
     }
 };
 
+validationHandlers["entity.1"] = validationHandlers["entity.0"];
+
+
 function validateDate(field, value) {
+    if (field.error === undefined) return false;
+
     var y = value.year(), m = value.month(), d = value.day(),
         valid = (y === null && m === null && d === null) || MB.utility.validDate(y, m, d);
 
@@ -109,50 +110,58 @@ function validateDatePeriod(begin, end, beginValid, endValid) {
 
 // used to track changes, handle validation, and update "action" accordingly
 
-ko.extenders.field = function(target, options) {
-    var relationship = options[0], name = options[1], fullName = options[2] || name;
+ko.extenders.field = function(observable, options) {
+    var self = {
+        observable: observable,
+        relationship: options[0],
+        name: options[1]
+    }, validateAndCompare = options[2];
+
+    observable.error = ko.observable("");
+    observable.error.subscribe(errorChanged, self);
+    observable.hasError = false;
+    observable.changed = false;
+
+    if (validateAndCompare !== false) {
+        fieldChanged.call(self, observable());
+        observable.subscribe(fieldChanged, self);
+    }
+    return observable;
+};
 
-    target.error = ko.observable("");
-    target.hasError = false;
+var errorChanged = function(error) {
+    var hasError = Boolean(error);
 
-    target.errorSub = target.error.subscribe(function(error) {
-        var hasError = Boolean(error);
+    if (hasError != this.observable.hasError)
+        this.relationship.hasErrors(
+            (this.relationship.errorCount += (hasError ? 1 : -1)) > 0);
 
-        if (hasError != target.hasError) {
-            relationship.errorCount += (hasError ? 1 : -1);
-            relationship.hasErrors(relationship.errorCount > 0);
-        }
-        target.hasError = hasError;
-    });
+    this.observable.hasError = hasError;
+};
 
-    delete fullName;
-    var noValidationOrComparison = options[3];
+var fieldChanged = function(newValue) {
+    newValue = ko.utils.unwrapObservable(newValue);
+    var observable = this.observable, relationship = this.relationship, name = this.name;
 
-    if (!noValidationOrComparison)
-        target.validationSub = target.subscribe(function(value) {
-            validationHandlers[name](target, value, relationship);
-        });
+    validationHandlers[name](observable, newValue, relationship);
 
-    if (relationship.action.peek() == "add" || noValidationOrComparison) return target;
+    if (relationship.action.peek() == "add") return;
 
-    target.changed = false;
+    var origValue = relationship.original_fields, fields = name.split("."), changed;
+    for (var i = 0; i < fields.length; i++) origValue = origValue[fields[i]];
 
-    target.subscribe(function(newValue) {
-        newValue = ko.utils.unwrapObservable(newValue);
-        // entities are unique, we compare them directly.
-        if (name != "target") newValue = ko.mapping.toJS(newValue);
+    // entities are unique, we compare them directly.
+    /^entity/.test(name)
+        ? (origValue = RE.Entity(origValue))
+        : (newValue = ko.toJS(newValue));
 
-        var origValue = Util.originalFields(relationship, name),
-            changed = !_.isEqual(origValue, newValue);
+    changed = !_.isEqual(origValue, newValue);
 
-        if (changed != target.changed)
-            relationship.changeCount += (changed ? 1 : -1);
+    if (changed != observable.changed)
+        relationship.changeCount += (changed ? 1 : -1);
 
-        target.changed = changed;
-        relationship.action(relationship.changeCount > 0 ? "edit" : "");
-    });
-
-    return target;
+    observable.changed = changed;
+    relationship.action(relationship.changeCount > 0 ? "edit" : "");
 };
 
 
@@ -194,7 +203,7 @@ Fields.PartialDate.prototype.write = function(obj) {
     date.year(obj.year);
     date.month(obj.month);
     date.day(obj.day);
-}
+};
 
 Fields.PartialDate.prototype.convert = function(obj) {
     obj = ko.utils.unwrapObservable(obj);
@@ -208,23 +217,35 @@ Fields.PartialDate.prototype.partChanged = function() {
 };
 
 
-var Attribute = function(name, value, attr, relationship) {
-    this.value = ko.observable(Util.convertAttr(attr, value));
+Fields.Attribute = function(attr, value, relationship) {
     this.attr = attr;
+    this.value = ko.observable(this.convert(value));
     this.relationship = relationship;
 
     return (ko.computed({read: this.value, write: this.write, owner: this})
-        .extend({field: [relationship, null, "attrs." + name, true]}));
+        .extend({field: [relationship, "attrs." + attr.name, false]}));
 };
 
-
-Attribute.prototype.write = function(newValue) {
-    newValue = Util.convertAttr(this.attr, ko.utils.unwrapObservable(newValue));
+Fields.Attribute.prototype.write = function(newValue) {
+    newValue = this.convert(ko.utils.unwrapObservable(newValue));
 
     if (!_.isEqual(this.value(), newValue)) {
         this.value(newValue);
-        var attrs = this.relationship.attributes;
-        attrs.notifySubscribers(attrs());
+        var attrs = this.relationship.attrs;
+        attrs.notifySubscribers(attrs.peek());
+    }
+};
+
+Fields.Attribute.prototype.convert = function(value) {
+    if (this.attr.children) {
+        if (!_.isArray(value)) value = [value];
+
+        return (_.chain(value)
+            .map(function(n) {return parseInt(n, 10)})
+            .compact().uniq().value()
+            .sort(function(a, b) {return a - b}));
+    } else {
+        return Boolean($.isNumeric(value) ? parseInt(value, 10) : value);
     }
 };
 
@@ -236,7 +257,7 @@ Attribute.prototype.write = function(newValue) {
 Fields.Attributes = function(relationship) {
     this.value = {};
     this.relationship = relationship;
-    return ko.computed({read: this.read, write: this.write, owner: this, deferEvaluation: true});
+    return ko.computed({read: this.read, write: this.update, owner: this, deferEvaluation: true});
 };
 
 Fields.Attributes.prototype.read = function() {
@@ -244,18 +265,15 @@ Fields.Attributes.prototype.read = function() {
     return this.value;
 };
 
-Fields.Attributes.prototype.write = function(newValue) {
-    this.update(newValue);
-};
-
 Fields.Attributes.prototype.update = function(value) {
-    var target = this.value, validAttrs = {}, self = this;
+    var target = this.value, validAttrs = {}, self = this,
+        typeInfo = Util.typeInfo(this.relationship.link_type()) || {};
 
-    Util.attrsForLinkType(this.relationship.link_type(), function(attr) {
-        var name = attr.name;
+    _.each(typeInfo.attrs || {}, function(info, id) {
+        var attr = Util.attrInfo(id), name = attr.name;
 
         if (target[name] === undefined) {
-            target[name] = new Attribute(name, value[name], attr, self.relationship);
+            target[name] = new Fields.Attribute(attr, value[name], self.relationship);
 
         } else if (value[name] !== undefined) {
             target[name](value[name]);
@@ -270,31 +288,26 @@ Fields.Attributes.prototype.update = function(value) {
 
         if (validAttrs[name] === undefined) {
             if (attr.hasError) attr.error("");
-
-            attr.errorSub.dispose();
             delete target[name];
         }
     }
 };
 
-
-Fields.Target = function(target, relationship) {
-    this.target = ko.observable(target);
+Fields.Entity = function(entity, relationship) {
+    this.entity = ko.observable(entity);
     this.relationship = relationship;
-
-    this.computed = ko.computed({read: this.target, write: this.write, owner: this})
-        .extend({field: [relationship, "target"]});
-
-    return this.computed
+    return ko.computed({read: this.entity, write: this.write, owner: this});
 };
 
-Fields.Target.prototype.write = function(newTarget) {
-    var relationship = this.relationship, oldTarget = this.target(),
-        newTarget = RE.Entity(ko.utils.unwrapObservable(newTarget));
+Fields.Entity.prototype.write = function(entity) {
+    var currentEntity = this.entity.peek();
+    entity = RE.Entity(entity);
 
-    if (oldTarget !== newTarget)
-        relationship.changeTarget(oldTarget, newTarget, this.target);
-}
+    if (currentEntity !== entity && currentEntity.type == entity.type) {
+        this.entity(entity);
+        this.relationship.entityChanged(currentEntity, entity);
+    }
+};
 
 return RE;
 
diff --git a/root/static/scripts/relationship-editor/Relationship.js b/root/static/scripts/relationship-editor/Relationship.js
index 845cd8d..c6123c8 100644
--- a/root/static/scripts/relationship-editor/Relationship.js
+++ b/root/static/scripts/relationship-editor/Relationship.js
@@ -20,263 +20,190 @@
 MB.RelationshipEditor = (function(RE) {
 
 var UI = RE.UI = RE.UI || {}, Util = RE.Util = RE.Util || {},
-    Fields = RE.Fields = RE.Fields || {}, mapping, cache = {};
-
-mapping = {
-    // entities (source, target) have their own mapping options in Entity.js
-    ignore:  ["source", "target", "visible", "direction"],
-    copy:    ["edits_pending", "id"],
-    include: ["link_type", "action", "backward", "begin_date", "end_date", "ended", "attributes"],
-    attributes: {
-        update: function(options) {return $.extend(true, {}, options.data)}
-    },
-    begin_date: {
-        update: function(options) {
-            return new Fields.PartialDate(options.data);
-        }
-    },
-    ended: {
-        update: function(options) {return Boolean(options.data)}
-    }
-};
-
-mapping.end_date = mapping.begin_date;
+    Fields = RE.Fields = RE.Fields || {}, cache = {};
 
 
-RE.Relationship = function(obj, tryToMerge, show) {
-    obj.link_type = obj.link_type || defaultLinkType(obj.source.type, obj.target.type);
-    var type = Util.types(obj.link_type), relationship, c = cache[type] = cache[type] || {};
+RE.Relationship = function(obj) {
+    var type0 = obj.entity[0].type, type1 = obj.entity[1].type, relationship, key;
 
+    if (!obj.link_type) obj.link_type = defaultLinkType(type0, type1);
     if (!obj.id) obj.id = _.uniqueId("new-");
-    relationship = c[obj.id] || (c[obj.id] = new Relationship(obj));
 
-    if (tryToMerge && relationship.source.mergeRelationship(relationship)) {
-        delete c[obj.id];
-        return null;
-    }
-
-    if (show) relationship.show();
-    return relationship;
+    key = [type0, type1, obj.id].join("-");
+    return cache[key] || (cache[key] = new Relationship(obj));
 };
 
 
 var Relationship = function(obj) {
     var self = this;
 
-    this.visible = false; // new relationships still being edited aren't visible
+    this.visible = false;
     this.id = obj.id;
     this.changeCount = 0;
     this.errorCount = 0;
-    this.action = ko.observable(obj.action || "");
     this.hasErrors = ko.observable(false);
-
-    obj.link_type = obj.link_type || defaultLinkType(obj.source.type, obj.target.type),
-    obj.backward = obj.backward || (obj.source.type != Util.types(obj.link_type).split("-")[0]);
-
-    this.link_type = new Fields.Integer(obj.link_type).extend({field: [this, "link_type"]});
-    this.backward = ko.observable(obj.backward).extend({field: [this, "backward"]});
-
-    this.begin_date = new Fields.PartialDate();
-    this.end_date = new Fields.PartialDate();
-    this.ended = ko.observable(false);
-    this.attributes = new Fields.Attributes(this);
-
-    this.dateRendering = ko.computed({read: this.renderDate, owner: this})
-        .extend({throttle: 100});
-
-    // entities have a refcount so that they can be deleted when they aren't
-    // referenced by any relationship. we use a computed observable for the target,
-    // so that we don't have to remember to decrement the refcount each time the
-    // target changes.
-
-    obj.source.refcount += 1;
-    this.source = obj.source; // source can't change
-
-    obj.target.refcount += 1;
-    this.target = new Fields.Target(obj.target, this);
-
-    this.type = ko.computed(function() {return Util.types(self.link_type())});
-    if (this.type.peek() == "recording-work") obj.target.performanceRefcount += 1;
-
-    // XXX trigger the validation subscription's callback, so that validation
-    // on the target's name is registered as well.
-    this.target.notifySubscribers(this.target.peek());
-
-    ko.mapping.fromJS(obj, mapping, this);
-
-    // add these *after* pulling in the obj mapping, otherwise they'll mark the
-    // relationship as having changes.
-    this.begin_date.extend({field: [this, "begin_date"]});
-    this.end_date.extend({field: [this, "end_date"]});
-    this.ended.extend({field: [this, "ended"]});
-    this.attributes.extend({field: [this, "attributes"]});
-
-    this.linkPhrase = ko.computed(this.buildLinkPhrase, this).extend({throttle: 1});
     this.loadingWork = ko.observable(false);
+    this.edits_pending = Boolean(obj.edits_pending);
 
-    this.edits_pending
-        ? (this.openEdits = ko.computed(this.buildOpenEdits, this))
-        : (this.edits_pending = false);
-
-    delete obj;
+    this.action = ko.observable(obj.action || "");
+    this.link_type = new Fields.Integer(obj.link_type);
+    this.attrs = new Fields.Attributes(this);
+    this.period = {
+        begin_date: new Fields.PartialDate(),
+        end_date:   new Fields.PartialDate(),
+        ended:      ko.observable(false)
+    };
+
+    var entity0 = RE.Entity(obj.entity[0]), entity1 = RE.Entity(obj.entity[1]);
+    this.entity = [new Fields.Entity(entity0, this), new Fields.Entity(entity1, this)];
+    this.type = entity0.type + "-" + entity1.type;
+
+    this.fromJS(obj);
+    this.dateRendering = ko.computed({read: this.renderDate, owner: this});
+    this.original_fields = this.toJS();
+
+    this.entity[0].extend({field: [this, "entity.0"]});
+    this.entity[1].extend({field: [this, "entity.1"]});
+    this.link_type.extend({field: [this, "link_type"]});
+    this.attrs.extend({field: [this, "attrs"]});
+    this.period.begin_date.extend({field: [this, "period.begin_date"]});
+    this.period.end_date.extend({field: [this, "period.end_date"]});
+    this.period.ended.extend({field: [this, "period.ended"]});
 };
 
 
-var defaultLinkType = function(sourceType, targetType) {
-    var type = sourceType + "-" + targetType, linkType;
-
-    if (!(linkType = Util.typeInfoByEntities(type)))
-        linkType = Util.typeInfoByEntities(targetType + "-" + sourceType);
-
-    linkType = linkType[0];
+var defaultLinkType = function(type0, type1) {
+    var linkType = Util.typeInfoByEntities(type0 + "-" + type1);
+    if (!linkType) return null; else linkType = linkType[0];
     return linkType.descr ? linkType.id : linkType.children[0].id;
 };
 
 
-Relationship.prototype.entity = function() {
-    return this.backward() ? [this.target(), this.source] : [this.source, this.target()];
+Relationship.prototype.toJS = function() {
+    var entity0 = this.entity[0].peek(), entity1 = this.entity[1].peek();
+    return {
+        id:        this.action.peek() == "action" ? undefined : this.id,
+        link_type: this.link_type.peek(),
+        action:    this.action.peek(),
+        period: {
+            begin_date: ko.toJS(this.period.begin_date),
+            end_date:   ko.toJS(this.period.end_date),
+            ended:      this.period.ended.peek(),
+        },
+        attrs:  ko.toJS(this.attrs),
+        entity: [this.entity[0].peek().toJS(), this.entity[1].peek().toJS()]
+    };
 };
 
 
-Relationship.prototype.changeTarget = function(oldTarget, newTarget, observable) {
-    var type = this.type.peek();
-
-    observable(newTarget);
-    newTarget.refcount += 1;
-
-    if (oldTarget) {
-        oldTarget.remove();
-
-        if (type == "recording-work") oldTarget.performanceRefcount -= 1;
-
-        if  (oldTarget.type != newTarget.type) {
-            // the type changed. our relationship cache is organized by type, so we
-            // have to move the position of this relationship in the cache.
-            var oldType = Util.types(this.link_type.peek()), newType;
-
-            this.link_type(defaultLinkType(this.source.type, newTarget.type));
-            newType = this.type();
+Relationship.prototype.fromJS = function(obj) {
+    this.link_type(obj.link_type);
+    this.period.begin_date((obj.period || {}).begin_date || "");
+    this.period.end_date((obj.period || {}).end_date || "");
+    this.period.ended(Boolean((obj.period || {}).ended));
+    this.attrs(obj.attrs || {});
+    this.entity[0](obj.entity[0]);
+    this.entity[1](obj.entity[1]);
+};
 
-            (cache[newType] = cache[newType] || {})[this.id] = cache[oldType][this.id];
-            delete cache[oldType][this.id];
 
-            // fix the direction.
-            var typeInfo = Util.typeInfo(this.link_type.peek());
-            this.backward(this.source.type != newType.split("-")[0]);
-        }
-    }
-
-    if (type == "recording-work") {
-        newTarget.performanceRefcount += 1;
-        this.workChanged(newTarget);
-    }
+Relationship.prototype.target = function(source) {
+    var entity0 = this.entity[0](), entity1 = this.entity[1]();
+    return source === entity0 ? entity1 : entity0;
 };
 
-// if the user changed a work, we need to request its relationships.
 
-var worksLoading = {};
+Relationship.prototype.entityChanged = function(oldEntity, newEntity) {
+    var entity0 = this.entity[0].peek(), entity1 = this.entity[1].peek(), self = this;
 
-Relationship.prototype.workChanged = function(work) {
-    var gid = work.gid, self = this;
-    if (worksLoading[gid]) return;
+    if (oldEntity !== entity0 && oldEntity !== entity1)
+        oldEntity.relationships.remove(this);
 
-    this.loadingWork(true);
-    worksLoading[gid] = 1;
+    if (entity0.type == "recording" && entity1.type == "work" && newEntity === entity1) {
+        oldEntity.performanceCount -= 1;
+        newEntity.performanceCount += 1;
 
-    $.get("/ws/js/entity/" + gid + "?inc=rels")
-        .success(function(data) {
-            Util.parseRelationships(data);
-        })
-        .complete(function() {
-            self.loadingWork(false);
-            delete worksLoading[gid];
-        });
-};
+        this.loadingWork(true);
 
+        $.get("/ws/js/entity/" + newEntity.gid + "?inc=rels")
+            .success(function(data) {
+                Util.parseRelationships(data);
+            })
+            .complete(function() {
+                self.loadingWork(false);
+            });
 
-Relationship.prototype.show = function() {
-    if (!this.visible) {
-        this.type.peek() == "recording-work"
-            ? this.source.performanceRelationships.push(this)
-            : this.source.relationships.push(this);
+    } else if (this.visible && newEntity.relationships.indexOf(this) == -1)
+        newEntity.relationships.push(this);
 
-        this.visible = true;
-    }
+    if (oldEntity.type == "recording" && entity1.type == "work")
+        oldEntity.performanceRelationships.remove(this);
 };
 
 
-Relationship.prototype.reset = function(obj) {
-    this.hasErrors(false);
-    this.errorCount = 0;
-    var fields = Util.originalFields(this);
-
-    if (fields) {
-        ko.mapping.fromJS(fields, this);
-        this.target(fields.target);
-        this.changeCount = 0;
+Relationship.prototype.show = function() {
+    if (this.type == "recording-work") {
+        this.entity[0].peek().performanceRelationships.push(this);
+        this.entity[1].peek().performanceCount += 1;
+    } else {
+        this.entity[0].peek().relationships.push(this);
+        this.entity[1].peek().relationships.push(this);
     }
+    this.visible = true;
 };
 
 
 Relationship.prototype.remove = function() {
-    // prevent this from being removed twice, otherwise it screws up refcounts
-    // everywhere. this can happen if the relationship is merged into another
-    // one (thus removed), and then removed again when the dialog is closed
-    // (because the dialog sees that this.visible is false).
     if (this.removed === true) return;
+    var entity0 = this.entity[0].peek(), entity1 = this.entity[1].peek();
 
-    var recordingWork = (this.type() == "recording-work"),
-        target = this.target.peek();
+    if (this.type == "recording-work") {
+        entity0.performanceRelationships.remove(this);
+        entity1.performanceCount -= 1;
 
-    if (recordingWork) {
-        this.source.performanceRelationships.remove(this);
-        target.performanceRefcount -= 1;
-    } else {
-        this.source.relationships.remove(this);
-    }
-
-    this.source.remove();
-    target.remove();
+        if (entity1.performanceCount <= 0) {
+            var relationships = entity1.relationships.slice(0);
 
-    if (recordingWork && target.performanceRefcount <= 0) {
-        var relationships = target.relationships.slice(0);
+            for (var i = 0; i < relationships.length; i++) {
+                var relationship = relationships[i],
+                    target = relationship.target(entity1);
 
-        for (var i = 0; i < relationships.length; i++)
-            relationships[i].remove();
+                if (target.type != "work" || target.performanceCount <= 0)
+                    relationship.remove();
+            }
+        }
+    } else {
+        entity0.relationships.remove(this);
+        entity1.relationships.remove(this);
     }
-    delete cache[this.type.peek()][this.id];
-    this.visible = false;
+
+    delete cache[this.type + "-" + this.id];
     this.removed = true;
+    this.visible = false;
 };
 
 // Constructs the link phrase to display for this relationship
 
-Relationship.prototype.buildLinkPhrase = function() {
-    var typeInfo = Util.typeInfo(this.link_type());
-    if (!typeInfo) return "";
-
-    var attrs = {}, m, phrase = this.source === this.entity()[0]
-        ? typeInfo.phrase : typeInfo.reverse_phrase;
+Relationship.prototype.linkPhrase = function(source) {
+    var typeInfo = Util.typeInfo(this.link_type()), attrs = {}, m,
+        phrase = source === this.entity[0]() ? typeInfo.phrase : typeInfo.reverse_phrase;
 
-    $.each(this.attributes(), function(name, observable) {
-        var value = observable(),
-            str = Util.attrRoot(name).l_name,
-            isArray = $.isArray(value);
+    _.each(this.attrs(), function(observable, name) {
+        var value = observable();
 
-        if (!value || isArray && !value.length) return;
-        if (isArray) {
-            value = $.map(value, function(v) {return Util.attrInfo(v).l_name});
+        if (_.isArray(value) && value.length) {
+            value = _.map(value, function(v) {return Util.attrInfo(v).l_name});
 
             var list = value.slice(0, -1).join(", ");
-            str = (list && list + " & ") + (value.pop() || "");
-        }
-        attrs[name] = str;
+            attrs[name] = (list && list + " & ") + (value.pop() || "");
+
+        } else if (value) attrs[name] = Util.attrRoot(name).l_name;
     });
+
     while (m = phrase.match(/\{(.*?)(?::(.*?))?\}/)) {
-        var replace = attrs[m[1]] !== undefined
+        phrase = phrase.replace(m[0], (attrs[m[1]] !== undefined)
             ? (m[2] && m[2].split("|")[0]) || attrs[m[1]]
-            : (m[2] && m[2].split("|")[1]) || "";
-        phrase = phrase.replace(m[0], replace);
+            : (m[2] && m[2].split("|")[1]) || "");
     }
     return _.clean(phrase);
 };
@@ -292,8 +219,9 @@ function renderDate(date) {
 }
 
 Relationship.prototype.renderDate = function() {
-    var begin_date = renderDate(this.begin_date.peek()),
-        end_date = renderDate(this.end_date.peek()), ended = this.ended();
+    var begin_date = renderDate(this.period.begin_date.peek()),
+        end_date = renderDate(this.period.end_date.peek()),
+        ended = this.period.ended();
 
     if (!begin_date && !end_date) return "";
     if (begin_date == end_date) return begin_date;
@@ -301,58 +229,41 @@ Relationship.prototype.renderDate = function() {
     return begin_date + " \u2013 " + (end_date || (ended ? "????" : ""));
 };
 
-// Contruction of form fields
-
-var simpleFields = ["id", "link_type", "action"], dateFields = ["year", "month", "day"],
-    entityFields = ["gid", "type"];
-
-var buildField = function(prefix, name, obj, fields) {
-    var field, value, prefix = prefix + (name && name + ".");
-
-    for (var i = 0; field = fields[i]; i++) {
-        value = ko.utils.unwrapObservable(obj[field]);
-        if (value) {
-            field = prefix + field;
+// Construction of form fields
 
-            if (_.isArray(value)) {
-                for (var j = 0; j < value.length; j++)
-                    this[field + "." + j] = value[j];
-            } else {
-                if (_.isBoolean(value)) value = value ? 1 : 0;
-                this[field] = value;
-            }
-        }
+var buildField = function(prefix, obj, result) {
+    if (_.isObject(obj) || _.isArray(obj)) {
+        _.each(obj, function(value, name) {
+            buildField(prefix + "." + name, value, result);
+        });
+    } else if (_.isBoolean(obj)) {
+        result[prefix] = obj ? 1 : 0;
+    } else if (_.isString(obj) || _.isNumber(obj)) {
+        result[prefix] = obj;
     }
 };
 
 Relationship.prototype.buildFields = function(num, result) {
-    var attrs = _.keys(this.attributes.peek()), entity = this.entity(),
-        prefix = "rel-editor.rels." + num + ".", bf = _.bind(buildField, result),
-        sf = (this.action.peek() == "add") ? _.rest(simpleFields) : simpleFields;
-
-    bf(prefix, "", this, sf);
-    bf(prefix, "period.begin_date", this.begin_date.peek(), dateFields);
-    bf(prefix, "period.end_date", this.end_date.peek(), dateFields);
-    result[prefix +  "period.ended"] = this.ended.peek() ? 1 : 0;
-    bf(prefix, "attrs", this.attributes.peek(), attrs);
-    bf(prefix, "entity.0", entity[0], entityFields);
-    bf(prefix, "entity.1", entity[1], entityFields);
+    var prefix = "rel-editor.rels." + num;
+    buildField(prefix, this.toJS(), result);
+    if (this.action.peek() == "add") delete result[prefix + ".id"];
 };
 
 // returns true if this relationship is a "duplicate" of the other.
 // doesn't compare attributes, but does compare dates.
 
 Relationship.prototype.isDuplicate = function(other) {
-    var thisent = this.entity(), otherent = other.entity();
     return (this.link_type.peek() == other.link_type.peek() &&
-            thisent[0] === otherent[0] && thisent[1] === otherent[1] &&
-            Util.mergeDates(this.begin_date, other.begin_date) &&
-            Util.mergeDates(this.end_date, other.end_date));
+            this.entity[0].peek() === other.entity[0].peek() &&
+            this.entity[1].peek() === other.entity[1].peek() &&
+            Util.mergeDates(this.period.begin_date, other.period.begin_date) &&
+            Util.mergeDates(this.period.end_date, other.period.end_date));
 };
 
 
 Relationship.prototype.buildOpenEdits = function() {
-    var orig = Util.originalFields(this), source = this.source, target = orig.target;
+    var entity0 = this.original_fields.entity[0],
+        entity1 = this.original_fields.entity[1];
 
     return _.sprintf(
         '/search/edits?auto_edit_filter=&order=desc&negation=0&combinator=and' +
@@ -362,12 +273,12 @@ Relationship.prototype.buildOpenEdits = function() {
         '&conditions.2.operator=%%3D&conditions.2.args=90%%2C233&conditions.2.args=91' +
         '&conditions.2.args=92&conditions.3.field=status&conditions.3.operator=%%3D' +
         '&conditions.3.args=1&field=Please+choose+a+condition',
-        encodeURIComponent(source.type),
-        encodeURIComponent(source.name()),
-        encodeURIComponent(source.id),
-        encodeURIComponent(target.type),
-        encodeURIComponent(target.name()),
-        encodeURIComponent(target.id)
+        encodeURIComponent(entity0.type),
+        encodeURIComponent(entity0.name),
+        encodeURIComponent(entity0.id),
+        encodeURIComponent(entity1.type),
+        encodeURIComponent(entity1.name),
+        encodeURIComponent(entity1.id)
     );
 };
 
diff --git a/root/static/scripts/relationship-editor/RelationshipEditor.js b/root/static/scripts/relationship-editor/RelationshipEditor.js
index 36658c4..291109f 100644
--- a/root/static/scripts/relationship-editor/RelationshipEditor.js
+++ b/root/static/scripts/relationship-editor/RelationshipEditor.js
@@ -24,6 +24,8 @@ var UI = RE.UI = RE.UI || {}, Util = RE.Util = RE.Util || {},
 
 RE.releaseViewModel = {
     RE: RE,
+    release: ko.observable({relationships: []}),
+    releaseGroup: ko.observable({relationships: []}),
     media: ko.observableArray([]),
 
     checkboxes: (function() {
@@ -39,14 +41,14 @@ RE.releaseViewModel = {
                 msg = strings[Math.min(strings.length - 1, data.recordingCount())];
 
             return msg ? "(" + msg + ")" : "";
-        }).extend({throttle: 100});
+        });
 
         data.workMessage = ko.computed(function() {
             var strings = data.workStrings(),
                 msg = strings[Math.min(strings.length - 1, data.workCount())];
 
             return msg ? "(" + msg + ")" : "";
-        }).extend({throttle: 100});
+        });
 
         return data;
     }()),
@@ -72,12 +74,12 @@ RE.releaseViewModel = {
 
                 _.each(recording.performanceRelationships.peek(), function(relationship) {
                     addChanged(relationship);
-                    _.each(relationship.target.peek().relationships.peek(), addChanged);
+                    _.each(relationship.entity[1].peek().relationships.peek(), addChanged);
                 });
             });
         });
-        _.each(this.release.relationships.peek(), addChanged);
-        _.each(this.releaseGroup.relationships.peek(), addChanged);
+        _.each(this.release.peek().relationships.peek(), addChanged);
+        _.each(this.releaseGroup.peek().relationships.peek(), addChanged);
 
         if (changed.length == 0) {
             this.submissionLoading(false);
@@ -118,10 +120,7 @@ RE.releaseViewModel = {
                 var parts = key.split(".");
 
                 if (parts[0] == "entity") {
-                    var i = parts[1];
-
-                    if (relationship.entity()[i] === relationship.target())
-                        relationship.target.error(error[0]);
+                    relationship.entity[parts[1]].error(error[0]);
 
                 } else if (parts[1] == "begin_date" || parts[1] == "end_date") {
                     relationship[parts[1]].error(error[0]);
@@ -156,9 +155,6 @@ UI.init = function(releaseGID, releaseGroupGID, data) {
     // preload image to avoid flickering
     $("<img/>").attr("src", "../../../static/images/icons/add.png");
 
-    RE.releaseViewModel.release = RE.Entity({type: "release", gid: releaseGID});
-    RE.releaseViewModel.releaseGroup = RE.Entity({type: "release_group", gid: releaseGroupGID});
-
     ko.applyBindings(RE.releaseViewModel, document.getElementById("content"));
 
     if (data) {
@@ -176,9 +172,8 @@ UI.init = function(releaseGID, releaseGroupGID, data) {
 
 
 releaseLoaded = function(data) {
-    data.type = "release";
-    data.release_group.type = "release_group";
-    RE.Entity(data);
+    RE.releaseViewModel.release(RE.Entity(data, "release"));
+    RE.releaseViewModel.releaseGroup(RE.Entity(data.release_group, "release_group"));
 
     for (var i = 0, trackCount = 0, medium; medium = data.mediums[i]; i++)
         trackCount += medium.tracks.length;
@@ -342,12 +337,12 @@ function initCheckboxes(trackCount) {
 function initButtons() {
     $("#batch-recording").click(function() {
         if (!$(this).hasClass("disabled"))
-            UI.BatchRecordingRelationshipDialog.show();
+            UI.BatchRelationshipDialog.show(UI.checkedRecordings());
     });
 
     $("#batch-work").click(function() {
         if (!$(this).hasClass("disabled"))
-            UI.BatchWorkRelationshipDialog.show();
+            UI.BatchRelationshipDialog.show(UI.checkedWorks());
     });
 
     $("#batch-create-works").click(function() {
@@ -355,11 +350,18 @@ function initButtons() {
     });
 
     $("#content").on("click", "span.add-rel", function(event) {
-        UI.AddDialog.show({source: ko.dataFor(this), target: Util.tempEntity("artist")}, event.pageX, event.pageY);
+        var source = ko.dataFor(this);
+        UI.AddDialog.show({
+            entity: [RE.Entity({type: "artist"}), source],
+            source: source,
+            posx: event.pageX,
+            posy: event.pageY
+        });
     });
 
     $("#content").on("click", "span.relate-work", function() {
-        UI.AddDialog.show({source: ko.dataFor(this), target: Util.tempEntity("work")});
+        var source = ko.dataFor(this), target = RE.Entity({type: "work", name: source.name});
+        UI.AddDialog.show({entity: [source, target], source: source});
     });
 
     $("#content").on("click", "span.remove-button", function() {
@@ -372,15 +374,24 @@ function initButtons() {
             return;
         }
         if (action == "remove") newAction = "";
-        if (action == "edit" && newAction == "remove") relationship.reset();
+
+        if (action == "edit" && newAction == "remove")
+            relationship.fromJS(relationship.original_fields);
+
         relationship.action(newAction);
     });
 
     $("#content").on("click", "span.link-phrase", function(event) {
-        var relationship = ko.dataFor(this);
+        var relationship = ko.dataFor(this),
+            source = ko.dataFor(this.parentNode.parentNode);
 
         if (relationship.action() != "remove")
-            UI.EditDialog.show(relationship, event.pageX, event.pageY);
+            UI.EditDialog.show({
+                relationship: relationship,
+                source: source,
+                posx: event.pageX,
+                posy: event.pageY
+            });
     });
 }
 
diff --git a/root/static/scripts/relationship-editor/Util.js b/root/static/scripts/relationship-editor/Util.js
index f33d940..8967ab0 100644
--- a/root/static/scripts/relationship-editor/Util.js
+++ b/root/static/scripts/relationship-editor/Util.js
@@ -19,7 +19,7 @@
 
 MB.RelationshipEditor = (function(RE) {
 
-var Util = RE.Util = RE.Util || {}, originalFields = {};
+var Util = RE.Util = RE.Util || {};
 
 
 Util.init = function(typeInfo, attrInfo) {
@@ -35,11 +35,6 @@ Util.init = function(typeInfo, attrInfo) {
             if (info = findItem(typeInfo[key], linkType)) return info;
     });
 
-    Util.types = _.memoize(function(linkType) {
-        for (var key in typeInfo)
-            if (findItem(typeInfo[key], linkType)) return key;
-    });
-
     Util.typeInfoByEntities = function(types) {return typeInfo[types]};
 
     Util.attrRoot = function(name) {return attrInfo[name]};
@@ -50,59 +45,30 @@ Util.init = function(typeInfo, attrInfo) {
 
 
 Util.parseRelationships = function(source) {
-    var result = [];
-
-    if (source.relationships) $.each(source.relationships, function(target_type, rel_types) {
+    if (source.relationships) _.each(source.relationships, function(rel_types, target_type) {
         if (source.type == "work" && target_type == "recording") return;
         if (target_type == "url") return; // no url support yet
 
-        $.each(rel_types, function(rel_type, rels) {
-
+        _.each(rel_types, function(rels, rel_type) {
             for (var i = 0, obj; obj = rels[i]; i++) {
-                var target = obj.target;
-                result.push(parseRelationship(obj, source, target_type));
-                result.push.apply(result, Util.parseRelationships(target));
-            }
-        });
-    });
-    return result;
-};
 
+                var target = obj.target, relationship, type, orig;
+                obj.attrs = obj.attributes;
+                delete obj.attributes;
+                delete obj.target;
 
-var parseRelationship = _.memoize(function(obj, source, target_type) {
-    var target, type = RE.Util.types(obj.link_type),
-        orig = originalFields[type] = originalFields[type] || {};
+                obj.entity = [RE.Entity(source), RE.Entity(target, target_type)];
+                if (obj.direction == "backward") obj.entity.reverse();
+                obj.period = {begin_date: obj.begin_date, end_date: obj.end_date, ended: obj.ended};
 
-    Util.attrsForLinkType(obj.link_type, function(attr) {
-        var name = attr.name;
-        obj.attributes[name] = Util.convertAttr(attr, obj.attributes[name]);
-    });
+                relationship = RE.Relationship(obj);
+                if (!relationship.visible) relationship.show();
 
-    obj.begin_date = Util.parseDate(obj.begin_date || "");
-    obj.end_date = Util.parseDate(obj.end_date || "");
-    obj.ended = Boolean(obj.ended);
-    obj.backward = (obj.direction == "backward");
-
-    orig = orig[obj.id] = $.extend(true, {}, obj);
-    orig.target.type = target_type;
-    orig.target = RE.Entity(orig.target);
-    orig.attributes = ko.toJS(obj.attributes);
-
-    target = obj.target;
-    target.type = target_type;
-
-    if (target_type == "url") {
-        target.name = target.url;
-        delete target.url;
-    }
-    obj.source = RE.Entity(source);
-    obj.target = RE.Entity(target);
-
-    return RE.Relationship(obj, false, true);
-
-}, function(obj, source, target_type) {
-    return [source.type, target_type, obj.id].join("-");
-});
+                Util.parseRelationships(target);
+            }
+        });
+    });
+};
 
 
 var dateRegex = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/;
@@ -138,46 +104,6 @@ Util.isMBID = function(str) {
     return MBIDRegex.test(str);
 };
 
-
-Util.tempEntity = function(type) {
-    var id = _.uniqueId("new-");
-    return RE.Entity({type: type, id: id, gid: id});
-};
-
-
-Util.convertAttr = function(root, value) {
-    if (root.children) {
-        if (!_.isArray(value)) value = [value];
-
-        return (_.chain(value)
-            .map(function(n) {return parseInt(n, 10)})
-            .compact().uniq().value()
-            .sort(function(a, b) {return a - b}));
-    } else {
-        return Boolean(_.isNumber(value) ? parseInt(value, 10) : value);
-    }
-};
-
-
-Util.attrsForLinkType = function(linkType, callback) {
-    var typeInfo = Util.typeInfo(linkType);
-    if (!typeInfo || !typeInfo.attrs) return {};
-
-    $.each(typeInfo.attrs, function(id, info) {
-        callback(Util.attrInfo(id));
-    });
-};
-
-
-Util.originalFields = function(relationship, field) {
-    var type = relationship.type.peek(), fields;
-
-    if (!(fields = originalFields[type])) return null;
-    if (!(fields = fields[relationship.id])) return null;
-
-    return field ? fields[field] : fields;
-};
-
 // Attempts to merge two dates, otherwise returns false if they conflict.
 
 Util.mergeDates = function(a, b) {
diff --git a/root/static/scripts/tests/RelationshipEditor.js b/root/static/scripts/tests/RelationshipEditor.js
index 2163b87..9a93408 100644
--- a/root/static/scripts/tests/RelationshipEditor.js
+++ b/root/static/scripts/tests/RelationshipEditor.js
@@ -109,6 +109,22 @@ var typeInfo = {
             "phrase": "medley of",
             "descr": 1
         }
+    ],
+    "recording-recording": [
+        {
+            "children": [
+                {
+                    "attrs": {"1": [0, 1]},
+                    "descr": 1,
+                    "id": 231,
+                    "phrase": "{additional} samples",
+                    "reverse_phrase": "{additional:additionally} sampled by"
+                }
+            ],
+            "id": 234,
+            "phrase": "remixes",
+            "reverse_phrase": "remixes"
+        }
     ]
 };
 
@@ -281,13 +297,13 @@ var testRelease = {
 
 $.extend(MB.text = MB.text || {}, {
     Entity: {
-        artist:          "Artist",
-        label:           "Label",
-        recording:       "Recording",
-        release:         "Release",
-        "release-group": "Release group",
-        url:             "URL",
-        work:            "Work",
+        artist:        "Artist",
+        label:         "Label",
+        recording:     "Recording",
+        release:       "Release",
+        release_group: "Release group",
+        url:           "URL",
+        work:          "Work",
     },
     AttributeNotSupported: "This attribute is not supported for the selected relationship type.",
     AttributeTooMany: "This attribute can only be specified {max} times. You specified {n}.",
@@ -295,10 +311,9 @@ $.extend(MB.text = MB.text || {}, {
     InvalidDate: "The date you've entered is not valid.",
     InvalidEndDate: "The end date cannot preceed the begin date.",
     InvalidValue: "The value you've entered is not valid.",
-    RequiredField: "Required field.",
+    RequiredField: "Required field."
 });
 
-MB.text.Date = {from: "from", until: "until", on: "on"};
 
 MB.tests.RelationshipEditor.Util = function() {
     QUnit.module("Relationship editor");
@@ -307,67 +322,6 @@ MB.tests.RelationshipEditor.Util = function() {
         var RE = MB.RelationshipEditor, Util = RE.Util;
 
         var tests = [
-            // artist-recording
-            {
-                source: Util.tempEntity("recording"),
-                target: Util.tempEntity("artist"),
-                expected: function() {
-                    return [this.target, this.source];
-                }
-            },
-            // recording-release
-            {
-                source: Util.tempEntity("recording"),
-                target: Util.tempEntity("release"),
-                expected: function() {
-                    return [this.source, this.target];
-                }
-            },
-            {
-                source: Util.tempEntity("recording"),
-                target: Util.tempEntity("release"),
-                backward: true,
-                expected: function() {
-                    return [this.target, this.source];
-                }
-            },
-            // recording-work
-            {
-                source: Util.tempEntity("recording"),
-                target: Util.tempEntity("work"),
-                expected: function() {
-                    return [this.source, this.target];
-                }
-            },
-            {
-                source: Util.tempEntity("recording"),
-                target: Util.tempEntity("work"),
-                backward: true,
-                expected: function() {
-                    return [this.target, this.source];
-                }
-            },
-            // work-work
-            {
-                source: Util.tempEntity("work"),
-                target: Util.tempEntity("work"),
-                backward: true,
-                expected: function() {
-                    return [this.target, this.source];
-                }
-            }
-        ];
-
-        $.each(tests, function(i, test) {
-            var obj = {source: test.source, target: test.target}, relationship;
-            if (test.backward) obj.backward = true;
-            relationship = RE.Relationship(obj);
-
-            QUnit.deepEqual(relationship.entity(), test.expected(),
-                relationship.type() + ", " + relationship.backward());
-        });
-
-        tests = [
             {date: "", expected: {
                 year: null, month: null, day: null}
             },
@@ -404,7 +358,7 @@ MB.tests.RelationshipEditor.Util = function() {
         ];
 
         $.each(tests, function(i, test) {
-            var result = RE.Util.convertAttr(test.root, test.value);
+            var result = new RE.Fields.Attribute(test.root, test.value)();
             QUnit.deepEqual(result, test.expected, String(test.value));
         });
 
@@ -448,13 +402,14 @@ MB.tests.RelationshipEditor.Fields = function() {
         });
 
         var relationship = RE.Relationship({
-            source: RE.Util.tempEntity("recording"),
-            target: RE.Util.tempEntity("work"),
+            entity: [RE.Entity({type: "recording"}), RE.Entity({type: "work"})],
             action: "add",
             link_type: 278
-        }, false);
+        });
+
+        relationship.show();
 
-        field = new Fields.Attributes(relationship);
+        field = relationship.attrs;
 
         tests = [
             {
@@ -477,23 +432,19 @@ MB.tests.RelationshipEditor.Fields = function() {
             QUnit.deepEqual(result, test.expected, JSON.stringify(test.input));
         });
 
-        // test Fields.Target
+        // test Fields.Entity
 
-        field = relationship.target;
+        field = relationship.entity[1];
         oldTarget = field.peek();
-        newTarget = RE.Util.tempEntity("work");
+        newTarget = RE.Entity({type: "work"});
 
-        QUnit.equal(oldTarget.refcount, 1, oldTarget.id + " refcount");
-        QUnit.equal(oldTarget.performanceRefcount, 1, oldTarget.id + " performanceRefcount");
-        QUnit.equal(newTarget.refcount, 0, newTarget.id + " refcount");
-        QUnit.equal(newTarget.performanceRefcount, 0, newTarget.id + " performanceRefcount");
+        QUnit.equal(oldTarget.performanceCount, 1, oldTarget.id + " performanceCount");
+        QUnit.equal(newTarget.performanceCount, 0, newTarget.id + " performanceCount");
 
         field(newTarget);
 
-        QUnit.equal(oldTarget.refcount, 0, oldTarget.id + " refcount");
-        QUnit.equal(oldTarget.performanceRefcount, 0, oldTarget.id + " performanceRefcount");
-        QUnit.equal(newTarget.refcount, 1, newTarget.id + " refcount");
-        QUnit.equal(newTarget.performanceRefcount, 1, newTarget.id + " performanceRefcount");
+        QUnit.equal(oldTarget.performanceCount, 0, oldTarget.id + " performanceCount");
+        QUnit.equal(newTarget.performanceCount, 1, newTarget.id + " performanceCount");
 
         // test Fields.PartialDate
 
@@ -518,15 +469,15 @@ MB.tests.RelationshipEditor.Relationship = function() {
     QUnit.test('Relationship', function() {
 
         var RE = MB.RelationshipEditor,
-            source = RE.Util.tempEntity("recording"),
-            target = RE.Util.tempEntity("artist");
+            source = RE.Entity({type: "recording"}), target = RE.Entity({type: "artist"});
 
         var relationship = RE.Relationship({
-            source: source,
-            target: target,
+            entity: [target, source],
             action: "add",
             link_type: 148
-        }, false);
+        });
+
+        relationship.show();
 
         // link phrase construction
 
@@ -554,9 +505,10 @@ MB.tests.RelationshipEditor.Relationship = function() {
 
         $.each(tests, function(i, test) {
             relationship.link_type(test.linkType);
-            relationship.backward(test.backward);
-            relationship.attributes(test.attrs);
-            var result = relationship.buildLinkPhrase();
+            relationship.attrs(test.attrs);
+
+            var result = relationship.linkPhrase(test.backward
+                ? relationship.entity[1]() : relationship.entity[0]());
 
             QUnit.equal(result, test.expected, [test.linkType, JSON.stringify(test.attrs)].join(", "));
         });
@@ -597,7 +549,8 @@ MB.tests.RelationshipEditor.Relationship = function() {
         ];
 
         $.each(tests, function(i, test) {
-            var a = relationship.begin_date(), b = relationship.end_date();
+            var a = relationship.period.begin_date(),
+                b = relationship.period.end_date();
 
             a.year(test.begin_date.year);
             a.month(test.begin_date.month);
@@ -607,7 +560,7 @@ MB.tests.RelationshipEditor.Relationship = function() {
             b.month(test.end_date.month);
             b.day(test.end_date.day);
 
-            relationship.ended(test.ended);
+            relationship.period.ended(test.ended);
             var result = relationship.renderDate();
 
             QUnit.equal(result, test.expected, [
@@ -619,39 +572,36 @@ MB.tests.RelationshipEditor.Relationship = function() {
 
         // test errors
 
-        // the target has an invalid gid to start with, so errorCount = 1
-        QUnit.equal(relationship.errorCount, 1, "relationship.errorCount");
-
-        // backward must be either true or false
-        relationship.backward("foo");
+        // the source/target have invalid gids to start with, so errorCount = 2
         QUnit.equal(relationship.errorCount, 2, "relationship.errorCount");
 
         // ended must be boolean
-        relationship.ended(null);
+        relationship.period.ended(null);
         QUnit.equal(relationship.errorCount, 3, "relationship.errorCount");
 
         // date must exist
-        relationship.begin_date("2001-01-32");
+        relationship.period.begin_date("2001-01-32");
         QUnit.equal(relationship.errorCount, 4, "relationship.errorCount");
 
-        relationship.begin_date("2001-01-31");
+        relationship.period.begin_date("2001-01-31");
         QUnit.equal(relationship.errorCount, 3, "relationship.errorCount");
 
         // end date must be after begin date
-        relationship.end_date("2000-01-31");
+        relationship.period.end_date("2000-01-31");
         QUnit.equal(relationship.errorCount, 4, "relationship.errorCount");
 
-        relationship.end_date("2002-01-31");
+        relationship.period.end_date("2002-01-31");
         QUnit.equal(relationship.errorCount, 3, "relationship.errorCount");
 
-        relationship.target().gid = "00000000-0000-0000-0000-000000000000";
-        relationship.target.notifySubscribers(relationship.target());
+        relationship.entity[0]().gid = "00000000-0000-0000-0000-000000000001";
+        relationship.entity[0].notifySubscribers(relationship.entity[0]());
         QUnit.equal(relationship.errorCount, 2, "relationship.errorCount");
 
-        relationship.backward(true);
+        relationship.entity[1]().gid = "00000000-0000-0000-0000-000000000002";
+        relationship.entity[1].notifySubscribers(relationship.entity[1]());
         QUnit.equal(relationship.errorCount, 1, "relationship.errorCount");
 
-        relationship.ended(true);
+        relationship.period.ended(true);
         QUnit.equal(relationship.errorCount, 0, "relationship.errorCount");
     });
 };
@@ -663,84 +613,79 @@ MB.tests.RelationshipEditor.Entity = function() {
     QUnit.test('Entity', function() {
 
         var RE = MB.RelationshipEditor,
-            entity = RE.Util.tempEntity("artist");
+            source = RE.Entity({type: "recording", name: "a recording"}),
+            target = RE.Entity({type: "artist", name: "foo", sortname: "bar"});
 
         QUnit.equal(
-            entity.rendering(),
-            _.sprintf('<a href="/artist/%s" target="_blank" />', entity.gid),
-            "artist link"
+            source.rendering,
+            _.sprintf('<a href="/recording/%s" target="_blank">a recording</a>', source.gid),
+            "recording link"
         );
 
-        entity.sortname("foo");
-
         QUnit.equal(
-            entity.rendering(),
-            _.sprintf('<a href="/artist/%s" target="_blank" title="foo" />', entity.gid),
-            "artist link w/ sortname"
+            target.rendering,
+            _.sprintf('<a href="/artist/%s" target="_blank" title="bar">foo</a>', target.gid),
+            "artist link"
         );
 
-        QUnit.equal(RE.Entity.isInstance(entity), true, "entity isInstance");
+        QUnit.equal(RE.Entity.isInstance(source), true, "entity isInstance");
         QUnit.equal(RE.Entity.isInstance({}), false, "object isInstance");
 
         var relationship = RE.Relationship({
-            source: RE.Util.tempEntity("recording"),
-            target: entity,
+            entity: [target, source],
             action: "add",
             link_type: 148,
-            attributes: {instrument: [123, 277], guest: true},
-            begin_date: "2001",
-            end_date: ""
-        }, false);
+            attrs: {instrument: [123, 277], guest: true},
+            period: {begin_date: "2001", end_date: ""}
+        });
 
         var duplicateRelationship = RE.Relationship({
-            source: relationship.source,
-            target: entity,
+            entity: [target, source],
             action: "add",
             link_type: 148,
-            attributes: {instrument: [229], solo: true},
-            begin_date: "",
-            end_date: "2002"
-        }, false);
+            attrs: {instrument: [229], solo: true},
+            period: {begin_date: "", end_date: "2002"}
+        });
 
         relationship.show();
         duplicateRelationship.show();
 
-        relationship.source.mergeRelationship(duplicateRelationship);
+        source.mergeRelationship(duplicateRelationship);
 
         QUnit.deepEqual(
-            ko.toJS(relationship.attributes),
+            ko.toJS(relationship.attrs),
             {instrument: [229], additional: false, guest: true, solo: true},
             "attributes"
         );
 
         QUnit.deepEqual(
-            ko.toJS(relationship.begin_date),
+            ko.toJS(relationship.period.begin_date),
             {year: 2001, month: null, day: null},
             "begin date"
         );
 
         QUnit.deepEqual(
-            ko.toJS(relationship.end_date),
+            ko.toJS(relationship.period.end_date),
             {year: 2002, month: null, day: null},
             "end date"
         );
 
-        QUnit.equal(relationship.source.relationships.indexOf(duplicateRelationship), -1,
+        QUnit.equal(source.relationships.indexOf(duplicateRelationship), -1,
             "removed from source's relationships");
 
         QUnit.equal(duplicateRelationship.removed, true,
             "relationship.removed");
 
         var notDuplicateRelationship = RE.Relationship({
-            source: relationship.source,
-            target: entity,
+            entity: [target, source],
             action: "add",
             link_type: 148,
-            begin_date: "2003",
-            end_date: "2004"
-        }, false);
+            period: {begin_date: "2003", end_date: "2004"}
+        });
+
+        notDuplicateRelationship.show();
 
-        QUnit.equal(relationship.source.mergeRelationship(notDuplicateRelationship),
+        QUnit.equal(source.mergeRelationship(notDuplicateRelationship),
             false, "different dates -> not merged")
     });
 };
@@ -761,7 +706,7 @@ MB.tests.RelationshipEditor.RelationshipEditor = function() {
         var recording = recordings[0];
         QUnit.equal(recording.number, "A", "recording number");
         QUnit.equal(recording.position, 1, "recording position");
-        QUnit.equal(recording.name(), "Love Me Do", "recording name");
+        QUnit.equal(recording.name, "Love Me Do", "recording name");
         QUnit.equal(recording.id, 6393661, "recording id");
         QUnit.equal(recording.gid, "87ec065e-f139-41b9-b3b9-f746addf5b1e", "recording gid");
     });
@@ -773,27 +718,91 @@ MB.tests.RelationshipEditor.Dialog = function() {
 
     QUnit.test('Dialog', function() {
 
-        var RE = MB.RelationshipEditor, UI = RE.UI, vm = RE.releaseViewModel,
-            recordings = vm.media()[0].recordings(), source = recordings[0],
-            target = RE.Util.tempEntity("artist");
+        var RE = MB.RelationshipEditor, UI = RE.UI, Util = RE.Util,
+            vm = RE.releaseViewModel,
+            recordings = vm.media()[0].recordings(),
+            source = recordings[0],
+            target = RE.Entity({type: "artist", gid: "00000000-0000-0000-0000-000000000003"});
 
-        // AddDialog
+        UI.Dialog.resize = function() {};
+
+        var tests = [
+            {
+                entity: [RE.Entity({type: "recording"}), RE.Entity({type: "release"})],
+                backward: true,
+                source: function() {return this.entity[1];},
+                target: function() {return this.entity[0];}
+            },
+            {
+                entity: [RE.Entity({type: "recording"}), RE.Entity({type: "release"})],
+                backward: false,
+                source: function() {return this.entity[0];},
+                target: function() {return this.entity[1];}
+            }
+        ];
+
+        $.each(tests, function(i, test) {
+            UI.AddDialog.show({entity: test.entity, source: test.source()});
+
+            QUnit.equal(UI.Dialog.backward(), test.backward,
+                "entities should be backward: " + test.backward);
+
+            QUnit.equal(UI.Dialog.source, test.source(),
+                "source should be entity[" + (test.backward ? "1" : "0") + "]");
 
-        target.gid = "00000000-0000-0000-0000-000000000000";
-        target.name("foo");
+            QUnit.equal(UI.Dialog.target, test.target(),
+                "target should be entity[" + (test.backward ? "0" : "1") + "]");
 
-        UI.AddDialog.show({source: source, target: target});
+            UI.AddDialog.hide();
+        });
+
+        // AddDialog
+
+        UI.AddDialog.show({entity: [target, source], source: source});
         var relationship = UI.Dialog.relationship();
         relationship.link_type(148);
-        relationship.attributes({instrument: [229]});
+        relationship.attrs({instrument: [229]});
         UI.AddDialog.accept();
 
         QUnit.equal(source.relationships()[0], relationship, "AddDialog");
 
+        // AddDialog - relationship between recordings on same release (MBS-5389)
+
+        UI.AddDialog.show({entity: [recordings[0], recordings[1]], source: recordings[1]});
+        relationship = UI.Dialog.relationship();
+        relationship.link_type(231);
+
+        QUnit.equal(UI.Dialog.sourceField(), relationship.entity[1], "AddDialog sourceField");
+        QUnit.equal(UI.Dialog.targetField(), relationship.entity[0], "AddDialog targetField");
+        QUnit.equal(UI.Dialog.source, recordings[1], "AddDialog source");
+        QUnit.equal(UI.Dialog.target, recordings[0], "AddDialog target");
+        QUnit.equal(UI.Dialog.backward(), true, "AddDialog: relationship is backward");
+
+        UI.Dialog.changeDirection();
+
+        QUnit.equal(UI.Dialog.sourceField(), relationship.entity[0], "AddDialog sourceField");
+        QUnit.equal(UI.Dialog.targetField(), relationship.entity[1], "AddDialog targetField");
+        // source and target should stay the same
+        QUnit.equal(UI.Dialog.source, recordings[1], "AddDialog source");
+        QUnit.equal(UI.Dialog.target, recordings[0], "AddDialog target");
+        QUnit.equal(UI.Dialog.backward(), false, "AddDialog: relationship is not backward");
+
+        UI.AddDialog.accept();
+
+        QUnit.equal(recordings[0].relationships()[1], relationship, "relationship added to recording 0");
+        QUnit.equal(recordings[1].relationships()[0], relationship, "relationship added to recording 1");
+
+        relationship.remove();
+
+        QUnit.equal(recordings[0].relationships()[1], undefined, "relationship removed from recording 0");
+        QUnit.equal(recordings[1].relationships()[0], undefined, "relationship removed from recording 1");
+
         // EditDialog
 
-        UI.EditDialog.show(relationship);
-        var dialogAttrs = UI.Dialog.attributes();
+        relationship = source.relationships()[0];
+
+        UI.EditDialog.show({relationship: relationship, source: source});
+        var dialogAttrs = UI.Dialog.attrs();
 
         var solo = _.find(dialogAttrs, function(attr) {
             return attr.data.name == "solo";
@@ -803,12 +812,12 @@ MB.tests.RelationshipEditor.Dialog = function() {
         UI.EditDialog.accept();
 
         QUnit.deepEqual(
-            ko.toJS(relationship.attributes),
+            ko.toJS(relationship.attrs),
             {instrument: [229], additional: false, guest: false, solo: true},
             "EditDialog"
         );
 
-        UI.EditDialog.show(relationship);
+        UI.EditDialog.show({relationship: relationship, source: source});
 
         var instrument = _.find(dialogAttrs, function(attr) {
             return attr.data.name == "instrument";
@@ -816,72 +825,55 @@ MB.tests.RelationshipEditor.Dialog = function() {
 
         instrument.value([229, 277]);
 
-        var newTarget = RE.Util.tempEntity("artist");
-        relationship.target(newTarget);
-        QUnit.equal(newTarget.refcount, 1, "newTarget refcount");
+        var newTarget = RE.Entity({type: "artist"});
+        UI.Dialog.targetField()(newTarget);
 
         // cancel should revert the change
         UI.EditDialog.hide();
 
-        QUnit.deepEqual(relationship.attributes().instrument(), [229], "attributes changed back");
-        QUnit.equal(relationship.target(), target, "target changed back");
-        QUnit.equal(newTarget.refcount, 0, "newTarget refcount");
+        QUnit.deepEqual(relationship.attrs().instrument(), [229], "attributes changed back");
+        QUnit.equal(relationship.entity[0](), target, "target changed back");
 
         // BatchRecordingRelationshipDialog
 
-        // XXX rewrite checkedRecordings so that batch tests work
-        UI.checkedRecordings = function() {return recordings};
-
-        UI.BatchRecordingRelationshipDialog.show();
+        UI.BatchRelationshipDialog.show(recordings);
 
-        newTarget = RE.Util.tempEntity("work");
-        newTarget.gid = "00000000-0000-0000-0000-000000000001";
-        newTarget.name("workfoo");
+        newTarget = RE.Entity({type: "artist", gid: "00000000-0000-0000-0000-000000000004"});
 
         relationship = UI.Dialog.relationship();
 
-        relationship.target(newTarget);
-        relationship.link_type(278);
-        relationship.attributes().live(true);
+        UI.Dialog.targetField()(newTarget);
+        relationship.link_type(154);
+        relationship.attrs().additional(true);
 
-        UI.BatchRecordingRelationshipDialog.accept();
+        UI.BatchRelationshipDialog.accept();
 
-        for (var i = 0; i <= 1; i++) {
-            var relationships = recordings[i].performanceRelationships();
-            QUnit.equal(relationships[0].target(), newTarget, "recording " + i + " target");
-            QUnit.deepEqual(
-                ko.toJS(relationships[0].attributes),
-                {live: true, partial: false, instrumental: false, cover: false},
-                "recording " + i + " attributes"
-            );
-        }
+        var attrs = {additional: true, instrument: []},
+            relationships = recordings[0].relationships();
 
-        // BatchWorkRelationshipDialog
+        QUnit.equal(relationships[1].entity[0](), newTarget, "recording 0 target");
+        QUnit.deepEqual(ko.toJS(relationships[1].attrs), attrs, "recording 0 attributes");
 
-        var works = [newTarget];
-        UI.checkedWorks = function() {return works};
+        relationships = recordings[1].relationships();
+        QUnit.equal(relationships[0].entity[0](), newTarget, "recording 0 target");
+        QUnit.deepEqual(ko.toJS(relationships[0].attrs), attrs, "recording 0 attributes");
 
-        UI.BatchWorkRelationshipDialog.show();
+        // BatchWorkRelationshipDialog
 
-        newTarget = RE.Util.tempEntity("artist");
-        newTarget.gid = "00000000-0000-0000-0000-000000000002";
-        newTarget.name("writer");
+        var works = [RE.Entity({type: "work", gid: "00000000-0000-0000-0000-000000000005"})];
+        UI.BatchRelationshipDialog.show(works);
 
         relationship = UI.Dialog.relationship();
 
-        relationship.target(newTarget);
+        relationship.entity[0](newTarget);
         relationship.link_type(167);
-        relationship.attributes().additional(true);
+        relationship.attrs().additional(true);
 
-        UI.BatchWorkRelationshipDialog.accept();
+        UI.BatchRelationshipDialog.accept();
 
-        var relationships = works[0].relationships();
-        QUnit.equal(relationships[0].target(), newTarget, "work target");
-        QUnit.deepEqual(
-            ko.toJS(relationships[0].attributes),
-            {additional: true},
-            "work attributes"
-        );
+        relationships = works[0].relationships();
+        QUnit.equal(relationships[0].entity[0](), newTarget, "work target");
+        QUnit.deepEqual(ko.toJS(relationships[0].attrs), {additional: true}, "work attributes");
     });
 };
 
diff --git a/root/static/scripts/tests/all.html b/root/static/scripts/tests/all.html
index b07c3ff..851d815 100644
--- a/root/static/scripts/tests/all.html
+++ b/root/static/scripts/tests/all.html
@@ -11,7 +11,6 @@
     <script type="text/javascript" charset="utf-8" src="../../lib/qunit/qunit/qunit.js"></script>
     <script type="text/javascript" charset="utf-8" src="../../lib/qunit-tap/lib/qunit-tap.js"></script>
     <script type="text/javascript" charset="utf-8" src="../../lib/knockout/knockout-latest.debug.js"></script>
-    <script type="text/javascript" charset="utf-8" src="../../lib/knockout/knockout.mapping-latest.debug.js"></script>
     <script type="text/javascript" charset="utf-8" src="../common/MB.js"></script>
     <script type="text/javascript" charset="utf-8" src="../common/MB/utility.js"></script>
     <script type="text/javascript" charset="utf-8" src="../common/MB/html.js"></script>

-----------------------------------------------------------------------


hooks/post-receive
-- 
mb_server
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.