new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/BookmarkUtils.jsm
@@ -0,0 +1,912 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [ "BookmarkUtils", "BookmarkValidators" ];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Bookmarks",
+ "resource://gre/modules/Bookmarks.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+
+// Imposed to limit database size.
+const DB_URL_LENGTH_MAX = 65536;
+const DB_TITLE_LENGTH_MAX = 4096;
+
+const MATCH_ANYWHERE_UNMODIFIED = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED;
+const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
+
+var BookmarkValidators = {
+ validateBookmarkObject(input, behavior) {
+ return validateBookmarkProperties(VALIDATORS, input, behavior);
+ },
+
+ isValidGuid(v) {
+ return typeof(v) == "string" && /^[a-zA-Z0-9\-_]{12}$/.test(v);
+ },
+};
+
+var BookmarkUtils = {
+ generateGuid(db) {
+ return db.executeCached("SELECT GENERATE_GUID() AS guid").then(rows => rows[0].getResultByName("guid"));
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // Insert implementation.
+
+ insertBookmark(item, parent, { preProcess, postProcess } = {}) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark",
+ Task.async(function*(db) {
+
+ // If a guid was not provided, generate one, so we won't need to fetch the
+ // bookmark just after having created it.
+ if (!item.hasOwnProperty("guid"))
+ item.guid = yield BookmarkUtils.generateGuid(db);
+
+ yield db.executeTransaction(function* transaction() {
+ if (preProcess) {
+ item = yield preProcess(db, item);
+ }
+
+ yield BookmarkUtils.insertBookmarkInto(db, item, parent);
+
+ if (postProcess) {
+ item = yield postProcess(db, item);
+ }
+ });
+
+ // If not a tag recalculate frecency...
+ let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+ if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
+ // ...though we don't wait for the calculation.
+ BookmarkUtils.updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ }
+
+ // Don't return an empty title to the caller.
+ if (item.hasOwnProperty("title") && item.title === null)
+ delete item.title;
+
+ return item;
+ }));
+ },
+
+ insertBookmarkInto: Task.async(function* (db, item, parent) {
+ if (item.type == Bookmarks.TYPE_BOOKMARK) {
+ // Ensure a page exists in moz_places for this URL.
+ yield db.executeCached(
+ `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
+ VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
+ `, { url: item.url.href, rev_host: PlacesUtils.getReversedHost(item.url),
+ frecency: item.url.protocol == "place:" ? 0 : -1 });
+ }
+
+ // Adjust indices.
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + 1
+ WHERE parent = :parent
+ AND position >= :index
+ `, { parent: parent._id, index: item.index });
+
+ // Insert the bookmark into the database.
+ yield db.executeCached(
+ `INSERT INTO moz_bookmarks (fk, type, parent, position, title,
+ dateAdded, lastModified, guid)
+ VALUES ((SELECT id FROM moz_places WHERE url = :url), :type, :parent,
+ :index, :title, :date_added, :last_modified, :guid)
+ `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
+ type: item.type, parent: parent._id, index: item.index,
+ title: item.title, date_added: BookmarkUtils.toPRTime(item.dateAdded),
+ last_modified: BookmarkUtils.toPRTime(item.lastModified), guid: item.guid });
+
+ yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
+ }),
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // Update implementation.
+
+ updateBookmark(info, item, newParent, { preProcess, postProcess } = {}) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ Task.async(function*(db) {
+
+ let tuples = new Map();
+ if (info.hasOwnProperty("lastModified"))
+ tuples.set("lastModified", { value: BookmarkUtils.toPRTime(info.lastModified) });
+ if (info.hasOwnProperty("title"))
+ tuples.set("title", { value: info.title });
+
+ yield db.executeTransaction(function* () {
+ if (info.hasOwnProperty("url")) {
+ // Ensure a page exists in moz_places for this URL.
+ yield db.executeCached(
+ `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
+ VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
+ `, { url: info.url ? info.url.href : null,
+ rev_host: PlacesUtils.getReversedHost(info.url),
+ frecency: info.url.protocol == "place:" ? 0 : -1 });
+ tuples.set("url", { value: info.url.href
+ , fragment: "fk = (SELECT id FROM moz_places WHERE url = :url)" });
+ }
+
+ if (newParent) {
+ // For simplicity, update the index regardless.
+ let newIndex = info.hasOwnProperty("index") ? info.index : item.index;
+ tuples.set("position", { value: newIndex });
+
+ if (newParent.guid == item.parentGuid) {
+ // Moving inside the original container.
+ // When moving "up", add 1 to each index in the interval.
+ // Otherwise when moving down, we subtract 1.
+ let sign = newIndex < item.index ? +1 : -1;
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :newParentId
+ AND position BETWEEN :lowIndex AND :highIndex
+ `, { sign: sign, newParentId: newParent._id,
+ lowIndex: Math.min(item.index, newIndex),
+ highIndex: Math.max(item.index, newIndex) });
+ } else {
+ // Moving across different containers.
+ tuples.set("parent", { value: newParent._id} );
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :oldParentId
+ AND position >= :oldIndex
+ `, { sign: -1, oldParentId: item._parentId, oldIndex: item.index });
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :newParentId
+ AND position >= :newIndex
+ `, { sign: +1, newParentId: newParent._id, newIndex: newIndex });
+
+ yield setAncestorsLastModified(db, item.parentGuid, info.lastModified);
+ }
+ yield setAncestorsLastModified(db, newParent.guid, info.lastModified);
+ }
+
+ yield db.executeCached(
+ `UPDATE moz_bookmarks
+ SET ${Array.from(tuples.keys()).map(v => tuples.get(v).fragment || `${v} = :${v}`).join(", ")}
+ WHERE guid = :guid
+ `, Object.assign({ guid: info.guid },
+ [...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {})));
+ });
+
+ // If the parent changed, update related non-enumerable properties.
+ let additionalParentInfo = {};
+ if (newParent) {
+ Object.defineProperty(additionalParentInfo, "_parentId",
+ { value: newParent._id, enumerable: false });
+ Object.defineProperty(additionalParentInfo, "_grandParentId",
+ { value: newParent._parentId, enumerable: false });
+ }
+
+ let updatedItem = mergeIntoNewObject(item, info, additionalParentInfo);
+
+ // Don't return an empty title to the caller.
+ if (updatedItem.hasOwnProperty("title") && updatedItem.title === null)
+ delete updatedItem.title;
+
+ return updatedItem;
+ }));
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // Remove implementation.
+
+ removeBookmark(item, options) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ Task.async(function*(db) {
+
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+
+ yield db.executeTransaction(function* transaction() {
+ // If it's a folder, remove its contents first.
+ if (item.type == Bookmarks.TYPE_FOLDER) {
+ if (options.preventRemovalOfNonEmptyFolders && item._childCount > 0) {
+ throw new Error("Cannot remove a non-empty folder.");
+ }
+ yield BookmarkUtils.removeFoldersContents(db, [item.guid]);
+ }
+
+ // Remove annotations first. If it's a tag, we can avoid paying that cost.
+ if (!isUntagging) {
+ // We don't go through the annotations service for this cause otherwise
+ // we'd get a pointless onItemChanged notification and it would also
+ // set lastModified to an unexpected value.
+ yield removeAnnotationsForItem(db, item._id);
+ }
+
+ // Remove the bookmark from the database.
+ yield db.executeCached(
+ `DELETE FROM moz_bookmarks WHERE guid = :guid`, { guid: item.guid });
+
+ // Fix indices in the parent.
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position - 1 WHERE
+ parent = :parentId AND position > :index
+ `, { parentId: item._parentId, index: item.index });
+
+ yield setAncestorsLastModified(db, item.parentGuid, new Date());
+ });
+
+ // If not a tag recalculate frecency...
+ if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
+ // ...though we don't wait for the calculation.
+ BookmarkUtils.updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ }
+
+ return item;
+ }));
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // Reorder implementation.
+
+ reorderChildren(parent, orderedChildrenGuids) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ db => db.executeTransaction(function* () {
+ // Select all of the direct children for the given parent.
+ let children = yield BookmarkUtils.fetchBookmarksByParent({ parentGuid: parent.guid });
+ if (!children.length)
+ return undefined;
+
+ // Reorder the children array according to the specified order, provided
+ // GUIDs come first, others are appended in somehow random order.
+ children.sort((a, b) => {
+ let i = orderedChildrenGuids.indexOf(a.guid);
+ let j = orderedChildrenGuids.indexOf(b.guid);
+ // This works provided fetchBookmarksByParent returns sorted children.
+ if (i == -1 && j == -1)
+ return 0;
+ return (i != -1 && j != -1 && i < j) || (i != -1 && j == -1) ? -1 : 1;
+ });
+
+ // Update the bookmarks position now. If any unknown guid have been
+ // inserted meanwhile, its position will be set to -position, and we'll
+ // handle it later.
+ // To do the update in a single step, we build a VALUES (guid, position)
+ // table. We then use count() in the sorting table to avoid skipping values
+ // when no more existing GUIDs have been provided.
+ let valuesTable = children.map((child, i) => `("${child.guid}", ${i})`)
+ .join();
+ yield db.execute(
+ `WITH sorting(g, p) AS (
+ VALUES ${valuesTable}
+ )
+ UPDATE moz_bookmarks SET position = (
+ SELECT CASE count(a.g) WHEN 0 THEN -position
+ ELSE count(a.g) - 1
+ END
+ FROM sorting a
+ JOIN sorting b ON b.p <= a.p
+ WHERE a.g = guid
+ AND parent = :parentId
+ )`, { parentId: parent._id});
+
+ // Update position of items that could have been inserted in the meanwhile.
+ // Since this can happen rarely and it's only done for schema coherence
+ // resonds, we won't notify about these changes.
+ yield db.executeCached(
+ `CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger
+ AFTER UPDATE OF position ON moz_bookmarks
+ WHEN NEW.position = -1
+ BEGIN
+ UPDATE moz_bookmarks
+ SET position = (SELECT MAX(position) FROM moz_bookmarks
+ WHERE parent = NEW.parent) +
+ (SELECT count(*) FROM moz_bookmarks
+ WHERE parent = NEW.parent
+ AND position BETWEEN OLD.position AND -1)
+ WHERE guid = NEW.guid;
+ END
+ `);
+
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = -1 WHERE position < 0`);
+
+ yield db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`);
+
+ return children;
+ }.bind(this))
+ );
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // Query implementation.
+
+ queryBookmarks(info) {
+ let queryParams = {tags_folder: PlacesUtils.tagsFolderId};
+ // we're searching for bookmarks, so exclude tags
+ let queryString = "WHERE p.parent <> :tags_folder";
+
+ if (info.title) {
+ queryString += " AND b.title = :title";
+ queryParams.title = info.title;
+ }
+
+ if (info.url) {
+ queryString += " AND h.url = :url";
+ queryParams.url = info.url;
+ }
+
+ if (info.query) {
+ queryString += " AND AUTOCOMPLETE_MATCH(:query, h.url, b.title, NULL, NULL, 1, 1, NULL, :matchBehavior, :searchBehavior) ";
+ queryParams.query = info.query;
+ queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED;
+ queryParams.searchBehavior = BEHAVIOR_BOOKMARK;
+ }
+
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: queryBookmarks",
+ Task.async(function*(db) {
+
+ // _id, _childCount, _grandParentId and _parentId fields
+ // are required to be in the result by the converting function
+ // hence setting them to NULL
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title,
+ h.url AS url, b.parent, p.parent,
+ NULL AS _id,
+ NULL AS _childCount,
+ NULL AS _grandParentId,
+ NULL AS _parentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ ${queryString}
+ `, queryParams);
+
+ return BookmarkUtils.rowsToItemsArray(rows);
+ }));
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////
+ // Fetch implementation.
+
+ fetchBookmark(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE b.guid = :guid
+ `, { guid: info.guid });
+
+ return rows.length ? BookmarkUtils.rowsToItemsArray(rows)[0] : null;
+ }));
+ },
+
+ fetchBookmarkByPosition(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarkByPosition",
+ Task.async(function*(db) {
+ let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.guid = :parentGuid
+ AND b.position = IFNULL(:index, (SELECT count(*) - 1
+ FROM moz_bookmarks
+ WHERE parent = p.id))
+ `, { parentGuid: info.parentGuid, index });
+
+ return rows.length ? BookmarkUtils.rowsToItemsArray(rows)[0] : null;
+ }));
+ },
+
+ fetchBookmarksByURL(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByURL",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `/* do not warn (bug no): not worth to add an index */
+ SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE h.url = :url
+ AND _grandParentId <> :tags_folder
+ ORDER BY b.lastModified DESC
+ `, { url: info.url.href,
+ tags_folder: PlacesUtils.tagsFolderId });
+
+ return rows.length ? BookmarkUtils.rowsToItemsArray(rows) : null;
+ }));
+ },
+
+ fetchRecentBookmarks(numberOfItems) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchRecentBookmarks",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ NULL AS _id, NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.parent <> :tags_folder
+ ORDER BY b.dateAdded DESC, b.ROWID DESC
+ LIMIT :numberOfItems
+ `, { tags_folder: PlacesUtils.tagsFolderId, numberOfItems });
+
+ return rows.length ? BookmarkUtils.rowsToItemsArray(rows) : [];
+ }));
+ },
+
+ fetchBookmarksByParent(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByParent",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.guid = :parentGuid
+ ORDER BY b.position ASC
+ `, { parentGuid: info.parentGuid });
+
+ return BookmarkUtils.rowsToItemsArray(rows);
+ }));
+ },
+
+ /**
+ * 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.
+ */
+ notify(observers, notification, args) {
+ for (let observer of observers) {
+ try {
+ observer[notification](...args);
+ } catch (ex) {}
+ }
+ },
+
+ /**
+ * Converts an URL object to an nsIURI.
+ *
+ * @param url
+ * the URL object to convert.
+ * @return nsIURI for the given URL.
+ */
+ toURI(url) {
+ return NetUtil.newURI(url.href);
+ },
+
+ /**
+ * Convert a Date object to a PRTime (microseconds).
+ *
+ * @param date
+ * the Date object to convert.
+ * @return microseconds from the epoch.
+ */
+ toPRTime(date) {
+ return date * 1000;
+ },
+
+ /**
+ * Convert a PRTime to a Date object.
+ *
+ * @param time
+ * microseconds from the epoch.
+ * @return a Date object.
+ */
+ toDate(time) {
+ return new Date(parseInt(time / 1000));
+ },
+
+ /**
+ * Convert an array of mozIStorageRow objects to an array of bookmark objects.
+ *
+ * @param rows
+ * the array of mozIStorageRow objects.
+ * @return an array of bookmark objects.
+ */
+ rowsToItemsArray(rows) {
+ return rows.map(row => {
+ let item = {};
+ for (let prop of ["guid", "index", "type"]) {
+ item[prop] = row.getResultByName(prop);
+ }
+ for (let prop of ["dateAdded", "lastModified"]) {
+ item[prop] = BookmarkUtils.toDate(row.getResultByName(prop));
+ }
+ for (let prop of ["title", "parentGuid", "url" ]) {
+ let val = row.getResultByName(prop);
+ if (val)
+ item[prop] = prop === "url" ? new URL(val) : val;
+ }
+ for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
+ let val = row.getResultByName(prop);
+ if (val !== null) {
+ // These properties should not be returned to the API consumer, thus
+ // they are non-enumerable and removed through Object.assign just before
+ // the object is returned.
+ // Configurable is set to support mergeIntoNewObject overwrites.
+ Object.defineProperty(item, prop, { value: val, enumerable: false,
+ configurable: true });
+ }
+ }
+
+ return item;
+ });
+ },
+
+ /**
+ * Updates frecency for a list of URLs.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param urls
+ * the array of URLs to update.
+ */
+ updateFrecency: Task.async(function* (db, urls) {
+ yield db.execute(
+ `UPDATE moz_places
+ SET frecency = NOTIFY_FRECENCY(
+ CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
+ ) WHERE url IN ( ${urls.map(url => JSON.stringify(url.href)).join(", ")} )
+ `);
+
+ yield db.execute(
+ `UPDATE moz_places
+ SET hidden = 0
+ WHERE url IN ( ${urls.map(url => JSON.stringify(url.href)).join(", ")} )
+ AND frecency <> 0
+ `);
+ }),
+
+ /**
+ * Remove all descendants of one or more bookmark folders.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param folderGuids
+ * array of folder guids.
+ */
+ removeFoldersContents: Task.async(function* (db, folderGuids) {
+ let itemsRemoved = [];
+ for (let folderGuid of folderGuids) {
+ let rows = yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :folderGuid
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ )
+ SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index',
+ b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded,
+ b.lastModified, b.title, p.parent AS _grandParentId,
+ NULL AS _childCount
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON b.fk = h.id
+ WHERE b.id IN descendants`, { folderGuid });
+
+ itemsRemoved = itemsRemoved.concat(BookmarkUtils.rowsToItemsArray(rows));
+
+ yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :folderGuid
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ )
+ DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid });
+ }
+
+ // Cleanup orphans.
+ yield removeOrphanAnnotations(db);
+
+ // TODO (Bug 1087576): this may leave orphan tags behind.
+
+ let urls = itemsRemoved.filter(item => "url" in item).map(item => item.url);
+ BookmarkUtils.updateFrecency(db, urls).then(null, Cu.reportError);
+
+ // Send onItemRemoved notifications to listeners.
+ // TODO (Bug 1087580): for the case of eraseEverything, this should send a
+ // single clear bookmarks notification rather than notifying for each
+ // bookmark.
+
+ // Notify listeners in reverse order to serve children before parents.
+ let observers = PlacesUtils.bookmarks.getObservers();
+ for (let item of itemsRemoved.reverse()) {
+ let uri = item.hasOwnProperty("url") ? BookmarkUtils.toURI(item.url) : null;
+ BookmarkUtils.notify(observers, "onItemRemoved", [ item._id, item._parentId,
+ item.index, item.type, uri,
+ item.guid, item.parentGuid ]);
+
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ if (isUntagging) {
+ for (let entry of (yield BookmarkUtils.fetchBookmarksByURL(item))) {
+ BookmarkUtils.notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ BookmarkUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "" ]);
+ }
+ }
+ }
+ }),
+
+ /**
+ * Remove properties that have the same value across two bookmark objects.
+ *
+ * @param dest
+ * destination bookmark object.
+ * @param src
+ * source bookmark object.
+ * @return a cleaned up bookmark object.
+ * @note "guid" is never removed.
+ */
+ removeSameValueProperties(dest, src) {
+ for (let prop in dest) {
+ let remove = false;
+ switch (prop) {
+ case "lastModified":
+ case "dateAdded":
+ remove = src.hasOwnProperty(prop) && dest[prop].getTime() == src[prop].getTime();
+ break;
+ case "url":
+ remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href;
+ break;
+ default:
+ remove = dest[prop] == src[prop];
+ }
+ if (remove && prop != "guid")
+ delete dest[prop];
+ }
+ },
+};
+
+////////////////////////////////////////////////////////////////////////////////
+// Helpers.
+
+/**
+ * Merges objects into a new object, included non-enumerable properties.
+ *
+ * @param sources
+ * source objects to merge.
+ * @return a new object including all properties from the source objects.
+ */
+function mergeIntoNewObject(...sources) {
+ let dest = {};
+ for (let src of sources) {
+ for (let prop of Object.getOwnPropertyNames(src)) {
+ Object.defineProperty(dest, prop, Object.getOwnPropertyDescriptor(src, prop));
+ }
+ }
+ return dest;
+}
+
+/**
+ * Executes a boolean validate function, throwing if it returns false.
+ *
+ * @param boolValidateFn
+ * A boolean validate function.
+ * @return the input value.
+ * @throws if input doesn't pass the validate function.
+ */
+function simpleValidateFunc(boolValidateFn) {
+ return (v, input) => {
+ if (!boolValidateFn(v, input))
+ throw new Error("Invalid value");
+ return v;
+ };
+}
+
+/**
+ * List of validators, one per each known property.
+ * Validators must throw if the property value is invalid and return a fixed up
+ * version of the value, if needed.
+ */
+const VALIDATORS = Object.freeze({
+ guid: simpleValidateFunc(v => BookmarkValidators.isValidGuid(v)),
+ parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
+ /^[a-zA-Z0-9\-_]{12}$/.test(v)),
+ index: simpleValidateFunc(v => Number.isInteger(v) &&
+ v >= Bookmarks.DEFAULT_INDEX),
+ dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
+ lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
+ type: simpleValidateFunc(v => Number.isInteger(v) &&
+ [ Bookmarks.TYPE_BOOKMARK
+ , Bookmarks.TYPE_FOLDER
+ , Bookmarks.TYPE_SEPARATOR ].includes(v)),
+ title: v => {
+ simpleValidateFunc(val => val === null || typeof(val) == "string").call(this, v);
+ if (!v)
+ return null;
+ return v.slice(0, DB_TITLE_LENGTH_MAX);
+ },
+ url: v => {
+ simpleValidateFunc(val => (typeof(val) == "string" && val.length <= DB_URL_LENGTH_MAX) ||
+ (val instanceof Ci.nsIURI && val.spec.length <= DB_URL_LENGTH_MAX) ||
+ (val instanceof URL && val.href.length <= DB_URL_LENGTH_MAX)
+ ).call(this, v);
+ if (typeof(v) === "string")
+ return new URL(v);
+ if (v instanceof Ci.nsIURI)
+ return new URL(v.spec);
+ return v;
+ }
+});
+
+/**
+ * Checks validity of a bookmark object, filling up default values for optional
+ * properties.
+ *
+ * @param validators (object)
+ An object containing input validators. Keys should be field names;
+ values should be validation functions.
+ * @param input (object)
+ * The bookmark object to validate.
+ * @param behavior (object) [optional]
+ * Object defining special behavior for some of the properties.
+ * The following behaviors may be optionally set:
+ * - requiredIf: if the provided condition is satisfied, then this
+ * property is required.
+ * - validIf: if the provided condition is not satisfied, then this
+ * property is invalid.
+ * - defaultValue: an undefined property should default to this value.
+ *
+ * @return a validated and normalized bookmark-item.
+ * @throws if the object contains invalid data.
+ * @note any unknown properties are pass-through.
+ */
+function validateBookmarkProperties(validators, input, behavior={}) {
+ if (!input)
+ throw new Error("Input should be a valid object");
+ let normalizedInput = {};
+ let required = new Set();
+ for (let prop in behavior) {
+ if (behavior[prop].hasOwnProperty("required") && behavior[prop].required) {
+ required.add(prop);
+ }
+ if (behavior[prop].hasOwnProperty("requiredIf") && behavior[prop].requiredIf(input)) {
+ required.add(prop);
+ }
+ if (behavior[prop].hasOwnProperty("validIf") && input[prop] !== undefined &&
+ !behavior[prop].validIf(input)) {
+ throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
+ }
+ if (behavior[prop].hasOwnProperty("defaultValue") && input[prop] === undefined) {
+ input[prop] = behavior[prop].defaultValue;
+ }
+ }
+
+ for (let prop in input) {
+ if (required.has(prop)) {
+ required.delete(prop);
+ } else if (input[prop] === undefined) {
+ // Skip undefined properties that are not required.
+ continue;
+ }
+ if (validators.hasOwnProperty(prop)) {
+ try {
+ normalizedInput[prop] = validators[prop](input[prop], input);
+ } catch(ex) {
+ throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
+ }
+ }
+ }
+ if (required.size > 0)
+ throw new Error(`The following properties were expected: ${[...required].join(", ")}`);
+ return normalizedInput;
+}
+
+/**
+ * Removes any orphan annotation entries.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ */
+var removeOrphanAnnotations = Task.async(function* (db) {
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE id IN (SELECT a.id from moz_items_annos a
+ LEFT JOIN moz_bookmarks b ON a.item_id = b.id
+ WHERE b.id ISNULL)
+ `);
+ yield db.executeCached(
+ `DELETE FROM moz_anno_attributes
+ WHERE id IN (SELECT n.id from moz_anno_attributes n
+ LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
+ LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
+ WHERE a1.id ISNULL AND a2.id ISNULL)
+ `);
+});
+
+/**
+ * Removes annotations for a given item.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param itemId
+ * internal id of the item for which to remove annotations.
+ */
+var removeAnnotationsForItem = Task.async(function* (db, itemId) {
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE item_id = :id
+ `, { id: itemId });
+ yield db.executeCached(
+ `DELETE FROM moz_anno_attributes
+ WHERE id IN (SELECT n.id from moz_anno_attributes n
+ LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
+ LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
+ WHERE a1.id ISNULL AND a2.id ISNULL)
+ `);
+});
+
+/**
+ * Updates lastModified for all the ancestors of a given folder GUID.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param folderGuid
+ * the GUID of the folder whose ancestors should be updated.
+ * @param time
+ * a Date object to use for the update.
+ *
+ * @note the folder itself is also updated.
+ */
+var setAncestorsLastModified = Task.async(function* (db, folderGuid, time) {
+ yield db.executeCached(
+ `WITH RECURSIVE
+ ancestors(aid) AS (
+ SELECT id FROM moz_bookmarks WHERE guid = :guid
+ UNION ALL
+ SELECT parent FROM moz_bookmarks
+ JOIN ancestors ON id = aid
+ WHERE type = :type
+ )
+ UPDATE moz_bookmarks SET lastModified = :time
+ WHERE id IN ancestors
+ `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
+ time: BookmarkUtils.toPRTime(time) });
+});
--- a/toolkit/components/places/Bookmarks.jsm
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -60,36 +60,25 @@
this.EXPORTED_SYMBOLS = [ "Bookmarks" ];
const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
Cu.importGlobalProperties(["URL"]);
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "Services",
- "resource://gre/modules/Services.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
- "resource://gre/modules/NetUtil.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "Promise",
- "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkUtils",
+ "resource://gre/modules/BookmarkUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkValidators",
+ "resource://gre/modules/BookmarkUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
- "resource://gre/modules/Sqlite.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
-// Imposed to limit database size.
-const DB_URL_LENGTH_MAX = 65536;
-const DB_TITLE_LENGTH_MAX = 4096;
-
-const MATCH_ANYWHERE_UNMODIFIED = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED;
-const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
-
var Bookmarks = Object.freeze({
/**
* Item's type constants.
* These should stay consistent with nsINavBookmarksService.idl
*/
TYPE_BOOKMARK: 1,
TYPE_FOLDER: 2,
TYPE_SEPARATOR: 3,
@@ -137,17 +126,17 @@ var Bookmarks = Object.freeze({
* @resolves to an object representing the created bookmark.
* @rejects if it's not possible to create the requested bookmark.
* @throws if the arguments are invalid.
*/
insert(info) {
// Ensure to use the same date for dateAdded and lastModified, even if
// dateAdded may be imposed by the caller.
let time = (info && info.dateAdded) || new Date();
- let insertInfo = validateBookmarkObject(info,
+ let insertInfo = BookmarkValidators.validateBookmarkObject(info,
{ type: { defaultValue: this.TYPE_BOOKMARK }
, index: { defaultValue: this.DEFAULT_INDEX }
, url: { requiredIf: b => b.type == this.TYPE_BOOKMARK
, validIf: b => b.type == this.TYPE_BOOKMARK }
, parentGuid: { required: true }
, title: { validIf: b => [ this.TYPE_BOOKMARK
, this.TYPE_FOLDER ].includes(b.type) }
, dateAdded: { defaultValue: time
@@ -155,45 +144,45 @@ var Bookmarks = Object.freeze({
b.dateAdded <= b.lastModified }
, lastModified: { defaultValue: time,
validIf: b => (!b.dateAdded && b.lastModified >= time) ||
(b.dateAdded && b.lastModified >= b.dateAdded) }
});
return Task.spawn(function* () {
// Ensure the parent exists.
- let parent = yield fetchBookmark({ guid: insertInfo.parentGuid });
+ let parent = yield BookmarkUtils.fetchBookmark({ guid: insertInfo.parentGuid });
if (!parent)
throw new Error("parentGuid must be valid");
// Set index in the appending case.
if (insertInfo.index == this.DEFAULT_INDEX ||
insertInfo.index > parent._childCount) {
insertInfo.index = parent._childCount;
}
- let item = yield insertBookmark(insertInfo, parent);
+ let item = yield BookmarkUtils.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") ? toURI(item.url) : null;
+ let uri = item.hasOwnProperty("url") ? BookmarkUtils.toURI(item.url) : null;
let itemId = yield PlacesUtils.promiseItemId(item.guid);
- notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
+ BookmarkUtils.notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
item.type, uri, item.title || null,
- toPRTime(item.dateAdded), item.guid,
+ BookmarkUtils.toPRTime(item.dateAdded), item.guid,
item.parentGuid ]);
// 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, "",
- toPRTime(entry.lastModified),
+ for (let entry of (yield BookmarkUtils.fetchBookmarksByURL(item))) {
+ BookmarkUtils.notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ BookmarkUtils.toPRTime(entry.lastModified),
entry.type, entry._parentId,
entry.guid, entry.parentGuid,
"" ]);
}
}
// Remove non-enumerable properties.
return Object.assign({}, item);
@@ -218,47 +207,47 @@ var Bookmarks = Object.freeze({
* @resolves to an object representing the updated bookmark.
* @rejects if it's not possible to update the given bookmark.
* @throws if the arguments are invalid.
*/
update(info) {
// The info object is first validated here to ensure it's consistent, then
// it's compared to the existing item to remove any properties that don't
// need to be updated.
- let updateInfo = validateBookmarkObject(info,
+ let updateInfo = BookmarkValidators.validateBookmarkObject(info,
{ guid: { required: true }
, index: { requiredIf: b => b.hasOwnProperty("parentGuid")
, validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX }
});
// There should be at last one more property in addition to guid.
if (Object.keys(updateInfo).length < 2)
throw new Error("Not enough properties to update");
return Task.spawn(function* () {
// Ensure the item exists.
- let item = yield fetchBookmark(updateInfo);
+ let item = yield BookmarkUtils.fetchBookmark(updateInfo);
if (!item)
throw new Error("No bookmarks found for the provided GUID");
if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type)
throw new Error("The bookmark type cannot be changed");
if (updateInfo.hasOwnProperty("dateAdded") &&
updateInfo.dateAdded.getTime() != item.dateAdded.getTime())
throw new Error("The bookmark dateAdded cannot be changed");
// Remove any property that will stay the same.
- removeSameValueProperties(updateInfo, item);
+ BookmarkUtils.removeSameValueProperties(updateInfo, item);
// Check if anything should still be updated.
if (Object.keys(updateInfo).length < 2) {
// Remove non-enumerable properties.
return Object.assign({}, item);
}
let time = (updateInfo && updateInfo.dateAdded) || new Date();
- updateInfo = validateBookmarkObject(updateInfo,
+ updateInfo = BookmarkValidators.validateBookmarkObject(updateInfo,
{ url: { validIf: () => item.type == this.TYPE_BOOKMARK }
, title: { validIf: () => [ this.TYPE_BOOKMARK
, this.TYPE_FOLDER ].includes(item.type) }
, lastModified: { defaultValue: new Date()
, validIf: b => b.lastModified >= item.dateAdded }
});
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: update",
@@ -279,85 +268,85 @@ var Bookmarks = Object.freeze({
)
SELECT guid FROM moz_bookmarks
WHERE id IN descendants
`, { id: item._id, type: this.TYPE_FOLDER });
if (rows.map(r => r.getResultByName("guid")).includes(updateInfo.parentGuid))
throw new Error("Cannot insert a folder into itself or one of its descendants");
}
- parent = yield fetchBookmark({ guid: updateInfo.parentGuid });
+ parent = yield BookmarkUtils.fetchBookmark({ guid: updateInfo.parentGuid });
if (!parent)
throw new Error("No bookmarks found for the provided parentGuid");
}
if (updateInfo.hasOwnProperty("index")) {
// If at this point we don't have a parent yet, we are moving into
// the same container. Thus we know it exists.
if (!parent)
- parent = yield fetchBookmark({ guid: item.parentGuid });
+ parent = yield BookmarkUtils.fetchBookmark({ guid: item.parentGuid });
if (updateInfo.index >= parent._childCount ||
updateInfo.index == this.DEFAULT_INDEX) {
updateInfo.index = parent._childCount;
// Fix the index when moving within the same container.
if (parent.guid == item.parentGuid)
updateInfo.index--;
}
}
- let updatedItem = yield updateBookmark(updateInfo, item, parent);
+ let updatedItem = yield BookmarkUtils.updateBookmark(updateInfo, item, parent);
if (item.type == this.TYPE_BOOKMARK &&
item.url.href != updatedItem.url.href) {
// ...though we don't wait for the calculation.
- updateFrecency(db, [item.url]).then(null, Cu.reportError);
- updateFrecency(db, [updatedItem.url]).then(null, Cu.reportError);
+ BookmarkUtils.updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ BookmarkUtils.updateFrecency(db, [updatedItem.url]).then(null, Cu.reportError);
}
// Notify onItemChanged to listeners.
let observers = PlacesUtils.bookmarks.getObservers();
// For lastModified, we only care about the original input, since we
// should not notify implciit lastModified changes.
if (info.hasOwnProperty("lastModified") &&
updateInfo.hasOwnProperty("lastModified") &&
item.lastModified != updatedItem.lastModified) {
- notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
+ BookmarkUtils.notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
false,
- `${toPRTime(updatedItem.lastModified)}`,
- toPRTime(updatedItem.lastModified),
+ `${BookmarkUtils.toPRTime(updatedItem.lastModified)}`,
+ BookmarkUtils.toPRTime(updatedItem.lastModified),
updatedItem.type,
updatedItem._parentId,
updatedItem.guid,
updatedItem.parentGuid, "" ]);
}
if (updateInfo.hasOwnProperty("title")) {
- notify(observers, "onItemChanged", [ updatedItem._id, "title",
+ BookmarkUtils.notify(observers, "onItemChanged", [ updatedItem._id, "title",
false, updatedItem.title,
- toPRTime(updatedItem.lastModified),
+ BookmarkUtils.toPRTime(updatedItem.lastModified),
updatedItem.type,
updatedItem._parentId,
updatedItem.guid,
updatedItem.parentGuid, "" ]);
}
if (updateInfo.hasOwnProperty("url")) {
- notify(observers, "onItemChanged", [ updatedItem._id, "uri",
+ BookmarkUtils.notify(observers, "onItemChanged", [ updatedItem._id, "uri",
false, updatedItem.url.href,
- toPRTime(updatedItem.lastModified),
+ BookmarkUtils.toPRTime(updatedItem.lastModified),
updatedItem.type,
updatedItem._parentId,
updatedItem.guid,
updatedItem.parentGuid,
item.url.href ]);
}
// If the item was moved, notify onItemMoved.
if (item.parentGuid != updatedItem.parentGuid ||
item.index != updatedItem.index) {
- notify(observers, "onItemMoved", [ updatedItem._id, item._parentId,
+ BookmarkUtils.notify(observers, "onItemMoved", [ updatedItem._id, item._parentId,
item.index, updatedItem._parentId,
updatedItem.index, updatedItem.type,
updatedItem.guid, item.parentGuid,
updatedItem.parentGuid ]);
}
// Remove non-enumerable properties.
return Object.assign({}, updatedItem);
@@ -392,37 +381,37 @@ var Bookmarks = Object.freeze({
// Disallow removing the root folders.
if ([this.rootGuid, this.menuGuid, this.toolbarGuid, this.unfiledGuid,
this.tagsGuid].includes(info.guid)) {
throw new Error("It's not possible to remove Places root folders.");
}
// Even if we ignore any other unneeded property, we still validate any
// known property to reduce likelihood of hidden bugs.
- let removeInfo = validateBookmarkObject(info);
+ let removeInfo = BookmarkValidators.validateBookmarkObject(info);
return Task.spawn(function* () {
- let item = yield fetchBookmark(removeInfo);
+ let item = yield BookmarkUtils.fetchBookmark(removeInfo);
if (!item)
throw new Error("No bookmarks found for the provided GUID.");
- item = yield removeBookmark(item, options);
+ item = yield BookmarkUtils.removeBookmark(item, options);
// Notify onItemRemoved to listeners.
let observers = PlacesUtils.bookmarks.getObservers();
- let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
- notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
+ let uri = item.hasOwnProperty("url") ? BookmarkUtils.toURI(item.url) : null;
+ BookmarkUtils.notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
item.type, uri, item.guid,
item.parentGuid ]);
let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
if (isUntagging) {
- for (let entry of (yield fetchBookmarksByURL(item))) {
- notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
- toPRTime(entry.lastModified),
+ for (let entry of (yield BookmarkUtils.fetchBookmarksByURL(item))) {
+ BookmarkUtils.notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ BookmarkUtils.toPRTime(entry.lastModified),
entry.type, entry._parentId,
entry.guid, entry.parentGuid,
"" ]);
}
}
// Remove non-enumerable properties.
return Object.assign({}, item);
@@ -436,18 +425,18 @@ var Bookmarks = Object.freeze({
*
* @return {Promise} resolved when the removal is complete.
* @resolves once the removal is complete.
*/
eraseEverything: function() {
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
db => db.executeTransaction(function* () {
const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid];
- yield removeFoldersContents(db, folderGuids);
- const time = toPRTime(new Date());
+ yield BookmarkUtils.removeFoldersContents(db, folderGuids);
+ const time = BookmarkUtils.toPRTime(new Date());
for (let folderGuid of folderGuids) {
yield db.executeCached(
`UPDATE moz_bookmarks SET lastModified = :time
WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
`, { folderGuid, time });
}
}.bind(this))
);
@@ -493,17 +482,17 @@ var Bookmarks = Object.freeze({
} else if (query.url instanceof Ci.nsIURI) {
query.url = query.url.spec;
} else {
throw new Error("Url option must be a string or a URL object");
}
}
return Task.spawn(function* () {
- let results = yield queryBookmarks(query);
+ let results = yield BookmarkUtils.queryBookmarks(query);
return results;
});
},
/**
* Returns a list of recently bookmarked items.
*
@@ -521,17 +510,17 @@ var Bookmarks = Object.freeze({
if (!typeof numberOfItems === 'number' || (numberOfItems % 1) !== 0) {
throw new Error("numberOfItems argument must be an integer");
}
if (numberOfItems <= 0) {
throw new Error("numberOfItems argument must be greater than zero");
}
return Task.spawn(function* () {
- return yield fetchRecentBookmarks(numberOfItems);
+ return yield BookmarkUtils.fetchRecentBookmarks(numberOfItems);
});
},
/**
* Fetches information about a bookmark-item.
*
* REMARK: any successful call to this method resolves to a single
* bookmark-item (or null), even when multiple bookmarks may exist
@@ -580,31 +569,31 @@ var Bookmarks = Object.freeze({
v => v.hasOwnProperty("parentGuid") && v.hasOwnProperty("index"),
v => v.hasOwnProperty("url")
].reduce((old, fn) => old + fn(info)|0, 0);
if (conditionsCount != 1)
throw new Error(`Unexpected number of conditions provided: ${conditionsCount}`);
// Even if we ignore any other unneeded property, we still validate any
// known property to reduce likelihood of hidden bugs.
- let fetchInfo = validateBookmarkObject(info,
+ let fetchInfo = BookmarkValidators.validateBookmarkObject(info,
{ parentGuid: { requiredIf: b => b.hasOwnProperty("index") }
, index: { requiredIf: b => b.hasOwnProperty("parentGuid")
, validIf: b => typeof(b.index) == "number" &&
b.index >= 0 || b.index == this.DEFAULT_INDEX }
});
return Task.spawn(function* () {
let results;
if (fetchInfo.hasOwnProperty("guid"))
- results = yield fetchBookmark(fetchInfo);
+ results = yield BookmarkUtils.fetchBookmark(fetchInfo);
else if (fetchInfo.hasOwnProperty("parentGuid") && fetchInfo.hasOwnProperty("index"))
- results = yield fetchBookmarkByPosition(fetchInfo);
+ results = yield BookmarkUtils.fetchBookmarkByPosition(fetchInfo);
else if (fetchInfo.hasOwnProperty("url"))
- results = yield fetchBookmarksByURL(fetchInfo);
+ results = yield BookmarkUtils.fetchBookmarksByURL(fetchInfo);
if (!results)
return null;
if (!Array.isArray(results))
results = [results];
// Remove non-enumerable properties.
results = results.map(r => Object.assign({}, r));
@@ -703,897 +692,35 @@ var Bookmarks = Object.freeze({
* incomplete, missing entries will be appended.
*
* @return {Promise} resolved when reordering is complete.
* @rejects if an error happens while reordering.
* @throws if the arguments are invalid.
*/
reorder(parentGuid, orderedChildrenGuids) {
let info = { guid: parentGuid };
- info = validateBookmarkObject(info, { guid: { required: true } });
+ info = BookmarkValidators.validateBookmarkObject(info, { guid: { required: true } });
if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length)
throw new Error("Must provide a sorted array of children GUIDs.");
- try {
- orderedChildrenGuids.forEach(VALIDATORS.guid);
- } catch (ex) {
+ if (!orderedChildrenGuids.every(BookmarkValidators.isValidGuid))
throw new Error("Invalid GUID found in the sorted children array.");
- }
return Task.spawn(function* () {
- let parent = yield fetchBookmark(info);
+ let parent = yield BookmarkUtils.fetchBookmark(info);
if (!parent || parent.type != this.TYPE_FOLDER)
throw new Error("No folder found for the provided GUID.");
- let sortedChildren = yield reorderChildren(parent, orderedChildrenGuids);
+ let sortedChildren = yield BookmarkUtils.reorderChildren(parent, orderedChildrenGuids);
let observers = PlacesUtils.bookmarks.getObservers();
// Note that child.index is the old index.
for (let i = 0; i < sortedChildren.length; ++i) {
let child = sortedChildren[i];
- notify(observers, "onItemMoved", [ child._id, child._parentId,
+ BookmarkUtils.notify(observers, "onItemMoved", [ child._id, child._parentId,
child.index, child._parentId,
i, child.type,
child.guid, child.parentGuid,
child.parentGuid ]);
}
}.bind(this));
}
});
-
-////////////////////////////////////////////////////////////////////////////////
-// Globals.
-
-/**
- * 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.
- */
-function notify(observers, notification, args) {
- for (let observer of observers) {
- try {
- observer[notification](...args);
- } catch (ex) {}
- }
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Update implementation.
-
-function updateBookmark(info, item, newParent) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
- Task.async(function*(db) {
-
- let tuples = new Map();
- if (info.hasOwnProperty("lastModified"))
- tuples.set("lastModified", { value: toPRTime(info.lastModified) });
- if (info.hasOwnProperty("title"))
- tuples.set("title", { value: info.title });
-
- yield db.executeTransaction(function* () {
- if (info.hasOwnProperty("url")) {
- // Ensure a page exists in moz_places for this URL.
- yield db.executeCached(
- `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
- VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
- `, { url: info.url ? info.url.href : null,
- rev_host: PlacesUtils.getReversedHost(info.url),
- frecency: info.url.protocol == "place:" ? 0 : -1 });
- tuples.set("url", { value: info.url.href
- , fragment: "fk = (SELECT id FROM moz_places WHERE url = :url)" });
- }
-
- if (newParent) {
- // For simplicity, update the index regardless.
- let newIndex = info.hasOwnProperty("index") ? info.index : item.index;
- tuples.set("position", { value: newIndex });
-
- if (newParent.guid == item.parentGuid) {
- // Moving inside the original container.
- // When moving "up", add 1 to each index in the interval.
- // Otherwise when moving down, we subtract 1.
- let sign = newIndex < item.index ? +1 : -1;
- yield db.executeCached(
- `UPDATE moz_bookmarks SET position = position + :sign
- WHERE parent = :newParentId
- AND position BETWEEN :lowIndex AND :highIndex
- `, { sign: sign, newParentId: newParent._id,
- lowIndex: Math.min(item.index, newIndex),
- highIndex: Math.max(item.index, newIndex) });
- } else {
- // Moving across different containers.
- tuples.set("parent", { value: newParent._id} );
- yield db.executeCached(
- `UPDATE moz_bookmarks SET position = position + :sign
- WHERE parent = :oldParentId
- AND position >= :oldIndex
- `, { sign: -1, oldParentId: item._parentId, oldIndex: item.index });
- yield db.executeCached(
- `UPDATE moz_bookmarks SET position = position + :sign
- WHERE parent = :newParentId
- AND position >= :newIndex
- `, { sign: +1, newParentId: newParent._id, newIndex: newIndex });
-
- yield setAncestorsLastModified(db, item.parentGuid, info.lastModified);
- }
- yield setAncestorsLastModified(db, newParent.guid, info.lastModified);
- }
-
- yield db.executeCached(
- `UPDATE moz_bookmarks
- SET ${Array.from(tuples.keys()).map(v => tuples.get(v).fragment || `${v} = :${v}`).join(", ")}
- WHERE guid = :guid
- `, Object.assign({ guid: info.guid },
- [...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {})));
- });
-
- // If the parent changed, update related non-enumerable properties.
- let additionalParentInfo = {};
- if (newParent) {
- Object.defineProperty(additionalParentInfo, "_parentId",
- { value: newParent._id, enumerable: false });
- Object.defineProperty(additionalParentInfo, "_grandParentId",
- { value: newParent._parentId, enumerable: false });
- }
-
- let updatedItem = mergeIntoNewObject(item, info, additionalParentInfo);
-
- // Don't return an empty title to the caller.
- if (updatedItem.hasOwnProperty("title") && updatedItem.title === null)
- delete updatedItem.title;
-
- return updatedItem;
- }));
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Insert implementation.
-
-function insertBookmark(item, parent) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark",
- Task.async(function*(db) {
-
- // If a guid was not provided, generate one, so we won't need to fetch the
- // bookmark just after having created it.
- if (!item.hasOwnProperty("guid"))
- item.guid = (yield db.executeCached("SELECT GENERATE_GUID() AS guid"))[0].getResultByName("guid");
-
- yield db.executeTransaction(function* transaction() {
- if (item.type == Bookmarks.TYPE_BOOKMARK) {
- // Ensure a page exists in moz_places for this URL.
- yield db.executeCached(
- `INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
- VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
- `, { url: item.url.href, rev_host: PlacesUtils.getReversedHost(item.url),
- frecency: item.url.protocol == "place:" ? 0 : -1 });
- }
-
- // Adjust indices.
- yield db.executeCached(
- `UPDATE moz_bookmarks SET position = position + 1
- WHERE parent = :parent
- AND position >= :index
- `, { parent: parent._id, index: item.index });
-
- // Insert the bookmark into the database.
- yield db.executeCached(
- `INSERT INTO moz_bookmarks (fk, type, parent, position, title,
- dateAdded, lastModified, guid)
- VALUES ((SELECT id FROM moz_places WHERE url = :url), :type, :parent,
- :index, :title, :date_added, :last_modified, :guid)
- `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
- type: item.type, parent: parent._id, index: item.index,
- title: item.title, date_added: toPRTime(item.dateAdded),
- last_modified: toPRTime(item.lastModified), guid: item.guid });
-
- yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
- });
-
- // If not a tag recalculate frecency...
- let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
- if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
- // ...though we don't wait for the calculation.
- updateFrecency(db, [item.url]).then(null, Cu.reportError);
- }
-
- // Don't return an empty title to the caller.
- if (item.hasOwnProperty("title") && item.title === null)
- delete item.title;
-
- return item;
- }));
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Query implementation.
-
-function queryBookmarks(info) {
- let queryParams = {tags_folder: PlacesUtils.tagsFolderId};
- // we're searching for bookmarks, so exclude tags
- let queryString = "WHERE p.parent <> :tags_folder";
-
- if (info.title) {
- queryString += " AND b.title = :title";
- queryParams.title = info.title;
- }
-
- if (info.url) {
- queryString += " AND h.url = :url";
- queryParams.url = info.url;
- }
-
- if (info.query) {
- queryString += " AND AUTOCOMPLETE_MATCH(:query, h.url, b.title, NULL, NULL, 1, 1, NULL, :matchBehavior, :searchBehavior) ";
- queryParams.query = info.query;
- queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED;
- queryParams.searchBehavior = BEHAVIOR_BOOKMARK;
- }
-
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: queryBookmarks",
- Task.async(function*(db) {
-
- // _id, _childCount, _grandParentId and _parentId fields
- // are required to be in the result by the converting function
- // hence setting them to NULL
- let rows = yield db.executeCached(
- `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
- b.dateAdded, b.lastModified, b.type, b.title,
- h.url AS url, b.parent, p.parent,
- NULL AS _id,
- NULL AS _childCount,
- NULL AS _grandParentId,
- NULL AS _parentId
- FROM moz_bookmarks b
- LEFT JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON h.id = b.fk
- ${queryString}
- `, queryParams);
-
- return rowsToItemsArray(rows);
- }));
-}
-
-
-////////////////////////////////////////////////////////////////////////////////
-// Fetch implementation.
-
-function fetchBookmark(info) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark",
- Task.async(function*(db) {
-
- let rows = yield db.executeCached(
- `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
- b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
- b.id AS _id, b.parent AS _parentId,
- (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
- FROM moz_bookmarks b
- LEFT JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON h.id = b.fk
- WHERE b.guid = :guid
- `, { guid: info.guid });
-
- return rows.length ? rowsToItemsArray(rows)[0] : null;
- }));
-}
-
-function fetchBookmarkByPosition(info) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarkByPosition",
- Task.async(function*(db) {
- let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
-
- let rows = yield db.executeCached(
- `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
- b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
- b.id AS _id, b.parent AS _parentId,
- (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
- FROM moz_bookmarks b
- LEFT JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON h.id = b.fk
- WHERE p.guid = :parentGuid
- AND b.position = IFNULL(:index, (SELECT count(*) - 1
- FROM moz_bookmarks
- WHERE parent = p.id))
- `, { parentGuid: info.parentGuid, index });
-
- return rows.length ? rowsToItemsArray(rows)[0] : null;
- }));
-}
-
-function fetchBookmarksByURL(info) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByURL",
- Task.async(function*(db) {
-
- let rows = yield db.executeCached(
- `/* do not warn (bug no): not worth to add an index */
- SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
- b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
- b.id AS _id, b.parent AS _parentId,
- (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
- FROM moz_bookmarks b
- LEFT JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON h.id = b.fk
- WHERE h.url = :url
- AND _grandParentId <> :tags_folder
- ORDER BY b.lastModified DESC
- `, { url: info.url.href,
- tags_folder: PlacesUtils.tagsFolderId });
-
- return rows.length ? rowsToItemsArray(rows) : null;
- }));
-}
-
-function fetchRecentBookmarks(numberOfItems) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchRecentBookmarks",
- Task.async(function*(db) {
-
- let rows = yield db.executeCached(
- `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
- b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
- NULL AS _id, NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId
- FROM moz_bookmarks b
- LEFT JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON h.id = b.fk
- WHERE p.parent <> :tags_folder
- ORDER BY b.dateAdded DESC, b.ROWID DESC
- LIMIT :numberOfItems
- `, { tags_folder: PlacesUtils.tagsFolderId, numberOfItems });
-
- return rows.length ? rowsToItemsArray(rows) : [];
- }));
-}
-
-function fetchBookmarksByParent(info) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByParent",
- Task.async(function*(db) {
-
- let rows = yield db.executeCached(
- `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
- b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
- b.id AS _id, b.parent AS _parentId,
- (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
- p.parent AS _grandParentId
- FROM moz_bookmarks b
- LEFT JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON h.id = b.fk
- WHERE p.guid = :parentGuid
- ORDER BY b.position ASC
- `, { parentGuid: info.parentGuid });
-
- return rowsToItemsArray(rows);
- }));
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Remove implementation.
-
-function removeBookmark(item, options) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
- Task.async(function*(db) {
-
- let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
-
- yield db.executeTransaction(function* transaction() {
- // If it's a folder, remove its contents first.
- if (item.type == Bookmarks.TYPE_FOLDER) {
- if (options.preventRemovalOfNonEmptyFolders && item._childCount > 0) {
- throw new Error("Cannot remove a non-empty folder.");
- }
- yield removeFoldersContents(db, [item.guid]);
- }
-
- // Remove annotations first. If it's a tag, we can avoid paying that cost.
- if (!isUntagging) {
- // We don't go through the annotations service for this cause otherwise
- // we'd get a pointless onItemChanged notification and it would also
- // set lastModified to an unexpected value.
- yield removeAnnotationsForItem(db, item._id);
- }
-
- // Remove the bookmark from the database.
- yield db.executeCached(
- `DELETE FROM moz_bookmarks WHERE guid = :guid`, { guid: item.guid });
-
- // Fix indices in the parent.
- yield db.executeCached(
- `UPDATE moz_bookmarks SET position = position - 1 WHERE
- parent = :parentId AND position > :index
- `, { parentId: item._parentId, index: item.index });
-
- yield setAncestorsLastModified(db, item.parentGuid, new Date());
- });
-
- // If not a tag recalculate frecency...
- if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
- // ...though we don't wait for the calculation.
- updateFrecency(db, [item.url]).then(null, Cu.reportError);
- }
-
- return item;
- }));
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Reorder implementation.
-
-function reorderChildren(parent, orderedChildrenGuids) {
- return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
- db => db.executeTransaction(function* () {
- // Select all of the direct children for the given parent.
- let children = yield fetchBookmarksByParent({ parentGuid: parent.guid });
- if (!children.length)
- return undefined;
-
- // Reorder the children array according to the specified order, provided
- // GUIDs come first, others are appended in somehow random order.
- children.sort((a, b) => {
- let i = orderedChildrenGuids.indexOf(a.guid);
- let j = orderedChildrenGuids.indexOf(b.guid);
- // This works provided fetchBookmarksByParent returns sorted children.
- if (i == -1 && j == -1)
- return 0;
- return (i != -1 && j != -1 && i < j) || (i != -1 && j == -1) ? -1 : 1;
- });
-
- // Update the bookmarks position now. If any unknown guid have been
- // inserted meanwhile, its position will be set to -position, and we'll
- // handle it later.
- // To do the update in a single step, we build a VALUES (guid, position)
- // table. We then use count() in the sorting table to avoid skipping values
- // when no more existing GUIDs have been provided.
- let valuesTable = children.map((child, i) => `("${child.guid}", ${i})`)
- .join();
- yield db.execute(
- `WITH sorting(g, p) AS (
- VALUES ${valuesTable}
- )
- UPDATE moz_bookmarks SET position = (
- SELECT CASE count(a.g) WHEN 0 THEN -position
- ELSE count(a.g) - 1
- END
- FROM sorting a
- JOIN sorting b ON b.p <= a.p
- WHERE a.g = guid
- AND parent = :parentId
- )`, { parentId: parent._id});
-
- // Update position of items that could have been inserted in the meanwhile.
- // Since this can happen rarely and it's only done for schema coherence
- // resonds, we won't notify about these changes.
- yield db.executeCached(
- `CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger
- AFTER UPDATE OF position ON moz_bookmarks
- WHEN NEW.position = -1
- BEGIN
- UPDATE moz_bookmarks
- SET position = (SELECT MAX(position) FROM moz_bookmarks
- WHERE parent = NEW.parent) +
- (SELECT count(*) FROM moz_bookmarks
- WHERE parent = NEW.parent
- AND position BETWEEN OLD.position AND -1)
- WHERE guid = NEW.guid;
- END
- `);
-
- yield db.executeCached(
- `UPDATE moz_bookmarks SET position = -1 WHERE position < 0`);
-
- yield db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`);
-
- return children;
- }.bind(this))
- );
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// Helpers.
-
-/**
- * Merges objects into a new object, included non-enumerable properties.
- *
- * @param sources
- * source objects to merge.
- * @return a new object including all properties from the source objects.
- */
-function mergeIntoNewObject(...sources) {
- let dest = {};
- for (let src of sources) {
- for (let prop of Object.getOwnPropertyNames(src)) {
- Object.defineProperty(dest, prop, Object.getOwnPropertyDescriptor(src, prop));
- }
- }
- return dest;
-}
-
-/**
- * Remove properties that have the same value across two bookmark objects.
- *
- * @param dest
- * destination bookmark object.
- * @param src
- * source bookmark object.
- * @return a cleaned up bookmark object.
- * @note "guid" is never removed.
- */
-function removeSameValueProperties(dest, src) {
- for (let prop in dest) {
- let remove = false;
- switch (prop) {
- case "lastModified":
- case "dateAdded":
- remove = src.hasOwnProperty(prop) && dest[prop].getTime() == src[prop].getTime();
- break;
- case "url":
- remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href;
- break;
- default:
- remove = dest[prop] == src[prop];
- }
- if (remove && prop != "guid")
- delete dest[prop];
- }
-}
-
-/**
- * Converts an URL object to an nsIURI.
- *
- * @param url
- * the URL object to convert.
- * @return nsIURI for the given URL.
- */
-function toURI(url) {
- return NetUtil.newURI(url.href);
-}
-
-/**
- * Convert a Date object to a PRTime (microseconds).
- *
- * @param date
- * the Date object to convert.
- * @return microseconds from the epoch.
- */
-function toPRTime(date) {
- return date * 1000;
-}
-
-/**
- * Convert a PRTime to a Date object.
- *
- * @param time
- * microseconds from the epoch.
- * @return a Date object.
- */
-function toDate(time) {
- return new Date(parseInt(time / 1000));
-}
-
-/**
- * Convert an array of mozIStorageRow objects to an array of bookmark objects.
- *
- * @param rows
- * the array of mozIStorageRow objects.
- * @return an array of bookmark objects.
- */
-function rowsToItemsArray(rows) {
- return rows.map(row => {
- let item = {};
- for (let prop of ["guid", "index", "type"]) {
- item[prop] = row.getResultByName(prop);
- }
- for (let prop of ["dateAdded", "lastModified"]) {
- item[prop] = toDate(row.getResultByName(prop));
- }
- for (let prop of ["title", "parentGuid", "url" ]) {
- let val = row.getResultByName(prop);
- if (val)
- item[prop] = prop === "url" ? new URL(val) : val;
- }
- for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
- let val = row.getResultByName(prop);
- if (val !== null) {
- // These properties should not be returned to the API consumer, thus
- // they are non-enumerable and removed through Object.assign just before
- // the object is returned.
- // Configurable is set to support mergeIntoNewObject overwrites.
- Object.defineProperty(item, prop, { value: val, enumerable: false,
- configurable: true });
- }
- }
-
- return item;
- });
-}
-
-/**
- * Executes a boolean validate function, throwing if it returns false.
- *
- * @param boolValidateFn
- * A boolean validate function.
- * @return the input value.
- * @throws if input doesn't pass the validate function.
- */
-function simpleValidateFunc(boolValidateFn) {
- return (v, input) => {
- if (!boolValidateFn(v, input))
- throw new Error("Invalid value");
- return v;
- };
-}
-
-/**
- * List of validators, one per each known property.
- * Validators must throw if the property value is invalid and return a fixed up
- * version of the value, if needed.
- */
-const VALIDATORS = Object.freeze({
- guid: simpleValidateFunc(v => typeof(v) == "string" &&
- /^[a-zA-Z0-9\-_]{12}$/.test(v)),
- parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
- /^[a-zA-Z0-9\-_]{12}$/.test(v)),
- index: simpleValidateFunc(v => Number.isInteger(v) &&
- v >= Bookmarks.DEFAULT_INDEX),
- dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
- lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
- type: simpleValidateFunc(v => Number.isInteger(v) &&
- [ Bookmarks.TYPE_BOOKMARK
- , Bookmarks.TYPE_FOLDER
- , Bookmarks.TYPE_SEPARATOR ].includes(v)),
- title: v => {
- simpleValidateFunc(val => val === null || typeof(val) == "string").call(this, v);
- if (!v)
- return null;
- return v.slice(0, DB_TITLE_LENGTH_MAX);
- },
- url: v => {
- simpleValidateFunc(val => (typeof(val) == "string" && val.length <= DB_URL_LENGTH_MAX) ||
- (val instanceof Ci.nsIURI && val.spec.length <= DB_URL_LENGTH_MAX) ||
- (val instanceof URL && val.href.length <= DB_URL_LENGTH_MAX)
- ).call(this, v);
- if (typeof(v) === "string")
- return new URL(v);
- if (v instanceof Ci.nsIURI)
- return new URL(v.spec);
- return v;
- }
-});
-
-/**
- * Checks validity of a bookmark object, filling up default values for optional
- * properties.
- *
- * @param input (object)
- * The bookmark object to validate.
- * @param behavior (object) [optional]
- * Object defining special behavior for some of the properties.
- * The following behaviors may be optionally set:
- * - requiredIf: if the provided condition is satisfied, then this
- * property is required.
- * - validIf: if the provided condition is not satisfied, then this
- * property is invalid.
- * - defaultValue: an undefined property should default to this value.
- *
- * @return a validated and normalized bookmark-item.
- * @throws if the object contains invalid data.
- * @note any unknown properties are pass-through.
- */
-function validateBookmarkObject(input, behavior={}) {
- if (!input)
- throw new Error("Input should be a valid object");
- let normalizedInput = {};
- let required = new Set();
- for (let prop in behavior) {
- if (behavior[prop].hasOwnProperty("required") && behavior[prop].required) {
- required.add(prop);
- }
- if (behavior[prop].hasOwnProperty("requiredIf") && behavior[prop].requiredIf(input)) {
- required.add(prop);
- }
- if (behavior[prop].hasOwnProperty("validIf") && input[prop] !== undefined &&
- !behavior[prop].validIf(input)) {
- throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
- }
- if (behavior[prop].hasOwnProperty("defaultValue") && input[prop] === undefined) {
- input[prop] = behavior[prop].defaultValue;
- }
- }
-
- for (let prop in input) {
- if (required.has(prop)) {
- required.delete(prop);
- } else if (input[prop] === undefined) {
- // Skip undefined properties that are not required.
- continue;
- }
- if (VALIDATORS.hasOwnProperty(prop)) {
- try {
- normalizedInput[prop] = VALIDATORS[prop](input[prop], input);
- } catch(ex) {
- throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
- }
- }
- }
- if (required.size > 0)
- throw new Error(`The following properties were expected: ${[...required].join(", ")}`);
- return normalizedInput;
-}
-
-/**
- * Updates frecency for a list of URLs.
- *
- * @param db
- * the Sqlite.jsm connection handle.
- * @param urls
- * the array of URLs to update.
- */
-var updateFrecency = Task.async(function* (db, urls) {
- yield db.execute(
- `UPDATE moz_places
- SET frecency = NOTIFY_FRECENCY(
- CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
- ) WHERE url IN ( ${urls.map(url => JSON.stringify(url.href)).join(", ")} )
- `);
-
- yield db.execute(
- `UPDATE moz_places
- SET hidden = 0
- WHERE url IN ( ${urls.map(url => JSON.stringify(url.href)).join(", ")} )
- AND frecency <> 0
- `);
-});
-
-/**
- * Removes any orphan annotation entries.
- *
- * @param db
- * the Sqlite.jsm connection handle.
- */
-var removeOrphanAnnotations = Task.async(function* (db) {
- yield db.executeCached(
- `DELETE FROM moz_items_annos
- WHERE id IN (SELECT a.id from moz_items_annos a
- LEFT JOIN moz_bookmarks b ON a.item_id = b.id
- WHERE b.id ISNULL)
- `);
- yield db.executeCached(
- `DELETE FROM moz_anno_attributes
- WHERE id IN (SELECT n.id from moz_anno_attributes n
- LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
- LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
- WHERE a1.id ISNULL AND a2.id ISNULL)
- `);
-});
-
-/**
- * Removes annotations for a given item.
- *
- * @param db
- * the Sqlite.jsm connection handle.
- * @param itemId
- * internal id of the item for which to remove annotations.
- */
-var removeAnnotationsForItem = Task.async(function* (db, itemId) {
- yield db.executeCached(
- `DELETE FROM moz_items_annos
- WHERE item_id = :id
- `, { id: itemId });
- yield db.executeCached(
- `DELETE FROM moz_anno_attributes
- WHERE id IN (SELECT n.id from moz_anno_attributes n
- LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
- LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
- WHERE a1.id ISNULL AND a2.id ISNULL)
- `);
-});
-
-/**
- * Updates lastModified for all the ancestors of a given folder GUID.
- *
- * @param db
- * the Sqlite.jsm connection handle.
- * @param folderGuid
- * the GUID of the folder whose ancestors should be updated.
- * @param time
- * a Date object to use for the update.
- *
- * @note the folder itself is also updated.
- */
-var setAncestorsLastModified = Task.async(function* (db, folderGuid, time) {
- yield db.executeCached(
- `WITH RECURSIVE
- ancestors(aid) AS (
- SELECT id FROM moz_bookmarks WHERE guid = :guid
- UNION ALL
- SELECT parent FROM moz_bookmarks
- JOIN ancestors ON id = aid
- WHERE type = :type
- )
- UPDATE moz_bookmarks SET lastModified = :time
- WHERE id IN ancestors
- `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
- time: toPRTime(time) });
-});
-
-/**
- * Remove all descendants of one or more bookmark folders.
- *
- * @param db
- * the Sqlite.jsm connection handle.
- * @param folderGuids
- * array of folder guids.
- */
-var removeFoldersContents =
-Task.async(function* (db, folderGuids) {
- let itemsRemoved = [];
- for (let folderGuid of folderGuids) {
- let rows = yield db.executeCached(
- `WITH RECURSIVE
- descendants(did) AS (
- SELECT b.id FROM moz_bookmarks b
- JOIN moz_bookmarks p ON b.parent = p.id
- WHERE p.guid = :folderGuid
- UNION ALL
- SELECT id FROM moz_bookmarks
- JOIN descendants ON parent = did
- )
- SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index',
- b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded,
- b.lastModified, b.title, p.parent AS _grandParentId,
- NULL AS _childCount
- FROM moz_bookmarks b
- JOIN moz_bookmarks p ON p.id = b.parent
- LEFT JOIN moz_places h ON b.fk = h.id
- WHERE b.id IN descendants`, { folderGuid });
-
- itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows));
-
- yield db.executeCached(
- `WITH RECURSIVE
- descendants(did) AS (
- SELECT b.id FROM moz_bookmarks b
- JOIN moz_bookmarks p ON b.parent = p.id
- WHERE p.guid = :folderGuid
- UNION ALL
- SELECT id FROM moz_bookmarks
- JOIN descendants ON parent = did
- )
- DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid });
- }
-
- // Cleanup orphans.
- yield removeOrphanAnnotations(db);
-
- // TODO (Bug 1087576): this may leave orphan tags behind.
-
- let urls = itemsRemoved.filter(item => "url" in item).map(item => item.url);
- updateFrecency(db, urls).then(null, Cu.reportError);
-
- // Send onItemRemoved notifications to listeners.
- // TODO (Bug 1087580): for the case of eraseEverything, this should send a
- // single clear bookmarks notification rather than notifying for each
- // bookmark.
-
- // Notify listeners in reverse order to serve children before parents.
- let observers = PlacesUtils.bookmarks.getObservers();
- for (let item of itemsRemoved.reverse()) {
- let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
- notify(observers, "onItemRemoved", [ item._id, item._parentId,
- item.index, item.type, uri,
- item.guid, item.parentGuid ]);
-
- let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
- if (isUntagging) {
- for (let entry of (yield fetchBookmarksByURL(item))) {
- notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
- toPRTime(entry.lastModified),
- entry.type, entry._parentId,
- entry.guid, entry.parentGuid,
- "" ]);
- }
- }
- }
-});