Bug 412002 - should be able to edit tags for multiple bookmarks at the same time (for highmind63@gmail.com, r=dietrich)
authorDietrich Ayala <dietrich@mozilla.com>
Mon, 29 Sep 2008 23:10:47 -0700
changeset 19900 74413bbdd4db727286b9a8875b780225db989b31
parent 19899 6c57ab47ea2f8b612abe2c3ecc9df16c88f7a33b
child 19901 e82e0b06ea423c11559b11fb0be1e02bf9038356
push id2529
push userdietrich@mozilla.com
push dateTue, 30 Sep 2008 06:17:02 +0000
treeherdermozilla-central@74413bbdd4db [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdietrich
bugs412002
milestone1.9.1b1pre
Bug 412002 - should be able to edit tags for multiple bookmarks at the same time (for highmind63@gmail.com, r=dietrich)
browser/components/places/content/editBookmarkOverlay.js
browser/components/places/content/editBookmarkOverlay.xul
browser/components/places/content/places.js
--- a/browser/components/places/content/editBookmarkOverlay.js
+++ b/browser/components/places/content/editBookmarkOverlay.js
@@ -37,27 +37,36 @@
 
 const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed";
 const STATIC_TITLE_ANNO = "bookmarks/staticTitle";
 const MAX_FOLDER_ITEM_IN_MENU_LIST = 5;
 
 var gEditItemOverlay = {
   _uri: null,
   _itemId: -1,
+  _itemIds: [],
+  _uris: [],
+  _tags: [],
+  _allTags: [],
+  _multiEdit: false,
   _itemType: -1,
   _readOnly: false,
   _microsummaries: null,
   _hiddenRows: [],
   _observersAdded: false,
   _staticFoldersListBuilt: false,
 
   get itemId() {
     return this._itemId;
   },
 
+  get multiEdit() {
+    return this._multiEdit;
+  },
+
   /**
    * Determines the initial data for the item edited or added by this dialog
    */
   _determineInfo: function EIO__determineInfo(aInfo) {
     // hidden rows
     if (aInfo && aInfo.hiddenRows)
       this._hiddenRows = aInfo.hiddenRows;
     else
@@ -86,35 +95,50 @@ var gEditItemOverlay = {
     this._element("locationRow").collapsed = !isBookmark || isQuery ||
       this._hiddenRows.indexOf("location") != -1;
     this._element("loadInSidebarCheckbox").collapsed = !isBookmark || isQuery ||
       this._readOnly || this._hiddenRows.indexOf("loadInSidebar") != -1;
     this._element("feedLocationRow").collapsed = !this._isLivemark ||
       this._hiddenRows.indexOf("feedLocation") != -1;
     this._element("siteLocationRow").collapsed = !this._isLivemark ||
       this._hiddenRows.indexOf("siteLocation") != -1;
+    this._element("selectionCount").hidden = !this._multiEdit;
   },
 
   /**
    * Initialize the panel
    * @param aFor
    *        Either a places-itemId (of a bookmark, folder or a live bookmark),
-   *        or a URI object (in which case, the panel would be initialized in
-   *        read-only mode).
+   *        an array of itemIds (used for bulk tagging), or a URI object (in 
+   *        which case, the panel would be initialized in read-only mode).
    * @param [optional] aInfo
    *        JS object which stores additional info for the panel
    *        initialization. The following properties may bet set:
    *        * hiddenRows (Strings array): list of rows to be hidden regardless
    *          of the item edited. Possible values: "title", "location",
    *          "description", "keyword", "loadInSidebar", "feedLocation",
    *          "siteLocation", folderPicker"
    *        * forceReadOnly - set this flag to initialize the panel to its
    *          read-only (view) mode even if the given item is editable.
    */
   initPanel: function EIO_initPanel(aFor, aInfo) {
+    var aItemIdList;
+    if (aFor.length) {
+      aItemIdList = aFor;
+      aFor = aItemIdList[0];
+    }
+    else if (this._multiEdit) {
+      this._multiEdit = false;
+      this._tags = [];
+      this._uris = [];
+      this._allTags = [];
+      this._itemIds = [];
+      this._element("selectionCount").hidden = true;
+    }
+
     this._folderMenuList = this._element("folderMenuList");
     this._folderTree = this._element("folderTree");
 
     this._determineInfo(aInfo);
     if (aFor instanceof Ci.nsIURI) {
       this._itemId = -1;
       this._uri = aFor;
       this._readOnly = true;
@@ -157,20 +181,37 @@ var gEditItemOverlay = {
                           PlacesUIUtils.getItemDescription(this._itemId));
     }
 
     if (this._itemId == -1 ||
         this._itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) {
       this._isLivemark = false;
 
       this._initTextField("locationField", this._uri.spec);
-      this._initTextField("tagsField",
-                           PlacesUtils.tagging
-                                      .getTagsForURI(this._uri, {}).join(", "),
-                          false);
+      if (!aItemIdList) {
+        var tags = PlacesUtils.tagging.getTagsForURI(this._uri, {}).join(", ");
+        this._initTextField("tagsField", tags, false);
+      }
+      else {
+        this._multiEdit = true;
+        this._allTags = [];
+        this._itemIds = aItemIdList;
+        var nodeToCheck = 0;
+        for (var i = 0; i < this._itemIds.length; i++) {
+          this._uris[i] = PlacesUtils.bookmarks.getBookmarkURI(this._itemIds[i], {});
+          this._tags[i] = PlacesUtils.tagging.getTagsForURI(this._uris[i], {});
+          if (this._tags[i].length < this._tags[nodeToCheck].length)
+            nodeToCheck =  i;
+        }
+        this._getCommonTags(nodeToCheck);
+        this._initTextField("tagsField", this._allTags.join(", "), false);
+        this._element("itemsCountText").value =
+          PlacesUIUtils.getFormattedString("detailsPane.multipleItems",
+                                           [this._itemIds.length]);
+      }
 
       // tags selector
       this._rebuildTagsSelectorList();
     }
 
     // name picker
     this._initNamePicker();
     
@@ -180,16 +221,34 @@ var gEditItemOverlay = {
     if (!this._observersAdded) {
       if (this._itemId != -1)
         PlacesUtils.bookmarks.addObserver(this, false);
       window.addEventListener("unload", this, false);
       this._observersAdded = true;
     }
   },
 
+  _getCommonTags: function(aArrIndex) {
+    var tempArray = this._tags[aArrIndex];
+    var isAllTag;
+    for (var k = 0; k < tempArray.length; k++) {
+      isAllTag = true;
+      for (var j = 0; j < this._tags.length; j++) {
+        if (j == aArrIndex)
+          continue;
+        if (this._tags[j].indexOf(tempArray[k]) == -1) {
+          isAllTag = false;
+          break;
+        }
+      }
+      if (isAllTag)
+        this._allTags.push(tempArray[k]);
+    }
+  },
+
   _initTextField: function(aTextFieldId, aValue, aReadOnly) {
     var field = this._element(aTextFieldId);
     field.readOnly = aReadOnly !== undefined ? aReadOnly : this._readOnly;
 
     if (field.value != aValue) {
       field.value = aValue;
 
       // clear the undo stack
@@ -457,23 +516,35 @@ var gEditItemOverlay = {
       this._observersAdded = false;
     }
     if (this._microsummaries) {
       this._microsummaries.removeObserver(this);
       this._microsummaries = null;
     }
     this._itemId = -1;
     this._uri = null;
+    this._uris = [];
+    this._tags = [];
+    this._allTags = [];
+    this._itemIds = [];
+    this._multiEdit = false;
   },
 
   onTagsFieldBlur: function EIO_onTagsFieldBlur() {
     this._updateTags();
   },
 
   _updateTags: function EIO__updateTags() {
+    if (this._multiEdit)
+      this._updateMultipleTagsForItems();
+    else
+      this._updateSingleTagForItem();
+  },
+
+  _updateSingleTagForItem: function EIO__updateSingleTagForItem() {
     var currentTags = PlacesUtils.tagging.getTagsForURI(this._uri, { });
     var tags = this._getTagsArrayFromTagField();
     if (tags.length > 0 || currentTags.length > 0) {
       var tagsToRemove = [];
       var tagsToAdd = [];
       var i;
       for (i = 0; i < currentTags.length; i++) {
         if (tags.indexOf(currentTags[i]) == -1)
@@ -490,16 +561,59 @@ var gEditItemOverlay = {
       }
       if (tagsToRemove.length > 0) {
         var untagTxn = PlacesUIUtils.ptm.untagURI(this._uri, tagsToRemove);
         PlacesUIUtils.ptm.doTransaction(untagTxn);
       }
     }
   },
 
+  _updateMultipleTagsForItems: function EIO__updateMultipleTagsForItems() {
+    var tags = this._getTagsArrayFromTagField();
+    if (tags.length > 0 || this._allTags.length > 0) {
+      var tagsToRemove = [];
+      var tagsToAdd = [];
+      var i;
+      for (i = 0; i < this._allTags.length; i++) {
+        if (tags.indexOf(this._allTags[i]) == -1)
+          tagsToRemove.push(this._allTags[i]);
+      }
+      for (i = 0; i < this._tags.length; i++) {
+        tagsToAdd[i] = [];
+        for (var j = 0; j < tags.length; j++) {
+          if (this._tags[i].indexOf(tags[j]) == -1)
+            tagsToAdd[i].push(tags[j]);
+        }
+      }
+
+      PlacesUIUtils.ptm.beginBatch();
+      if (tagsToAdd.length > 0) {
+        var tagTxn;
+        for (i = 0; i < this._uris.length; i++) {
+          if (tagsToAdd[i].length > 0) {
+            tagTxn = PlacesUIUtils.ptm.tagURI(this._uris[i], tagsToAdd[i]);
+            PlacesUIUtils.ptm.doTransaction(tagTxn);
+          }
+        }
+      }
+      if (tagsToRemove.length > 0) {
+        var untagTxn;
+        for (var i = 0; i < this._uris.length; i++) {
+          untagTxn = PlacesUIUtils.ptm.untagURI(this._uris[i], tagsToRemove);
+          PlacesUIUtils.ptm.doTransaction(untagTxn);
+        }
+      }
+      PlacesUIUtils.ptm.endBatch();
+      this._allTags = tags;
+      this._tags = [];
+      for (i = 0; i < this._uris.length; i++)
+        this._tags[i] = PlacesUtils.tagging.getTagsForURI(this._uris[i], {});
+    }
+  },
+
   onNamePickerInput: function EIO_onNamePickerInput() {
     var title = this._element("namePicker").value;
     this._element("userEnteredName").label = title;
   },
 
   onNamePickerChange: function EIO_onNamePickerChange() {
     if (this._itemId == -1)
       return;
@@ -817,18 +931,20 @@ var gEditItemOverlay = {
 
   // nsIDOMEventListener
   handleEvent: function EIO_nsIDOMEventListener(aEvent) {
     switch (aEvent.type) {
     case "CheckboxStateChange":
       // Update the tags field when items are checked/unchecked in the listbox
       var tags = this._getTagsArrayFromTagField();
 
-      if (aEvent.target.checked)
-        tags.push(aEvent.target.label);
+      if (aEvent.target.checked) {
+        if (tags.indexOf(aEvent.target.label) == -1)
+          tags.push(aEvent.target.label);
+      }
       else {
         var indexOfItem = tags.indexOf(aEvent.target.label);
         if (indexOfItem != -1)
           tags.splice(indexOfItem, 1);
       }
       this._element("tagsField").value = tags.join(", ");
       this._updateTags();
       break;
--- a/browser/components/places/content/editBookmarkOverlay.xul
+++ b/browser/components/places/content/editBookmarkOverlay.xul
@@ -161,16 +161,24 @@
         <hbox id="editBMPanel_newFolderBox" collapsed="true">
           <button label="&newFolderButton.label;"
                   id="editBMPanel_newFolderButton"
                   accesskey="&newFolderButton.accesskey;"
                   oncommand="gEditItemOverlay.newFolder();"/>
           <spacer flex="1"/>
         </hbox>
 
+        <row align="center" id="editBMPanel_selectionCount" hidden="true">
+          <spacer flex="3"/>
+          <vbox id="editBMPanel_itemsCountBox" align="center">
+            <label id="editBMPanel_itemsCountText"/>
+          </vbox>
+          <spacer flex="3"/>
+        </row>
+
         <row align="center" id="editBMPanel_tagsRow">
           <label value="&editBookmarkOverlay.tags.label;"
                  accesskey="&editBookmarkOverlay.tags.accesskey;"
                  control="editBMPanel_tagsField"
                  observes="paneElementsBroadcaster"/>
           <textbox id="editBMPanel_tagsField"
                    onblur="gEditItemOverlay.onTagsFieldBlur();"
                    observes="paneElementsBroadcaster"
--- a/browser/components/places/content/places.js
+++ b/browser/components/places/content/places.js
@@ -224,17 +224,17 @@ var PlacesOrganizer = {
 
     // Make sure the search UI is hidden.
     PlacesSearchBox.hideSearchUI();
     if (resetSearchBox)
       PlacesSearchBox.searchFilter.reset();
 
     this._setSearchScopeForNode(node);
     if (this._places.treeBoxObject.focused)
-      this._fillDetailsPane(node);
+      this._fillDetailsPane([node]);
   },
 
   /**
    * Sets the search scope based on node's properties
    * @param   aNode
    *          the node to set up scope from
    */
   _setSearchScopeForNode: function PO__setScopeForNode(aNode) {
@@ -314,18 +314,19 @@ var PlacesOrganizer = {
   /**
    * Handle focus changes on the trees.
    * When moving focus between panes we should update the details pane contents.
    * @param   aEvent
    *          The mouse event.
    */
   onTreeFocus: function PO_onTreeFocus(aEvent) {
     var currentView = aEvent.currentTarget;
-    var selectedNode = currentView.selectedNode;
-    this._fillDetailsPane(selectedNode);
+    var selectedNodes = currentView.selectedNode ? [currentView.selectedNode] :
+                        this._content.getSelectionNodes();
+    this._fillDetailsPane(selectedNodes);
   },
 
   openFlatContainer: function PO_openFlatContainerFlatContainer(aContainer) {
     if (aContainer.itemId != -1)
       this._places.selectItems([aContainer.itemId]);
     else if (PlacesUtils.nodeIsQuery(aContainer))
       this._places.selectPlaceURI(aContainer.uri);
   },
@@ -588,16 +589,23 @@ var PlacesOrganizer = {
      * the wasminimal attribute here is used to persist the "more/less"
      * state in a bookmark->folder->bookmark scenario.
      */
     var infoBox = document.getElementById("infoBox");
     var infoBoxExpander = document.getElementById("infoBoxExpander");
 #ifdef XP_WIN
     var infoBoxExpanderLabel = document.getElementById("infoBoxExpanderLabel");
 #endif
+    if (!aNode) {
+      infoBoxExpander.hidden = true;
+#ifdef XP_WIN
+      infoBoxExpanderLabel.hidden = true;
+#endif
+      return;
+    }
     if (aNode.itemId != -1 &&
         ((PlacesUtils.nodeIsFolder(aNode) &&
           !PlacesUtils.nodeIsLivemarkContainer(aNode)) ||
          PlacesUtils.nodeIsLivemarkItem(aNode))) {
       if (infoBox.getAttribute("minimal") == "true")
         infoBox.setAttribute("wasminimal", "true");
       infoBox.removeAttribute("minimal");
       infoBoxExpander.hidden = true;
@@ -623,58 +631,85 @@ var PlacesOrganizer = {
     var height = previewBox.boxObject.height;
     var width = height * (screen.width / screen.height);
     canvas.width = width;
     canvas.height = height;
   },
 
   onContentTreeSelect: function PO_onContentTreeSelect() {
     if (this._content.treeBoxObject.focused)
-      this._fillDetailsPane(this._content.selectedNode);
+      this._fillDetailsPane(this._content.getSelectionNodes());
   },
 
-  _fillDetailsPane: function PO__fillDetailsPane(aSelectedNode) {
+  _fillDetailsPane: function PO__fillDetailsPane(aNodeList) {
     var infoBox = document.getElementById("infoBox");
     var detailsDeck = document.getElementById("detailsDeck");
-
+    var aSelectedNode = aNodeList.length == 1 ? aNodeList[0] : null;
     // If a textbox within a panel is focused, force-blur it so its contents
     // are saved
     if (gEditItemOverlay.itemId != -1) {
       var focusedElement = document.commandDispatcher.focusedElement;
       if ((focusedElement instanceof HTMLInputElement ||
            focusedElement instanceof HTMLTextAreaElement) &&
           /^editBMPanel.*/.test(focusedElement.parentNode.parentNode.id))
         focusedElement.blur();
 
-      // don't update the panel if we are already editing this node
+      // don't update the panel if we are already editing this node unless we're
+      // in multi-edit mode
       if (aSelectedNode && gEditItemOverlay.itemId == aSelectedNode.itemId &&
-          detailsDeck.selectedIndex == 1)
+          detailsDeck.selectedIndex == 1 && !gEditItemOverlay.multiEdit)
         return;
     }
  
     if (aSelectedNode && !PlacesUtils.nodeIsSeparator(aSelectedNode)) {
       detailsDeck.selectedIndex = 1;
       // Using the concrete itemId is arguably wrong. The bookmarks API
       // does allow setting properties for folder shortcuts as well, but since
       // the UI does not distinct between the couple, we better just show
       // the concrete item properties.
       if (aSelectedNode.type ==
           Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) {
         gEditItemOverlay.initPanel(asQuery(aSelectedNode).folderItemId,
                                   { hiddenRows: ["folderPicker"],
                                     forceReadOnly: true });
+
       }
       else {
         var itemId = PlacesUtils.getConcreteItemId(aSelectedNode);
         gEditItemOverlay.initPanel(itemId != -1 ? itemId :
                                    PlacesUtils._uri(aSelectedNode.uri),
                                    { hiddenRows: ["folderPicker"] });
       }
       this._detectAndSetDetailsPaneMinimalState(aSelectedNode);
     }
+    else if (!aSelectedNode && aNodeList[0]) {
+      var itemIds = [];
+      for (var i = 0; i < aNodeList.length; i++) {
+        if (!PlacesUtils.nodeIsBookmark(aNodeList[i])) {
+          detailsDeck.selectedIndex = 0;
+          var selectItemDesc = document.getElementById("selectItemDescription");
+          var itemsCountLabel = document.getElementById("itemsCountText");
+          selectItemDesc.hidden = false;
+          itemsCountLabel.value =
+            PlacesUIUtils.getFormattedString("detailsPane.multipleItems",
+                                             [aNodeList.length]);
+          return;
+        }
+        itemIds[i] = PlacesUtils.getConcreteItemId(aNodeList[i]);
+      }
+      detailsDeck.selectedIndex = 1;
+      gEditItemOverlay.initPanel(itemIds,
+                                 { hiddenRows: ["folderPicker",
+                                                "loadInSidebar",
+                                                "location",
+                                                "keyword",
+                                                "description",
+                                                "name"]});
+      this._detectAndSetDetailsPaneMinimalState(aSelectedNode);
+    }
     else {
       detailsDeck.selectedIndex = 0;
       var selectItemDesc = document.getElementById("selectItemDescription");
       var itemsCountLabel = document.getElementById("itemsCountText");
       var rowCount = this._content.treeBoxObject.view.rowCount;
       if (rowCount == 0) {
         selectItemDesc.hidden = true;
         itemsCountLabel.value = PlacesUIUtils.getString("detailsPane.noItems");