summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesTransactions.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/PlacesTransactions.jsm')
-rw-r--r--toolkit/components/places/PlacesTransactions.jsm1645
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);
+ }
+};