diff options
Diffstat (limited to 'toolkit/components/places/PlacesTransactions.jsm')
-rw-r--r-- | toolkit/components/places/PlacesTransactions.jsm | 1645 |
1 files changed, 1645 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesTransactions.jsm b/toolkit/components/places/PlacesTransactions.jsm new file mode 100644 index 000000000..c355d92b6 --- /dev/null +++ b/toolkit/components/places/PlacesTransactions.jsm @@ -0,0 +1,1645 @@ +/* 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); + } +}; |