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