Bug 1221764 - Implement simple chrome.bookmarks events, r?aswan r?mak draft
authorTom Schuster <evilpies@gmail.com>
Wed, 17 Aug 2016 10:22:10 -0400
changeset 435422 465ad19dc4a086045c9bc71f795d08bb5978ea9e
parent 434636 908557c762f798605a2f96e4c943791cbada1b50
child 536309 53c46962abfdb248b88fd187bc20c22a4e299e2e
push id35038
push userbmo:bob.silverberg@gmail.com
push dateTue, 08 Nov 2016 17:31:43 +0000
reviewersaswan, mak
bugs1221764
milestone52.0a1
Bug 1221764 - Implement simple chrome.bookmarks events, r?aswan r?mak MozReview-Commit-ID: LWbhYf8CpZD
browser/components/extensions/ext-bookmarks.js
browser/components/extensions/schemas/bookmarks.json
browser/components/extensions/test/xpcshell/test_ext_bookmarks.js
toolkit/components/places/Bookmarks.jsm
--- a/browser/components/extensions/ext-bookmarks.js
+++ b/browser/components/extensions/ext-bookmarks.js
@@ -1,21 +1,28 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+const {
+  SingletonEventManager,
+} = ExtensionUtils;
 
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+                                  "resource://devtools/shared/event-emitter.js");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
 
+let listenerCount = 0;
+
 function getTree(rootGuid, onlyChildren) {
   function convert(node, parent) {
     let treenode = {
       id: node.guid,
       title: node.title || "",
       index: node.index,
       dateAdded: node.dateAdded / 1000,
     };
@@ -72,16 +79,113 @@ function convert(result) {
     node.url = result.url.href; // Output is always URL object.
   } else {
     node.dateGroupModified = result.lastModified.getTime();
   }
 
   return node;
 }
 
+let observer = {
+  skipTags: true,
+  skipDescendantsOnItemRemoval: true,
+
+  onBeginUpdateBatch() {},
+  onEndUpdateBatch() {},
+
+  onItemAdded(id, parentId, index, itemType, uri, title, dateAdded, guid, parentGuid, source) {
+    if (itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR) {
+      return;
+    }
+
+    let bookmark = {
+      id: guid,
+      parentId: parentGuid,
+      index,
+      title,
+      dateAdded: dateAdded / 1000,
+    };
+
+    if (itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) {
+      bookmark.url = uri.spec;
+    } else {
+      bookmark.dateGroupModified = bookmark.dateAdded;
+    }
+
+    this.emit("created", bookmark);
+  },
+
+  onItemVisited() {},
+
+  onItemMoved(id, oldParentId, oldIndex, newParentId, newIndex, itemType, guid, oldParentGuid, newParentGuid, source) {
+    if (itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR) {
+      return;
+    }
+
+    let info = {
+      parentId: newParentGuid,
+      index: newIndex,
+      oldParentId: oldParentGuid,
+      oldIndex,
+    };
+    this.emit("moved", {guid, info});
+  },
+
+  onItemRemoved(id, parentId, index, itemType, uri, guid, parentGuid, source) {
+    if (itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR) {
+      return;
+    }
+
+    let node = {
+      id: guid,
+      parentId: parentGuid,
+      index,
+    };
+
+    if (itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) {
+      node.url = uri.spec;
+    }
+
+    this.emit("removed", {guid, info: {parentId: parentGuid, index, node}});
+  },
+
+  onItemChanged(id, prop, isAnno, val, lastMod, itemType, parentId, guid, parentGuid, oldVal, source) {
+    if (itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR) {
+      return;
+    }
+
+    let info = {};
+    if (prop == "title") {
+      info.title = val;
+    } else if (prop == "uri") {
+      info.url = val;
+    } else {
+      // Not defined yet.
+      return;
+    }
+
+    this.emit("changed", {guid, info});
+  },
+};
+EventEmitter.decorate(observer);
+
+function decrementListeners() {
+  listenerCount -= 1;
+  if (!listenerCount) {
+    PlacesUtils.bookmarks.removeObserver(observer);
+  }
+}
+
+function incrementListeners() {
+  listenerCount++;
+  if (listenerCount == 1) {
+    PlacesUtils.bookmarks.addObserver(observer, false);
+  }
+}
+
 extensions.registerSchemaAPI("bookmarks", "addon_parent", context => {
   return {
     bookmarks: {
       get: function(idOrIdList) {
         let list = Array.isArray(idOrIdList) ? idOrIdList : [idOrIdList];
 
         return Task.spawn(function* () {
           let bookmarks = [];
@@ -208,11 +312,63 @@ extensions.registerSchemaAPI("bookmarks"
 
         try {
           return PlacesUtils.bookmarks.remove(info).then(result => {})
             .catch(error => Promise.reject({message: error.message}));
         } catch (e) {
           return Promise.reject({message: `Invalid bookmark: ${JSON.stringify(info)}`});
         }
       },
+
+      onCreated: new SingletonEventManager(context, "bookmarks.onCreated", fire => {
+        let listener = (event, bookmark) => {
+          context.runSafe(fire, bookmark.id, bookmark);
+        };
+
+        observer.on("created", listener);
+        incrementListeners();
+        return () => {
+          observer.off("created", listener);
+          decrementListeners();
+        };
+      }).api(),
+
+      onRemoved: new SingletonEventManager(context, "bookmarks.onRemoved", fire => {
+        let listener = (event, data) => {
+          context.runSafe(fire, data.guid, data.info);
+        };
+
+        observer.on("removed", listener);
+        incrementListeners();
+        return () => {
+          observer.off("removed", listener);
+          decrementListeners();
+        };
+      }).api(),
+
+      onChanged: new SingletonEventManager(context, "bookmarks.onChanged", fire => {
+        let listener = (event, data) => {
+          context.runSafe(fire, data.guid, data.info);
+        };
+
+        observer.on("changed", listener);
+        incrementListeners();
+        return () => {
+          observer.off("changed", listener);
+          decrementListeners();
+        };
+      }).api(),
+
+      onMoved: new SingletonEventManager(context, "bookmarks.onMoved", fire => {
+        let listener = (event, data) => {
+          context.runSafe(fire, data.guid, data.info);
+        };
+
+        observer.on("moved", listener);
+        incrementListeners();
+        return () => {
+          observer.off("moved", listener);
+          decrementListeners();
+        };
+      }).api(),
     },
   };
 });
--- a/browser/components/extensions/schemas/bookmarks.json
+++ b/browser/components/extensions/schemas/bookmarks.json
@@ -446,33 +446,31 @@
             "parameters": []
           }
         ]
       }
     ],
     "events": [
       {
         "name": "onCreated",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when a bookmark or folder is created.",
         "parameters": [
           {
             "type": "string",
             "name": "id"
           },
           {
             "$ref": "BookmarkTreeNode",
             "name": "bookmark"
           }
         ]
       },
       {
         "name": "onRemoved",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when a bookmark or folder is removed.  When a folder is removed recursively, a single notification is fired for the folder, and none for its contents.",
         "parameters": [
           {
             "type": "string",
             "name": "id"
           },
           {
@@ -483,17 +481,16 @@
               "index": { "type": "integer" },
               "node": { "$ref": "BookmarkTreeNode" }
             }
           }
         ]
       },
       {
         "name": "onChanged",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when a bookmark or folder changes.  <b>Note:</b> Currently, only title and url changes trigger this.",
         "parameters": [
           {
             "type": "string",
             "name": "id"
           },
           {
@@ -506,17 +503,16 @@
                 "optional": true
               }
             }
           }
         ]
       },
       {
         "name": "onMoved",
-        "unsupported": true,
         "type": "function",
         "description": "Fired when a bookmark or folder is moved to a different parent folder.",
         "parameters": [
           {
             "type": "string",
             "name": "id"
           },
           {
--- a/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js
@@ -1,16 +1,18 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 function backgroundScript() {
   let unsortedId, ourId;
   let initialBookmarkCount = 0;
   let createdBookmarks = new Set();
+  let createdFolderId;
+  let collectedEvents = [];
   const nonExistentId = "000000000000";
   const bookmarkGuids = {
     menuGuid:    "menu________",
     toolbarGuid: "toolbar_____",
     unfiledGuid: "unfiled_____",
   };
 
   function checkOurBookmark(bookmark) {
@@ -32,16 +34,83 @@ function backgroundScript() {
       browser.test.assertEq(expected.parentId, bookmark.parentId, "Bookmark has the expected parentId");
     }
   }
 
   function expectedError() {
     browser.test.fail("Did not get expected error");
   }
 
+  function checkOnCreated(id, parentId, index, title, url, dateAdded) {
+    let createdData = collectedEvents.pop();
+    browser.test.assertEq("onCreated", createdData.event, "onCreated was the last event received");
+    browser.test.assertEq(id, createdData.id, "onCreated event received the expected id");
+    let bookmark = createdData.bookmark;
+    browser.test.assertEq(id, bookmark.id, "onCreated event received the expected bookmark id");
+    browser.test.assertEq(parentId, bookmark.parentId, "onCreated event received the expected bookmark parentId");
+    browser.test.assertEq(index, bookmark.index, "onCreated event received the expected bookmark index");
+    browser.test.assertEq(title, bookmark.title, "onCreated event received the expected bookmark title");
+    browser.test.assertEq(url, bookmark.url, "onCreated event received the expected bookmark url");
+    browser.test.assertEq(dateAdded, bookmark.dateAdded, "onCreated event received the expected bookmark dateAdded");
+  }
+
+  function checkOnChanged(id, url, title) {
+    // If both url and title are changed, then url is fired last.
+    let changedData = collectedEvents.pop();
+    browser.test.assertEq("onChanged", changedData.event, "onChanged was the last event received");
+    browser.test.assertEq(id, changedData.id, "onChanged event received the expected id");
+    browser.test.assertEq(url, changedData.info.url, "onChanged event received the expected url");
+    // title is fired first.
+    changedData = collectedEvents.pop();
+    browser.test.assertEq("onChanged", changedData.event, "onChanged was the last event received");
+    browser.test.assertEq(id, changedData.id, "onChanged event received the expected id");
+    browser.test.assertEq(title, changedData.info.title, "onChanged event received the expected title");
+  }
+
+  function checkOnMoved(id, parentId, oldParentId, index, oldIndex) {
+    let movedData = collectedEvents.pop();
+    browser.test.assertEq("onMoved", movedData.event, "onMoved was the last event received");
+    browser.test.assertEq(id, movedData.id, "onMoved event received the expected id");
+    let info = movedData.info;
+    browser.test.assertEq(parentId, info.parentId, "onMoved event received the expected parentId");
+    browser.test.assertEq(oldParentId, info.oldParentId, "onMoved event received the expected oldParentId");
+    browser.test.assertEq(index, info.index, "onMoved event received the expected index");
+    browser.test.assertEq(oldIndex, info.oldIndex, "onMoved event received the expected oldIndex");
+  }
+
+  function checkOnRemoved(id, parentId, index, url) {
+    let removedData = collectedEvents.pop();
+    browser.test.assertEq("onRemoved", removedData.event, "onRemoved was the last event received");
+    browser.test.assertEq(id, removedData.id, "onRemoved event received the expected id");
+    let info = removedData.info;
+    browser.test.assertEq(parentId, removedData.info.parentId, "onRemoved event received the expected parentId");
+    browser.test.assertEq(index, removedData.info.index, "onRemoved event received the expected index");
+    let node = info.node;
+    browser.test.assertEq(id, node.id, "onRemoved event received the expected node id");
+    browser.test.assertEq(parentId, node.parentId, "onRemoved event received the expected node parentId");
+    browser.test.assertEq(index, node.index, "onRemoved event received the expected node index");
+    browser.test.assertEq(url, node.url, "onRemoved event received the expected node url");
+  }
+
+  browser.bookmarks.onChanged.addListener((id, info) => {
+    collectedEvents.push({event: "onChanged", id, info});
+  });
+
+  browser.bookmarks.onCreated.addListener((id, bookmark) => {
+    collectedEvents.push({event: "onCreated", id, bookmark});
+  });
+
+  browser.bookmarks.onMoved.addListener((id, info) => {
+    collectedEvents.push({event: "onMoved", id, info});
+  });
+
+  browser.bookmarks.onRemoved.addListener((id, info) => {
+    collectedEvents.push({event: "onRemoved", id, info});
+  });
+
   browser.bookmarks.get(["not-a-bookmark-guid"]).then(expectedError, error => {
     browser.test.assertTrue(
       error.message.includes("Invalid value for property 'guid': not-a-bookmark-guid"),
       "Expected error thrown when trying to get a bookmark using an invalid guid"
     );
 
     return browser.bookmarks.get([nonExistentId]).then(expectedError, error => {
       browser.test.assertTrue(
@@ -52,16 +121,18 @@ function backgroundScript() {
   }).then(() => {
     return browser.bookmarks.search({});
   }).then(results => {
     initialBookmarkCount = results.length;
     return browser.bookmarks.create({title: "test bookmark", url: "http://example.org"});
   }).then(result => {
     ourId = result.id;
     checkOurBookmark(result);
+    browser.test.assertEq(1, collectedEvents.length, "1 expected event received");
+    checkOnCreated(ourId, bookmarkGuids.unfiledGuid, 0, "test bookmark", "http://example.org/", result.dateAdded);
 
     return browser.bookmarks.get(ourId);
   }).then(results => {
     browser.test.assertEq(results.length, 1);
     checkOurBookmark(results[0]);
 
     unsortedId = results[0].parentId;
     return browser.bookmarks.get(unsortedId);
@@ -91,16 +162,19 @@ function backgroundScript() {
 
       return browser.bookmarks.update(ourId, {title: "new test title", url: "http://example.com/"});
     });
   }).then(result => {
     browser.test.assertEq("new test title", result.title, "Updated bookmark has the expected title");
     browser.test.assertEq("http://example.com/", result.url, "Updated bookmark has the expected URL");
     browser.test.assertEq(ourId, result.id, "Updated bookmark has the expected id");
 
+    browser.test.assertEq(2, collectedEvents.length, "2 expected events received");
+    checkOnChanged(ourId, "http://example.com/", "new test title");
+
     return Promise.resolve().then(() => {
       return browser.bookmarks.update(ourId, {url: "this is not a valid url"});
     }).then(expectedError, error => {
       browser.test.assertTrue(
         error.message.includes("Invalid bookmark:"),
         "Expected error thrown when trying update with an invalid url"
       );
       return browser.bookmarks.getTree();
@@ -124,58 +198,78 @@ function backgroundScript() {
           "Expected error thrown when trying to create a bookmark with an invalid parentId"
       );
     });
   }).then(() => {
     return browser.bookmarks.remove(ourId);
   }).then(result => {
     browser.test.assertEq(undefined, result, "Removing a bookmark returns undefined");
 
+    browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+    checkOnRemoved(ourId, bookmarkGuids.unfiledGuid, 0, "http://example.com/");
+
     return browser.bookmarks.get(ourId).then(expectedError, error => {
       browser.test.assertTrue(
         error.message.includes("Bookmark not found"),
         "Expected error thrown when trying to get a removed bookmark"
       );
     });
   }).then(() => {
     return browser.bookmarks.remove(nonExistentId).then(expectedError, error => {
       browser.test.assertTrue(
         error.message.includes("No bookmarks found for the provided GUID"),
         "Expected error thrown when trying removed a non-existent bookmark"
       );
     });
   }).then(() => {
     // test bookmarks.search
     return Promise.all([
-      browser.bookmarks.create({title: "MØzillä", url: "http://møzîllä.örg"}),
-      browser.bookmarks.create({title: "Example", url: "http://example.org"}),
+      browser.bookmarks.create({title: "MØzillä", url: "http://møzîllä.örg/"}),
+      browser.bookmarks.create({title: "Example", url: "http://example.org/"}),
       browser.bookmarks.create({title: "Mozilla Folder"}),
-      browser.bookmarks.create({title: "EFF", url: "http://eff.org"}),
-      browser.bookmarks.create({title: "Menu Item", url: "http://menu.org", parentId: bookmarkGuids.menuGuid}),
-      browser.bookmarks.create({title: "Toolbar Item", url: "http://toolbar.org", parentId: bookmarkGuids.toolbarGuid}),
+      browser.bookmarks.create({title: "EFF", url: "http://eff.org/"}),
+      browser.bookmarks.create({title: "Menu Item", url: "http://menu.org/", parentId: bookmarkGuids.menuGuid}),
+      browser.bookmarks.create({title: "Toolbar Item", url: "http://toolbar.org/", parentId: bookmarkGuids.toolbarGuid}),
     ]);
   }).then(results => {
+    browser.test.assertEq(6, collectedEvents.length, "6 expected events received");
+    checkOnCreated(results[5].id, bookmarkGuids.toolbarGuid, 0, "Toolbar Item", "http://toolbar.org/", results[5].dateAdded);
+    checkOnCreated(results[4].id, bookmarkGuids.menuGuid, 0, "Menu Item", "http://menu.org/", results[4].dateAdded);
+    checkOnCreated(results[3].id, bookmarkGuids.unfiledGuid, 0, "EFF", "http://eff.org/", results[3].dateAdded);
+    checkOnCreated(results[2].id, bookmarkGuids.unfiledGuid, 0, "Mozilla Folder", undefined, results[2].dateAdded);
+    checkOnCreated(results[1].id, bookmarkGuids.unfiledGuid, 0, "Example", "http://example.org/", results[1].dateAdded);
+    checkOnCreated(results[0].id, bookmarkGuids.unfiledGuid, 0, "MØzillä", "http://møzîllä.örg/", results[0].dateAdded);
+
     for (let result of results) {
       if (result.title !== "Mozilla Folder") {
         createdBookmarks.add(result.id);
       }
     }
-    let createdFolderId = results[2].id;
+    let folderResult = results[2];
+    createdFolderId = folderResult.id;
     return Promise.all([
-      browser.bookmarks.create({title: "Mozilla", url: "http://allizom.org", parentId: createdFolderId}),
-      browser.bookmarks.create({title: "Mozilla Corporation", url: "http://allizom.com", parentId: createdFolderId}),
-      browser.bookmarks.create({title: "Firefox", url: "http://allizom.org/firefox", parentId: createdFolderId}),
+      browser.bookmarks.create({title: "Mozilla", url: "http://allizom.org/", parentId: createdFolderId}),
+      browser.bookmarks.create({title: "Mozilla Corporation", url: "http://allizom.com/", parentId: createdFolderId}),
+      browser.bookmarks.create({title: "Firefox", url: "http://allizom.org/firefox/", parentId: createdFolderId}),
     ]).then(results => {
+      browser.test.assertEq(3, collectedEvents.length, "3 expected events received");
+      checkOnCreated(results[2].id, createdFolderId, 0, "Firefox", "http://allizom.org/firefox/", results[2].dateAdded);
+      checkOnCreated(results[1].id, createdFolderId, 0, "Mozilla Corporation", "http://allizom.com/", results[1].dateAdded);
+      checkOnCreated(results[0].id, createdFolderId, 0, "Mozilla", "http://allizom.org/", results[0].dateAdded);
+
       return browser.bookmarks.create({
         title: "About Mozilla",
-        url: "http://allizom.org/about",
+        url: "http://allizom.org/about/",
         parentId: createdFolderId,
         index: 1,
       });
-    }).then(() => {
+    }).then(result => {
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnCreated(result.id, createdFolderId, 1, "About Mozilla", "http://allizom.org/about/", result.dateAdded);
+
       // returns all items on empty object
       return browser.bookmarks.search({});
     }).then(results => {
       browser.test.assertTrue(results.length >= 9, "At least as many bookmarks as added were returned by search({})");
 
       return Promise.resolve().then(() => {
         return browser.bookmarks.remove(createdFolderId);
       }).then(expectedError, error => {
@@ -370,30 +464,43 @@ function backgroundScript() {
     let corporationBookmark = results[0][0];
     let childCount = results[1].length;
 
     browser.test.assertEq(2, corporationBookmark.index, "Bookmark has the expected index");
 
     return browser.bookmarks.move(corporationBookmark.id, {index: 0}).then(result => {
       browser.test.assertEq(0, result.index, "Bookmark has the expected index");
 
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnMoved(corporationBookmark.id, createdFolderId, createdFolderId, 0, 2);
+
       return browser.bookmarks.move(corporationBookmark.id, {parentId: bookmarkGuids.menuGuid});
     }).then(result => {
       browser.test.assertEq(bookmarkGuids.menuGuid, result.parentId, "Bookmark has the expected parent");
       browser.test.assertEq(childCount, result.index, "Bookmark has the expected index");
 
-      return browser.bookmarks.move(corporationBookmark.id, {index: 1});
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnMoved(corporationBookmark.id, bookmarkGuids.menuGuid, createdFolderId, 1, 0);
+
+      return browser.bookmarks.move(corporationBookmark.id, {index: 0});
     }).then(result => {
       browser.test.assertEq(bookmarkGuids.menuGuid, result.parentId, "Bookmark has the expected parent");
-      browser.test.assertEq(1, result.index, "Bookmark has the expected index");
+      browser.test.assertEq(0, result.index, "Bookmark has the expected index");
+
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnMoved(corporationBookmark.id, bookmarkGuids.menuGuid, bookmarkGuids.menuGuid, 0, 1);
 
       return browser.bookmarks.move(corporationBookmark.id, {parentId: bookmarkGuids.toolbarGuid, index: 1});
     }).then(result => {
       browser.test.assertEq(bookmarkGuids.toolbarGuid, result.parentId, "Bookmark has the expected parent");
       browser.test.assertEq(1, result.index, "Bookmark has the expected index");
+
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnMoved(corporationBookmark.id, bookmarkGuids.toolbarGuid, bookmarkGuids.menuGuid, 1, 0);
+
       createdBookmarks.add(corporationBookmark.id);
     });
   }).then(() => {
     return browser.bookmarks.getRecent(4);
   }).then(results => {
     browser.test.assertEq(4, results.length, "Expected number of results returned by getRecent");
     let prevDate = results[0].dateAdded;
     for (let bookmark of results) {
@@ -410,29 +517,39 @@ function backgroundScript() {
 
     return browser.bookmarks.search({});
   }).then(results => {
     let startBookmarkCount = results.length;
 
     return browser.bookmarks.search({title: "Mozilla Folder"}).then(result => {
       return browser.bookmarks.removeTree(result[0].id);
     }).then(() => {
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnRemoved(createdFolderId, bookmarkGuids.unfiledGuid, 1);
+
       return browser.bookmarks.search({}).then(results => {
         browser.test.assertEq(
           startBookmarkCount - 4,
           results.length,
           "Expected number of results returned after removeTree");
       });
     });
   }).then(() => {
     return browser.bookmarks.create({title: "Empty Folder"});
   }).then(result => {
     let emptyFolderId = result.id;
+
+    browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+    checkOnCreated(emptyFolderId, bookmarkGuids.unfiledGuid, 3, "Empty Folder", undefined, result.dateAdded);
+
     browser.test.assertEq("Empty Folder", result.title, "Folder has the expected title");
     return browser.bookmarks.remove(emptyFolderId).then(() => {
+      browser.test.assertEq(1, collectedEvents.length, "1 expected events received");
+      checkOnRemoved(emptyFolderId, bookmarkGuids.unfiledGuid, 3);
+
       return browser.bookmarks.get(emptyFolderId).then(expectedError, error => {
         browser.test.assertTrue(
           error.message.includes("Bookmark not found"),
           "Expected error thrown when trying to get a removed folder"
         );
       });
     });
   }).then(() => {
@@ -451,19 +568,22 @@ function backgroundScript() {
         "Expected error thrown when calling move with a non-existent bookmark"
       );
     });
   }).then(() => {
     // remove all created bookmarks
     let promises = Array.from(createdBookmarks, guid => browser.bookmarks.remove(guid));
     return Promise.all(promises);
   }).then(() => {
+    browser.test.assertEq(createdBookmarks.size, collectedEvents.length, "expected number of events received");
+
     return browser.bookmarks.search({});
   }).then(results => {
     browser.test.assertEq(initialBookmarkCount, results.length, "All created bookmarks have been removed");
+
     return browser.test.notifyPass("bookmarks");
   }).catch(error => {
     browser.test.fail(`Error: ${String(error)} :: ${error.stack}`);
     browser.test.notifyFail("bookmarks");
   });
 }
 
 let extensionData = {
--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -184,23 +184,27 @@ var Bookmarks = Object.freeze({
       let item = yield insertBookmark(insertInfo, parent);
 
       // Notify onItemAdded to listeners.
       let observers = PlacesUtils.bookmarks.getObservers();
       // We need the itemId to notify, though once the switch to guids is
       // complete we may stop using it.
       let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
       let itemId = yield PlacesUtils.promiseItemId(item.guid);
+
+      // Pass tagging information for the observers to skip over these notifications when needed.
+      let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+      let isTagsFolder = parent._id == PlacesUtils.tagsFolderId;
       notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
                                          item.type, uri, item.title || null,
                                          PlacesUtils.toPRTime(item.dateAdded), item.guid,
-                                         item.parentGuid, item.source ]);
+                                         item.parentGuid, item.source ],
+                                       { isTagging: isTagging || isTagsFolder });
 
       // If it's a tag, notify OnItemChanged to all bookmarks for this URL.
-      let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
       if (isTagging) {
         for (let entry of (yield fetchBookmarksByURL(item))) {
           notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
                                                PlacesUtils.toPRTime(entry.lastModified),
                                                entry.type, entry._parentId,
                                                entry.guid, entry.parentGuid,
                                                "", item.source ]);
         }
@@ -425,22 +429,23 @@ var Bookmarks = Object.freeze({
         throw new Error("No bookmarks found for the provided GUID.");
 
       item = yield removeBookmark(item, options);
 
       // Notify onItemRemoved to listeners.
       let { source = Bookmarks.SOURCES.DEFAULT } = options;
       let observers = PlacesUtils.bookmarks.getObservers();
       let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+      let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
       notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
                                            item.type, uri, item.guid,
                                            item.parentGuid,
-                                           source ]);
+                                           source ],
+                                         { isTagging: isUntagging });
 
-      let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
       if (isUntagging) {
         for (let entry of (yield fetchBookmarksByURL(item))) {
           notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
                                                PlacesUtils.toPRTime(entry.lastModified),
                                                entry.type, entry._parentId,
                                                entry.guid, entry.parentGuid,
                                                "", source ]);
         }
@@ -785,19 +790,30 @@ var Bookmarks = Object.freeze({
  * Sends a bookmarks notification through the given observers.
  *
  * @param observers
  *        array of nsINavBookmarkObserver objects.
  * @param notification
  *        the notification name.
  * @param args
  *        array of arguments to pass to the notification.
+ * @param information
+ *        Information about the notification, so we can filter based
+ *        based on the observer's preferences.
  */
-function notify(observers, notification, args) {
+function notify(observers, notification, args, information = {}) {
   for (let observer of observers) {
+    if (information.isTagging && observer.skipTags) {
+      continue;
+    }
+
+    if (information.isDescendantRemoval && observer.skipDescendantsOnItemRemoval) {
+      continue;
+    }
+
     try {
       observer[notification](...args);
     } catch (ex) {}
   }
 }
 
 // Update implementation.
 
@@ -1479,17 +1495,20 @@ Task.async(function* (db, folderGuids, o
   // Notify listeners in reverse order to serve children before parents.
   let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
   let observers = PlacesUtils.bookmarks.getObservers();
   for (let item of itemsRemoved.reverse()) {
     let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
     notify(observers, "onItemRemoved", [ item._id, item._parentId,
                                          item.index, item.type, uri,
                                          item.guid, item.parentGuid,
-                                         source ]);
+                                         source ],
+                                       // Notify observers that this item is being
+                                       // removed as a descendent.
+                                       { isDescendantRemoval: true });
 
     let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
     if (isUntagging) {
       for (let entry of (yield fetchBookmarksByURL(item))) {
         notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
                                              PlacesUtils.toPRTime(entry.lastModified),
                                              entry.type, entry._parentId,
                                              entry.guid, entry.parentGuid,