summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesUtils.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/PlacesUtils.jsm')
-rw-r--r--toolkit/components/places/PlacesUtils.jsm3863
1 files changed, 3863 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesUtils.jsm b/toolkit/components/places/PlacesUtils.jsm
new file mode 100644
index 000000000..4b7bcb82a
--- /dev/null
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -0,0 +1,3863 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+this.EXPORTED_SYMBOLS = [
+ "PlacesUtils"
+, "PlacesAggregatedTransaction"
+, "PlacesCreateFolderTransaction"
+, "PlacesCreateBookmarkTransaction"
+, "PlacesCreateSeparatorTransaction"
+, "PlacesCreateLivemarkTransaction"
+, "PlacesMoveItemTransaction"
+, "PlacesRemoveItemTransaction"
+, "PlacesEditItemTitleTransaction"
+, "PlacesEditBookmarkURITransaction"
+, "PlacesSetItemAnnotationTransaction"
+, "PlacesSetPageAnnotationTransaction"
+, "PlacesEditBookmarkKeywordTransaction"
+, "PlacesEditBookmarkPostDataTransaction"
+, "PlacesEditItemDateAddedTransaction"
+, "PlacesEditItemLastModifiedTransaction"
+, "PlacesSortFolderByNameTransaction"
+, "PlacesTagURITransaction"
+, "PlacesUntagURITransaction"
+];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Bookmarks",
+ "resource://gre/modules/Bookmarks.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "History",
+ "resource://gre/modules/History.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
+ "resource://gre/modules/AsyncShutdown.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
+ "resource://gre/modules/PlacesSyncUtils.jsm");
+
+// The minimum amount of transactions before starting a batch. Usually we do
+// do incremental updates, a batch will cause views to completely
+// refresh instead.
+const MIN_TRANSACTIONS_FOR_BATCH = 5;
+
+// On Mac OSX, the transferable system converts "\r\n" to "\n\n", where
+// we really just want "\n". On other platforms, the transferable system
+// converts "\r\n" to "\n".
+const NEWLINE = AppConstants.platform == "macosx" ? "\n" : "\r\n";
+
+function QI_node(aNode, aIID) {
+ var result = null;
+ try {
+ result = aNode.QueryInterface(aIID);
+ }
+ catch (e) {
+ }
+ return result;
+}
+function asContainer(aNode) {
+ return QI_node(aNode, Ci.nsINavHistoryContainerResultNode);
+}
+function asQuery(aNode) {
+ return QI_node(aNode, Ci.nsINavHistoryQueryResultNode);
+}
+
+/**
+ * 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) {}
+ }
+}
+
+/**
+ * Sends a keyword change notification.
+ *
+ * @param url
+ * the url to notify about.
+ * @param keyword
+ * The keyword to notify, or empty string if a keyword was removed.
+ */
+function* notifyKeywordChange(url, keyword, source) {
+ // Notify bookmarks about the removal.
+ let bookmarks = [];
+ yield PlacesUtils.bookmarks.fetch({ url }, b => bookmarks.push(b));
+ // We don't want to yield in the gIgnoreKeywordNotifications section.
+ for (let bookmark of bookmarks) {
+ bookmark.id = yield PlacesUtils.promiseItemId(bookmark.guid);
+ bookmark.parentId = yield PlacesUtils.promiseItemId(bookmark.parentGuid);
+ }
+ let observers = PlacesUtils.bookmarks.getObservers();
+ gIgnoreKeywordNotifications = true;
+ for (let bookmark of bookmarks) {
+ notify(observers, "onItemChanged", [ bookmark.id, "keyword", false,
+ keyword,
+ bookmark.lastModified * 1000,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid, bookmark.parentGuid,
+ "", source
+ ]);
+ }
+ gIgnoreKeywordNotifications = false;
+}
+
+/**
+ * Serializes the given node in JSON format.
+ *
+ * @param aNode
+ * An nsINavHistoryResultNode
+ * @param aIsLivemark
+ * Whether the node represents a livemark.
+ */
+function serializeNode(aNode, aIsLivemark) {
+ let data = {};
+
+ data.title = aNode.title;
+ data.id = aNode.itemId;
+ data.livemark = aIsLivemark;
+
+ let guid = aNode.bookmarkGuid;
+ if (guid) {
+ data.itemGuid = guid;
+ if (aNode.parent)
+ data.parent = aNode.parent.itemId;
+ let grandParent = aNode.parent && aNode.parent.parent;
+ if (grandParent)
+ data.grandParentId = grandParent.itemId;
+
+ data.dateAdded = aNode.dateAdded;
+ data.lastModified = aNode.lastModified;
+
+ let annos = PlacesUtils.getAnnotationsForItem(data.id);
+ if (annos.length > 0)
+ data.annos = annos;
+ }
+
+ if (PlacesUtils.nodeIsURI(aNode)) {
+ // Check for url validity.
+ NetUtil.newURI(aNode.uri);
+
+ // Tag root accepts only folder nodes, not URIs.
+ if (data.parent == PlacesUtils.tagsFolderId)
+ throw new Error("Unexpected node type");
+
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ data.uri = aNode.uri;
+
+ if (aNode.tags)
+ data.tags = aNode.tags;
+ }
+ else if (PlacesUtils.nodeIsContainer(aNode)) {
+ // Tag containers accept only uri nodes.
+ if (data.grandParentId == PlacesUtils.tagsFolderId)
+ throw new Error("Unexpected node type");
+
+ let concreteId = PlacesUtils.getConcreteItemId(aNode);
+ if (concreteId != -1) {
+ // This is a bookmark or a tag container.
+ if (PlacesUtils.nodeIsQuery(aNode) || concreteId != aNode.itemId) {
+ // This is a folder shortcut.
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ data.uri = aNode.uri;
+ data.concreteId = concreteId;
+ }
+ else {
+ // This is a bookmark folder.
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER;
+ }
+ }
+ else {
+ // This is a grouped container query, dynamically generated.
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ data.uri = aNode.uri;
+ }
+ }
+ else if (PlacesUtils.nodeIsSeparator(aNode)) {
+ // Tag containers don't accept separators.
+ if (data.parent == PlacesUtils.tagsFolderId ||
+ data.grandParentId == PlacesUtils.tagsFolderId)
+ throw new Error("Unexpected node type");
+
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR;
+ }
+
+ return JSON.stringify(data);
+}
+
+// Imposed to limit database size.
+const DB_URL_LENGTH_MAX = 65536;
+const DB_TITLE_LENGTH_MAX = 4096;
+
+/**
+ * List of bookmark object 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 BOOKMARK_VALIDATORS = Object.freeze({
+ guid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)),
+ parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
+ /^[a-zA-Z0-9\-_]{12}$/.test(v)),
+ index: simpleValidateFunc(v => Number.isInteger(v) &&
+ v >= PlacesUtils.bookmarks.DEFAULT_INDEX),
+ dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
+ lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
+ type: simpleValidateFunc(v => Number.isInteger(v) &&
+ [ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ , PlacesUtils.bookmarks.TYPE_FOLDER
+ , PlacesUtils.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;
+ },
+ source: simpleValidateFunc(v => Number.isInteger(v) &&
+ Object.values(PlacesUtils.bookmarks.SOURCES).includes(v)),
+});
+
+// Sync bookmark records can contain additional properties.
+const SYNC_BOOKMARK_VALIDATORS = Object.freeze({
+ // Sync uses Places GUIDs for all records except roots.
+ syncId: simpleValidateFunc(v => typeof v == "string" && (
+ (PlacesSyncUtils.bookmarks.ROOTS.includes(v) ||
+ PlacesUtils.isValidGuid(v)))),
+ parentSyncId: v => SYNC_BOOKMARK_VALIDATORS.syncId(v),
+ // Sync uses kinds instead of types, which distinguish between livemarks,
+ // queries, and smart bookmarks.
+ kind: simpleValidateFunc(v => typeof v == "string" &&
+ Object.values(PlacesSyncUtils.bookmarks.KINDS).includes(v)),
+ query: simpleValidateFunc(v => v === null || (typeof v == "string" && v)),
+ folder: simpleValidateFunc(v => typeof v == "string" && v &&
+ v.length <= Ci.nsITaggingService.MAX_TAG_LENGTH),
+ tags: v => {
+ if (v === null) {
+ return [];
+ }
+ if (!Array.isArray(v)) {
+ throw new Error("Invalid tag array");
+ }
+ for (let tag of v) {
+ if (typeof tag != "string" || !tag ||
+ tag.length > Ci.nsITaggingService.MAX_TAG_LENGTH) {
+ throw new Error(`Invalid tag: ${tag}`);
+ }
+ }
+ return v;
+ },
+ keyword: simpleValidateFunc(v => v === null || typeof v == "string"),
+ description: simpleValidateFunc(v => v === null || typeof v == "string"),
+ loadInSidebar: simpleValidateFunc(v => v === true || v === false),
+ feed: v => v === null ? v : BOOKMARK_VALIDATORS.url(v),
+ site: v => v === null ? v : BOOKMARK_VALIDATORS.url(v),
+ title: BOOKMARK_VALIDATORS.title,
+ url: BOOKMARK_VALIDATORS.url,
+});
+
+this.PlacesUtils = {
+ // Place entries that are containers, e.g. bookmark folders or queries.
+ TYPE_X_MOZ_PLACE_CONTAINER: "text/x-moz-place-container",
+ // Place entries that are bookmark separators.
+ TYPE_X_MOZ_PLACE_SEPARATOR: "text/x-moz-place-separator",
+ // Place entries that are not containers or separators
+ TYPE_X_MOZ_PLACE: "text/x-moz-place",
+ // Place entries in shortcut url format (url\ntitle)
+ TYPE_X_MOZ_URL: "text/x-moz-url",
+ // Place entries formatted as HTML anchors
+ TYPE_HTML: "text/html",
+ // Place entries as raw URL text
+ TYPE_UNICODE: "text/unicode",
+ // Used to track the action that populated the clipboard.
+ TYPE_X_MOZ_PLACE_ACTION: "text/x-moz-place-action",
+
+ EXCLUDE_FROM_BACKUP_ANNO: "places/excludeFromBackup",
+ LMANNO_FEEDURI: "livemark/feedURI",
+ LMANNO_SITEURI: "livemark/siteURI",
+ POST_DATA_ANNO: "bookmarkProperties/POSTData",
+ READ_ONLY_ANNO: "placesInternal/READ_ONLY",
+ CHARSET_ANNO: "URIProperties/characterSet",
+ MOBILE_ROOT_ANNO: "mobile/bookmarksRoot",
+
+ TOPIC_SHUTDOWN: "places-shutdown",
+ TOPIC_INIT_COMPLETE: "places-init-complete",
+ TOPIC_DATABASE_LOCKED: "places-database-locked",
+ TOPIC_EXPIRATION_FINISHED: "places-expiration-finished",
+ TOPIC_FEEDBACK_UPDATED: "places-autocomplete-feedback-updated",
+ TOPIC_FAVICONS_EXPIRED: "places-favicons-expired",
+ TOPIC_VACUUM_STARTING: "places-vacuum-starting",
+ TOPIC_BOOKMARKS_RESTORE_BEGIN: "bookmarks-restore-begin",
+ TOPIC_BOOKMARKS_RESTORE_SUCCESS: "bookmarks-restore-success",
+ TOPIC_BOOKMARKS_RESTORE_FAILED: "bookmarks-restore-failed",
+
+ asContainer: aNode => asContainer(aNode),
+ asQuery: aNode => asQuery(aNode),
+
+ endl: NEWLINE,
+
+ /**
+ * Makes a URI from a spec.
+ * @param aSpec
+ * The string spec of the URI
+ * @returns A URI object for the spec.
+ */
+ _uri: function PU__uri(aSpec) {
+ return NetUtil.newURI(aSpec);
+ },
+
+ /**
+ * Is a string a valid GUID?
+ *
+ * @param guid: (String)
+ * @return (Boolean)
+ */
+ isValidGuid(guid) {
+ return typeof guid == "string" && guid &&
+ (/^[a-zA-Z0-9\-_]{12}$/.test(guid));
+ },
+
+ /**
+ * Converts a string or n URL object to an nsIURI.
+ *
+ * @param url (URL) or (String)
+ * the URL to convert.
+ * @return nsIURI for the given URL.
+ */
+ toURI(url) {
+ url = (url instanceof URL) ? url.href : url;
+
+ return NetUtil.newURI(url);
+ },
+
+ /**
+ * 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));
+ },
+
+ /**
+ * Wraps a string in a nsISupportsString wrapper.
+ * @param aString
+ * The string to wrap.
+ * @returns A nsISupportsString object containing a string.
+ */
+ toISupportsString: function PU_toISupportsString(aString) {
+ let s = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ s.data = aString;
+ return s;
+ },
+
+ getFormattedString: function PU_getFormattedString(key, params) {
+ return bundle.formatStringFromName(key, params, params.length);
+ },
+
+ getString: function PU_getString(key) {
+ return bundle.GetStringFromName(key);
+ },
+
+ /**
+ * Makes a moz-action URI for the given action and set of parameters.
+ *
+ * @param type
+ * The action type.
+ * @param params
+ * A JS object of action params.
+ * @returns A moz-action URI as a string.
+ */
+ mozActionURI(type, params) {
+ let encodedParams = {};
+ for (let key in params) {
+ // Strip null or undefined.
+ // Regardless, don't encode them or they would be converted to a string.
+ if (params[key] === null || params[key] === undefined) {
+ continue;
+ }
+ encodedParams[key] = encodeURIComponent(params[key]);
+ }
+ return "moz-action:" + type + "," + JSON.stringify(encodedParams);
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a Bookmark folder.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a Bookmark folder, false otherwise
+ */
+ nodeIsFolder: function PU_nodeIsFolder(aNode) {
+ return (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER ||
+ aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT);
+ },
+
+ /**
+ * Determines whether or not a ResultNode represents a bookmarked URI.
+ * @param aNode
+ * A result node
+ * @returns true if the node represents a bookmarked URI, false otherwise
+ */
+ nodeIsBookmark: function PU_nodeIsBookmark(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI &&
+ aNode.itemId != -1;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a Bookmark separator.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a Bookmark separator, false otherwise
+ */
+ nodeIsSeparator: function PU_nodeIsSeparator(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a URL item.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a URL item, false otherwise
+ */
+ nodeIsURI: function PU_nodeIsURI(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a Query item.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a Query item, false otherwise
+ */
+ nodeIsQuery: function PU_nodeIsQuery(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY;
+ },
+
+ /**
+ * Generator for a node's ancestors.
+ * @param aNode
+ * A result node
+ */
+ nodeAncestors: function* PU_nodeAncestors(aNode) {
+ let node = aNode.parent;
+ while (node) {
+ yield node;
+ node = node.parent;
+ }
+ },
+
+ /**
+ * Checks validity of an 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 props (object)
+ * The 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 item.
+ * @throws if the object contains invalid data.
+ * @note any unknown properties are pass-through.
+ */
+ validateItemProperties(validators, props, behavior={}) {
+ if (!props)
+ throw new Error("Input should be a valid object");
+ // Make a shallow copy of `props` to avoid mutating the original object
+ // when filling in defaults.
+ let input = Object.assign({}, props);
+ 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;
+ },
+
+ BOOKMARK_VALIDATORS,
+ SYNC_BOOKMARK_VALIDATORS,
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver
+ , Ci.nsITransactionListener
+ ]),
+
+ _shutdownFunctions: [],
+ registerShutdownFunction: function PU_registerShutdownFunction(aFunc)
+ {
+ // If this is the first registered function, add the shutdown observer.
+ if (this._shutdownFunctions.length == 0) {
+ Services.obs.addObserver(this, this.TOPIC_SHUTDOWN, false);
+ }
+ this._shutdownFunctions.push(aFunc);
+ },
+
+ // nsIObserver
+ observe: function PU_observe(aSubject, aTopic, aData)
+ {
+ switch (aTopic) {
+ case this.TOPIC_SHUTDOWN:
+ Services.obs.removeObserver(this, this.TOPIC_SHUTDOWN);
+ while (this._shutdownFunctions.length > 0) {
+ this._shutdownFunctions.shift().apply(this);
+ }
+ if (this._bookmarksServiceObserversQueue.length > 0) {
+ // Since we are shutting down, there's no reason to add the observers.
+ this._bookmarksServiceObserversQueue.length = 0;
+ }
+ break;
+ case "bookmarks-service-ready":
+ this._bookmarksServiceReady = true;
+ while (this._bookmarksServiceObserversQueue.length > 0) {
+ let observerInfo = this._bookmarksServiceObserversQueue.shift();
+ this.bookmarks.addObserver(observerInfo.observer, observerInfo.weak);
+ }
+
+ // Initialize the keywords cache to start observing bookmarks
+ // notifications. This is needed as far as we support both the old and
+ // the new bookmarking APIs at the same time.
+ gKeywordsCachePromise.catch(Cu.reportError);
+ break;
+ }
+ },
+
+ onPageAnnotationSet: function() {},
+ onPageAnnotationRemoved: function() {},
+
+
+ // nsITransactionListener
+
+ didDo: function PU_didDo(aManager, aTransaction, aDoResult)
+ {
+ updateCommandsOnActiveWindow();
+ },
+
+ didUndo: function PU_didUndo(aManager, aTransaction, aUndoResult)
+ {
+ updateCommandsOnActiveWindow();
+ },
+
+ didRedo: function PU_didRedo(aManager, aTransaction, aRedoResult)
+ {
+ updateCommandsOnActiveWindow();
+ },
+
+ didBeginBatch: function PU_didBeginBatch(aManager, aResult)
+ {
+ // A no-op transaction is pushed to the stack, in order to make safe and
+ // easy to implement "Undo" an unknown number of transactions (including 0),
+ // "above" beginBatch and endBatch. Otherwise,implementing Undo that way
+ // head to dataloss: for example, if no changes were done in the
+ // edit-item panel, the last transaction on the undo stack would be the
+ // initial createItem transaction, or even worse, the batched editing of
+ // some other item.
+ // DO NOT MOVE this to the window scope, that would leak (bug 490068)!
+ this.transactionManager.doTransaction({ doTransaction: function() {},
+ undoTransaction: function() {},
+ redoTransaction: function() {},
+ isTransient: false,
+ merge: function() { return false; }
+ });
+ },
+
+ willDo: function PU_willDo() {},
+ willUndo: function PU_willUndo() {},
+ willRedo: function PU_willRedo() {},
+ willBeginBatch: function PU_willBeginBatch() {},
+ willEndBatch: function PU_willEndBatch() {},
+ didEndBatch: function PU_didEndBatch() {},
+ willMerge: function PU_willMerge() {},
+ didMerge: function PU_didMerge() {},
+
+ /**
+ * Determines whether or not a ResultNode is a host container.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a host container, false otherwise
+ */
+ nodeIsHost: function PU_nodeIsHost(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
+ aNode.parent &&
+ asQuery(aNode.parent).queryOptions.resultType ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a day container.
+ * @param node
+ * A NavHistoryResultNode
+ * @returns true if the node is a day container, false otherwise
+ */
+ nodeIsDay: function PU_nodeIsDay(aNode) {
+ var resultType;
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
+ aNode.parent &&
+ ((resultType = asQuery(aNode.parent).queryOptions.resultType) ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY);
+ },
+
+ /**
+ * Determines whether or not a result-node is a tag container.
+ * @param aNode
+ * A result-node
+ * @returns true if the node is a tag container, false otherwise
+ */
+ nodeIsTagQuery: function PU_nodeIsTagQuery(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
+ asQuery(aNode).queryOptions.resultType ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a container.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a container item, false otherwise
+ */
+ containerTypes: [Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
+ Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT,
+ Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY],
+ nodeIsContainer: function PU_nodeIsContainer(aNode) {
+ return this.containerTypes.includes(aNode.type);
+ },
+
+ /**
+ * Determines whether or not a ResultNode is an history related container.
+ * @param node
+ * A result node
+ * @returns true if the node is an history related container, false otherwise
+ */
+ nodeIsHistoryContainer: function PU_nodeIsHistoryContainer(aNode) {
+ var resultType;
+ return this.nodeIsQuery(aNode) &&
+ ((resultType = asQuery(aNode).queryOptions.resultType) ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY ||
+ resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY ||
+ this.nodeIsDay(aNode) ||
+ this.nodeIsHost(aNode));
+ },
+
+ /**
+ * Gets the concrete item-id for the given node. Generally, this is just
+ * node.itemId, but for folder-shortcuts that's node.folderItemId.
+ */
+ getConcreteItemId: function PU_getConcreteItemId(aNode) {
+ if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT)
+ return asQuery(aNode).folderItemId;
+ else if (PlacesUtils.nodeIsTagQuery(aNode)) {
+ // RESULTS_AS_TAG_CONTENTS queries are similar to folder shortcuts
+ // so we can still get the concrete itemId for them.
+ var queries = aNode.getQueries();
+ var folders = queries[0].getFolders();
+ return folders[0];
+ }
+ return aNode.itemId;
+ },
+
+ /**
+ * Gets the concrete item-guid for the given node. For everything but folder
+ * shortcuts, this is just node.bookmarkGuid. For folder shortcuts, this is
+ * node.targetFolderGuid (see nsINavHistoryService.idl for the semantics).
+ *
+ * @param aNode
+ * a result node.
+ * @return the concrete item-guid for aNode.
+ * @note unlike getConcreteItemId, this doesn't allow retrieving the guid of a
+ * ta container.
+ */
+ getConcreteItemGuid(aNode) {
+ if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT)
+ return asQuery(aNode).targetFolderGuid;
+ return aNode.bookmarkGuid;
+ },
+
+ /**
+ * Reverse a host based on the moz_places algorithm, that is reverse the host
+ * string and add a trailing period. For example "google.com" becomes
+ * "moc.elgoog.".
+ *
+ * @param url
+ * the URL to generate a rev host for.
+ * @return the reversed host string.
+ */
+ getReversedHost(url) {
+ return url.host.split("").reverse().join("") + ".";
+ },
+
+ /**
+ * String-wraps a result node according to the rules of the specified
+ * content type for copy or move operations.
+ *
+ * @param aNode
+ * The Result node to wrap (serialize)
+ * @param aType
+ * The content type to serialize as
+ * @param [optional] aFeedURI
+ * Used instead of the node's URI if provided.
+ * This is useful for wrapping a livemark as TYPE_X_MOZ_URL,
+ * TYPE_HTML or TYPE_UNICODE.
+ * @return A string serialization of the node
+ */
+ wrapNode(aNode, aType, aFeedURI) {
+ // when wrapping a node, we want all the items, even if the original
+ // query options are excluding them.
+ // This can happen when copying from the left hand pane of the bookmarks
+ // organizer.
+ // @return [node, shouldClose]
+ function gatherDataFromNode(node, gatherDataFunc) {
+ if (PlacesUtils.nodeIsFolder(node) &&
+ node.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT &&
+ asQuery(node).queryOptions.excludeItems) {
+ let folderRoot = PlacesUtils.getFolderContents(node.itemId, false, true).root;
+ try {
+ return gatherDataFunc(folderRoot);
+ } finally {
+ folderRoot.containerOpen = false;
+ }
+ }
+ // If we didn't create our own query, do not alter the node's state.
+ return gatherDataFunc(node);
+ }
+
+ function gatherDataHtml(node) {
+ let htmlEscape = s => s.replace(/&/g, "&amp;")
+ .replace(/>/g, "&gt;")
+ .replace(/</g, "&lt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&apos;");
+
+ // escape out potential HTML in the title
+ let escapedTitle = node.title ? htmlEscape(node.title) : "";
+
+ if (aFeedURI) {
+ return `<A HREF="${aFeedURI}">${escapedTitle}</A>${NEWLINE}`;
+ }
+
+ if (PlacesUtils.nodeIsContainer(node)) {
+ asContainer(node);
+ let wasOpen = node.containerOpen;
+ if (!wasOpen)
+ node.containerOpen = true;
+
+ let childString = "<DL><DT>" + escapedTitle + "</DT>" + NEWLINE;
+ let cc = node.childCount;
+ for (let i = 0; i < cc; ++i) {
+ childString += "<DD>"
+ + NEWLINE
+ + gatherDataHtml(node.getChild(i))
+ + "</DD>"
+ + NEWLINE;
+ }
+ node.containerOpen = wasOpen;
+ return childString + "</DL>" + NEWLINE;
+ }
+ if (PlacesUtils.nodeIsURI(node))
+ return `<A HREF="${node.uri}">${escapedTitle}</A>${NEWLINE}`;
+ if (PlacesUtils.nodeIsSeparator(node))
+ return "<HR>" + NEWLINE;
+ return "";
+ }
+
+ function gatherDataText(node) {
+ if (aFeedURI) {
+ return aFeedURI;
+ }
+
+ if (PlacesUtils.nodeIsContainer(node)) {
+ asContainer(node);
+ let wasOpen = node.containerOpen;
+ if (!wasOpen)
+ node.containerOpen = true;
+
+ let childString = node.title + NEWLINE;
+ let cc = node.childCount;
+ for (let i = 0; i < cc; ++i) {
+ let child = node.getChild(i);
+ let suffix = i < (cc - 1) ? NEWLINE : "";
+ childString += gatherDataText(child) + suffix;
+ }
+ node.containerOpen = wasOpen;
+ return childString;
+ }
+ if (PlacesUtils.nodeIsURI(node))
+ return node.uri;
+ if (PlacesUtils.nodeIsSeparator(node))
+ return "--------------------";
+ return "";
+ }
+
+ switch (aType) {
+ case this.TYPE_X_MOZ_PLACE:
+ case this.TYPE_X_MOZ_PLACE_SEPARATOR:
+ case this.TYPE_X_MOZ_PLACE_CONTAINER: {
+ // Serialize the node to JSON.
+ return serializeNode(aNode, aFeedURI);
+ }
+ case this.TYPE_X_MOZ_URL: {
+ if (aFeedURI || PlacesUtils.nodeIsURI(aNode))
+ return (aFeedURI || aNode.uri) + NEWLINE + aNode.title;
+ return "";
+ }
+ case this.TYPE_HTML: {
+ return gatherDataFromNode(aNode, gatherDataHtml);
+ }
+ }
+
+ // Otherwise, we wrap as TYPE_UNICODE.
+ return gatherDataFromNode(aNode, gatherDataText);
+ },
+
+ /**
+ * Unwraps data from the Clipboard or the current Drag Session.
+ * @param blob
+ * A blob (string) of data, in some format we potentially know how
+ * to parse.
+ * @param type
+ * The content type of the blob.
+ * @returns An array of objects representing each item contained by the source.
+ */
+ unwrapNodes: function PU_unwrapNodes(blob, type) {
+ // We split on "\n" because the transferable system converts "\r\n" to "\n"
+ var nodes = [];
+ switch (type) {
+ case this.TYPE_X_MOZ_PLACE:
+ case this.TYPE_X_MOZ_PLACE_SEPARATOR:
+ case this.TYPE_X_MOZ_PLACE_CONTAINER:
+ nodes = JSON.parse("[" + blob + "]");
+ break;
+ case this.TYPE_X_MOZ_URL: {
+ let parts = blob.split("\n");
+ // data in this type has 2 parts per entry, so if there are fewer
+ // than 2 parts left, the blob is malformed and we should stop
+ // but drag and drop of files from the shell has parts.length = 1
+ if (parts.length != 1 && parts.length % 2)
+ break;
+ for (let i = 0; i < parts.length; i=i+2) {
+ let uriString = parts[i];
+ let titleString = "";
+ if (parts.length > i+1)
+ titleString = parts[i+1];
+ else {
+ // for drag and drop of files, try to use the leafName as title
+ try {
+ titleString = this._uri(uriString).QueryInterface(Ci.nsIURL)
+ .fileName;
+ }
+ catch (e) {}
+ }
+ // note: this._uri() will throw if uriString is not a valid URI
+ if (this._uri(uriString)) {
+ nodes.push({ uri: uriString,
+ title: titleString ? titleString : uriString,
+ type: this.TYPE_X_MOZ_URL });
+ }
+ }
+ break;
+ }
+ case this.TYPE_UNICODE: {
+ let parts = blob.split("\n");
+ for (let i = 0; i < parts.length; i++) {
+ let uriString = parts[i];
+ // text/uri-list is converted to TYPE_UNICODE but it could contain
+ // comments line prepended by #, we should skip them
+ if (uriString.substr(0, 1) == '\x23')
+ continue;
+ // note: this._uri() will throw if uriString is not a valid URI
+ if (uriString != "" && this._uri(uriString))
+ nodes.push({ uri: uriString,
+ title: uriString,
+ type: this.TYPE_X_MOZ_URL });
+ }
+ break;
+ }
+ default:
+ throw Cr.NS_ERROR_INVALID_ARG;
+ }
+ return nodes;
+ },
+
+ /**
+ * Generates a nsINavHistoryResult for the contents of a folder.
+ * @param folderId
+ * The folder to open
+ * @param [optional] excludeItems
+ * True to hide all items (individual bookmarks). This is used on
+ * the left places pane so you just get a folder hierarchy.
+ * @param [optional] expandQueries
+ * True to make query items expand as new containers. For managing,
+ * you want this to be false, for menus and such, you want this to
+ * be true.
+ * @returns A nsINavHistoryResult containing the contents of the
+ * folder. The result.root is guaranteed to be open.
+ */
+ getFolderContents:
+ function PU_getFolderContents(aFolderId, aExcludeItems, aExpandQueries) {
+ var query = this.history.getNewQuery();
+ query.setFolders([aFolderId], 1);
+ var options = this.history.getNewQueryOptions();
+ options.excludeItems = aExcludeItems;
+ options.expandQueries = aExpandQueries;
+
+ var result = this.history.executeQuery(query, options);
+ result.root.containerOpen = true;
+ return result;
+ },
+
+ /**
+ * Fetch all annotations for a URI, including all properties of each
+ * annotation which would be required to recreate it.
+ * @param aURI
+ * The URI for which annotations are to be retrieved.
+ * @return Array of objects, each containing the following properties:
+ * name, flags, expires, value
+ */
+ getAnnotationsForURI: function PU_getAnnotationsForURI(aURI) {
+ var annosvc = this.annotations;
+ var annos = [], val = null;
+ var annoNames = annosvc.getPageAnnotationNames(aURI);
+ for (var i = 0; i < annoNames.length; i++) {
+ var flags = {}, exp = {}, storageType = {};
+ annosvc.getPageAnnotationInfo(aURI, annoNames[i], flags, exp, storageType);
+ val = annosvc.getPageAnnotation(aURI, annoNames[i]);
+ annos.push({name: annoNames[i],
+ flags: flags.value,
+ expires: exp.value,
+ value: val});
+ }
+ return annos;
+ },
+
+ /**
+ * Fetch all annotations for an item, including all properties of each
+ * annotation which would be required to recreate it.
+ * @param aItemId
+ * The identifier of the itme for which annotations are to be
+ * retrieved.
+ * @return Array of objects, each containing the following properties:
+ * name, flags, expires, mimeType, type, value
+ */
+ getAnnotationsForItem: function PU_getAnnotationsForItem(aItemId) {
+ var annosvc = this.annotations;
+ var annos = [], val = null;
+ var annoNames = annosvc.getItemAnnotationNames(aItemId);
+ for (var i = 0; i < annoNames.length; i++) {
+ var flags = {}, exp = {}, storageType = {};
+ annosvc.getItemAnnotationInfo(aItemId, annoNames[i], flags, exp, storageType);
+ val = annosvc.getItemAnnotation(aItemId, annoNames[i]);
+ annos.push({name: annoNames[i],
+ flags: flags.value,
+ expires: exp.value,
+ value: val});
+ }
+ return annos;
+ },
+
+ /**
+ * Annotate a URI with a batch of annotations.
+ * @param aURI
+ * The URI for which annotations are to be set.
+ * @param aAnnotations
+ * Array of objects, each containing the following properties:
+ * name, flags, expires.
+ * If the value for an annotation is not set it will be removed.
+ */
+ setAnnotationsForURI: function PU_setAnnotationsForURI(aURI, aAnnos) {
+ var annosvc = this.annotations;
+ aAnnos.forEach(function(anno) {
+ if (anno.value === undefined || anno.value === null) {
+ annosvc.removePageAnnotation(aURI, anno.name);
+ }
+ else {
+ let flags = ("flags" in anno) ? anno.flags : 0;
+ let expires = ("expires" in anno) ?
+ anno.expires : Ci.nsIAnnotationService.EXPIRE_NEVER;
+ annosvc.setPageAnnotation(aURI, anno.name, anno.value, flags, expires);
+ }
+ });
+ },
+
+ /**
+ * Annotate an item with a batch of annotations.
+ * @param aItemId
+ * The identifier of the item for which annotations are to be set
+ * @param aAnnotations
+ * Array of objects, each containing the following properties:
+ * name, flags, expires.
+ * If the value for an annotation is not set it will be removed.
+ */
+ setAnnotationsForItem: function PU_setAnnotationsForItem(aItemId, aAnnos, aSource) {
+ var annosvc = this.annotations;
+
+ aAnnos.forEach(function(anno) {
+ if (anno.value === undefined || anno.value === null) {
+ annosvc.removeItemAnnotation(aItemId, anno.name, aSource);
+ }
+ else {
+ let flags = ("flags" in anno) ? anno.flags : 0;
+ let expires = ("expires" in anno) ?
+ anno.expires : Ci.nsIAnnotationService.EXPIRE_NEVER;
+ annosvc.setItemAnnotation(aItemId, anno.name, anno.value, flags,
+ expires, aSource);
+ }
+ });
+ },
+
+ // Identifier getters for special folders.
+ // You should use these everywhere PlacesUtils is available to avoid XPCOM
+ // traversal just to get roots' ids.
+ get placesRootId() {
+ delete this.placesRootId;
+ return this.placesRootId = this.bookmarks.placesRoot;
+ },
+
+ get bookmarksMenuFolderId() {
+ delete this.bookmarksMenuFolderId;
+ return this.bookmarksMenuFolderId = this.bookmarks.bookmarksMenuFolder;
+ },
+
+ get toolbarFolderId() {
+ delete this.toolbarFolderId;
+ return this.toolbarFolderId = this.bookmarks.toolbarFolder;
+ },
+
+ get tagsFolderId() {
+ delete this.tagsFolderId;
+ return this.tagsFolderId = this.bookmarks.tagsFolder;
+ },
+
+ get unfiledBookmarksFolderId() {
+ delete this.unfiledBookmarksFolderId;
+ return this.unfiledBookmarksFolderId = this.bookmarks.unfiledBookmarksFolder;
+ },
+
+ get mobileFolderId() {
+ delete this.mobileFolderId;
+ return this.mobileFolderId = this.bookmarks.mobileFolder;
+ },
+
+ /**
+ * Checks if aItemId is a root.
+ *
+ * @param aItemId
+ * item id to look for.
+ * @returns true if aItemId is a root, false otherwise.
+ */
+ isRootItem: function PU_isRootItem(aItemId) {
+ return aItemId == PlacesUtils.bookmarksMenuFolderId ||
+ aItemId == PlacesUtils.toolbarFolderId ||
+ aItemId == PlacesUtils.unfiledBookmarksFolderId ||
+ aItemId == PlacesUtils.tagsFolderId ||
+ aItemId == PlacesUtils.placesRootId ||
+ aItemId == PlacesUtils.mobileFolderId;
+ },
+
+ /**
+ * Set the POST data associated with a bookmark, if any.
+ * Used by POST keywords.
+ * @param aBookmarkId
+ *
+ * @deprecated Use PlacesUtils.keywords.insert() API instead.
+ */
+ setPostDataForBookmark(aBookmarkId, aPostData) {
+ if (!aPostData)
+ throw new Error("Must provide valid POST data");
+ // For now we don't have a unified API to create a keyword with postData,
+ // thus here we can just try to complete a keyword that should already exist
+ // without any post data.
+ let stmt = PlacesUtils.history.DBConnection.createStatement(
+ `UPDATE moz_keywords SET post_data = :post_data
+ WHERE id = (SELECT k.id FROM moz_keywords k
+ JOIN moz_bookmarks b ON b.fk = k.place_id
+ WHERE b.id = :item_id
+ AND post_data ISNULL
+ LIMIT 1)`);
+ stmt.params.item_id = aBookmarkId;
+ stmt.params.post_data = aPostData;
+ try {
+ stmt.execute();
+ }
+ finally {
+ stmt.finalize();
+ }
+
+ // Update the cache.
+ return Task.spawn(function* () {
+ let guid = yield PlacesUtils.promiseItemGuid(aBookmarkId);
+ let bm = yield PlacesUtils.bookmarks.fetch(guid);
+
+ // Fetch keywords for this href.
+ let cache = yield gKeywordsCachePromise;
+ for (let [ , entry ] of cache) {
+ // Set the POST data on keywords not having it.
+ if (entry.url.href == bm.url.href && !entry.postData) {
+ entry.postData = aPostData;
+ }
+ }
+ }).catch(Cu.reportError);
+ },
+
+ /**
+ * Get the POST data associated with a bookmark, if any.
+ * @param aBookmarkId
+ * @returns string of POST data if set for aBookmarkId. null otherwise.
+ *
+ * @deprecated Use PlacesUtils.keywords.fetch() API instead.
+ */
+ getPostDataForBookmark(aBookmarkId) {
+ let stmt = PlacesUtils.history.DBConnection.createStatement(
+ `SELECT k.post_data
+ FROM moz_keywords k
+ JOIN moz_places h ON h.id = k.place_id
+ JOIN moz_bookmarks b ON b.fk = h.id
+ WHERE b.id = :item_id`);
+ stmt.params.item_id = aBookmarkId;
+ try {
+ if (!stmt.executeStep())
+ return null;
+ return stmt.row.post_data;
+ }
+ finally {
+ stmt.finalize();
+ }
+ },
+
+ /**
+ * Get the URI (and any associated POST data) for a given keyword.
+ * @param aKeyword string keyword
+ * @returns an array containing a string URL and a string of POST data
+ *
+ * @deprecated
+ */
+ getURLAndPostDataForKeyword(aKeyword) {
+ Deprecated.warning("getURLAndPostDataForKeyword() is deprecated, please " +
+ "use PlacesUtils.keywords.fetch() instead",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=1100294");
+
+ let stmt = PlacesUtils.history.DBConnection.createStatement(
+ `SELECT h.url, k.post_data
+ FROM moz_keywords k
+ JOIN moz_places h ON h.id = k.place_id
+ WHERE k.keyword = :keyword`);
+ stmt.params.keyword = aKeyword.toLowerCase();
+ try {
+ if (!stmt.executeStep())
+ return [ null, null ];
+ return [ stmt.row.url, stmt.row.post_data ];
+ }
+ finally {
+ stmt.finalize();
+ }
+ },
+
+ /**
+ * Get all bookmarks for a URL, excluding items under tags.
+ */
+ getBookmarksForURI:
+ function PU_getBookmarksForURI(aURI) {
+ var bmkIds = this.bookmarks.getBookmarkIdsForURI(aURI);
+
+ // filter the ids list
+ return bmkIds.filter(function(aID) {
+ var parentId = this.bookmarks.getFolderIdForItem(aID);
+ var grandparentId = this.bookmarks.getFolderIdForItem(parentId);
+ // item under a tag container
+ if (grandparentId == this.tagsFolderId)
+ return false;
+ return true;
+ }, this);
+ },
+
+ /**
+ * Get the most recently added/modified bookmark for a URL, excluding items
+ * under tags.
+ *
+ * @param aURI
+ * nsIURI of the page we will look for.
+ * @returns itemId of the found bookmark, or -1 if nothing is found.
+ */
+ getMostRecentBookmarkForURI:
+ function PU_getMostRecentBookmarkForURI(aURI) {
+ var bmkIds = this.bookmarks.getBookmarkIdsForURI(aURI);
+ for (var i = 0; i < bmkIds.length; i++) {
+ // Find the first folder which isn't a tag container
+ var itemId = bmkIds[i];
+ var parentId = this.bookmarks.getFolderIdForItem(itemId);
+ // Optimization: if this is a direct child of a root we don't need to
+ // check if its grandparent is a tag.
+ if (parentId == this.unfiledBookmarksFolderId ||
+ parentId == this.toolbarFolderId ||
+ parentId == this.bookmarksMenuFolderId)
+ return itemId;
+
+ var grandparentId = this.bookmarks.getFolderIdForItem(parentId);
+ if (grandparentId != this.tagsFolderId)
+ return itemId;
+ }
+ return -1;
+ },
+
+ /**
+ * Returns a nsNavHistoryContainerResultNode with forced excludeItems and
+ * expandQueries.
+ * @param aNode
+ * The node to convert
+ * @param [optional] excludeItems
+ * True to hide all items (individual bookmarks). This is used on
+ * the left places pane so you just get a folder hierarchy.
+ * @param [optional] expandQueries
+ * True to make query items expand as new containers. For managing,
+ * you want this to be false, for menus and such, you want this to
+ * be true.
+ * @returns A nsINavHistoryContainerResultNode containing the unfiltered
+ * contents of the container.
+ * @note The returned container node could be open or closed, we don't
+ * guarantee its status.
+ */
+ getContainerNodeWithOptions:
+ function PU_getContainerNodeWithOptions(aNode, aExcludeItems, aExpandQueries) {
+ if (!this.nodeIsContainer(aNode))
+ throw Cr.NS_ERROR_INVALID_ARG;
+
+ // excludeItems is inherited by child containers in an excludeItems view.
+ var excludeItems = asQuery(aNode).queryOptions.excludeItems ||
+ asQuery(aNode.parentResult.root).queryOptions.excludeItems;
+ // expandQueries is inherited by child containers in an expandQueries view.
+ var expandQueries = asQuery(aNode).queryOptions.expandQueries &&
+ asQuery(aNode.parentResult.root).queryOptions.expandQueries;
+
+ // If our options are exactly what we expect, directly return the node.
+ if (excludeItems == aExcludeItems && expandQueries == aExpandQueries)
+ return aNode;
+
+ // Otherwise, get contents manually.
+ var queries = {}, options = {};
+ this.history.queryStringToQueries(aNode.uri, queries, {}, options);
+ options.value.excludeItems = aExcludeItems;
+ options.value.expandQueries = aExpandQueries;
+ return this.history.executeQueries(queries.value,
+ queries.value.length,
+ options.value).root;
+ },
+
+ /**
+ * Returns true if a container has uri nodes in its first level.
+ * Has better performance than (getURLsForContainerNode(node).length > 0).
+ * @param aNode
+ * The container node to search through.
+ * @returns true if the node contains uri nodes, false otherwise.
+ */
+ hasChildURIs: function PU_hasChildURIs(aNode) {
+ if (!this.nodeIsContainer(aNode))
+ return false;
+
+ let root = this.getContainerNodeWithOptions(aNode, false, true);
+ let result = root.parentResult;
+ let didSuppressNotifications = false;
+ let wasOpen = root.containerOpen;
+ if (!wasOpen) {
+ didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = true;
+
+ root.containerOpen = true;
+ }
+
+ let found = false;
+ for (let i = 0; i < root.childCount && !found; i++) {
+ let child = root.getChild(i);
+ if (this.nodeIsURI(child))
+ found = true;
+ }
+
+ if (!wasOpen) {
+ root.containerOpen = false;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = false;
+ }
+ return found;
+ },
+
+ /**
+ * Returns an array containing all the uris in the first level of the
+ * passed in container.
+ * If you only need to know if the node contains uris, use hasChildURIs.
+ * @param aNode
+ * The container node to search through
+ * @returns array of uris in the first level of the container.
+ */
+ getURLsForContainerNode: function PU_getURLsForContainerNode(aNode) {
+ let urls = [];
+ if (!this.nodeIsContainer(aNode))
+ return urls;
+
+ let root = this.getContainerNodeWithOptions(aNode, false, true);
+ let result = root.parentResult;
+ let wasOpen = root.containerOpen;
+ let didSuppressNotifications = false;
+ if (!wasOpen) {
+ didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = true;
+
+ root.containerOpen = true;
+ }
+
+ for (let i = 0; i < root.childCount; ++i) {
+ let child = root.getChild(i);
+ if (this.nodeIsURI(child))
+ urls.push({uri: child.uri, isBookmark: this.nodeIsBookmark(child)});
+ }
+
+ if (!wasOpen) {
+ root.containerOpen = false;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = false;
+ }
+ return urls;
+ },
+
+ /**
+ * Gets a shared Sqlite.jsm readonly connection to the Places database,
+ * usable only for SELECT queries.
+ *
+ * This is intended to be used mostly internally, components outside of
+ * Places should, when possible, use API calls and file bugs to get proper
+ * APIs, where they are missing.
+ * Keep in mind the Places DB schema is by no means frozen or even stable.
+ * Your custom queries can - and will - break overtime.
+ *
+ * Example:
+ * let db = yield PlacesUtils.promiseDBConnection();
+ * let rows = yield db.executeCached(sql, params);
+ */
+ promiseDBConnection: () => gAsyncDBConnPromised,
+
+ /**
+ * Performs a read/write operation on the Places database through a Sqlite.jsm
+ * wrapped connection to the Places database.
+ *
+ * This is intended to be used only by Places itself, always use APIs if you
+ * need to modify the Places database. Use promiseDBConnection if you need to
+ * SELECT from the database and there's no covering API.
+ * Keep in mind the Places DB schema is by no means frozen or even stable.
+ * Your custom queries can - and will - break overtime.
+ *
+ * As all operations on the Places database are asynchronous, if shutdown
+ * is initiated while an operation is pending, this could cause dataloss.
+ * Using `withConnectionWrapper` ensures that shutdown waits until all
+ * operations are complete before proceeding.
+ *
+ * Example:
+ * yield withConnectionWrapper("Bookmarks: Remove a bookmark", Task.async(function*(db) {
+ * // Proceed with the db, asynchronously.
+ * // Shutdown will not interrupt operations that take place here.
+ * }));
+ *
+ * @param {string} name The name of the operation. Used for debugging, logging
+ * and crash reporting.
+ * @param {function(db)} task A function that takes as argument a Sqlite.jsm
+ * connection and returns a Promise. Shutdown is guaranteed to not interrupt
+ * execution of `task`.
+ */
+ withConnectionWrapper: (name, task) => {
+ if (!name) {
+ throw new TypeError("Expecting a user-readable name");
+ }
+ return Task.spawn(function*() {
+ let db = yield gAsyncDBWrapperPromised;
+ return db.executeBeforeShutdown(name, task);
+ });
+ },
+
+ /**
+ * Given a uri returns list of itemIds associated to it.
+ *
+ * @param aURI
+ * nsIURI or spec of the page.
+ * @param aCallback
+ * Function to be called when done.
+ * The function will receive an array of itemIds associated to aURI and
+ * aURI itself.
+ *
+ * @return A object with a .cancel() method allowing to cancel the request.
+ *
+ * @note Children of live bookmarks folders are excluded. The callback function is
+ * not invoked if the request is cancelled or hits an error.
+ */
+ asyncGetBookmarkIds: function PU_asyncGetBookmarkIds(aURI, aCallback)
+ {
+ let abort = false;
+ let itemIds = [];
+ Task.spawn(function* () {
+ let conn = yield this.promiseDBConnection();
+ const QUERY_STR = `SELECT b.id FROM moz_bookmarks b
+ JOIN moz_places h on h.id = b.fk
+ WHERE h.url_hash = hash(:url) AND h.url = :url`;
+ let spec = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ yield conn.executeCached(QUERY_STR, { url: spec }, aRow => {
+ if (abort)
+ throw StopIteration;
+ itemIds.push(aRow.getResultByIndex(0));
+ });
+ if (!abort)
+ aCallback(itemIds, aURI);
+ }.bind(this)).then(null, Cu.reportError);
+ return { cancel: () => { abort = true; } };
+ },
+
+ /**
+ * Lazily adds a bookmarks observer, waiting for the bookmarks service to be
+ * alive before registering the observer. This is especially useful in the
+ * startup path, to avoid initializing the service just to add an observer.
+ *
+ * @param aObserver
+ * Object implementing nsINavBookmarkObserver
+ * @param [optional]aWeakOwner
+ * Whether to use weak ownership.
+ *
+ * @note Correct functionality of lazy observers relies on the fact Places
+ * notifies categories before real observers, and uses
+ * PlacesCategoriesStarter component to kick-off the registration.
+ */
+ _bookmarksServiceReady: false,
+ _bookmarksServiceObserversQueue: [],
+ addLazyBookmarkObserver:
+ function PU_addLazyBookmarkObserver(aObserver, aWeakOwner) {
+ if (this._bookmarksServiceReady) {
+ this.bookmarks.addObserver(aObserver, aWeakOwner === true);
+ return;
+ }
+ this._bookmarksServiceObserversQueue.push({ observer: aObserver,
+ weak: aWeakOwner === true });
+ },
+
+ /**
+ * Removes a bookmarks observer added through addLazyBookmarkObserver.
+ *
+ * @param aObserver
+ * Object implementing nsINavBookmarkObserver
+ */
+ removeLazyBookmarkObserver:
+ function PU_removeLazyBookmarkObserver(aObserver) {
+ if (this._bookmarksServiceReady) {
+ this.bookmarks.removeObserver(aObserver);
+ return;
+ }
+ let index = -1;
+ for (let i = 0;
+ i < this._bookmarksServiceObserversQueue.length && index == -1; i++) {
+ if (this._bookmarksServiceObserversQueue[i].observer === aObserver)
+ index = i;
+ }
+ if (index != -1) {
+ this._bookmarksServiceObserversQueue.splice(index, 1);
+ }
+ },
+
+ /**
+ * Sets the character-set for a URI.
+ *
+ * @param aURI nsIURI
+ * @param aCharset character-set value.
+ * @return {Promise}
+ */
+ setCharsetForURI: function PU_setCharsetForURI(aURI, aCharset) {
+ let deferred = Promise.defer();
+
+ // Delaying to catch issues with asynchronous behavior while waiting
+ // to implement asynchronous annotations in bug 699844.
+ Services.tm.mainThread.dispatch(function() {
+ if (aCharset && aCharset.length > 0) {
+ PlacesUtils.annotations.setPageAnnotation(
+ aURI, PlacesUtils.CHARSET_ANNO, aCharset, 0,
+ Ci.nsIAnnotationService.EXPIRE_NEVER);
+ } else {
+ PlacesUtils.annotations.removePageAnnotation(
+ aURI, PlacesUtils.CHARSET_ANNO);
+ }
+ deferred.resolve();
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+
+ return deferred.promise;
+ },
+
+ /**
+ * Gets the last saved character-set for a URI.
+ *
+ * @param aURI nsIURI
+ * @return {Promise}
+ * @resolve a character-set or null.
+ */
+ getCharsetForURI: function PU_getCharsetForURI(aURI) {
+ let deferred = Promise.defer();
+
+ Services.tm.mainThread.dispatch(function() {
+ let charset = null;
+
+ try {
+ charset = PlacesUtils.annotations.getPageAnnotation(aURI,
+ PlacesUtils.CHARSET_ANNO);
+ } catch (ex) { }
+
+ deferred.resolve(charset);
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+
+ return deferred.promise;
+ },
+
+ /**
+ * Promised wrapper for mozIAsyncHistory::getPlacesInfo for a single place.
+ *
+ * @param aPlaceIdentifier
+ * either an nsIURI or a GUID (@see getPlacesInfo)
+ * @resolves to the place info object handed to handleResult.
+ */
+ promisePlaceInfo: function PU_promisePlaceInfo(aPlaceIdentifier) {
+ let deferred = Promise.defer();
+ PlacesUtils.asyncHistory.getPlacesInfo(aPlaceIdentifier, {
+ _placeInfo: null,
+ handleResult: function handleResult(aPlaceInfo) {
+ this._placeInfo = aPlaceInfo;
+ },
+ handleError: function handleError(aResultCode, aPlaceInfo) {
+ deferred.reject(new Components.Exception("Error", aResultCode));
+ },
+ handleCompletion: function() {
+ deferred.resolve(this._placeInfo);
+ }
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Gets favicon data for a given page url.
+ *
+ * @param aPageUrl url of the page to look favicon for.
+ * @resolves to an object representing a favicon entry, having the following
+ * properties: { uri, dataLen, data, mimeType }
+ * @rejects JavaScript exception if the given url has no associated favicon.
+ */
+ promiseFaviconData: function (aPageUrl) {
+ let deferred = Promise.defer();
+ PlacesUtils.favicons.getFaviconDataForPage(NetUtil.newURI(aPageUrl),
+ function (aURI, aDataLen, aData, aMimeType) {
+ if (aURI) {
+ deferred.resolve({ uri: aURI,
+ dataLen: aDataLen,
+ data: aData,
+ mimeType: aMimeType });
+ } else {
+ deferred.reject();
+ }
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Gets the favicon link url (moz-anno:) for a given page url.
+ *
+ * @param aPageURL url of the page to lookup the favicon for.
+ * @resolves to the nsIURL of the favicon link
+ * @rejects if the given url has no associated favicon.
+ */
+ promiseFaviconLinkUrl: function (aPageUrl) {
+ let deferred = Promise.defer();
+ if (!(aPageUrl instanceof Ci.nsIURI))
+ aPageUrl = NetUtil.newURI(aPageUrl);
+
+ PlacesUtils.favicons.getFaviconURLForPage(aPageUrl, uri => {
+ if (uri) {
+ uri = PlacesUtils.favicons.getFaviconLinkForIcon(uri);
+ deferred.resolve(uri);
+ } else {
+ deferred.reject("favicon not found for uri");
+ }
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Get the unique id for an item (a bookmark, a folder or a separator) given
+ * its item id.
+ *
+ * @param aItemId
+ * an item id
+ * @return {Promise}
+ * @resolves to the GUID.
+ * @rejects if aItemId is invalid.
+ */
+ promiseItemGuid(aItemId) {
+ return GuidHelper.getItemGuid(aItemId)
+ },
+
+ /**
+ * Get the item id for an item (a bookmark, a folder or a separator) given
+ * its unique id.
+ *
+ * @param aGuid
+ * an item GUID
+ * @return {Promise}
+ * @resolves to the GUID.
+ * @rejects if there's no item for the given GUID.
+ */
+ promiseItemId(aGuid) {
+ return GuidHelper.getItemId(aGuid)
+ },
+
+ /**
+ * Invalidate the GUID cache for the given itemId.
+ *
+ * @param aItemId
+ * an item id
+ */
+ invalidateCachedGuidFor(aItemId) {
+ GuidHelper.invalidateCacheForItemId(aItemId)
+ },
+
+ /**
+ * Asynchronously retrieve a JS-object representation of a places bookmarks
+ * item (a bookmark, a folder, or a separator) along with all of its
+ * descendants.
+ *
+ * @param [optional] aItemGuid
+ * the (topmost) item to be queried. If it's not passed, the places
+ * root is queried: that is, you get a representation of the entire
+ * bookmarks hierarchy.
+ * @param [optional] aOptions
+ * Options for customizing the query behavior, in the form of a JS
+ * object with any of the following properties:
+ * - excludeItemsCallback: a function for excluding items, along with
+ * their descendants. Given an item object (that has everything set
+ * apart its potential children data), it should return true if the
+ * item should be excluded. Once an item is excluded, the function
+ * isn't called for any of its descendants. This isn't called for
+ * the root item.
+ * WARNING: since the function may be called for each item, using
+ * this option can slow down the process significantly if the
+ * callback does anything that's not relatively trivial. It is
+ * highly recommended to avoid any synchronous I/O or DB queries.
+ * - includeItemIds: opt-in to include the deprecated id property.
+ * Use it if you must. It'll be removed once the switch to GUIDs is
+ * complete.
+ *
+ * @return {Promise}
+ * @resolves to a JS object that represents either a single item or a
+ * bookmarks tree. Each node in the tree has the following properties set:
+ * - guid (string): the item's GUID (same as aItemGuid for the top item).
+ * - [deprecated] id (number): the item's id. This is only if
+ * aOptions.includeItemIds is set.
+ * - type (string): the item's type. @see PlacesUtils.TYPE_X_*
+ * - title (string): the item's title. If it has no title, this property
+ * isn't set.
+ * - dateAdded (number, microseconds from the epoch): the date-added value of
+ * the item.
+ * - lastModified (number, microseconds from the epoch): the last-modified
+ * value of the item.
+ * - annos (see getAnnotationsForItem): the item's annotations. This is not
+ * set if there are no annotations set for the item).
+ * - index: the item's index under it's parent.
+ *
+ * The root object (i.e. the one for aItemGuid) also has the following
+ * properties set:
+ * - parentGuid (string): the GUID of the root's parent. This isn't set if
+ * the root item is the places root.
+ * - itemsCount (number, not enumerable): the number of items, including the
+ * root item itself, which are represented in the resolved object.
+ *
+ * Bookmark items also have the following properties:
+ * - uri (string): the item's url.
+ * - tags (string): csv string of the bookmark's tags.
+ * - charset (string): the last known charset of the bookmark.
+ * - keyword (string): the bookmark's keyword (unset if none).
+ * - postData (string): the bookmark's keyword postData (unset if none).
+ * - iconuri (string): the bookmark's favicon url.
+ * The last four properties are not set at all if they're irrelevant (e.g.
+ * |charset| is not set if no charset was previously set for the bookmark
+ * url).
+ *
+ * Folders may also have the following properties:
+ * - children (array): the folder's children information, each of them
+ * having the same set of properties as above.
+ *
+ * @rejects if the query failed for any reason.
+ * @note if aItemGuid points to a non-existent item, the returned promise is
+ * resolved to null.
+ */
+ promiseBookmarksTree: Task.async(function* (aItemGuid = "", aOptions = {}) {
+ let createItemInfoObject = function* (aRow, aIncludeParentGuid) {
+ let item = {};
+ let copyProps = (...props) => {
+ for (let prop of props) {
+ let val = aRow.getResultByName(prop);
+ if (val !== null)
+ item[prop] = val;
+ }
+ };
+ copyProps("guid", "title", "index", "dateAdded", "lastModified");
+ if (aIncludeParentGuid)
+ copyProps("parentGuid");
+
+ let itemId = aRow.getResultByName("id");
+ if (aOptions.includeItemIds)
+ item.id = itemId;
+
+ // Cache it for promiseItemId consumers regardless.
+ GuidHelper.updateCache(itemId, item.guid);
+
+ let type = aRow.getResultByName("type");
+ if (type == Ci.nsINavBookmarksService.TYPE_BOOKMARK)
+ copyProps("charset", "tags", "iconuri");
+
+ // Add annotations.
+ if (aRow.getResultByName("has_annos")) {
+ try {
+ item.annos = PlacesUtils.getAnnotationsForItem(itemId);
+ } catch (e) {
+ Cu.reportError("Unexpected error while reading annotations " + e);
+ }
+ }
+
+ switch (type) {
+ case Ci.nsINavBookmarksService.TYPE_BOOKMARK:
+ item.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ // If this throws due to an invalid url, the item will be skipped.
+ item.uri = NetUtil.newURI(aRow.getResultByName("url")).spec;
+ // Keywords are cached, so this should be decently fast.
+ let entry = yield PlacesUtils.keywords.fetch({ url: item.uri });
+ if (entry) {
+ item.keyword = entry.keyword;
+ item.postData = entry.postData;
+ }
+ break;
+ case Ci.nsINavBookmarksService.TYPE_FOLDER:
+ item.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER;
+ // Mark root folders.
+ if (itemId == PlacesUtils.placesRootId)
+ item.root = "placesRoot";
+ else if (itemId == PlacesUtils.bookmarksMenuFolderId)
+ item.root = "bookmarksMenuFolder";
+ else if (itemId == PlacesUtils.unfiledBookmarksFolderId)
+ item.root = "unfiledBookmarksFolder";
+ else if (itemId == PlacesUtils.toolbarFolderId)
+ item.root = "toolbarFolder";
+ else if (itemId == PlacesUtils.mobileFolderId)
+ item.root = "mobileFolder";
+ break;
+ case Ci.nsINavBookmarksService.TYPE_SEPARATOR:
+ item.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR;
+ break;
+ default:
+ Cu.reportError("Unexpected bookmark type");
+ break;
+ }
+ return item;
+ }.bind(this);
+
+ const QUERY_STR =
+ `/* do not warn (bug no): cannot use an index */
+ WITH RECURSIVE
+ descendants(fk, level, type, id, guid, parent, parentGuid, position,
+ title, dateAdded, lastModified) AS (
+ SELECT b1.fk, 0, b1.type, b1.id, b1.guid, b1.parent,
+ (SELECT guid FROM moz_bookmarks WHERE id = b1.parent),
+ b1.position, b1.title, b1.dateAdded, b1.lastModified
+ FROM moz_bookmarks b1 WHERE b1.guid=:item_guid
+ UNION ALL
+ SELECT b2.fk, level + 1, b2.type, b2.id, b2.guid, b2.parent,
+ descendants.guid, b2.position, b2.title, b2.dateAdded,
+ b2.lastModified
+ FROM moz_bookmarks b2
+ JOIN descendants ON b2.parent = descendants.id AND b2.id <> :tags_folder)
+ SELECT d.level, d.id, d.guid, d.parent, d.parentGuid, d.type,
+ d.position AS [index], d.title, d.dateAdded, d.lastModified,
+ h.url, f.url AS iconuri,
+ (SELECT GROUP_CONCAT(t.title, ',')
+ FROM moz_bookmarks b2
+ JOIN moz_bookmarks t ON t.id = +b2.parent AND t.parent = :tags_folder
+ WHERE b2.fk = h.id
+ ) AS tags,
+ EXISTS (SELECT 1 FROM moz_items_annos
+ WHERE item_id = d.id LIMIT 1) AS has_annos,
+ (SELECT a.content FROM moz_annos a
+ JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id
+ WHERE place_id = h.id AND n.name = :charset_anno
+ ) AS charset
+ FROM descendants d
+ LEFT JOIN moz_bookmarks b3 ON b3.id = d.parent
+ LEFT JOIN moz_places h ON h.id = d.fk
+ LEFT JOIN moz_favicons f ON f.id = h.favicon_id
+ ORDER BY d.level, d.parent, d.position`;
+
+
+ if (!aItemGuid)
+ aItemGuid = this.bookmarks.rootGuid;
+
+ let hasExcludeItemsCallback =
+ aOptions.hasOwnProperty("excludeItemsCallback");
+ let excludedParents = new Set();
+ let shouldExcludeItem = (aItem, aParentGuid) => {
+ let exclude = excludedParents.has(aParentGuid) ||
+ aOptions.excludeItemsCallback(aItem);
+ if (exclude) {
+ if (aItem.type == this.TYPE_X_MOZ_PLACE_CONTAINER)
+ excludedParents.add(aItem.guid);
+ }
+ return exclude;
+ };
+
+ let rootItem = null;
+ let parentsMap = new Map();
+ let conn = yield this.promiseDBConnection();
+ let rows = yield conn.executeCached(QUERY_STR,
+ { tags_folder: PlacesUtils.tagsFolderId,
+ charset_anno: PlacesUtils.CHARSET_ANNO,
+ item_guid: aItemGuid });
+ let yieldCounter = 0;
+ for (let row of rows) {
+ let item;
+ if (!rootItem) {
+ try {
+ // This is the first row.
+ rootItem = item = yield createItemInfoObject(row, true);
+ Object.defineProperty(rootItem, "itemsCount", { value: 1
+ , writable: true
+ , enumerable: false
+ , configurable: false });
+ } catch (ex) {
+ throw new Error("Failed to fetch the data for the root item " + ex);
+ }
+ } else {
+ try {
+ // Our query guarantees that we always visit parents ahead of their
+ // children.
+ item = yield createItemInfoObject(row, false);
+ let parentGuid = row.getResultByName("parentGuid");
+ if (hasExcludeItemsCallback && shouldExcludeItem(item, parentGuid))
+ continue;
+
+ let parentItem = parentsMap.get(parentGuid);
+ if ("children" in parentItem)
+ parentItem.children.push(item);
+ else
+ parentItem.children = [item];
+
+ rootItem.itemsCount++;
+ } catch (ex) {
+ // This is a bogus child, report and skip it.
+ Cu.reportError("Failed to fetch the data for an item " + ex);
+ continue;
+ }
+ }
+
+ if (item.type == this.TYPE_X_MOZ_PLACE_CONTAINER)
+ parentsMap.set(item.guid, item);
+
+ // With many bookmarks we end up stealing the CPU - even with yielding!
+ // So we let everyone else have a go every few items (bug 1186714).
+ if (++yieldCounter % 50 == 0) {
+ yield new Promise(resolve => {
+ Services.tm.currentThread.dispatch(resolve, Ci.nsIThread.DISPATCH_NORMAL);
+ });
+ }
+ }
+
+ return rootItem;
+ })
+};
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "history", function() {
+ let hs = Cc["@mozilla.org/browser/nav-history-service;1"]
+ .getService(Ci.nsINavHistoryService)
+ .QueryInterface(Ci.nsIBrowserHistory)
+ .QueryInterface(Ci.nsPIPlacesDatabase);
+ return Object.freeze(new Proxy(hs, {
+ get: function(target, name) {
+ let property, object;
+ if (name in target) {
+ property = target[name];
+ object = target;
+ } else {
+ property = History[name];
+ object = History;
+ }
+ if (typeof property == "function") {
+ return property.bind(object);
+ }
+ return property;
+ }
+ }));
+});
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "asyncHistory",
+ "@mozilla.org/browser/history;1",
+ "mozIAsyncHistory");
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "bhistory", function() {
+ return PlacesUtils.history;
+});
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "favicons",
+ "@mozilla.org/browser/favicon-service;1",
+ "mozIAsyncFavicons");
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => {
+ let bm = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
+ .getService(Ci.nsINavBookmarksService);
+ return Object.freeze(new Proxy(bm, {
+ get: (target, name) => target.hasOwnProperty(name) ? target[name]
+ : Bookmarks[name]
+ }));
+});
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "annotations",
+ "@mozilla.org/browser/annotation-service;1",
+ "nsIAnnotationService");
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "tagging",
+ "@mozilla.org/browser/tagging-service;1",
+ "nsITaggingService");
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "livemarks",
+ "@mozilla.org/browser/livemark-service;2",
+ "mozIAsyncLivemarks");
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "keywords", () => Keywords);
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "transactionManager", function() {
+ let tm = Cc["@mozilla.org/transactionmanager;1"].
+ createInstance(Ci.nsITransactionManager);
+ tm.AddListener(PlacesUtils);
+ this.registerShutdownFunction(function () {
+ // Clear all references to local transactions in the transaction manager,
+ // this prevents from leaking it.
+ this.transactionManager.RemoveListener(this);
+ this.transactionManager.clear();
+ });
+
+ // Bug 750269
+ // The transaction manager keeps strong references to transactions, and by
+ // that, also to the global for each transaction. A transaction, however,
+ // could be either the transaction itself (for which the global is this
+ // module) or some js-proxy in another global, usually a window. The later
+ // would leak because the transaction lifetime (in the manager's stacks)
+ // is independent of the global from which doTransaction was called.
+ // To avoid such a leak, we hide the native doTransaction from callers,
+ // and let each doTransaction call go through this module.
+ // Doing so ensures that, as long as the transaction is any of the
+ // PlacesXXXTransaction objects declared in this module, the object
+ // referenced by the transaction manager has the module itself as global.
+ return Object.create(tm, {
+ "doTransaction": {
+ value: function(aTransaction) {
+ tm.doTransaction(aTransaction);
+ }
+ }
+ });
+});
+
+XPCOMUtils.defineLazyGetter(this, "bundle", function() {
+ const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties";
+ return Cc["@mozilla.org/intl/stringbundle;1"].
+ getService(Ci.nsIStringBundleService).
+ createBundle(PLACES_STRING_BUNDLE_URI);
+});
+
+/**
+ * Setup internal databases for closing properly during shutdown.
+ *
+ * 1. Places initiates shutdown.
+ * 2. Before places can move to the step where it closes the low-level connection,
+ * we need to make sure that we have closed `conn`.
+ * 3. Before we can close `conn`, we need to make sure that all external clients
+ * have stopped using `conn`.
+ * 4. Before we can close Sqlite, we need to close `conn`.
+ */
+function setupDbForShutdown(conn, name) {
+ try {
+ let state = "0. Not started.";
+ let promiseClosed = new Promise((resolve, reject) => {
+ // The service initiates shutdown.
+ // Before it can safely close its connection, we need to make sure
+ // that we have closed the high-level connection.
+ try {
+ AsyncShutdown.placesClosingInternalConnection.addBlocker(`${name} closing as part of Places shutdown`,
+ Task.async(function*() {
+ state = "1. Service has initiated shutdown";
+
+ // At this stage, all external clients have finished using the
+ // database. We just need to close the high-level connection.
+ yield conn.close();
+ state = "2. Closed Sqlite.jsm connection.";
+
+ resolve();
+ }),
+ () => state
+ );
+ } catch (ex) {
+ // It's too late to block shutdown, just close the connection.
+ conn.close();
+ reject(ex);
+ }
+ });
+
+ // Make sure that Sqlite.jsm doesn't close until we are done
+ // with the high-level connection.
+ Sqlite.shutdown.addBlocker(`${name} must be closed before Sqlite.jsm`,
+ () => promiseClosed.catch(Cu.reportError),
+ () => state
+ );
+ } catch (ex) {
+ // It's too late to block shutdown, just close the connection.
+ conn.close();
+ throw ex;
+ }
+}
+
+XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised",
+ () => Sqlite.cloneStorageConnection({
+ connection: PlacesUtils.history.DBConnection,
+ readOnly: true
+ }).then(conn => {
+ setupDbForShutdown(conn, "PlacesUtils read-only connection");
+ return conn;
+ }).catch(Cu.reportError)
+);
+
+XPCOMUtils.defineLazyGetter(this, "gAsyncDBWrapperPromised",
+ () => Sqlite.wrapStorageConnection({
+ connection: PlacesUtils.history.DBConnection,
+ }).then(conn => {
+ setupDbForShutdown(conn, "PlacesUtils wrapped connection");
+ return conn;
+ }).catch(Cu.reportError)
+);
+
+/**
+ * Keywords management API.
+ * Sooner or later these keywords will merge with search keywords, this is an
+ * interim API that should then be replaced by a unified one.
+ * Keywords are associated with URLs and can have POST data.
+ * A single URL can have multiple keywords, provided they differ by POST data.
+ */
+var Keywords = {
+ /**
+ * Fetches a keyword entry based on keyword or URL.
+ *
+ * @param keywordOrEntry
+ * Either the keyword to fetch or an entry providing keyword
+ * or url property to find keywords for. If both properties are set,
+ * this returns their intersection.
+ * @param onResult [optional]
+ * Callback invoked for each found entry.
+ * @return {Promise}
+ * @resolves to an object in the form: { keyword, url, postData },
+ * or null if a keyword entry was not found.
+ */
+ fetch(keywordOrEntry, onResult=null) {
+ if (typeof(keywordOrEntry) == "string")
+ keywordOrEntry = { keyword: keywordOrEntry };
+
+ if (keywordOrEntry === null || typeof(keywordOrEntry) != "object" ||
+ (("keyword" in keywordOrEntry) && typeof(keywordOrEntry.keyword) != "string"))
+ throw new Error("Invalid keyword");
+
+ let hasKeyword = "keyword" in keywordOrEntry;
+ let hasUrl = "url" in keywordOrEntry;
+
+ if (!hasKeyword && !hasUrl)
+ throw new Error("At least keyword or url must be provided");
+ if (onResult && typeof onResult != "function")
+ throw new Error("onResult callback must be a valid function");
+
+ if (hasUrl)
+ keywordOrEntry.url = new URL(keywordOrEntry.url);
+ if (hasKeyword)
+ keywordOrEntry.keyword = keywordOrEntry.keyword.trim().toLowerCase();
+
+ let safeOnResult = entry => {
+ if (onResult) {
+ try {
+ onResult(entry);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ };
+
+ return gKeywordsCachePromise.then(cache => {
+ let entries = [];
+ if (hasKeyword) {
+ let entry = cache.get(keywordOrEntry.keyword);
+ if (entry)
+ entries.push(entry);
+ }
+ if (hasUrl) {
+ for (let entry of cache.values()) {
+ if (entry.url.href == keywordOrEntry.url.href)
+ entries.push(entry);
+ }
+ }
+
+ entries = entries.filter(e => {
+ return (!hasUrl || e.url.href == keywordOrEntry.url.href) &&
+ (!hasKeyword || e.keyword == keywordOrEntry.keyword);
+ });
+
+ entries.forEach(safeOnResult);
+ return entries.length ? entries[0] : null;
+ });
+ },
+
+ /**
+ * Adds a new keyword and postData for the given URL.
+ *
+ * @param keywordEntry
+ * An object describing the keyword to insert, in the form:
+ * {
+ * keyword: non-empty string,
+ * URL: URL or href to associate to the keyword,
+ * postData: optional POST data to associate to the keyword
+ * }
+ * @note Do not define a postData property if there isn't any POST data.
+ * @resolves when the addition is complete.
+ */
+ insert(keywordEntry) {
+ if (!keywordEntry || typeof keywordEntry != "object")
+ throw new Error("Input should be a valid object");
+
+ if (!("keyword" in keywordEntry) || !keywordEntry.keyword ||
+ typeof(keywordEntry.keyword) != "string")
+ throw new Error("Invalid keyword");
+ if (("postData" in keywordEntry) && keywordEntry.postData &&
+ typeof(keywordEntry.postData) != "string")
+ throw new Error("Invalid POST data");
+ if (!("url" in keywordEntry))
+ throw new Error("undefined is not a valid URL");
+ let { keyword, url,
+ source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = keywordEntry;
+ keyword = keyword.trim().toLowerCase();
+ let postData = keywordEntry.postData || null;
+ // This also checks href for validity
+ url = new URL(url);
+
+ return PlacesUtils.withConnectionWrapper("Keywords.insert", Task.async(function*(db) {
+ let cache = yield gKeywordsCachePromise;
+
+ // Trying to set the same keyword is a no-op.
+ let oldEntry = cache.get(keyword);
+ if (oldEntry && oldEntry.url.href == url.href &&
+ oldEntry.postData == keywordEntry.postData) {
+ return;
+ }
+
+ // A keyword can only be associated to a single page.
+ // If another page is using the new keyword, we must update the keyword
+ // entry.
+ // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete
+ // trigger.
+ if (oldEntry) {
+ yield db.executeCached(
+ `UPDATE moz_keywords
+ SET place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
+ post_data = :post_data
+ WHERE keyword = :keyword
+ `, { url: url.href, keyword: keyword, post_data: postData });
+ yield notifyKeywordChange(oldEntry.url.href, "", source);
+ } else {
+ // An entry for the given page could be missing, in such a case we need to
+ // create it. The IGNORE conflict can trigger on `guid`.
+ yield db.executeCached(
+ `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
+ VALUES (:url, hash(:url), :rev_host, 0, :frecency,
+ IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
+ GENERATE_GUID()))
+ `, { url: url.href, rev_host: PlacesUtils.getReversedHost(url),
+ frecency: url.protocol == "place:" ? 0 : -1 });
+ yield db.executeCached(
+ `INSERT INTO moz_keywords (keyword, place_id, post_data)
+ VALUES (:keyword, (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :post_data)
+ `, { url: url.href, keyword: keyword, post_data: postData });
+ }
+
+ cache.set(keyword, { keyword, url, postData });
+
+ // In any case, notify about the new keyword.
+ yield notifyKeywordChange(url.href, keyword, source);
+ }.bind(this))
+ );
+ },
+
+ /**
+ * Removes a keyword.
+ *
+ * @param keyword
+ * The keyword to remove.
+ * @return {Promise}
+ * @resolves when the removal is complete.
+ */
+ remove(keywordOrEntry) {
+ if (typeof(keywordOrEntry) == "string")
+ keywordOrEntry = { keyword: keywordOrEntry };
+
+ if (keywordOrEntry === null || typeof(keywordOrEntry) != "object" ||
+ !keywordOrEntry.keyword || typeof keywordOrEntry.keyword != "string")
+ throw new Error("Invalid keyword");
+
+ let { keyword,
+ source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = keywordOrEntry;
+ keyword = keywordOrEntry.keyword.trim().toLowerCase();
+ return PlacesUtils.withConnectionWrapper("Keywords.remove", Task.async(function*(db) {
+ let cache = yield gKeywordsCachePromise;
+ if (!cache.has(keyword))
+ return;
+ let { url } = cache.get(keyword);
+ cache.delete(keyword);
+
+ yield db.execute(`DELETE FROM moz_keywords WHERE keyword = :keyword`,
+ { keyword });
+
+ // Notify bookmarks about the removal.
+ yield notifyKeywordChange(url.href, "", source);
+ }.bind(this))) ;
+ }
+};
+
+// Set by the keywords API to distinguish notifications fired by the old API.
+// Once the old API will be gone, we can remove this and stop observing.
+var gIgnoreKeywordNotifications = false;
+
+XPCOMUtils.defineLazyGetter(this, "gKeywordsCachePromise", () =>
+ PlacesUtils.withConnectionWrapper("PlacesUtils: gKeywordsCachePromise",
+ Task.async(function*(db) {
+ let cache = new Map();
+ let rows = yield db.execute(
+ `SELECT keyword, url, post_data
+ FROM moz_keywords k
+ JOIN moz_places h ON h.id = k.place_id
+ `);
+ for (let row of rows) {
+ let keyword = row.getResultByName("keyword");
+ let entry = { keyword,
+ url: new URL(row.getResultByName("url")),
+ postData: row.getResultByName("post_data") };
+ cache.set(keyword, entry);
+ }
+
+ // Helper to get a keyword from an href.
+ function keywordsForHref(href) {
+ let keywords = [];
+ for (let [ key, val ] of cache) {
+ if (val.url.href == href)
+ keywords.push(key);
+ }
+ return keywords;
+ }
+
+ // Start observing changes to bookmarks. For now we are going to keep that
+ // relation for backwards compatibility reasons, but mostly because we are
+ // lacking a UI to manage keywords directly.
+ let observer = {
+ QueryInterface: XPCOMUtils.generateQI(Ci.nsINavBookmarkObserver),
+ onBeginUpdateBatch() {},
+ onEndUpdateBatch() {},
+ onItemAdded() {},
+ onItemVisited() {},
+ onItemMoved() {},
+
+ onItemRemoved(id, parentId, index, itemType, uri, guid, parentGuid) {
+ if (itemType != PlacesUtils.bookmarks.TYPE_BOOKMARK)
+ return;
+
+ let keywords = keywordsForHref(uri.spec);
+ // This uri has no keywords associated, so there's nothing to do.
+ if (keywords.length == 0)
+ return;
+
+ Task.spawn(function* () {
+ // If the uri is not bookmarked anymore, we can remove this keyword.
+ let bookmark = yield PlacesUtils.bookmarks.fetch({ url: uri });
+ if (!bookmark) {
+ for (let keyword of keywords) {
+ yield PlacesUtils.keywords.remove(keyword);
+ }
+ }
+ }).catch(Cu.reportError);
+ },
+
+ onItemChanged(id, prop, isAnno, val, lastMod, itemType, parentId, guid,
+ parentGuid, oldVal) {
+ if (gIgnoreKeywordNotifications) {
+ return;
+ }
+
+ if (prop == "keyword") {
+ this._onKeywordChanged(guid, val).catch(Cu.reportError);
+ } else if (prop == "uri") {
+ this._onUrlChanged(guid, val, oldVal).catch(Cu.reportError);
+ }
+ },
+
+ _onKeywordChanged: Task.async(function* (guid, keyword) {
+ let bookmark = yield PlacesUtils.bookmarks.fetch(guid);
+ // Due to mixed sync/async operations, by this time the bookmark could
+ // have disappeared and we already handle removals in onItemRemoved.
+ if (!bookmark) {
+ return;
+ }
+
+ if (keyword.length == 0) {
+ // We are removing a keyword.
+ let keywords = keywordsForHref(bookmark.url.href)
+ for (let kw of keywords) {
+ cache.delete(kw);
+ }
+ } else {
+ // We are adding a new keyword.
+ cache.set(keyword, { keyword, url: bookmark.url });
+ }
+ }),
+
+ _onUrlChanged: Task.async(function* (guid, url, oldUrl) {
+ // Check if the old url is associated with keywords.
+ let entries = [];
+ yield PlacesUtils.keywords.fetch({ url: oldUrl }, e => entries.push(e));
+ if (entries.length == 0) {
+ return;
+ }
+
+ // Move the keywords to the new url.
+ for (let entry of entries) {
+ yield PlacesUtils.keywords.remove(entry.keyword);
+ entry.url = new URL(url);
+ yield PlacesUtils.keywords.insert(entry);
+ }
+ }),
+ };
+
+ PlacesUtils.bookmarks.addObserver(observer, false);
+ PlacesUtils.registerShutdownFunction(() => {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ });
+ return cache;
+ })
+));
+
+// Sometime soon, likely as part of the transition to mozIAsyncBookmarks,
+// itemIds will be deprecated in favour of GUIDs, which play much better
+// with multiple undo/redo operations. Because these GUIDs are already stored,
+// and because we don't want to revise the transactions API once more when this
+// happens, transactions are set to work with GUIDs exclusively, in the sense
+// that they may never expose itemIds, nor do they accept them as input.
+// More importantly, transactions which add or remove items guarantee to
+// restore the GUIDs on undo/redo, so that the following transactions that may
+// done or undo can assume the items they're interested in are stil accessible
+// through the same GUID.
+// The current bookmarks API, however, doesn't expose the necessary means for
+// working with GUIDs. So, until it does, this helper object accesses the
+// Places database directly in order to switch between GUIDs and itemIds, and
+// "restore" GUIDs on items re-created items.
+var GuidHelper = {
+ // Cache for GUID<->itemId paris.
+ guidsForIds: new Map(),
+ idsForGuids: new Map(),
+
+ getItemId: Task.async(function* (aGuid) {
+ let cached = this.idsForGuids.get(aGuid);
+ if (cached !== undefined)
+ return cached;
+
+ let itemId = yield PlacesUtils.withConnectionWrapper("GuidHelper.getItemId",
+ Task.async(function* (db) {
+ let rows = yield db.executeCached(
+ "SELECT b.id, b.guid from moz_bookmarks b WHERE b.guid = :guid LIMIT 1",
+ { guid: aGuid });
+ if (rows.length == 0)
+ throw new Error("no item found for the given GUID");
+
+ return rows[0].getResultByName("id");
+ }));
+
+ this.updateCache(itemId, aGuid);
+ return itemId;
+ }),
+
+ getItemGuid: Task.async(function* (aItemId) {
+ let cached = this.guidsForIds.get(aItemId);
+ if (cached !== undefined)
+ return cached;
+
+ let guid = yield PlacesUtils.withConnectionWrapper("GuidHelper.getItemGuid",
+ Task.async(function* (db) {
+
+ let rows = yield db.executeCached(
+ "SELECT b.id, b.guid from moz_bookmarks b WHERE b.id = :id LIMIT 1",
+ { id: aItemId });
+ if (rows.length == 0)
+ throw new Error("no item found for the given itemId");
+
+ return rows[0].getResultByName("guid");
+ }));
+
+ this.updateCache(aItemId, guid);
+ return guid;
+ }),
+
+ /**
+ * Updates the cache.
+ *
+ * @note This is the only place where the cache should be populated,
+ * invalidation relies on both Maps being populated at the same time.
+ */
+ updateCache(aItemId, aGuid) {
+ if (typeof(aItemId) != "number" || aItemId <= 0)
+ throw new Error("Trying to update the GUIDs cache with an invalid itemId");
+ if (typeof(aGuid) != "string" || !/^[a-zA-Z0-9\-_]{12}$/.test(aGuid))
+ throw new Error("Trying to update the GUIDs cache with an invalid GUID");
+ this.ensureObservingRemovedItems();
+ this.guidsForIds.set(aItemId, aGuid);
+ this.idsForGuids.set(aGuid, aItemId);
+ },
+
+ invalidateCacheForItemId(aItemId) {
+ let guid = this.guidsForIds.get(aItemId);
+ this.guidsForIds.delete(aItemId);
+ this.idsForGuids.delete(guid);
+ },
+
+ ensureObservingRemovedItems: function () {
+ if (!("observer" in this)) {
+ /**
+ * This observers serves two purposes:
+ * (1) Invalidate cached id<->GUID paris on when items are removed.
+ * (2) Cache GUIDs given us free of charge by onItemAdded/onItemRemoved.
+ * So, for exmaple, when the NewBookmark needs the new GUID, we already
+ * have it cached.
+ */
+ this.observer = {
+ onItemAdded: (aItemId, aParentId, aIndex, aItemType, aURI, aTitle,
+ aDateAdded, aGuid, aParentGuid) => {
+ this.updateCache(aItemId, aGuid);
+ this.updateCache(aParentId, aParentGuid);
+ },
+ onItemRemoved:
+ (aItemId, aParentId, aIndex, aItemTyep, aURI, aGuid, aParentGuid) => {
+ this.guidsForIds.delete(aItemId);
+ this.idsForGuids.delete(aGuid);
+ this.updateCache(aParentId, aParentGuid);
+ },
+
+ QueryInterface: XPCOMUtils.generateQI(Ci.nsINavBookmarkObserver),
+
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onItemChanged: function() {},
+ onItemVisited: function() {},
+ onItemMoved: function() {},
+ };
+ PlacesUtils.bookmarks.addObserver(this.observer, false);
+ PlacesUtils.registerShutdownFunction(() => {
+ PlacesUtils.bookmarks.removeObserver(this.observer);
+ });
+ }
+ }
+};
+
+// Transactions handlers.
+
+/**
+ * Updates commands in the undo group of the active window commands.
+ * Inactive windows commands will be updated on focus.
+ */
+function updateCommandsOnActiveWindow()
+{
+ let win = Services.focus.activeWindow;
+ if (win && win instanceof Ci.nsIDOMWindow) {
+ // Updating "undo" will cause a group update including "redo".
+ win.updateCommands("undo");
+ }
+}
+
+
+/**
+ * Used to cache bookmark information in transactions.
+ *
+ * @note To avoid leaks any non-primitive property should be copied.
+ * @note Used internally, DO NOT EXPORT.
+ */
+function TransactionItemCache()
+{
+}
+
+TransactionItemCache.prototype = {
+ set id(v) {
+ this._id = (parseInt(v) > 0 ? v : null);
+ },
+ get id() {
+ return this._id || -1;
+ },
+ set parentId(v) {
+ this._parentId = (parseInt(v) > 0 ? v : null);
+ },
+ get parentId() {
+ return this._parentId || -1;
+ },
+ keyword: null,
+ title: null,
+ dateAdded: null,
+ lastModified: null,
+ postData: null,
+ itemType: null,
+ set uri(v) {
+ this._uri = (v instanceof Ci.nsIURI ? v.clone() : null);
+ },
+ get uri() {
+ return this._uri || null;
+ },
+ set feedURI(v) {
+ this._feedURI = (v instanceof Ci.nsIURI ? v.clone() : null);
+ },
+ get feedURI() {
+ return this._feedURI || null;
+ },
+ set siteURI(v) {
+ this._siteURI = (v instanceof Ci.nsIURI ? v.clone() : null);
+ },
+ get siteURI() {
+ return this._siteURI || null;
+ },
+ set index(v) {
+ this._index = (parseInt(v) >= 0 ? v : null);
+ },
+ // Index can be 0.
+ get index() {
+ return this._index != null ? this._index : PlacesUtils.bookmarks.DEFAULT_INDEX;
+ },
+ set annotations(v) {
+ this._annotations = Array.isArray(v) ? Cu.cloneInto(v, {}) : null;
+ },
+ get annotations() {
+ return this._annotations || null;
+ },
+ set tags(v) {
+ this._tags = (v && Array.isArray(v) ? Array.prototype.slice.call(v) : null);
+ },
+ get tags() {
+ return this._tags || null;
+ },
+};
+
+
+/**
+ * Base transaction implementation.
+ *
+ * @note used internally, DO NOT EXPORT.
+ */
+function BaseTransaction()
+{
+}
+
+BaseTransaction.prototype = {
+ name: null,
+ set childTransactions(v) {
+ this._childTransactions = (Array.isArray(v) ? Array.prototype.slice.call(v) : null);
+ },
+ get childTransactions() {
+ return this._childTransactions || null;
+ },
+ doTransaction: function BTXN_doTransaction() {},
+ redoTransaction: function BTXN_redoTransaction() {
+ return this.doTransaction();
+ },
+ undoTransaction: function BTXN_undoTransaction() {},
+ merge: function BTXN_merge() {
+ return false;
+ },
+ get isTransient() {
+ return false;
+ },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsITransaction
+ ]),
+};
+
+
+/**
+ * Transaction for performing several Places Transactions in a single batch.
+ *
+ * @param aName
+ * title of the aggregate transactions
+ * @param aTransactions
+ * an array of transactions to perform
+ *
+ * @return nsITransaction object
+ */
+this.PlacesAggregatedTransaction =
+ function PlacesAggregatedTransaction(aName, aTransactions)
+{
+ // Copy the transactions array to decouple it from its prototype, which
+ // otherwise keeps alive its associated global object.
+ this.childTransactions = aTransactions;
+ this.name = aName;
+ this.item = new TransactionItemCache();
+
+ // Check child transactions number. We will batch if we have more than
+ // MIN_TRANSACTIONS_FOR_BATCH total number of transactions.
+ let countTransactions = function(aTransactions, aTxnCount)
+ {
+ for (let i = 0;
+ i < aTransactions.length && aTxnCount < MIN_TRANSACTIONS_FOR_BATCH;
+ ++i, ++aTxnCount) {
+ let txn = aTransactions[i];
+ if (txn.childTransactions && txn.childTransactions.length > 0)
+ aTxnCount = countTransactions(txn.childTransactions, aTxnCount);
+ }
+ return aTxnCount;
+ }
+
+ let txnCount = countTransactions(this.childTransactions, 0);
+ this._useBatch = txnCount >= MIN_TRANSACTIONS_FOR_BATCH;
+}
+
+PlacesAggregatedTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function ATXN_doTransaction()
+ {
+ this._isUndo = false;
+ if (this._useBatch)
+ PlacesUtils.bookmarks.runInBatchMode(this, null);
+ else
+ this.runBatched(false);
+ },
+
+ undoTransaction: function ATXN_undoTransaction()
+ {
+ this._isUndo = true;
+ if (this._useBatch)
+ PlacesUtils.bookmarks.runInBatchMode(this, null);
+ else
+ this.runBatched(true);
+ },
+
+ runBatched: function ATXN_runBatched()
+ {
+ // Use a copy of the transactions array, so we won't reverse the original
+ // one on undoing.
+ let transactions = this.childTransactions.slice(0);
+ if (this._isUndo)
+ transactions.reverse();
+ for (let i = 0; i < transactions.length; ++i) {
+ let txn = transactions[i];
+ if (this.item.parentId != -1)
+ txn.item.parentId = this.item.parentId;
+ if (this._isUndo)
+ txn.undoTransaction();
+ else
+ txn.doTransaction();
+ }
+ }
+};
+
+
+/**
+ * Transaction for creating a new folder.
+ *
+ * @param aTitle
+ * the title for the new folder
+ * @param aParentId
+ * the id of the parent folder in which the new folder should be added
+ * @param [optional] aIndex
+ * the index of the item in aParentId
+ * @param [optional] aAnnotations
+ * array of annotations to set for the new folder
+ * @param [optional] aChildTransactions
+ * array of transactions for items to be created in the new folder
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateFolderTransaction =
+ function PlacesCreateFolderTransaction(aTitle, aParentId, aIndex, aAnnotations,
+ aChildTransactions)
+{
+ this.item = new TransactionItemCache();
+ this.item.title = aTitle;
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+ this.item.annotations = aAnnotations;
+ this.childTransactions = aChildTransactions;
+}
+
+PlacesCreateFolderTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CFTXN_doTransaction()
+ {
+ this.item.id = PlacesUtils.bookmarks.createFolder(this.item.parentId,
+ this.item.title,
+ this.item.index);
+ if (this.item.annotations && this.item.annotations.length > 0)
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ // Set the new parent id into child transactions.
+ for (let i = 0; i < this.childTransactions.length; ++i) {
+ this.childTransactions[i].item.parentId = this.item.id;
+ }
+
+ let txn = new PlacesAggregatedTransaction("Create folder childTxn",
+ this.childTransactions);
+ txn.doTransaction();
+ }
+ },
+
+ undoTransaction: function CFTXN_undoTransaction()
+ {
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ let txn = new PlacesAggregatedTransaction("Create folder childTxn",
+ this.childTransactions);
+ txn.undoTransaction();
+ }
+
+ // Remove item only after all child transactions have been reverted.
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }
+};
+
+
+/**
+ * Transaction for creating a new bookmark.
+ *
+ * @param aURI
+ * the nsIURI of the new bookmark
+ * @param aParentId
+ * the id of the folder in which the bookmark should be added.
+ * @param [optional] aIndex
+ * the index of the item in aParentId
+ * @param [optional] aTitle
+ * the title of the new bookmark
+ * @param [optional] aKeyword
+ * the keyword for the new bookmark
+ * @param [optional] aAnnotations
+ * array of annotations to set for the new bookmark
+ * @param [optional] aChildTransactions
+ * child transactions to commit after creating the bookmark. Prefer
+ * using any of the arguments above if possible. In general, a child
+ * transations should be used only if the change it does has to be
+ * reverted manually when removing the bookmark item.
+ * a child transaction must support setting its bookmark-item
+ * identifier via an "id" js setter.
+ * @param [optional] aPostData
+ * keyword's POST data, if available.
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateBookmarkTransaction =
+ function PlacesCreateBookmarkTransaction(aURI, aParentId, aIndex, aTitle,
+ aKeyword, aAnnotations,
+ aChildTransactions, aPostData)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+ this.item.title = aTitle;
+ this.item.keyword = aKeyword;
+ this.item.postData = aPostData;
+ this.item.annotations = aAnnotations;
+ this.childTransactions = aChildTransactions;
+}
+
+PlacesCreateBookmarkTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CITXN_doTransaction()
+ {
+ this.item.id = PlacesUtils.bookmarks.insertBookmark(this.item.parentId,
+ this.item.uri,
+ this.item.index,
+ this.item.title);
+ if (this.item.keyword) {
+ PlacesUtils.bookmarks.setKeywordForBookmark(this.item.id,
+ this.item.keyword);
+ if (this.item.postData) {
+ PlacesUtils.setPostDataForBookmark(this.item.id,
+ this.item.postData);
+ }
+ }
+ if (this.item.annotations && this.item.annotations.length > 0)
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ // Set the new item id into child transactions.
+ for (let i = 0; i < this.childTransactions.length; ++i) {
+ this.childTransactions[i].item.id = this.item.id;
+ }
+ let txn = new PlacesAggregatedTransaction("Create item childTxn",
+ this.childTransactions);
+ txn.doTransaction();
+ }
+ },
+
+ undoTransaction: function CITXN_undoTransaction()
+ {
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ // Undo transactions should always be done in reverse order.
+ let txn = new PlacesAggregatedTransaction("Create item childTxn",
+ this.childTransactions);
+ txn.undoTransaction();
+ }
+
+ // Remove item only after all child transactions have been reverted.
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }
+};
+
+
+/**
+ * Transaction for creating a new separator.
+ *
+ * @param aParentId
+ * the id of the folder in which the separator should be added
+ * @param [optional] aIndex
+ * the index of the item in aParentId
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateSeparatorTransaction =
+ function PlacesCreateSeparatorTransaction(aParentId, aIndex)
+{
+ this.item = new TransactionItemCache();
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+}
+
+PlacesCreateSeparatorTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CSTXN_doTransaction()
+ {
+ this.item.id =
+ PlacesUtils.bookmarks.insertSeparator(this.item.parentId, this.item.index);
+ },
+
+ undoTransaction: function CSTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }
+};
+
+
+/**
+ * Transaction for creating a new livemark item.
+ *
+ * @see mozIAsyncLivemarks for documentation regarding the arguments.
+ *
+ * @param aFeedURI
+ * nsIURI of the feed
+ * @param [optional] aSiteURI
+ * nsIURI of the page serving the feed
+ * @param aTitle
+ * title for the livemark
+ * @param aParentId
+ * the id of the folder in which the livemark should be added
+ * @param [optional] aIndex
+ * the index of the livemark in aParentId
+ * @param [optional] aAnnotations
+ * array of annotations to set for the new livemark.
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateLivemarkTransaction =
+ function PlacesCreateLivemarkTransaction(aFeedURI, aSiteURI, aTitle, aParentId,
+ aIndex, aAnnotations)
+{
+ this.item = new TransactionItemCache();
+ this.item.feedURI = aFeedURI;
+ this.item.siteURI = aSiteURI;
+ this.item.title = aTitle;
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+ this.item.annotations = aAnnotations;
+}
+
+PlacesCreateLivemarkTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CLTXN_doTransaction()
+ {
+ this._promise = PlacesUtils.livemarks.addLivemark(
+ { title: this.item.title
+ , feedURI: this.item.feedURI
+ , parentId: this.item.parentId
+ , index: this.item.index
+ , siteURI: this.item.siteURI
+ }).then(aLivemark => {
+ this.item.id = aLivemark.id;
+ if (this.item.annotations && this.item.annotations.length > 0) {
+ PlacesUtils.setAnnotationsForItem(this.item.id,
+ this.item.annotations);
+ }
+ }, Cu.reportError);
+ },
+
+ undoTransaction: function CLTXN_undoTransaction()
+ {
+ // The getLivemark callback may fail, but it is used just to serialize,
+ // so it doesn't matter.
+ this._promise = PlacesUtils.livemarks.getLivemark({ id: this.item.id })
+ .then(null, null).then( () => {
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ });
+ }
+};
+
+
+/**
+ * Transaction for removing a livemark item.
+ *
+ * @param aLivemarkId
+ * the identifier of the folder for the livemark.
+ *
+ * @return nsITransaction object
+ * @note used internally by PlacesRemoveItemTransaction, DO NOT EXPORT.
+ */
+function PlacesRemoveLivemarkTransaction(aLivemarkId)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aLivemarkId;
+ this.item.title = PlacesUtils.bookmarks.getItemTitle(this.item.id);
+ this.item.parentId = PlacesUtils.bookmarks.getFolderIdForItem(this.item.id);
+
+ let annos = PlacesUtils.getAnnotationsForItem(this.item.id);
+ // Exclude livemark service annotations, those will be recreated automatically
+ let annosToExclude = [PlacesUtils.LMANNO_FEEDURI,
+ PlacesUtils.LMANNO_SITEURI];
+ this.item.annotations = annos.filter(function(aValue, aIndex, aArray) {
+ return !annosToExclude.includes(aValue.name);
+ });
+ this.item.dateAdded = PlacesUtils.bookmarks.getItemDateAdded(this.item.id);
+ this.item.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(this.item.id);
+}
+
+PlacesRemoveLivemarkTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function RLTXN_doTransaction()
+ {
+ PlacesUtils.livemarks.getLivemark({ id: this.item.id })
+ .then(aLivemark => {
+ this.item.feedURI = aLivemark.feedURI;
+ this.item.siteURI = aLivemark.siteURI;
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }, Cu.reportError);
+ },
+
+ undoTransaction: function RLTXN_undoTransaction()
+ {
+ // Undo work must be serialized, otherwise won't be able to know the
+ // feedURI and siteURI of the livemark.
+ // The getLivemark callback is expected to receive a failure status but it
+ // is used just to serialize, so doesn't matter.
+ PlacesUtils.livemarks.getLivemark({ id: this.item.id })
+ .then(null, () => {
+ PlacesUtils.livemarks.addLivemark({ parentId: this.item.parentId
+ , title: this.item.title
+ , siteURI: this.item.siteURI
+ , feedURI: this.item.feedURI
+ , index: this.item.index
+ , lastModified: this.item.lastModified
+ }).then(
+ aLivemark => {
+ let itemId = aLivemark.id;
+ PlacesUtils.bookmarks.setItemDateAdded(itemId, this.item.dateAdded);
+ PlacesUtils.setAnnotationsForItem(itemId, this.item.annotations);
+ }, Cu.reportError);
+ });
+ }
+};
+
+
+/**
+ * Transaction for moving an Item.
+ *
+ * @param aItemId
+ * the id of the item to move
+ * @param aNewParentId
+ * id of the new parent to move to
+ * @param aNewIndex
+ * index of the new position to move to
+ *
+ * @return nsITransaction object
+ */
+this.PlacesMoveItemTransaction =
+ function PlacesMoveItemTransaction(aItemId, aNewParentId, aNewIndex)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.item.parentId = PlacesUtils.bookmarks.getFolderIdForItem(this.item.id);
+ this.new = new TransactionItemCache();
+ this.new.parentId = aNewParentId;
+ this.new.index = aNewIndex;
+}
+
+PlacesMoveItemTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function MITXN_doTransaction()
+ {
+ this.item.index = PlacesUtils.bookmarks.getItemIndex(this.item.id);
+ PlacesUtils.bookmarks.moveItem(this.item.id,
+ this.new.parentId, this.new.index);
+ this._undoIndex = PlacesUtils.bookmarks.getItemIndex(this.item.id);
+ },
+
+ undoTransaction: function MITXN_undoTransaction()
+ {
+ // moving down in the same parent takes in count removal of the item
+ // so to revert positions we must move to oldIndex + 1
+ if (this.new.parentId == this.item.parentId &&
+ this.item.index > this._undoIndex) {
+ PlacesUtils.bookmarks.moveItem(this.item.id, this.item.parentId,
+ this.item.index + 1);
+ }
+ else {
+ PlacesUtils.bookmarks.moveItem(this.item.id, this.item.parentId,
+ this.item.index);
+ }
+ }
+};
+
+
+/**
+ * Transaction for removing an Item
+ *
+ * @param aItemId
+ * id of the item to remove
+ *
+ * @return nsITransaction object
+ */
+this.PlacesRemoveItemTransaction =
+ function PlacesRemoveItemTransaction(aItemId)
+{
+ if (PlacesUtils.isRootItem(aItemId))
+ throw Cr.NS_ERROR_INVALID_ARG;
+
+ // if the item lives within a tag container, use the tagging transactions
+ let parent = PlacesUtils.bookmarks.getFolderIdForItem(aItemId);
+ let grandparent = PlacesUtils.bookmarks.getFolderIdForItem(parent);
+ if (grandparent == PlacesUtils.tagsFolderId) {
+ let uri = PlacesUtils.bookmarks.getBookmarkURI(aItemId);
+ return new PlacesUntagURITransaction(uri, [parent]);
+ }
+
+ // if the item is a livemark container we will not save its children.
+ if (PlacesUtils.annotations.itemHasAnnotation(aItemId,
+ PlacesUtils.LMANNO_FEEDURI))
+ return new PlacesRemoveLivemarkTransaction(aItemId);
+
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.item.itemType = PlacesUtils.bookmarks.getItemType(this.item.id);
+ if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_FOLDER) {
+ this.childTransactions = this._getFolderContentsTransactions();
+ // Remove this folder itself.
+ let txn = PlacesUtils.bookmarks.getRemoveFolderTransaction(this.item.id);
+ this.childTransactions.push(txn);
+ }
+ else if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) {
+ this.item.uri = PlacesUtils.bookmarks.getBookmarkURI(this.item.id);
+ this.item.keyword =
+ PlacesUtils.bookmarks.getKeywordForBookmark(this.item.id);
+ if (this.item.keyword)
+ this.item.postData = PlacesUtils.getPostDataForBookmark(this.item.id);
+ }
+
+ if (this.item.itemType != Ci.nsINavBookmarksService.TYPE_SEPARATOR)
+ this.item.title = PlacesUtils.bookmarks.getItemTitle(this.item.id);
+
+ this.item.parentId = PlacesUtils.bookmarks.getFolderIdForItem(this.item.id);
+ this.item.annotations = PlacesUtils.getAnnotationsForItem(this.item.id);
+ this.item.dateAdded = PlacesUtils.bookmarks.getItemDateAdded(this.item.id);
+ this.item.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(this.item.id);
+}
+
+PlacesRemoveItemTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function RITXN_doTransaction()
+ {
+ this.item.index = PlacesUtils.bookmarks.getItemIndex(this.item.id);
+
+ if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_FOLDER) {
+ let txn = new PlacesAggregatedTransaction("Remove item childTxn",
+ this.childTransactions);
+ txn.doTransaction();
+ }
+ else {
+ // Before removing the bookmark, save its tags.
+ let tags = this.item.uri ?
+ PlacesUtils.tagging.getTagsForURI(this.item.uri) : null;
+
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+
+ // If this was the last bookmark (excluding tag-items) for this url,
+ // persist the tags.
+ if (tags && PlacesUtils.getMostRecentBookmarkForURI(this.item.uri) == -1) {
+ this.item.tags = tags;
+ }
+ }
+ },
+
+ undoTransaction: function RITXN_undoTransaction()
+ {
+ if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) {
+ this.item.id = PlacesUtils.bookmarks.insertBookmark(this.item.parentId,
+ this.item.uri,
+ this.item.index,
+ this.item.title);
+ if (this.item.tags && this.item.tags.length > 0)
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ if (this.item.keyword) {
+ PlacesUtils.bookmarks.setKeywordForBookmark(this.item.id,
+ this.item.keyword);
+ if (this.item.postData) {
+ PlacesUtils.bookmarks.setPostDataForBookmark(this.item.id);
+ }
+ }
+ }
+ else if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_FOLDER) {
+ let txn = new PlacesAggregatedTransaction("Remove item childTxn",
+ this.childTransactions);
+ txn.undoTransaction();
+ }
+ else { // TYPE_SEPARATOR
+ this.item.id = PlacesUtils.bookmarks.insertSeparator(this.item.parentId,
+ this.item.index);
+ }
+
+ if (this.item.annotations && this.item.annotations.length > 0)
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+
+ PlacesUtils.bookmarks.setItemDateAdded(this.item.id, this.item.dateAdded);
+ PlacesUtils.bookmarks.setItemLastModified(this.item.id,
+ this.item.lastModified);
+ },
+
+ /**
+ * Returns a flat, ordered list of transactions for a depth-first recreation
+ * of items within this folder.
+ */
+ _getFolderContentsTransactions:
+ function RITXN__getFolderContentsTransactions()
+ {
+ let transactions = [];
+ let contents =
+ PlacesUtils.getFolderContents(this.item.id, false, false).root;
+ for (let i = 0; i < contents.childCount; ++i) {
+ let txn = new PlacesRemoveItemTransaction(contents.getChild(i).itemId);
+ transactions.push(txn);
+ }
+ contents.containerOpen = false;
+ // Reverse transactions to preserve parent-child relationship.
+ return transactions.reverse();
+ }
+};
+
+
+/**
+ * Transaction for editting a bookmark's title.
+ *
+ * @param aItemId
+ * id of the item to edit
+ * @param aNewTitle
+ * new title for the item to edit
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditItemTitleTransaction =
+ function PlacesEditItemTitleTransaction(aItemId, aNewTitle)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.title = aNewTitle;
+}
+
+PlacesEditItemTitleTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EITTXN_doTransaction()
+ {
+ this.item.title = PlacesUtils.bookmarks.getItemTitle(this.item.id);
+ PlacesUtils.bookmarks.setItemTitle(this.item.id, this.new.title);
+ },
+
+ undoTransaction: function EITTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.setItemTitle(this.item.id, this.item.title);
+ }
+};
+
+
+/**
+ * Transaction for editing a bookmark's uri.
+ *
+ * @param aItemId
+ * id of the bookmark to edit
+ * @param aNewURI
+ * new uri for the bookmark
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditBookmarkURITransaction =
+ function PlacesEditBookmarkURITransaction(aItemId, aNewURI) {
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.uri = aNewURI;
+}
+
+PlacesEditBookmarkURITransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EBUTXN_doTransaction()
+ {
+ this.item.uri = PlacesUtils.bookmarks.getBookmarkURI(this.item.id);
+ PlacesUtils.bookmarks.changeBookmarkURI(this.item.id, this.new.uri);
+ // move tags from old URI to new URI
+ this.item.tags = PlacesUtils.tagging.getTagsForURI(this.item.uri);
+ if (this.item.tags.length > 0) {
+ // only untag the old URI if this is the only bookmark
+ if (PlacesUtils.getBookmarksForURI(this.item.uri, {}).length == 0)
+ PlacesUtils.tagging.untagURI(this.item.uri, this.item.tags);
+ PlacesUtils.tagging.tagURI(this.new.uri, this.item.tags);
+ }
+ },
+
+ undoTransaction: function EBUTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.changeBookmarkURI(this.item.id, this.item.uri);
+ // move tags from new URI to old URI
+ if (this.item.tags.length > 0) {
+ // only untag the new URI if this is the only bookmark
+ if (PlacesUtils.getBookmarksForURI(this.new.uri, {}).length == 0)
+ PlacesUtils.tagging.untagURI(this.new.uri, this.item.tags);
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ }
+ }
+};
+
+
+/**
+ * Transaction for setting/unsetting an item annotation
+ *
+ * @param aItemId
+ * id of the item where to set annotation
+ * @param aAnnotationObject
+ * Object representing an annotation, containing the following
+ * properties: name, flags, expires, value.
+ * If value is null the annotation will be removed
+ *
+ * @return nsITransaction object
+ */
+this.PlacesSetItemAnnotationTransaction =
+ function PlacesSetItemAnnotationTransaction(aItemId, aAnnotationObject)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.annotations = [aAnnotationObject];
+}
+
+PlacesSetItemAnnotationTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function SIATXN_doTransaction()
+ {
+ let annoName = this.new.annotations[0].name;
+ if (PlacesUtils.annotations.itemHasAnnotation(this.item.id, annoName)) {
+ // fill the old anno if it is set
+ let flags = {}, expires = {}, type = {};
+ PlacesUtils.annotations.getItemAnnotationInfo(this.item.id, annoName, flags,
+ expires, type);
+ let value = PlacesUtils.annotations.getItemAnnotation(this.item.id,
+ annoName);
+ this.item.annotations = [{ name: annoName,
+ type: type.value,
+ flags: flags.value,
+ value: value,
+ expires: expires.value }];
+ }
+ else {
+ // create an empty old anno
+ this.item.annotations = [{ name: annoName,
+ flags: 0,
+ value: null,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER }];
+ }
+
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.new.annotations);
+ },
+
+ undoTransaction: function SIATXN_undoTransaction()
+ {
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+ }
+};
+
+
+/**
+ * Transaction for setting/unsetting a page annotation
+ *
+ * @param aURI
+ * URI of the page where to set annotation
+ * @param aAnnotationObject
+ * Object representing an annotation, containing the following
+ * properties: name, flags, expires, value.
+ * If value is null the annotation will be removed
+ *
+ * @return nsITransaction object
+ */
+this.PlacesSetPageAnnotationTransaction =
+ function PlacesSetPageAnnotationTransaction(aURI, aAnnotationObject)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ this.new = new TransactionItemCache();
+ this.new.annotations = [aAnnotationObject];
+}
+
+PlacesSetPageAnnotationTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function SPATXN_doTransaction()
+ {
+ let annoName = this.new.annotations[0].name;
+ if (PlacesUtils.annotations.pageHasAnnotation(this.item.uri, annoName)) {
+ // fill the old anno if it is set
+ let flags = {}, expires = {}, type = {};
+ PlacesUtils.annotations.getPageAnnotationInfo(this.item.uri, annoName, flags,
+ expires, type);
+ let value = PlacesUtils.annotations.getPageAnnotation(this.item.uri,
+ annoName);
+ this.item.annotations = [{ name: annoName,
+ flags: flags.value,
+ value: value,
+ expires: expires.value }];
+ }
+ else {
+ // create an empty old anno
+ this.item.annotations = [{ name: annoName,
+ type: Ci.nsIAnnotationService.TYPE_STRING,
+ flags: 0,
+ value: null,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER }];
+ }
+
+ PlacesUtils.setAnnotationsForURI(this.item.uri, this.new.annotations);
+ },
+
+ undoTransaction: function SPATXN_undoTransaction()
+ {
+ PlacesUtils.setAnnotationsForURI(this.item.uri, this.item.annotations);
+ }
+};
+
+
+/**
+ * Transaction for editing a bookmark's keyword.
+ *
+ * @param aItemId
+ * id of the bookmark to edit
+ * @param aNewKeyword
+ * new keyword for the bookmark
+ * @param aNewPostData [optional]
+ * new keyword's POST data, if available
+ * @param aOldKeyword [optional]
+ * old keyword of the bookmark
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditBookmarkKeywordTransaction =
+ function PlacesEditBookmarkKeywordTransaction(aItemId, aNewKeyword,
+ aNewPostData, aOldKeyword) {
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.item.keyword = aOldKeyword;
+ this.item.href = (PlacesUtils.bookmarks.getBookmarkURI(aItemId)).spec;
+ this.new = new TransactionItemCache();
+ this.new.keyword = aNewKeyword;
+ this.new.postData = aNewPostData
+}
+
+PlacesEditBookmarkKeywordTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EBKTXN_doTransaction()
+ {
+ let done = false;
+ Task.spawn(function* () {
+ if (this.item.keyword) {
+ let oldEntry = yield PlacesUtils.keywords.fetch(this.item.keyword);
+ this.item.postData = oldEntry.postData;
+ yield PlacesUtils.keywords.remove(this.item.keyword);
+ }
+
+ if (this.new.keyword) {
+ yield PlacesUtils.keywords.insert({
+ url: this.item.href,
+ keyword: this.new.keyword,
+ postData: this.new.postData || this.item.postData
+ });
+ }
+ }.bind(this)).catch(Cu.reportError)
+ .then(() => done = true);
+ // TODO: Until we can move to PlacesTransactions.jsm, we must spin the
+ // events loop :(
+ let thread = Services.tm.currentThread;
+ while (!done) {
+ thread.processNextEvent(true);
+ }
+ },
+
+ undoTransaction: function EBKTXN_undoTransaction()
+ {
+
+ let done = false;
+ Task.spawn(function* () {
+ if (this.new.keyword) {
+ yield PlacesUtils.keywords.remove(this.new.keyword);
+ }
+
+ if (this.item.keyword) {
+ yield PlacesUtils.keywords.insert({
+ url: this.item.href,
+ keyword: this.item.keyword,
+ postData: this.item.postData
+ });
+ }
+ }.bind(this)).catch(Cu.reportError)
+ .then(() => done = true);
+ // TODO: Until we can move to PlacesTransactions.jsm, we must spin the
+ // events loop :(
+ let thread = Services.tm.currentThread;
+ while (!done) {
+ thread.processNextEvent(true);
+ }
+ }
+};
+
+
+/**
+ * Transaction for editing the post data associated with a bookmark.
+ *
+ * @param aItemId
+ * id of the bookmark to edit
+ * @param aPostData
+ * post data
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditBookmarkPostDataTransaction =
+ function PlacesEditBookmarkPostDataTransaction(aItemId, aPostData)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.postData = aPostData;
+}
+
+PlacesEditBookmarkPostDataTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction() {
+ // Setting null postData is not supported by the current schema.
+ if (this.new.postData) {
+ this.item.postData = PlacesUtils.getPostDataForBookmark(this.item.id);
+ PlacesUtils.setPostDataForBookmark(this.item.id, this.new.postData);
+ }
+ },
+
+ undoTransaction() {
+ // Setting null postData is not supported by the current schema.
+ if (this.item.postData) {
+ PlacesUtils.setPostDataForBookmark(this.item.id, this.item.postData);
+ }
+ }
+};
+
+
+/**
+ * Transaction for editing an item's date added property.
+ *
+ * @param aItemId
+ * id of the item to edit
+ * @param aNewDateAdded
+ * new date added for the item
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditItemDateAddedTransaction =
+ function PlacesEditItemDateAddedTransaction(aItemId, aNewDateAdded)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.dateAdded = aNewDateAdded;
+}
+
+PlacesEditItemDateAddedTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EIDATXN_doTransaction()
+ {
+ // Child transactions have the id set as parentId.
+ if (this.item.id == -1 && this.item.parentId != -1)
+ this.item.id = this.item.parentId;
+ this.item.dateAdded =
+ PlacesUtils.bookmarks.getItemDateAdded(this.item.id);
+ PlacesUtils.bookmarks.setItemDateAdded(this.item.id, this.new.dateAdded);
+ },
+
+ undoTransaction: function EIDATXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.setItemDateAdded(this.item.id, this.item.dateAdded);
+ }
+};
+
+
+/**
+ * Transaction for editing an item's last modified time.
+ *
+ * @param aItemId
+ * id of the item to edit
+ * @param aNewLastModified
+ * new last modified date for the item
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditItemLastModifiedTransaction =
+ function PlacesEditItemLastModifiedTransaction(aItemId, aNewLastModified)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.lastModified = aNewLastModified;
+}
+
+PlacesEditItemLastModifiedTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction:
+ function EILMTXN_doTransaction()
+ {
+ // Child transactions have the id set as parentId.
+ if (this.item.id == -1 && this.item.parentId != -1)
+ this.item.id = this.item.parentId;
+ this.item.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(this.item.id);
+ PlacesUtils.bookmarks.setItemLastModified(this.item.id,
+ this.new.lastModified);
+ },
+
+ undoTransaction:
+ function EILMTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.setItemLastModified(this.item.id,
+ this.item.lastModified);
+ }
+};
+
+
+/**
+ * Transaction for sorting a folder by name
+ *
+ * @param aFolderId
+ * id of the folder to sort
+ *
+ * @return nsITransaction object
+ */
+this.PlacesSortFolderByNameTransaction =
+ function PlacesSortFolderByNameTransaction(aFolderId)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aFolderId;
+}
+
+PlacesSortFolderByNameTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function SFBNTXN_doTransaction()
+ {
+ this._oldOrder = [];
+
+ let contents =
+ PlacesUtils.getFolderContents(this.item.id, false, false).root;
+ let count = contents.childCount;
+
+ // sort between separators
+ let newOrder = [];
+ let preSep = []; // temporary array for sorting each group of items
+ let sortingMethod =
+ function (a, b) {
+ if (PlacesUtils.nodeIsContainer(a) && !PlacesUtils.nodeIsContainer(b))
+ return -1;
+ if (!PlacesUtils.nodeIsContainer(a) && PlacesUtils.nodeIsContainer(b))
+ return 1;
+ return a.title.localeCompare(b.title);
+ };
+
+ for (let i = 0; i < count; ++i) {
+ let item = contents.getChild(i);
+ this._oldOrder[item.itemId] = i;
+ if (PlacesUtils.nodeIsSeparator(item)) {
+ if (preSep.length > 0) {
+ preSep.sort(sortingMethod);
+ newOrder = newOrder.concat(preSep);
+ preSep.splice(0, preSep.length);
+ }
+ newOrder.push(item);
+ }
+ else
+ preSep.push(item);
+ }
+ contents.containerOpen = false;
+
+ if (preSep.length > 0) {
+ preSep.sort(sortingMethod);
+ newOrder = newOrder.concat(preSep);
+ }
+
+ // set the nex indexes
+ let callback = {
+ runBatched: function() {
+ for (let i = 0; i < newOrder.length; ++i) {
+ PlacesUtils.bookmarks.setItemIndex(newOrder[i].itemId, i);
+ }
+ }
+ };
+ PlacesUtils.bookmarks.runInBatchMode(callback, null);
+ },
+
+ undoTransaction: function SFBNTXN_undoTransaction()
+ {
+ let callback = {
+ _self: this,
+ runBatched: function() {
+ for (let item in this._self._oldOrder)
+ PlacesUtils.bookmarks.setItemIndex(item, this._self._oldOrder[item]);
+ }
+ };
+ PlacesUtils.bookmarks.runInBatchMode(callback, null);
+ }
+};
+
+
+/**
+ * Transaction for tagging a URL with the given set of tags. Current tags set
+ * for the URL persist. It's the caller's job to check whether or not aURI
+ * was already tagged by any of the tags in aTags, undoing this tags
+ * transaction removes them all from aURL!
+ *
+ * @param aURI
+ * the URL to tag.
+ * @param aTags
+ * Array of tags to set for the given URL.
+ */
+this.PlacesTagURITransaction =
+ function PlacesTagURITransaction(aURI, aTags)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ this.item.tags = aTags;
+}
+
+PlacesTagURITransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function TUTXN_doTransaction()
+ {
+ if (PlacesUtils.getMostRecentBookmarkForURI(this.item.uri) == -1) {
+ // There is no bookmark for this uri, but we only allow to tag bookmarks.
+ // Force an unfiled bookmark first.
+ this.item.id =
+ PlacesUtils.bookmarks
+ .insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ this.item.uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ PlacesUtils.history.getPageTitle(this.item.uri));
+ }
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ },
+
+ undoTransaction: function TUTXN_undoTransaction()
+ {
+ if (this.item.id != -1) {
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ this.item.id = -1;
+ }
+ PlacesUtils.tagging.untagURI(this.item.uri, this.item.tags);
+ }
+};
+
+
+/**
+ * Transaction for removing tags from a URL. It's the caller's job to check
+ * whether or not aURI isn't tagged by any of the tags in aTags, undoing this
+ * tags transaction adds them all to aURL!
+ *
+ * @param aURI
+ * the URL to un-tag.
+ * @param aTags
+ * Array of tags to unset. pass null to remove all tags from the given
+ * url.
+ */
+this.PlacesUntagURITransaction =
+ function PlacesUntagURITransaction(aURI, aTags)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ if (aTags) {
+ // Within this transaction, we cannot rely on tags given by itemId
+ // since the tag containers may be gone after we call untagURI.
+ // Thus, we convert each tag given by its itemId to name.
+ let tags = [];
+ for (let i = 0; i < aTags.length; ++i) {
+ if (typeof(aTags[i]) == "number")
+ tags.push(PlacesUtils.bookmarks.getItemTitle(aTags[i]));
+ else
+ tags.push(aTags[i]);
+ }
+ this.item.tags = tags;
+ }
+}
+
+PlacesUntagURITransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function UTUTXN_doTransaction()
+ {
+ // Filter tags existing on the bookmark, otherwise on undo we may try to
+ // set nonexistent tags.
+ let tags = PlacesUtils.tagging.getTagsForURI(this.item.uri);
+ this.item.tags = this.item.tags.filter(function (aTag) {
+ return tags.includes(aTag);
+ });
+ PlacesUtils.tagging.untagURI(this.item.uri, this.item.tags);
+ },
+
+ undoTransaction: function UTUTXN_undoTransaction()
+ {
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ }
+};
+
+/**
+ * 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;
+ };
+}