/* -*- 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"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesUIUtils", "resource:///modules/PlacesUIUtils.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, "&") .replace(/>/g, ">") .replace(/${escapedTitle}${NEWLINE}`; } if (PlacesUtils.nodeIsContainer(node)) { asContainer(node); let wasOpen = node.containerOpen; if (!wasOpen) node.containerOpen = true; let childString = "
" + escapedTitle + "
" + NEWLINE; let cc = node.childCount; for (let i = 0; i < cc; ++i) { childString += "
" + NEWLINE + gatherDataHtml(node.getChild(i)) + "
" + NEWLINE; } node.containerOpen = wasOpen; return childString + "
" + NEWLINE; } if (PlacesUtils.nodeIsURI(node)) return `${escapedTitle}${NEWLINE}`; if (PlacesUtils.nodeIsSeparator(node)) return "
" + 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. * @throws if the blob contains invalid data. */ 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) && this._uri(uriString).scheme != "place") { 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, as well as // empty URIs if (uriString.substr(0, 1) == '\x23' || uriString == "") continue; // note: this._uri() will throw if uriString is not a valid URI if (this._uri(uriString).scheme != "place") 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, aMultiple=false) { 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 foundFirst = !aMultiple; let found = false; for (let i = 0; i < root.childCount && !found; i++) { let child = root.getChild(i); if (this.nodeIsURI(child)) { found = foundFirst; foundFirst = 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 childId = contents.getChild(i).itemId; if (!PlacesUIUtils._isLivemark(childId)) { let txn = new PlacesRemoveItemTransaction(childId); 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; }; }