/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; this.EXPORTED_SYMBOLS = ["PlacesTransactions"]; /** * Overview * -------- * This modules serves as the transactions manager for Places (hereinafter PTM). * It implements all the elementary transactions for its UI commands: creating * items, editing their various properties, and so forth. * * Note that since the effect of invoking a Places command is not limited to the * window in which it was performed (e.g. a folder created in the Library may be * the parent of a bookmark created in some browser window), PTM is a singleton. * It's therefore unnecessary to initialize PTM in any way apart importing this * module. * * PTM shares most of its semantics with common command pattern implementations. * However, the asynchronous design of contemporary and future APIs, combined * with the commitment to serialize all UI operations, does make things a little * bit different. For example, when |undo| is called in order to undo the top * undo entry, the caller cannot tell for sure what entry would it be, because * the execution of some transactions is either in process, or enqueued to be. * * Also note that unlike the nsITransactionManager, for example, this API is by * no means generic. That is, it cannot be used to execute anything but the * elementary transactions implemented here (Please file a bug if you find * anything uncovered). More-complex transactions (e.g. creating a folder and * moving a bookmark into it) may be implemented as a batch (see below). * * A note about GUIDs and item-ids * ------------------------------- * There's an ongoing effort (see bug 1071511) to deprecate item-ids in Places * in favor of GUIDs. Both because new APIs (e.g. Bookmark.jsm) expose them to * the minimum necessary, and because GUIDs play much better with implementing * |redo|, this API doesn't support item-ids at all, and only accepts bookmark * GUIDs, both for input (e.g. for setting the parent folder for a new bookmark) * and for output (when the GUID for such a bookmark is propagated). * * When working in conjugation with older Places API which only expose item ids, * use PlacesUtils.promiseItemGuid for converting those to GUIDs (note that * for result nodes, the guid is available through their bookmarkGuid getter). * Should you need to convert GUIDs to item-ids, use PlacesUtils.promiseItemId. * * Constructing transactions * ------------------------- * At the bottom of this module you will find transactions for all Places UI * commands. They are exposed as constructors set on the PlacesTransactions * object (e.g. PlacesTransactions.NewFolder). The input for this constructors * is taken in the form of a single argument, a plain object consisting of the * properties for the transaction. Input properties may be either required or * optional (for example, |keyword| is required for the EditKeyword transaction, * but optional for the NewBookmark transaction). * * To make things simple, a given input property has the same basic meaning and * valid values across all transactions which accept it in the input object. * Here is a list of all supported input properties along with their expected * values: * - url: a URL object, an nsIURI object, or a href. * - urls: an array of urls, as above. * - feedUrl: an url (as above), holding the url for a live bookmark. * - siteUrl an url (as above), holding the url for the site with which * a live bookmark is associated. * - tag - a string. * - tags: an array of strings. * - guid, parentGuid, newParentGuid: a valid Places GUID string. * - guids: an array of valid Places GUID strings. * - title: a string * - index, newIndex: the position of an item in its containing folder, * starting from 0. * integer and PlacesUtils.bookmarks.DEFAULT_INDEX * - annotation: see PlacesUtils.setAnnotationsForItem * - annotations: an array of annotation objects as above. * - excludingAnnotation: a string (annotation name). * - excludingAnnotations: an array of string (annotation names). * * If a required property is missing in the input object (e.g. not specifying * parentGuid for NewBookmark), or if the value for any of the input properties * is invalid "on the surface" (e.g. a numeric value for GUID, or a string that * isn't 12-characters long), the transaction constructor throws right way. * More complex errors (e.g. passing a non-existent GUID for parentGuid) only * reveal once the transaction is executed. * * Executing Transactions (the |transact| method of transactions) * -------------------------------------------------------------- * Once a transaction is created, you must call its |transact| method for it to * be executed and take effect. |transact| is an asynchronous method that takes * no arguments, and returns a promise that resolves once the transaction is * executed. Executing one of the transactions for creating items (NewBookmark, * NewFolder, NewSeparator or NewLivemark) resolve to the new item's GUID. * There's no resolution value for other transactions. * If a transaction fails to execute, |transact| rejects and the transactions * history is not affected. * * |transact| throws if it's called more than once (successfully or not) on the * same transaction object. * * Batches * ------- * Sometimes it is useful to "batch" or "merge" transactions. For example, * something like "Bookmark All Tabs" may be implemented as one NewFolder * transaction followed by numerous NewBookmark transactions - all to be undone * or redone in a single undo or redo command. Use |PlacesTransactions.batch| * in such cases. It can take either an array of transactions which will be * executed in the given order and later be treated a a single entry in the * transactions history, or a generator function that is passed to Task.spawn, * that is to "contain" the batch: once the generator function is called a batch * starts, and it lasts until the asynchronous generator iteration is complete * All transactions executed by |transact| during this time are to be treated as * a single entry in the transactions history. * * In both modes, |PlacesTransactions.batch| returns a promise that is to be * resolved when the batch ends. In the array-input mode, there's no resolution * value. In the generator mode, the resolution value is whatever the generator * function returned (the semantics are the same as in Task.spawn, basically). * * The array-input mode of |PlacesTransactions.batch| is useful for implementing * a batch of mostly-independent transaction (for example, |paste| into a folder * can be implemented as a batch of multiple NewBookmark transactions). * The generator mode is useful when the resolution value of executing one * transaction is the input of one more subsequent transaction. * * In the array-input mode, if any transactions fails to execute, the batch * continues (exceptions are logged). Only transactions that were executed * successfully are added to the transactions history. * * WARNING: "nested" batches are not supported, if you call batch while another * batch is still running, the new batch is enqueued with all other PTM work * and thus not run until the running batch ends. The same goes for undo, redo * and clearTransactionsHistory (note batches cannot be done partially, meaning * undo and redo calls that during a batch are just enqueued). * * ***************************************************************************** * IT"S PARTICULARLY IMPORTANT NOT TO YIELD ANY PROMISE RETURNED BY ANY OF * THESE METHODS (undo, redo, clearTransactionsHistory) FROM A BATCH FUNCTION. * UNTIL WE FIND A WAY TO THROW IN THAT CASE (SEE BUG 1091446) DOING SO WILL * COMPLETELY BREAK PTM UNTIL SHUTDOWN, NOT ALLOWING THE EXECUTION OF ANY * TRANSACTION! * ***************************************************************************** * * Serialization * ------------- * All |PlacesTransaction| operations are serialized. That is, even though the * implementation is asynchronous, the order in which PlacesTransactions methods * is called does guarantee the order in which they are to be invoked. * * The only exception to this rule is |transact| calls done during a batch (see * above). |transact| calls are serialized with each other (and with undo, redo * and clearTransactionsHistory), but they are, of course, not serialized with * batches. * * The transactions-history structure * ---------------------------------- * The transactions-history is a two-dimensional stack of transactions: the * transactions are ordered in reverse to the order they were committed. * It's two-dimensional because PTM allows batching transactions together for * the purpose of undo or redo (see Batches above). * * The undoPosition property is set to the index of the top entry. If there is * no entry at that index, there is nothing to undo. * Entries prior to undoPosition, if any, are redo entries, the first one being * the top redo entry. * * [ [2nd redo txn, 1st redo txn], <= 2nd redo entry * [2nd redo txn, 1st redo txn], <= 1st redo entry * [1st undo txn, 2nd undo txn], <= 1st undo entry * [1st undo txn, 2nd undo txn] <= 2nd undo entry ] * undoPostion: 2. * * Note that when a new entry is created, all redo entries are removed. */ Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); Components.utils.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Promise", "resource://gre/modules/Promise.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "console", "resource://gre/modules/Console.jsm"); Components.utils.importGlobalProperties(["URL"]); var TransactionsHistory = []; TransactionsHistory.__proto__ = { __proto__: Array.prototype, // The index of the first undo entry (if any) - See the documentation // at the top of this file. _undoPosition: 0, get undoPosition() { return this._undoPosition; }, // Handy shortcuts get topUndoEntry() { return this.undoPosition < this.length ? this[this.undoPosition] : null; }, get topRedoEntry() { return this.undoPosition > 0 ? this[this.undoPosition - 1] : null; }, // Outside of this module, the API of transactions is inaccessible, and so // are any internal properties. To achieve that, transactions are proxified // in their constructors. This maps the proxies to their respective raw // objects. proxifiedToRaw: new WeakMap(), /** * Proxify a transaction object for consumers. * @param aRawTransaction * the raw transaction object. * @return the proxified transaction object. * @see getRawTransaction for retrieving the raw transaction. */ proxifyTransaction: function (aRawTransaction) { let proxy = Object.freeze({ transact() { return TransactionsManager.transact(this); } }); this.proxifiedToRaw.set(proxy, aRawTransaction); return proxy; }, /** * Check if the given object is a the proxy object for some transaction. * @param aValue * any JS value. * @return true if aValue is the proxy object for some transaction, false * otherwise. */ isProxifiedTransactionObject(aValue) { return this.proxifiedToRaw.has(aValue); }, /** * Get the raw transaction for the given proxy. * @param aProxy * the proxy object * @return the transaction proxified by aProxy; |undefined| is returned if * aProxy is not a proxified transaction. */ getRawTransaction(aProxy) { return this.proxifiedToRaw.get(aProxy); }, /** * Add a transaction either as a new entry, if forced or if there are no undo * entries, or to the top undo entry. * * @param aProxifiedTransaction * the proxified transaction object to be added to the transaction * history. * @param [optional] aForceNewEntry * Force a new entry for the transaction. Default: false. * If false, an entry will we created only if there's no undo entry * to extend. */ add(aProxifiedTransaction, aForceNewEntry = false) { if (!this.isProxifiedTransactionObject(aProxifiedTransaction)) throw new Error("aProxifiedTransaction is not a proxified transaction"); if (this.length == 0 || aForceNewEntry) { this.clearRedoEntries(); this.unshift([aProxifiedTransaction]); } else { this[this.undoPosition].unshift(aProxifiedTransaction); } }, /** * Clear all undo entries. */ clearUndoEntries() { if (this.undoPosition < this.length) this.splice(this.undoPosition); }, /** * Clear all redo entries. */ clearRedoEntries() { if (this.undoPosition > 0) { this.splice(0, this.undoPosition); this._undoPosition = 0; } }, /** * Clear all entries. */ clearAllEntries() { if (this.length > 0) { this.splice(0); this._undoPosition = 0; } } }; var PlacesTransactions = { /** * @see Batches in the module documentation. */ batch(aToBatch) { if (Array.isArray(aToBatch)) { if (aToBatch.length == 0) throw new Error("aToBatch must not be an empty array"); if (aToBatch.some( o => !TransactionsHistory.isProxifiedTransactionObject(o))) { throw new Error("aToBatch contains non-transaction element"); } return TransactionsManager.batch(function* () { for (let txn of aToBatch) { try { yield txn.transact(); } catch (ex) { console.error(ex); } } }); } if (typeof(aToBatch) == "function") { return TransactionsManager.batch(aToBatch); } throw new Error("aToBatch must be either a function or a transactions array"); }, /** * Asynchronously undo the transaction immediately after the current undo * position in the transactions history in the reverse order, if any, and * adjusts the undo position. * * @return {Promises). The promise always resolves. * @note All undo manager operations are queued. This means that transactions * history may change by the time your request is fulfilled. */ undo() { return TransactionsManager.undo(); }, /** * Asynchronously redo the transaction immediately before the current undo * position in the transactions history, if any, and adjusts the undo * position. * * @return {Promises). The promise always resolves. * @note All undo manager operations are queued. This means that transactions * history may change by the time your request is fulfilled. */ redo() { return TransactionsManager.redo(); }, /** * Asynchronously clear the undo, redo, or all entries from the transactions * history. * * @param [optional] aUndoEntries * Whether or not to clear undo entries. Default: true. * @param [optional] aRedoEntries * Whether or not to clear undo entries. Default: true. * * @return {Promises). The promise always resolves. * @throws if both aUndoEntries and aRedoEntries are false. * @note All undo manager operations are queued. This means that transactions * history may change by the time your request is fulfilled. */ clearTransactionsHistory(aUndoEntries = true, aRedoEntries = true) { return TransactionsManager.clearTransactionsHistory(aUndoEntries, aRedoEntries); }, /** * The numbers of entries in the transactions history. */ get length() { return TransactionsHistory.length; }, /** * Get the transaction history entry at a given index. Each entry consists * of one or more transaction objects. * * @param aIndex * the index of the entry to retrieve. * @return an array of transaction objects in their undo order (that is, * reversely to the order they were executed). * @throw if aIndex is invalid (< 0 or >= length). * @note the returned array is a clone of the history entry and is not * kept in sync with the original entry if it changes. */ entry(aIndex) { if (!Number.isInteger(aIndex) || aIndex < 0 || aIndex >= this.length) throw new Error("Invalid index"); return TransactionsHistory[aIndex]; }, /** * The index of the top undo entry in the transactions history. * If there are no undo entries, it equals to |length|. * Entries past this point * Entries at and past this point are redo entries. */ get undoPosition() { return TransactionsHistory.undoPosition; }, /** * Shortcut for accessing the top undo entry in the transaction history. */ get topUndoEntry() { return TransactionsHistory.topUndoEntry; }, /** * Shortcut for accessing the top redo entry in the transaction history. */ get topRedoEntry() { return TransactionsHistory.topRedoEntry; } }; /** * Helper for serializing the calls to TransactionsManager methods. It allows * us to guarantee that the order in which TransactionsManager asynchronous * methods are called also enforces the order in which they're executed, and * that they are never executed in parallel. * * In other words: Enqueuer.enqueue(aFunc1); Enqueuer.enqueue(aFunc2) is roughly * the same as Task.spawn(aFunc1).then(Task.spawn(aFunc2)). */ function Enqueuer() { this._promise = Promise.resolve(); } Enqueuer.prototype = { /** * Spawn a functions once all previous functions enqueued are done running, * and all promises passed to alsoWaitFor are no longer pending. * * @param aFunc * @see Task.spawn. * @return a promise that resolves once aFunc is done running. The promise * "mirrors" the promise returned by aFunc. */ enqueue(aFunc) { let promise = this._promise.then(Task.async(aFunc)); // Propagate exceptions to the caller, but dismiss them internally. this._promise = promise.catch(console.error); return promise; }, /** * Same as above, but for a promise returned by a function that already run. * This is useful, for example, for serializing transact calls with undo calls, * even though transact has its own Enqueuer. * * @param aPromise * any promise. */ alsoWaitFor(aPromise) { // We don't care if aPromise resolves or rejects, but just that is not // pending anymore. let promise = aPromise.catch(console.error); this._promise = Promise.all([this._promise, promise]); }, /** * The promise for this queue. */ get promise() { return this._promise; } }; var TransactionsManager = { // See the documentation at the top of this file. |transact| calls are not // serialized with |batch| calls. _mainEnqueuer: new Enqueuer(), _transactEnqueuer: new Enqueuer(), // Is a batch in progress? set when we enter a batch function and unset when // it's execution is done. _batching: false, // If a batch started, this indicates if we've already created an entry in the // transactions history for the batch (i.e. if at least one transaction was // executed successfully). _createdBatchEntry: false, // Transactions object should never be recycled (that is, |execute| should // only be called once (or not at all) after they're constructed. // This keeps track of all transactions which were executed. _executedTransactions: new WeakSet(), transact(aTxnProxy) { let rawTxn = TransactionsHistory.getRawTransaction(aTxnProxy); if (!rawTxn) throw new Error("|transact| was called with an unexpected object"); if (this._executedTransactions.has(rawTxn)) throw new Error("Transactions objects may not be recycled."); // Add it in advance so one doesn't accidentally do // sameTxn.transact(); sameTxn.transact(); this._executedTransactions.add(rawTxn); let promise = this._transactEnqueuer.enqueue(function* () { // Don't try to catch exceptions. If execute fails, we better not add the // transaction to the undo stack. let retval = yield rawTxn.execute(); let forceNewEntry = !this._batching || !this._createdBatchEntry; TransactionsHistory.add(aTxnProxy, forceNewEntry); if (this._batching) this._createdBatchEntry = true; this._updateCommandsOnActiveWindow(); return retval; }.bind(this)); this._mainEnqueuer.alsoWaitFor(promise); return promise; }, batch(aTask) { return this._mainEnqueuer.enqueue(function* () { this._batching = true; this._createdBatchEntry = false; let rv; try { // We should return here, but bug 958949 makes that impossible. rv = (yield Task.spawn(aTask)); } finally { this._batching = false; this._createdBatchEntry = false; } return rv; }.bind(this)); }, /** * Undo the top undo entry, if any, and update the undo position accordingly. */ undo() { let promise = this._mainEnqueuer.enqueue(function* () { let entry = TransactionsHistory.topUndoEntry; if (!entry) return; for (let txnProxy of entry) { try { yield TransactionsHistory.getRawTransaction(txnProxy).undo(); } catch (ex) { // If one transaction is broken, it's not safe to work with any other // undo entry. Report the error and clear the undo history. console.error(ex, "Couldn't undo a transaction, clearing all undo entries."); TransactionsHistory.clearUndoEntries(); return; } } TransactionsHistory._undoPosition++; this._updateCommandsOnActiveWindow(); }.bind(this)); this._transactEnqueuer.alsoWaitFor(promise); return promise; }, /** * Redo the top redo entry, if any, and update the undo position accordingly. */ redo() { let promise = this._mainEnqueuer.enqueue(function* () { let entry = TransactionsHistory.topRedoEntry; if (!entry) return; for (let i = entry.length - 1; i >= 0; i--) { let transaction = TransactionsHistory.getRawTransaction(entry[i]); try { if (transaction.redo) yield transaction.redo(); else yield transaction.execute(); } catch (ex) { // If one transaction is broken, it's not safe to work with any other // redo entry. Report the error and clear the undo history. console.error(ex, "Couldn't redo a transaction, clearing all redo entries."); TransactionsHistory.clearRedoEntries(); return; } } TransactionsHistory._undoPosition--; this._updateCommandsOnActiveWindow(); }.bind(this)); this._transactEnqueuer.alsoWaitFor(promise); return promise; }, clearTransactionsHistory(aUndoEntries, aRedoEntries) { let promise = this._mainEnqueuer.enqueue(function* () { if (aUndoEntries && aRedoEntries) TransactionsHistory.clearAllEntries(); else if (aUndoEntries) TransactionsHistory.clearUndoEntries(); else if (aRedoEntries) TransactionsHistory.clearRedoEntries(); else throw new Error("either aUndoEntries or aRedoEntries should be true"); }.bind(this)); this._transactEnqueuer.alsoWaitFor(promise); return promise; }, // Updates commands in the undo group of the active window commands. // Inactive windows commands will be updated on focus. _updateCommandsOnActiveWindow() { // Updating "undo" will cause a group update including "redo". try { let win = Services.focus.activeWindow; if (win) win.updateCommands("undo"); } catch (ex) { console.error(ex, "Couldn't update undo commands"); } } }; /** * Internal helper for defining the standard transactions and their input. * It takes the required and optional properties, and generates the public * constructor (which takes the input in the form of a plain object) which, * when called, creates the argument-less "public" |execute| method by binding * the input properties to the function arguments (required properties first, * then the optional properties). * * If this seems confusing, look at the consumers. * * This magic serves two purposes: * (1) It completely hides the transactions' internals from the module * consumers. * (2) It keeps each transaction implementation to what is about, bypassing * all this bureaucracy while still validating input appropriately. */ function DefineTransaction(aRequiredProps = [], aOptionalProps = []) { for (let prop of [...aRequiredProps, ...aOptionalProps]) { if (!DefineTransaction.inputProps.has(prop)) throw new Error("Property '" + prop + "' is not defined"); } let ctor = function (aInput) { // We want to support both syntaxes: // let t = new PlacesTransactions.NewBookmark(), // let t = PlacesTransactions.NewBookmark() if (this == PlacesTransactions) return new ctor(aInput); if (aRequiredProps.length > 0 || aOptionalProps.length > 0) { // Bind the input properties to the arguments of execute. let input = DefineTransaction.verifyInput(aInput, aRequiredProps, aOptionalProps); let executeArgs = [this, ...aRequiredProps.map(prop => input[prop]), ...aOptionalProps.map(prop => input[prop])]; this.execute = Function.bind.apply(this.execute, executeArgs); } return TransactionsHistory.proxifyTransaction(this); }; return ctor; } function simpleValidateFunc(aCheck) { return v => { if (!aCheck(v)) throw new Error("Invalid value"); return v; }; } DefineTransaction.strValidate = simpleValidateFunc(v => typeof(v) == "string"); DefineTransaction.strOrNullValidate = simpleValidateFunc(v => typeof(v) == "string" || v === null); DefineTransaction.indexValidate = simpleValidateFunc(v => Number.isInteger(v) && v >= PlacesUtils.bookmarks.DEFAULT_INDEX); DefineTransaction.guidValidate = simpleValidateFunc(v => /^[a-zA-Z0-9\-_]{12}$/.test(v)); function isPrimitive(v) { return v === null || (typeof(v) != "object" && typeof(v) != "function"); } DefineTransaction.annotationObjectValidate = function (obj) { let checkProperty = (aPropName, aRequired, aCheckFunc) => { if (aPropName in obj) return aCheckFunc(obj[aPropName]); return !aRequired; }; if (obj && checkProperty("name", true, v => typeof(v) == "string" && v.length > 0) && checkProperty("expires", false, Number.isInteger) && checkProperty("flags", false, Number.isInteger) && checkProperty("value", false, isPrimitive) ) { // Nothing else should be set let validKeys = ["name", "value", "flags", "expires"]; if (Object.keys(obj).every( (k) => validKeys.includes(k))) return obj; } throw new Error("Invalid annotation object"); }; DefineTransaction.urlValidate = function(url) { // When this module is updated to use Bookmarks.jsm, we should actually // convert nsIURIs/spec to URL objects. if (url instanceof Components.interfaces.nsIURI) return url; let spec = url instanceof URL ? url.href : url; return NetUtil.newURI(spec); }; DefineTransaction.inputProps = new Map(); DefineTransaction.defineInputProps = function (aNames, aValidationFunction, aDefaultValue) { for (let name of aNames) { // Workaround bug 449811. let propName = name; this.inputProps.set(propName, { validateValue: function (aValue) { if (aValue === undefined) return aDefaultValue; try { return aValidationFunction(aValue); } catch (ex) { throw new Error(`Invalid value for input property ${propName}`); } }, validateInput: function (aInput, aRequired) { if (aRequired && !(propName in aInput)) throw new Error(`Required input property is missing: ${propName}`); return this.validateValue(aInput[propName]); }, isArrayProperty: false }); } }; DefineTransaction.defineArrayInputProp = function (aName, aBasePropertyName) { let baseProp = this.inputProps.get(aBasePropertyName); if (!baseProp) throw new Error(`Unknown input property: ${aBasePropertyName}`); this.inputProps.set(aName, { validateValue: function (aValue) { if (aValue == undefined) return []; if (!Array.isArray(aValue)) throw new Error(`${aName} input property value must be an array`); // This also takes care of abandoning the global scope of the input // array (through Array.prototype). return aValue.map(baseProp.validateValue); }, // We allow setting either the array property itself (e.g. urls), or a // single element of it (url, in that example), that is then transformed // into a single-element array. validateInput: function (aInput, aRequired) { if (aName in aInput) { // It's not allowed to set both though. if (aBasePropertyName in aInput) { throw new Error(`It is not allowed to set both ${aName} and ${aBasePropertyName} as input properties`); } let array = this.validateValue(aInput[aName]); if (aRequired && array.length == 0) { throw new Error(`Empty array passed for required input property: ${aName}`); } return array; } // If the property is required and it's not set as is, check if the base // property is set. if (aRequired && !(aBasePropertyName in aInput)) throw new Error(`Required input property is missing: ${aName}`); if (aBasePropertyName in aInput) return [baseProp.validateValue(aInput[aBasePropertyName])]; return []; }, isArrayProperty: true }); }; DefineTransaction.validatePropertyValue = function (aProp, aInput, aRequired) { return this.inputProps.get(aProp).validateInput(aInput, aRequired); }; DefineTransaction.getInputObjectForSingleValue = function (aInput, aRequiredProps, aOptionalProps) { // The following input forms may be deduced from a single value: // * a single required property with or without optional properties (the given // value is set to the required property). // * a single optional property with no required properties. if (aRequiredProps.length > 1 || (aRequiredProps.length == 0 && aOptionalProps.length > 1)) { throw new Error("Transaction input isn't an object"); } let propName = aRequiredProps.length == 1 ? aRequiredProps[0] : aOptionalProps[0]; let propValue = this.inputProps.get(propName).isArrayProperty && !Array.isArray(aInput) ? [aInput] : aInput; return { [propName]: propValue }; }; DefineTransaction.verifyInput = function (aInput, aRequiredProps = [], aOptionalProps = []) { if (aRequiredProps.length == 0 && aOptionalProps.length == 0) return {}; // If there's just a single required/optional property, we allow passing it // as is, so, for example, one could do PlacesTransactions.RemoveItem(myGuid) // rather than PlacesTransactions.RemoveItem({ guid: myGuid}). // This shortcut isn't supported for "complex" properties - e.g. one cannot // pass an annotation object this way (note there is no use case for this at // the moment anyway). let input = aInput; let isSinglePropertyInput = isPrimitive(aInput) || Array.isArray(aInput) || (aInput instanceof Components.interfaces.nsISupports); if (isSinglePropertyInput) { input = this.getInputObjectForSingleValue(aInput, aRequiredProps, aOptionalProps); } let fixedInput = { }; for (let prop of aRequiredProps) { fixedInput[prop] = this.validatePropertyValue(prop, input, true); } for (let prop of aOptionalProps) { fixedInput[prop] = this.validatePropertyValue(prop, input, false); } return fixedInput; }; // Update the documentation at the top of this module if you add or // remove properties. DefineTransaction.defineInputProps(["url", "feedUrl", "siteUrl"], DefineTransaction.urlValidate, null); DefineTransaction.defineInputProps(["guid", "parentGuid", "newParentGuid"], DefineTransaction.guidValidate); DefineTransaction.defineInputProps(["title"], DefineTransaction.strOrNullValidate, null); DefineTransaction.defineInputProps(["keyword", "oldKeyword", "postData", "tag", "excludingAnnotation"], DefineTransaction.strValidate, ""); DefineTransaction.defineInputProps(["index", "newIndex"], DefineTransaction.indexValidate, PlacesUtils.bookmarks.DEFAULT_INDEX); DefineTransaction.defineInputProps(["annotation"], DefineTransaction.annotationObjectValidate); DefineTransaction.defineArrayInputProp("guids", "guid"); DefineTransaction.defineArrayInputProp("urls", "url"); DefineTransaction.defineArrayInputProp("tags", "tag"); DefineTransaction.defineArrayInputProp("annotations", "annotation"); DefineTransaction.defineArrayInputProp("excludingAnnotations", "excludingAnnotation"); /** * Internal helper for implementing the execute method of NewBookmark, NewFolder * and NewSeparator. * * @param aTransaction * The transaction object * @param aParentGuid * The GUID of the parent folder * @param aCreateItemFunction(aParentId, aGuidToRestore) * The function to be called for creating the item on execute and redo. * It should return the itemId for the new item * - aGuidToRestore - the GUID to set for the item (used for redo). * @param [optional] aOnUndo * an additional function to call after undo * @param [optional] aOnRedo * an additional function to call after redo */ function* ExecuteCreateItem(aTransaction, aParentGuid, aCreateItemFunction, aOnUndo = null, aOnRedo = null) { let parentId = yield PlacesUtils.promiseItemId(aParentGuid), itemId = yield aCreateItemFunction(parentId, ""), guid = yield PlacesUtils.promiseItemGuid(itemId); // On redo, we'll restore the date-added and last-modified properties. let dateAdded = 0, lastModified = 0; aTransaction.undo = function* () { if (dateAdded == 0) { dateAdded = PlacesUtils.bookmarks.getItemDateAdded(itemId); lastModified = PlacesUtils.bookmarks.getItemLastModified(itemId); } PlacesUtils.bookmarks.removeItem(itemId); if (aOnUndo) { yield aOnUndo(); } }; aTransaction.redo = function* () { parentId = yield PlacesUtils.promiseItemId(aParentGuid); itemId = yield aCreateItemFunction(parentId, guid); if (aOnRedo) yield aOnRedo(); // aOnRedo is called first to make sure it doesn't override // lastModified. PlacesUtils.bookmarks.setItemDateAdded(itemId, dateAdded); PlacesUtils.bookmarks.setItemLastModified(itemId, lastModified); PlacesUtils.bookmarks.setItemLastModified(parentId, dateAdded); }; return guid; } /** * Creates items (all types) from a bookmarks tree representation, as defined * in PlacesUtils.promiseBookmarksTree. * * @param aBookmarksTree * the bookmarks tree object. You may pass either a bookmarks tree * returned by promiseBookmarksTree, or a manually defined one. * @param [optional] aRestoring (default: false) * Whether or not the items are restored. Only in restore mode, are * the guid, dateAdded and lastModified properties honored. * @param [optional] aExcludingAnnotations * Array of annotations names to ignore in aBookmarksTree. This argument * is ignored if aRestoring is set. * @note the id, root and charset properties of items in aBookmarksTree are * always ignored. The index property is ignored for all items but the * root one. * @return {Promise} */ function* createItemsFromBookmarksTree(aBookmarksTree, aRestoring = false, aExcludingAnnotations = []) { function extractLivemarkDetails(aAnnos) { let feedURI = null, siteURI = null; aAnnos = aAnnos.filter( aAnno => { switch (aAnno.name) { case PlacesUtils.LMANNO_FEEDURI: feedURI = NetUtil.newURI(aAnno.value); return false; case PlacesUtils.LMANNO_SITEURI: siteURI = NetUtil.newURI(aAnno.value); return false; default: return true; } } ); return [feedURI, siteURI]; } function* createItem(aItem, aParentGuid, aIndex = PlacesUtils.bookmarks.DEFAULT_INDEX) { let itemId; let guid = aRestoring ? aItem.guid : undefined; let parentId = yield PlacesUtils.promiseItemId(aParentGuid); let annos = aItem.annos ? [...aItem.annos] : []; switch (aItem.type) { case PlacesUtils.TYPE_X_MOZ_PLACE: { let uri = NetUtil.newURI(aItem.uri); itemId = PlacesUtils.bookmarks.insertBookmark( parentId, uri, aIndex, aItem.title, guid); if ("keyword" in aItem) { yield PlacesUtils.keywords.insert({ keyword: aItem.keyword, url: uri.spec }); } if ("tags" in aItem) { PlacesUtils.tagging.tagURI(uri, aItem.tags.split(",")); } break; } case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: { // Either a folder or a livemark let [feedURI, siteURI] = extractLivemarkDetails(annos); if (!feedURI) { itemId = PlacesUtils.bookmarks.createFolder( parentId, aItem.title, aIndex, guid); if (guid === undefined) guid = yield PlacesUtils.promiseItemGuid(itemId); if ("children" in aItem) { for (let child of aItem.children) { yield createItem(child, guid); } } } else { let livemark = yield PlacesUtils.livemarks.addLivemark({ title: aItem.title , feedURI: feedURI , siteURI: siteURI , parentId: parentId , index: aIndex , guid: guid}); itemId = livemark.id; } break; } case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: { itemId = PlacesUtils.bookmarks.insertSeparator(parentId, aIndex, guid); break; } } if (annos.length > 0) { if (!aRestoring && aExcludingAnnotations.length > 0) { annos = annos.filter(a => !aExcludingAnnotations.includes(a.name)); } PlacesUtils.setAnnotationsForItem(itemId, annos); } if (aRestoring) { if ("dateAdded" in aItem) PlacesUtils.bookmarks.setItemDateAdded(itemId, aItem.dateAdded); if ("lastModified" in aItem) PlacesUtils.bookmarks.setItemLastModified(itemId, aItem.lastModified); } return itemId; } return yield createItem(aBookmarksTree, aBookmarksTree.parentGuid, aBookmarksTree.index); } /** *************************************************************************** * The Standard Places Transactions. * * See the documentation at the top of this file. The valid values for input * are also documented there. *****************************************************************************/ var PT = PlacesTransactions; /** * Transaction for creating a bookmark. * * Required Input Properties: url, parentGuid. * Optional Input Properties: index, title, keyword, annotations, tags. * * When this transaction is executed, it's resolved to the new bookmark's GUID. */ PT.NewBookmark = DefineTransaction(["parentGuid", "url"], ["index", "title", "keyword", "postData", "annotations", "tags"]); PT.NewBookmark.prototype = Object.seal({ execute: function (aParentGuid, aURI, aIndex, aTitle, aKeyword, aPostData, aAnnos, aTags) { return ExecuteCreateItem(this, aParentGuid, function* (parentId, guidToRestore = "") { let itemId = PlacesUtils.bookmarks.insertBookmark( parentId, aURI, aIndex, aTitle, guidToRestore); if (aKeyword) { yield PlacesUtils.keywords.insert({ url: aURI.spec, keyword: aKeyword, postData: aPostData }); } if (aAnnos.length) { PlacesUtils.setAnnotationsForItem(itemId, aAnnos); } if (aTags.length > 0) { let currentTags = PlacesUtils.tagging.getTagsForURI(aURI); aTags = aTags.filter(t => !currentTags.includes(t)); PlacesUtils.tagging.tagURI(aURI, aTags); } return itemId; }, function _additionalOnUndo() { if (aTags.length > 0) { PlacesUtils.tagging.untagURI(aURI, aTags); } }); } }); /** * Transaction for creating a folder. * * Required Input Properties: title, parentGuid. * Optional Input Properties: index, annotations. * * When this transaction is executed, it's resolved to the new folder's GUID. */ PT.NewFolder = DefineTransaction(["parentGuid", "title"], ["index", "annotations"]); PT.NewFolder.prototype = Object.seal({ execute: function (aParentGuid, aTitle, aIndex, aAnnos) { return ExecuteCreateItem(this, aParentGuid, function* (parentId, guidToRestore = "") { let itemId = PlacesUtils.bookmarks.createFolder( parentId, aTitle, aIndex, guidToRestore); if (aAnnos.length > 0) PlacesUtils.setAnnotationsForItem(itemId, aAnnos); return itemId; }); } }); /** * Transaction for creating a separator. * * Required Input Properties: parentGuid. * Optional Input Properties: index. * * When this transaction is executed, it's resolved to the new separator's * GUID. */ PT.NewSeparator = DefineTransaction(["parentGuid"], ["index"]); PT.NewSeparator.prototype = Object.seal({ execute: function (aParentGuid, aIndex) { return ExecuteCreateItem(this, aParentGuid, function* (parentId, guidToRestore = "") { let itemId = PlacesUtils.bookmarks.insertSeparator( parentId, aIndex, guidToRestore); return itemId; }); } }); /** * Transaction for creating a live bookmark (see mozIAsyncLivemarks for the * semantics). * * Required Input Properties: feedUrl, title, parentGuid. * Optional Input Properties: siteUrl, index, annotations. * * When this transaction is executed, it's resolved to the new livemark's * GUID. */ PT.NewLivemark = DefineTransaction(["feedUrl", "title", "parentGuid"], ["siteUrl", "index", "annotations"]); PT.NewLivemark.prototype = Object.seal({ execute: function* (aFeedURI, aTitle, aParentGuid, aSiteURI, aIndex, aAnnos) { let livemarkInfo = { title: aTitle , feedURI: aFeedURI , siteURI: aSiteURI , index: aIndex }; let createItem = function* () { livemarkInfo.parentId = yield PlacesUtils.promiseItemId(aParentGuid); let livemark = yield PlacesUtils.livemarks.addLivemark(livemarkInfo); if (aAnnos.length > 0) PlacesUtils.setAnnotationsForItem(livemark.id, aAnnos); if ("dateAdded" in livemarkInfo) { PlacesUtils.bookmarks.setItemDateAdded(livemark.id, livemarkInfo.dateAdded); PlacesUtils.bookmarks.setItemLastModified(livemark.id, livemarkInfo.lastModified); } return livemark; }; let livemark = yield createItem(); this.undo = function* () { livemarkInfo.guid = livemark.guid; if (!("dateAdded" in livemarkInfo)) { livemarkInfo.dateAdded = PlacesUtils.bookmarks.getItemDateAdded(livemark.id); livemarkInfo.lastModified = PlacesUtils.bookmarks.getItemLastModified(livemark.id); } yield PlacesUtils.livemarks.removeLivemark(livemark); }; this.redo = function* () { livemark = yield createItem(); }; return livemark.guid; } }); /** * Transaction for moving an item. * * Required Input Properties: guid, newParentGuid. * Optional Input Properties newIndex. */ PT.Move = DefineTransaction(["guid", "newParentGuid"], ["newIndex"]); PT.Move.prototype = Object.seal({ execute: function* (aGuid, aNewParentGuid, aNewIndex) { let itemId = yield PlacesUtils.promiseItemId(aGuid), oldParentId = PlacesUtils.bookmarks.getFolderIdForItem(itemId), oldIndex = PlacesUtils.bookmarks.getItemIndex(itemId), newParentId = yield PlacesUtils.promiseItemId(aNewParentGuid); PlacesUtils.bookmarks.moveItem(itemId, newParentId, aNewIndex); let undoIndex = PlacesUtils.bookmarks.getItemIndex(itemId); this.undo = () => { // Moving down in the same parent takes in count removal of the item // so to revert positions we must move to oldIndex + 1 if (newParentId == oldParentId && oldIndex > undoIndex) PlacesUtils.bookmarks.moveItem(itemId, oldParentId, oldIndex + 1); else PlacesUtils.bookmarks.moveItem(itemId, oldParentId, oldIndex); }; } }); /** * Transaction for setting the title for an item. * * Required Input Properties: guid, title. */ PT.EditTitle = DefineTransaction(["guid", "title"]); PT.EditTitle.prototype = Object.seal({ execute: function* (aGuid, aTitle) { let itemId = yield PlacesUtils.promiseItemId(aGuid), oldTitle = PlacesUtils.bookmarks.getItemTitle(itemId); PlacesUtils.bookmarks.setItemTitle(itemId, aTitle); this.undo = () => { PlacesUtils.bookmarks.setItemTitle(itemId, oldTitle); }; } }); /** * Transaction for setting the URI for an item. * * Required Input Properties: guid, url. */ PT.EditUrl = DefineTransaction(["guid", "url"]); PT.EditUrl.prototype = Object.seal({ execute: function* (aGuid, aURI) { let itemId = yield PlacesUtils.promiseItemId(aGuid), oldURI = PlacesUtils.bookmarks.getBookmarkURI(itemId), oldURITags = PlacesUtils.tagging.getTagsForURI(oldURI), newURIAdditionalTags = null; PlacesUtils.bookmarks.changeBookmarkURI(itemId, aURI); // Move tags from old URI to new URI. if (oldURITags.length > 0) { // Only untag the old URI if this is the only bookmark. if (PlacesUtils.getBookmarksForURI(oldURI, {}).length == 0) PlacesUtils.tagging.untagURI(oldURI, oldURITags); let currentNewURITags = PlacesUtils.tagging.getTagsForURI(aURI); newURIAdditionalTags = oldURITags.filter(t => !currentNewURITags.includes(t)); if (newURIAdditionalTags) PlacesUtils.tagging.tagURI(aURI, newURIAdditionalTags); } this.undo = () => { PlacesUtils.bookmarks.changeBookmarkURI(itemId, oldURI); // Move tags from new URI to old URI. if (oldURITags.length > 0) { // Only untag the new URI if this is the only bookmark. if (newURIAdditionalTags && newURIAdditionalTags.length > 0 && PlacesUtils.getBookmarksForURI(aURI, {}).length == 0) { PlacesUtils.tagging.untagURI(aURI, newURIAdditionalTags); } PlacesUtils.tagging.tagURI(oldURI, oldURITags); } }; } }); /** * Transaction for setting annotations for an item. * * Required Input Properties: guid, annotationObject */ PT.Annotate = DefineTransaction(["guids", "annotations"]); PT.Annotate.prototype = { *execute(aGuids, aNewAnnos) { let undoAnnosForItem = new Map(); // itemId => undoAnnos; for (let guid of aGuids) { let itemId = yield PlacesUtils.promiseItemId(guid); let currentAnnos = PlacesUtils.getAnnotationsForItem(itemId); let undoAnnos = []; for (let newAnno of aNewAnnos) { let currentAnno = currentAnnos.find(a => a.name == newAnno.name); if (currentAnno) { undoAnnos.push(currentAnno); } else { // An unset value removes the annotation. undoAnnos.push({ name: newAnno.name }); } } undoAnnosForItem.set(itemId, undoAnnos); PlacesUtils.setAnnotationsForItem(itemId, aNewAnnos); } this.undo = function() { for (let [itemId, undoAnnos] of undoAnnosForItem) { PlacesUtils.setAnnotationsForItem(itemId, undoAnnos); } }; this.redo = function* () { for (let guid of aGuids) { let itemId = yield PlacesUtils.promiseItemId(guid); PlacesUtils.setAnnotationsForItem(itemId, aNewAnnos); } }; } }; /** * Transaction for setting the keyword for a bookmark. * * Required Input Properties: guid, keyword. */ PT.EditKeyword = DefineTransaction(["guid", "keyword"], ["postData", "oldKeyword"]); PT.EditKeyword.prototype = Object.seal({ execute: function* (aGuid, aKeyword, aPostData, aOldKeyword) { let url; let oldKeywordEntry; if (aOldKeyword) { oldKeywordEntry = yield PlacesUtils.keywords.fetch(aOldKeyword); url = oldKeywordEntry.url; yield PlacesUtils.keywords.remove(aOldKeyword); } if (aKeyword) { if (!url) { url = (yield PlacesUtils.bookmarks.fetch(aGuid)).url; } yield PlacesUtils.keywords.insert({ url: url, keyword: aKeyword, postData: aPostData || (oldKeywordEntry ? oldKeywordEntry.postData : "") }); } this.undo = function* () { if (aKeyword) { yield PlacesUtils.keywords.remove(aKeyword); } if (oldKeywordEntry) { yield PlacesUtils.keywords.insert(oldKeywordEntry); } }; } }); /** * Transaction for sorting a folder by name. * * Required Input Properties: guid. */ PT.SortByName = DefineTransaction(["guid"]); PT.SortByName.prototype = { execute: function* (aGuid) { let itemId = yield PlacesUtils.promiseItemId(aGuid), oldOrder = [], // [itemId] = old index contents = PlacesUtils.getFolderContents(itemId, false, false).root, count = contents.childCount; // Sort between separators. let newOrder = [], // nodes, in the new order. preSep = []; // Temporary array for sorting each group of nodes. let sortingMethod = (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 node = contents.getChild(i); oldOrder[node.itemId] = i; if (PlacesUtils.nodeIsSeparator(node)) { if (preSep.length > 0) { preSep.sort(sortingMethod); newOrder = newOrder.concat(preSep); preSep.splice(0, preSep.length); } newOrder.push(node); } else preSep.push(node); } 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); this.undo = () => { let callback = { runBatched: function() { for (let item in oldOrder) { PlacesUtils.bookmarks.setItemIndex(item, oldOrder[item]); } } }; PlacesUtils.bookmarks.runInBatchMode(callback, null); }; } }; /** * Transaction for removing an item (any type). * * Required Input Properties: guids. */ PT.Remove = DefineTransaction(["guids"]); PT.Remove.prototype = { *execute(aGuids) { function promiseBookmarksTree(guid) { try { return PlacesUtils.promiseBookmarksTree(guid); } catch (ex) { throw new Error("Failed to get info for the specified item (guid: " + guid + "). Ex: " + ex); } } let toRestore = []; for (let guid of aGuids) { toRestore.push(yield promiseBookmarksTree(guid)); } let removeThem = Task.async(function* () { for (let guid of aGuids) { PlacesUtils.bookmarks.removeItem(yield PlacesUtils.promiseItemId(guid)); } }); yield removeThem(); this.undo = Task.async(function* () { for (let info of toRestore) { yield createItemsFromBookmarksTree(info, true); } }); this.redo = removeThem; } }; /** * Transactions for removing all bookmarks for one or more urls. * * Required Input Properties: urls. */ PT.RemoveBookmarksForUrls = DefineTransaction(["urls"]); PT.RemoveBookmarksForUrls.prototype = { *execute(aUrls) { let guids = []; for (let url of aUrls) { yield PlacesUtils.bookmarks.fetch({ url }, info => { guids.push(info.guid); }); } let removeTxn = TransactionsHistory.getRawTransaction(PT.Remove(guids)); yield removeTxn.execute(); this.undo = removeTxn.undo.bind(removeTxn); this.redo = removeTxn.redo.bind(removeTxn); } }; /** * Transaction for tagging urls. * * Required Input Properties: urls, tags. */ PT.Tag = DefineTransaction(["urls", "tags"]); PT.Tag.prototype = { execute: function* (aURIs, aTags) { let onUndo = [], onRedo = []; for (let uri of aURIs) { // Workaround bug 449811. let currentURI = uri; let promiseIsBookmarked = function* () { let deferred = Promise.defer(); PlacesUtils.asyncGetBookmarkIds( currentURI, ids => { deferred.resolve(ids.length > 0); }); return deferred.promise; }; if (yield promiseIsBookmarked(currentURI)) { // Tagging is only allowed for bookmarked URIs (but see 424160). let createTxn = TransactionsHistory.getRawTransaction( PT.NewBookmark({ url: currentURI , tags: aTags , parentGuid: PlacesUtils.bookmarks.unfiledGuid })); yield createTxn.execute(); onUndo.unshift(createTxn.undo.bind(createTxn)); onRedo.push(createTxn.redo.bind(createTxn)); } else { let currentTags = PlacesUtils.tagging.getTagsForURI(currentURI); let newTags = aTags.filter(t => !currentTags.includes(t)); PlacesUtils.tagging.tagURI(currentURI, newTags); onUndo.unshift(() => { PlacesUtils.tagging.untagURI(currentURI, newTags); }); onRedo.push(() => { PlacesUtils.tagging.tagURI(currentURI, newTags); }); } } this.undo = function* () { for (let f of onUndo) { yield f(); } }; this.redo = function* () { for (let f of onRedo) { yield f(); } }; } }; /** * Transaction for removing tags from a URI. * * Required Input Properties: urls. * Optional Input Properties: tags. * * If |tags| is not set, all tags set for |url| are removed. */ PT.Untag = DefineTransaction(["urls"], ["tags"]); PT.Untag.prototype = { execute: function* (aURIs, aTags) { let onUndo = [], onRedo = []; for (let uri of aURIs) { // Workaround bug 449811. let currentURI = uri; let tagsToRemove; let tagsSet = PlacesUtils.tagging.getTagsForURI(currentURI); if (aTags.length > 0) tagsToRemove = aTags.filter(t => tagsSet.includes(t)); else tagsToRemove = tagsSet; PlacesUtils.tagging.untagURI(currentURI, tagsToRemove); onUndo.unshift(() => { PlacesUtils.tagging.tagURI(currentURI, tagsToRemove); }); onRedo.push(() => { PlacesUtils.tagging.untagURI(currentURI, tagsToRemove); }); } this.undo = function* () { for (let f of onUndo) { yield f(); } }; this.redo = function* () { for (let f of onRedo) { yield f(); } }; } }; /** * Transaction for copying an item. * * Required Input Properties: guid, newParentGuid * Optional Input Properties: newIndex, excludingAnnotations. */ PT.Copy = DefineTransaction(["guid", "newParentGuid"], ["newIndex", "excludingAnnotations"]); PT.Copy.prototype = { execute: function* (aGuid, aNewParentGuid, aNewIndex, aExcludingAnnotations) { let creationInfo = null; try { creationInfo = yield PlacesUtils.promiseBookmarksTree(aGuid); } catch (ex) { throw new Error("Failed to get info for the specified item (guid: " + aGuid + "). Ex: " + ex); } creationInfo.parentGuid = aNewParentGuid; creationInfo.index = aNewIndex; let newItemId = yield createItemsFromBookmarksTree(creationInfo, false, aExcludingAnnotations); let newItemInfo = null; this.undo = function* () { if (!newItemInfo) { let newItemGuid = yield PlacesUtils.promiseItemGuid(newItemId); newItemInfo = yield PlacesUtils.promiseBookmarksTree(newItemGuid); } PlacesUtils.bookmarks.removeItem(newItemId); }; this.redo = function* () { newItemId = yield createItemsFromBookmarksTree(newItemInfo, true); } return yield PlacesUtils.promiseItemGuid(newItemId); } };