toolkit/components/places/PlacesTransactions.jsm

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/toolkit/components/places/PlacesTransactions.jsm	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,1269 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +"use strict";
     1.9 +
    1.10 +this.EXPORTED_SYMBOLS = ["PlacesTransactions"];
    1.11 +
    1.12 +/**
    1.13 + * Overview
    1.14 + * --------
    1.15 + * This modules serves as the transactions manager for Places, and implements
    1.16 + * all the standard transactions for its UI commands (creating items, editing
    1.17 + * various properties, etc.). It shares most of its semantics with common
    1.18 + * command pattern implementations, the HTML5 Undo Manager in particular.
    1.19 + * However, the asynchronous design of [future] Places APIs, combined with the
    1.20 + * commitment to serialize all UI operations, makes things a little bit
    1.21 + * different.  For example, when |undo| is called in order to undo the top undo
    1.22 + * entry, the caller cannot tell for sure what entry would it be because the
    1.23 + * execution of some transaction is either in process, or queued.
    1.24 + *
    1.25 + * GUIDs and item-ids
    1.26 + * -------------------
    1.27 + * The Bookmarks API still relies heavily on item-ids, but since those do not
    1.28 + * play nicely with the concept of undo and redo (especially not in an
    1.29 + * asynchronous environment), this API only accepts bookmark GUIDs, both for
    1.30 + * input (e.g. for specifying the parent folder for a new bookmark) and for
    1.31 + * output (when the GUID for such a bookmark is propagated).
    1.32 + *
    1.33 + * GUIDs are readily available when dealing with the "output" of this API and
    1.34 + * when result nodes are used (see nsINavHistoryResultNode::bookmarkGUID).
    1.35 + * If you only have item-ids in hand, use PlacesUtils.promiseItemGUID for
    1.36 + * converting them.  Should you need to convert them back into itemIds, use
    1.37 + * PlacesUtils.promiseItemId.
    1.38 + *
    1.39 + * The Standard Transactions
    1.40 + * -------------------------
    1.41 + * At the bottom of this module you will find implementations for all Places UI
    1.42 + * commands (One should almost never fallback to raw Places APIs.  Please file
    1.43 + * a bug if you find anything uncovered). The transactions' constructors are
    1.44 + * set on the PlacesTransactions object (e.g. PlacesTransactions.NewFolder).
    1.45 + * The input for this constructors is taken in the form of a single argument
    1.46 + * plain object.  Input properties may be either required (e.g. the |keyword|
    1.47 + * property for the EditKeyword transaction) or optional (e.g. the |keyword|
    1.48 + * property for NewBookmark).  Once a transaction is created, you may pass it
    1.49 + * to |transact| or use it in the for batching (see next section).
    1.50 + *
    1.51 + * The constructors throw right away when any required input is missing or when
    1.52 + * some input is invalid "on the surface" (e.g. GUID values are validated to be
    1.53 + * 12-characters strings, but are not validated to point to existing item.  Such
    1.54 + * an error will reveal when the transaction is executed).
    1.55 + *
    1.56 + * To make things simple, a given input property has the same basic meaning and
    1.57 + * valid values across all transactions which accept it in the input object.
    1.58 + * Here is a list of all supported input properties along with their expected
    1.59 + * values:
    1.60 + *  - uri: an nsIURI object.
    1.61 + *  - feedURI: an nsIURI object, holding the url for a live bookmark.
    1.62 + *  - siteURI: an nsIURI object, holding the url for the site with which
    1.63 + *             a live bookmark is associated.
    1.64 + *  - GUID, parentGUID, newParentGUID: a valid places GUID string.
    1.65 + *  - title: a string
    1.66 + *  - index, newIndex: the position of an item in its containing folder,
    1.67 + *    starting from 0.
    1.68 + *    integer and PlacesUtils.bookmarks.DEFAULT_INDEX
    1.69 + *  - annotationObject: see PlacesUtils.setAnnotationsForItem
    1.70 + *  - annotations: an array of annotation objects as above.
    1.71 + *  - tags: an array of strings.
    1.72 + *
    1.73 + * Batching transactions
    1.74 + * ---------------------
    1.75 + * Sometimes it is useful to "batch" or "merge" transactions. For example,
    1.76 + * "Bookmark All Tabs" may be implemented as one NewFolder transaction followed
    1.77 + * by numerous NewBookmark transactions - all to be undone or redone in a single
    1.78 + * command.  The |transact| method makes this possible using a generator
    1.79 + * function as an input.  These generators have the same semantics as in
    1.80 + * Task.jsm except that when you yield a transaction, it's executed, and the
    1.81 + * resolution (e.g. the new bookmark GUID) is sent to the generator so you can
    1.82 + * use it as the input for another transaction.  See |transact| for the details.
    1.83 + *
    1.84 + * "Custom" transactions
    1.85 + * ---------------------
    1.86 + * In the legacy transactions API it was possible to pass-in transactions
    1.87 + * implemented "externally".  For various reason this isn't allowed anymore:
    1.88 + * transact throws right away if one attempts to pass a transaction that was not
    1.89 + * created by this module.  However, it's almost always possible to achieve the
    1.90 + * same functionality with the batching technique described above.
    1.91 + *
    1.92 + * The transactions-history structure
    1.93 + * ----------------------------------
    1.94 + * The transactions-history is a two-dimensional stack of transactions: the
    1.95 + * transactions are ordered in reverse to the order they were committed.
    1.96 + * It's two-dimensional because the undo manager allows batching transactions
    1.97 + * together for the purpose of undo or redo (batched transactions can never be
    1.98 + * undone or redone partially).
    1.99 + *
   1.100 + * The undoPosition property is set to the index of the top entry. If there is
   1.101 + * no entry at that index, there is nothing to undo.
   1.102 + * Entries prior to undoPosition, if any, are redo entries, the first one being
   1.103 + * the top redo entry.
   1.104 + *
   1.105 + * [ [2nd redo txn, 1st redo txn],  <= 2nd redo entry
   1.106 + *   [2nd redo txn, 1st redo txn],  <= 1st redo entry
   1.107 + *   [1st undo txn, 2nd undo txn],  <= 1st undo entry
   1.108 + *   [1st undo txn, 2nd undo txn]   <= 2nd undo entry ]
   1.109 + * undoPostion: 2.
   1.110 + *
   1.111 + * Note that when a new entry is created, all redo entries are removed.
   1.112 + */
   1.113 +
   1.114 +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
   1.115 +Components.utils.import("resource://gre/modules/Services.jsm");
   1.116 +XPCOMUtils.defineLazyModuleGetter(this, "Promise",
   1.117 +                                  "resource://gre/modules/Promise.jsm");
   1.118 +XPCOMUtils.defineLazyModuleGetter(this, "Task",
   1.119 +                                  "resource://gre/modules/Task.jsm");
   1.120 +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
   1.121 +                                  "resource://gre/modules/NetUtil.jsm");
   1.122 +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
   1.123 +                                  "resource://gre/modules/PlacesUtils.jsm");
   1.124 +XPCOMUtils.defineLazyModuleGetter(this, "console",
   1.125 +                                  "resource://gre/modules/devtools/Console.jsm");
   1.126 +
   1.127 +// Updates commands in the undo group of the active window commands.
   1.128 +// Inactive windows commands will be updated on focus.
   1.129 +function updateCommandsOnActiveWindow() {
   1.130 +  // Updating "undo" will cause a group update including "redo".
   1.131 +  try {
   1.132 +    let win = Services.focus.activeWindow;
   1.133 +    if (win)
   1.134 +      win.updateCommands("undo");
   1.135 +  }
   1.136 +  catch(ex) { console.error(ex, "Couldn't update undo commands"); }
   1.137 +}
   1.138 +
   1.139 +// The internal object for managing the transactions history.
   1.140 +// The public API is included in PlacesTransactions.
   1.141 +// TODO bug 982099: extending the array "properly" makes it painful to implement
   1.142 +// getters.  If/when ES6 gets proper array subclassing we can revise this.
   1.143 +let TransactionsHistory = [];
   1.144 +TransactionsHistory.__proto__ = {
   1.145 +  __proto__: Array.prototype,
   1.146 +
   1.147 +  // The index of the first undo entry (if any) - See the documentation
   1.148 +  // at the top of this file.
   1.149 +  _undoPosition: 0,
   1.150 +  get undoPosition() this._undoPosition,
   1.151 +
   1.152 +  // Handy shortcuts
   1.153 +  get topUndoEntry() this.undoPosition < this.length ?
   1.154 +                     this[this.undoPosition] : null,
   1.155 +  get topRedoEntry() this.undoPosition > 0 ?
   1.156 +                     this[this.undoPosition - 1] : null,
   1.157 +
   1.158 +  // Outside of this module, the API of transactions is inaccessible, and so
   1.159 +  // are any internal properties.  To achieve that, transactions are proxified
   1.160 +  // in their constructors.  This maps the proxies to their respective raw
   1.161 +  // objects.
   1.162 +  proxifiedToRaw: new WeakMap(),
   1.163 +
   1.164 +  /**
   1.165 +   * Proxify a transaction object for consumers.
   1.166 +   * @param aRawTransaction
   1.167 +   *        the raw transaction object.
   1.168 +   * @return the proxified transaction object.
   1.169 +   * @see getRawTransaction for retrieving the raw transaction.
   1.170 +   */
   1.171 +  proxifyTransaction: function (aRawTransaction) {
   1.172 +    let proxy = Object.freeze({});
   1.173 +    this.proxifiedToRaw.set(proxy, aRawTransaction);
   1.174 +    return proxy;
   1.175 +  },
   1.176 +
   1.177 +  /**
   1.178 +   * Check if the given object is a the proxy object for some transaction.
   1.179 +   * @param aValue
   1.180 +   *        any JS value.
   1.181 +   * @return true if aValue is the proxy object for some transaction, false
   1.182 +   * otherwise.
   1.183 +   */
   1.184 +  isProxifiedTransactionObject:
   1.185 +  function (aValue) this.proxifiedToRaw.has(aValue),
   1.186 +
   1.187 +  /**
   1.188 +   * Get the raw transaction for the given proxy.
   1.189 +   * @param aProxy
   1.190 +   *        the proxy object
   1.191 +   * @return the transaction proxified by aProxy; |undefined| is returned if
   1.192 +   * aProxy is not a proxified transaction.
   1.193 +   */
   1.194 +  getRawTransaction: function (aProxy) this.proxifiedToRaw.get(aProxy),
   1.195 +
   1.196 +  /**
   1.197 +   * Undo the top undo entry, if any, and update the undo position accordingly.
   1.198 +   */
   1.199 +  undo: function* () {
   1.200 +    let entry = this.topUndoEntry;
   1.201 +    if (!entry)
   1.202 +      return;
   1.203 +
   1.204 +    for (let transaction of entry) {
   1.205 +      try {
   1.206 +        yield TransactionsHistory.getRawTransaction(transaction).undo();
   1.207 +      }
   1.208 +      catch(ex) {
   1.209 +        // If one transaction is broken, it's not safe to work with any other
   1.210 +        // undo entry.  Report the error and clear the undo history.
   1.211 +        console.error(ex,
   1.212 +                      "Couldn't undo a transaction, clearing all undo entries.");
   1.213 +        this.clearUndoEntries();
   1.214 +        return;
   1.215 +      }
   1.216 +    }
   1.217 +    this._undoPosition++;
   1.218 +    updateCommandsOnActiveWindow();
   1.219 +  },
   1.220 +
   1.221 +  /**
   1.222 +   * Redo the top redo entry, if any, and update the undo position accordingly.
   1.223 +   */
   1.224 +  redo: function* () {
   1.225 +    let entry = this.topRedoEntry;
   1.226 +    if (!entry)
   1.227 +      return;
   1.228 +
   1.229 +    for (let i = entry.length - 1; i >= 0; i--) {
   1.230 +      let transaction = TransactionsHistory.getRawTransaction(entry[i]);
   1.231 +      try {
   1.232 +        if (transaction.redo)
   1.233 +          yield transaction.redo();
   1.234 +        else
   1.235 +          yield transaction.execute();
   1.236 +      }
   1.237 +      catch(ex) {
   1.238 +        // If one transaction is broken, it's not safe to work with any other
   1.239 +        // redo entry. Report the error and clear the undo history.
   1.240 +        console.error(ex,
   1.241 +                      "Couldn't redo a transaction, clearing all redo entries.");
   1.242 +        this.clearRedoEntries();
   1.243 +        return;
   1.244 +      }
   1.245 +    }
   1.246 +    this._undoPosition--;
   1.247 +    updateCommandsOnActiveWindow();
   1.248 +  },
   1.249 +
   1.250 +  /**
   1.251 +   * Add a transaction either as a new entry, if forced or if there are no undo
   1.252 +   * entries, or to the top undo entry.
   1.253 +   *
   1.254 +   * @param aProxifiedTransaction
   1.255 +   *        the proxified transaction object to be added to the transaction
   1.256 +   *        history.
   1.257 +   * @param [optional] aForceNewEntry
   1.258 +   *        Force a new entry for the transaction. Default: false.
   1.259 +   *        If false, an entry will we created only if there's no undo entry
   1.260 +   *        to extend.
   1.261 +   */
   1.262 +  add: function (aProxifiedTransaction, aForceNewEntry = false) {
   1.263 +    if (!this.isProxifiedTransactionObject(aProxifiedTransaction))
   1.264 +      throw new Error("aProxifiedTransaction is not a proxified transaction");
   1.265 +
   1.266 +    if (this.length == 0 || aForceNewEntry) {
   1.267 +      this.clearRedoEntries();
   1.268 +      this.unshift([aProxifiedTransaction]);
   1.269 +    }
   1.270 +    else {
   1.271 +      this[this.undoPosition].unshift(aProxifiedTransaction);
   1.272 +    }
   1.273 +    updateCommandsOnActiveWindow();
   1.274 +  },
   1.275 +
   1.276 +  /**
   1.277 +   * Clear all undo entries.
   1.278 +   */
   1.279 +  clearUndoEntries: function () {
   1.280 +    if (this.undoPosition < this.length)
   1.281 +      this.splice(this.undoPosition);
   1.282 +  },
   1.283 +
   1.284 +  /**
   1.285 +   * Clear all redo entries.
   1.286 +   */
   1.287 +  clearRedoEntries: function () {
   1.288 +    if (this.undoPosition > 0) {
   1.289 +      this.splice(0, this.undoPosition);
   1.290 +      this._undoPosition = 0;
   1.291 +    }
   1.292 +  },
   1.293 +
   1.294 +  /**
   1.295 +   * Clear all entries.
   1.296 +   */
   1.297 +  clearAllEntries: function () {
   1.298 +    if (this.length > 0) {
   1.299 +      this.splice(0);
   1.300 +      this._undoPosition = 0;
   1.301 +    }
   1.302 +  }
   1.303 +};
   1.304 +
   1.305 +
   1.306 +// Our transaction manager is asynchronous in the sense that all of its methods
   1.307 +// don't execute synchronously. However, all actions must be serialized.
   1.308 +let currentTask = Promise.resolve();
   1.309 +function Serialize(aTask) {
   1.310 +  // Ignore failures.
   1.311 +  return currentTask = currentTask.then( () => Task.spawn(aTask) )
   1.312 +                                  .then(null, Components.utils.reportError);
   1.313 +}
   1.314 +
   1.315 +// Transactions object should never be recycled (that is, |execute| should
   1.316 +// only be called once, or not at all, after they're constructed.
   1.317 +// This keeps track of all transactions which were executed.
   1.318 +let executedTransactions = new WeakMap(); // TODO: use WeakSet (bug 792439)
   1.319 +executedTransactions.add = k => executedTransactions.set(k, null);
   1.320 +
   1.321 +let PlacesTransactions = {
   1.322 +  /**
   1.323 +   * Asynchronously transact either a single transaction, or a sequence of
   1.324 +   * transactions that would be treated as a single entry in the transactions
   1.325 +   * history.
   1.326 +   *
   1.327 +   * @param aToTransact
   1.328 +   *        Either a transaction object or a generator function (ES6-style only)
   1.329 +   *        that yields transaction objects.
   1.330 +   *
   1.331 +   *        Generator mode how-to: when a transaction is yielded, it's executed.
   1.332 +   *        Then, if it was executed successfully, the resolution of |execute|
   1.333 +   *        is sent to the generator.  If |execute| threw or rejected, the
   1.334 +   *        exception is propagated to the generator.
   1.335 +   *        Any other value yielded by a generator function is handled the
   1.336 +   *        same way as in a Task (see Task.jsm).
   1.337 +   *
   1.338 +   * @return {Promise}
   1.339 +   * @resolves either to the resolution of |execute|, in single-transaction mode,
   1.340 +   * or to the return value of the generator, in generator-mode.
   1.341 +   * @rejects either if |execute| threw, in single-transaction mode, or if
   1.342 +   * the generator function threw (or didn't handle) an exception, in generator
   1.343 +   * mode.
   1.344 +   * @throws if aTransactionOrGeneratorFunction is neither a transaction object
   1.345 +   * created by this module or a generator function.
   1.346 +   * @note If no transaction was executed successfully, the transactions history
   1.347 +   * is not affected.
   1.348 +   *
   1.349 +   * @note All PlacesTransactions operations are serialized. This means that the
   1.350 +   * transactions history state may change by the time the transaction/generator
   1.351 +   * is processed.  It's guaranteed, however, that a generator function "blocks"
   1.352 +   * the queue: that is, it is assured that no other operations are performed
   1.353 +   * by or on PlacesTransactions until the generator returns.  Keep in mind you
   1.354 +   * are not protected from consumers who use the raw places APIs directly.
   1.355 +   */
   1.356 +  transact: function (aToTransact) {
   1.357 +    let isGeneratorObj =
   1.358 +      o => Object.prototype.toString.call(o) ==  "[object Generator]";
   1.359 +
   1.360 +    let generator = null;
   1.361 +    if (typeof(aToTransact) == "function") {
   1.362 +      generator = aToTransact();
   1.363 +      if (!isGeneratorObj(generator))
   1.364 +        throw new Error("aToTransact is not a generator function");
   1.365 +    }
   1.366 +    else if (!TransactionsHistory.isProxifiedTransactionObject(aToTransact)) {
   1.367 +      throw new Error("aToTransact is not a valid transaction object");
   1.368 +    }
   1.369 +    else if (executedTransactions.has(aToTransact)) {
   1.370 +      throw new Error("Transactions objects may not be recycled.");
   1.371 +    }
   1.372 +
   1.373 +    return Serialize(function* () {
   1.374 +      // The entry in the transactions history is created once the first
   1.375 +      // transaction is committed. This means that if |transact| is called
   1.376 +      // in its "generator mode" and no transactions are committed by the
   1.377 +      // generator, the transactions history is left unchanged.
   1.378 +      // Bug 982115: Depending on how this API is actually used we may revise
   1.379 +      // this decision and make it so |transact| always forces a new entry.
   1.380 +      let forceNewEntry = true;
   1.381 +      function* transactOneTransaction(aTransaction) {
   1.382 +        let retval =
   1.383 +          yield TransactionsHistory.getRawTransaction(aTransaction).execute();
   1.384 +        executedTransactions.add(aTransaction);
   1.385 +        TransactionsHistory.add(aTransaction, forceNewEntry);
   1.386 +        forceNewEntry = false;
   1.387 +        return retval;
   1.388 +      }
   1.389 +
   1.390 +      function* transactBatch(aGenerator) {
   1.391 +        let error = false;
   1.392 +        let sendValue = undefined;
   1.393 +        while (true) {
   1.394 +          let next = error ?
   1.395 +                     aGenerator.throw(sendValue) : aGenerator.next(sendValue);
   1.396 +          sendValue = next.value;
   1.397 +          if (isGeneratorObj(sendValue)) {
   1.398 +            sendValue = yield transactBatch(sendValue);
   1.399 +          }
   1.400 +          else if (typeof(sendValue) == "object" && sendValue) {
   1.401 +            if (TransactionsHistory.isProxifiedTransactionObject(sendValue)) {
   1.402 +              if (executedTransactions.has(sendValue)) {
   1.403 +                sendValue = new Error("Transactions may not be recycled.");
   1.404 +                error = true;
   1.405 +              }
   1.406 +              else {
   1.407 +                sendValue = yield transactOneTransaction(sendValue);
   1.408 +              }
   1.409 +            }
   1.410 +            else if ("then" in sendValue) {
   1.411 +              sendValue = yield sendValue;
   1.412 +            }
   1.413 +          }
   1.414 +          if (next.done)
   1.415 +            break;
   1.416 +        }
   1.417 +        return sendValue;
   1.418 +      }
   1.419 +
   1.420 +      if (generator)
   1.421 +        return yield transactBatch(generator);
   1.422 +      else
   1.423 +        return yield transactOneTransaction(aToTransact);
   1.424 +    }.bind(this));
   1.425 +  },
   1.426 +
   1.427 +  /**
   1.428 +   * Asynchronously undo the transaction immediately after the current undo
   1.429 +   * position in the transactions history in the reverse order, if any, and
   1.430 +   * adjusts the undo position.
   1.431 +   *
   1.432 +   * @return {Promises).  The promise always resolves.
   1.433 +   * @note All undo manager operations are queued. This means that transactions
   1.434 +   * history may change by the time your request is fulfilled.
   1.435 +   */
   1.436 +  undo: function () Serialize(() => TransactionsHistory.undo()),
   1.437 +
   1.438 +  /**
   1.439 +   * Asynchronously redo the transaction immediately before the current undo
   1.440 +   * position in the transactions history, if any, and adjusts the undo
   1.441 +   * position.
   1.442 +   *
   1.443 +   * @return {Promises).  The promise always resolves.
   1.444 +   * @note All undo manager operations are queued. This means that transactions
   1.445 +   * history may change by the time your request is fulfilled.
   1.446 +   */
   1.447 +  redo: function () Serialize(() => TransactionsHistory.redo()),
   1.448 +
   1.449 +  /**
   1.450 +   * Asynchronously clear the undo, redo, or all entries from the transactions
   1.451 +   * history.
   1.452 +   *
   1.453 +   * @param [optional] aUndoEntries
   1.454 +   *        Whether or not to clear undo entries.  Default: true.
   1.455 +   * @param [optional] aRedoEntries
   1.456 +   *        Whether or not to clear undo entries.  Default: true.
   1.457 +   *
   1.458 +   * @return {Promises).  The promise always resolves.
   1.459 +   * @throws if both aUndoEntries and aRedoEntries are false.
   1.460 +   * @note All undo manager operations are queued. This means that transactions
   1.461 +   * history may change by the time your request is fulfilled.
   1.462 +   */
   1.463 +  clearTransactionsHistory:
   1.464 +  function (aUndoEntries = true, aRedoEntries = true) {
   1.465 +    return Serialize(function* () {
   1.466 +      if (aUndoEntries && aRedoEntries)
   1.467 +        TransactionsHistory.clearAllEntries();
   1.468 +      else if (aUndoEntries)
   1.469 +        TransactionsHistory.clearUndoEntries();
   1.470 +      else if (aRedoEntries)
   1.471 +        TransactionsHistory.clearRedoEntries();
   1.472 +      else
   1.473 +        throw new Error("either aUndoEntries or aRedoEntries should be true");
   1.474 +    });
   1.475 +  },
   1.476 +
   1.477 +  /**
   1.478 +   * The numbers of entries in the transactions history.
   1.479 +   */
   1.480 +  get length() TransactionsHistory.length,
   1.481 +
   1.482 +  /**
   1.483 +   * Get the transaction history entry at a given index.  Each entry consists
   1.484 +   * of one or more transaction objects.
   1.485 +   *
   1.486 +   * @param aIndex
   1.487 +   *        the index of the entry to retrieve.
   1.488 +   * @return an array of transaction objects in their undo order (that is,
   1.489 +   * reversely to the order they were executed).
   1.490 +   * @throw if aIndex is invalid (< 0 or >= length).
   1.491 +   * @note the returned array is a clone of the history entry and is not
   1.492 +   * kept in sync with the original entry if it changes.
   1.493 +   */
   1.494 +  entry: function (aIndex) {
   1.495 +    if (!Number.isInteger(aIndex) || aIndex < 0 || aIndex >= this.length)
   1.496 +      throw new Error("Invalid index");
   1.497 +
   1.498 +    return TransactionsHistory[aIndex];
   1.499 +  },
   1.500 +
   1.501 +  /**
   1.502 +   * The index of the top undo entry in the transactions history.
   1.503 +   * If there are no undo entries, it equals to |length|.
   1.504 +   * Entries past this point
   1.505 +   * Entries at and past this point are redo entries.
   1.506 +   */
   1.507 +  get undoPosition() TransactionsHistory.undoPosition,
   1.508 +
   1.509 +  /**
   1.510 +   * Shortcut for accessing the top undo entry in the transaction history.
   1.511 +   */
   1.512 +  get topUndoEntry() TransactionsHistory.topUndoEntry,
   1.513 +
   1.514 +  /**
   1.515 +   * Shortcut for accessing the top redo entry in the transaction history.
   1.516 +   */
   1.517 +  get topRedoEntry() TransactionsHistory.topRedoEntry
   1.518 +};
   1.519 +
   1.520 +/**
   1.521 + * Internal helper for defining the standard transactions and their input.
   1.522 + * It takes the required and optional properties, and generates the public
   1.523 + * constructor (which takes the input in the form of a plain object) which,
   1.524 + * when called, creates the argument-less "public" |execute| method by binding
   1.525 + * the input properties to the function arguments (required properties first,
   1.526 + * then the optional properties).
   1.527 + *
   1.528 + * If this seems confusing, look at the consumers.
   1.529 + *
   1.530 + * This magic serves two purposes:
   1.531 + * (1) It completely hides the transactions' internals from the module
   1.532 + *     consumers.
   1.533 + * (2) It keeps each transaction implementation to what is about, bypassing
   1.534 + *     all this bureaucracy while still validating input appropriately.
   1.535 + */
   1.536 +function DefineTransaction(aRequiredProps = [], aOptionalProps = []) {
   1.537 +  for (let prop of [...aRequiredProps, ...aOptionalProps]) {
   1.538 +    if (!DefineTransaction.inputProps.has(prop))
   1.539 +      throw new Error("Property '" + prop + "' is not defined");
   1.540 +  }
   1.541 +
   1.542 +  let ctor = function (aInput) {
   1.543 +    // We want to support both syntaxes:
   1.544 +    // let t = new PlacesTransactions.NewBookmark(),
   1.545 +    // let t = PlacesTransactions.NewBookmark()
   1.546 +    if (this == PlacesTransactions)
   1.547 +      return new ctor(aInput);
   1.548 +
   1.549 +    if (aRequiredProps.length > 0 || aOptionalProps.length > 0) {
   1.550 +      // Bind the input properties to the arguments of execute.
   1.551 +      let input = DefineTransaction.verifyInput(aInput, aRequiredProps,
   1.552 +                                                aOptionalProps);
   1.553 +      let executeArgs = [this,
   1.554 +                         ...[input[prop] for (prop of aRequiredProps)],
   1.555 +                         ...[input[prop] for (prop of aOptionalProps)]];
   1.556 +      this.execute = Function.bind.apply(this.execute, executeArgs);
   1.557 +    }
   1.558 +    return TransactionsHistory.proxifyTransaction(this);
   1.559 +  };
   1.560 +  return ctor;
   1.561 +}
   1.562 +
   1.563 +DefineTransaction.isStr = v => typeof(v) == "string";
   1.564 +DefineTransaction.isURI = v => v instanceof Components.interfaces.nsIURI;
   1.565 +DefineTransaction.isIndex = v => Number.isInteger(v) &&
   1.566 +                                 v >= PlacesUtils.bookmarks.DEFAULT_INDEX;
   1.567 +DefineTransaction.isGUID = v => /^[a-zA-Z0-9\-_]{12}$/.test(v);
   1.568 +DefineTransaction.isPrimitive = v => v === null || (typeof(v) != "object" &&
   1.569 +                                                    typeof(v) != "function");
   1.570 +DefineTransaction.isAnnotationObject = function (obj) {
   1.571 +  let checkProperty = (aPropName, aRequired, aCheckFunc) => {
   1.572 +    if (aPropName in obj)
   1.573 +      return aCheckFunc(obj[aPropName]);
   1.574 +
   1.575 +    return !aRequired;
   1.576 +  };
   1.577 +
   1.578 +  if (obj &&
   1.579 +      checkProperty("name",    true,  DefineTransaction.isStr)      &&
   1.580 +      checkProperty("expires", false, Number.isInteger) &&
   1.581 +      checkProperty("flags",   false, Number.isInteger) &&
   1.582 +      checkProperty("value",   false, DefineTransaction.isPrimitive) ) {
   1.583 +    // Nothing else should be set
   1.584 +    let validKeys = ["name", "value", "flags", "expires"];
   1.585 +    if (Object.keys(obj).every( (k) => validKeys.indexOf(k) != -1 ))
   1.586 +      return true;
   1.587 +  }
   1.588 +  return false;
   1.589 +};
   1.590 +
   1.591 +DefineTransaction.inputProps = new Map();
   1.592 +DefineTransaction.defineInputProps =
   1.593 +function (aNames, aValidationFunction, aDefaultValue) {
   1.594 +  for (let name of aNames) {
   1.595 +    this.inputProps.set(name, {
   1.596 +      validate:     aValidationFunction,
   1.597 +      defaultValue: aDefaultValue,
   1.598 +      isGUIDProp:   false
   1.599 +    });
   1.600 +  }
   1.601 +};
   1.602 +
   1.603 +DefineTransaction.defineArrayInputProp =
   1.604 +function (aName, aValidationFunction, aDefaultValue) {
   1.605 +  this.inputProps.set(aName, {
   1.606 +    validate:     (v) => Array.isArray(v) && v.every(aValidationFunction),
   1.607 +    defaultValue: aDefaultValue,
   1.608 +    isGUIDProp:   false
   1.609 +  });
   1.610 +};
   1.611 +
   1.612 +DefineTransaction.verifyPropertyValue =
   1.613 +function (aProp, aValue, aRequired) {
   1.614 +  if (aValue === undefined) {
   1.615 +    if (aRequired)
   1.616 +      throw new Error("Required property is missing: " + aProp);
   1.617 +    return this.inputProps.get(aProp).defaultValue;
   1.618 +  }
   1.619 +
   1.620 +  if (!this.inputProps.get(aProp).validate(aValue))
   1.621 +    throw new Error("Invalid value for property: " + aProp);
   1.622 +
   1.623 +  if (Array.isArray(aValue)) {
   1.624 +    // The original array cannot be referenced by this module because it would
   1.625 +    // then implicitly reference its global as well.
   1.626 +    return Components.utils.cloneInto(aValue, {});
   1.627 +  }
   1.628 +
   1.629 +  return aValue;
   1.630 +};
   1.631 +
   1.632 +DefineTransaction.verifyInput =
   1.633 +function (aInput, aRequired = [], aOptional = []) {
   1.634 +  if (aRequired.length == 0 && aOptional.length == 0)
   1.635 +    return {};
   1.636 +
   1.637 +  // If there's just a single required/optional property, we allow passing it
   1.638 +  // as is, so, for example, one could do PlacesTransactions.RemoveItem(myGUID)
   1.639 +  // rather than PlacesTransactions.RemoveItem({ GUID: myGUID}).
   1.640 +  // This shortcut isn't supported for "complex" properties - e.g. one cannot
   1.641 +  // pass an annotation object this way (note there is no use case for this at
   1.642 +  // the moment anyway).
   1.643 +  let isSinglePropertyInput =
   1.644 +    this.isPrimitive(aInput) ||
   1.645 +    (aInput instanceof Components.interfaces.nsISupports);
   1.646 +  let fixedInput = { };
   1.647 +  if (aRequired.length > 0) {
   1.648 +    if (isSinglePropertyInput) {
   1.649 +      if (aRequired.length == 1) {
   1.650 +        let prop = aRequired[0], value = aInput;
   1.651 +        value = this.verifyPropertyValue(prop, value, true);
   1.652 +        fixedInput[prop] = value;
   1.653 +      }
   1.654 +      else {
   1.655 +        throw new Error("Transaction input isn't an object");
   1.656 +      }
   1.657 +    }
   1.658 +    else {
   1.659 +      for (let prop of aRequired) {
   1.660 +        let value = this.verifyPropertyValue(prop, aInput[prop], true);
   1.661 +        fixedInput[prop] = value;
   1.662 +      }
   1.663 +    }
   1.664 +  }
   1.665 +
   1.666 +  if (aOptional.length > 0) {
   1.667 +    if (isSinglePropertyInput && !aRequired.length > 0) {
   1.668 +      if (aOptional.length == 1) {
   1.669 +        let prop = aOptional[0], value = aInput;
   1.670 +        value = this.verifyPropertyValue(prop, value, true);
   1.671 +        fixedInput[prop] = value;
   1.672 +      }
   1.673 +      else if (aInput !== null && aInput !== undefined) {
   1.674 +        throw new Error("Transaction input isn't an object");
   1.675 +      }
   1.676 +    }
   1.677 +    else {
   1.678 +      for (let prop of aOptional) {
   1.679 +        let value = this.verifyPropertyValue(prop, aInput[prop], false);
   1.680 +        if (value !== undefined)
   1.681 +          fixedInput[prop] = value;
   1.682 +        else
   1.683 +          fixedInput[prop] = this.defaultValues[prop];
   1.684 +      }
   1.685 +    }
   1.686 +  }
   1.687 +
   1.688 +  return fixedInput;
   1.689 +};
   1.690 +
   1.691 +// Update the documentation at the top of this module if you add or
   1.692 +// remove properties.
   1.693 +DefineTransaction.defineInputProps(["uri", "feedURI", "siteURI"],
   1.694 +                                   DefineTransaction.isURI, null);
   1.695 +DefineTransaction.defineInputProps(["GUID", "parentGUID", "newParentGUID"],
   1.696 +                                   DefineTransaction.isGUID);
   1.697 +DefineTransaction.defineInputProps(["title", "keyword", "postData"],
   1.698 +                                   DefineTransaction.isStr, "");
   1.699 +DefineTransaction.defineInputProps(["index", "newIndex"],
   1.700 +                                   DefineTransaction.isIndex,
   1.701 +                                   PlacesUtils.bookmarks.DEFAULT_INDEX);
   1.702 +DefineTransaction.defineInputProps(["annotationObject"],
   1.703 +                                   DefineTransaction.isAnnotationObject);
   1.704 +DefineTransaction.defineArrayInputProp("tags",
   1.705 +                                       DefineTransaction.isStr, null);
   1.706 +DefineTransaction.defineArrayInputProp("annotations",
   1.707 +                                       DefineTransaction.isAnnotationObject,
   1.708 +                                       null);
   1.709 +
   1.710 +/**
   1.711 + * Internal helper for implementing the execute method of NewBookmark, NewFolder
   1.712 + * and NewSeparator.
   1.713 + *
   1.714 + * @param aTransaction
   1.715 + *        The transaction object
   1.716 + * @param aParentGUID
   1.717 + *        The guid of the parent folder
   1.718 + * @param aCreateItemFunction(aParentId, aGUIDToRestore)
   1.719 + *        The function to be called for creating the item on execute and redo.
   1.720 + *        It should return the itemId for the new item
   1.721 + *        - aGUIDToRestore - the GUID to set for the item (used for redo).
   1.722 + * @param [optional] aOnUndo
   1.723 + *        an additional function to call after undo
   1.724 + * @param [optional] aOnRedo
   1.725 + *        an additional function to call after redo
   1.726 + */
   1.727 +function* ExecuteCreateItem(aTransaction, aParentGUID, aCreateItemFunction,
   1.728 +                            aOnUndo = null, aOnRedo = null) {
   1.729 +  let parentId = yield PlacesUtils.promiseItemId(aParentGUID),
   1.730 +      itemId = yield aCreateItemFunction(parentId, ""),
   1.731 +      guid = yield PlacesUtils.promiseItemGUID(itemId);
   1.732 +
   1.733 +  // On redo, we'll restore the date-added and last-modified properties.
   1.734 +  let dateAdded = 0, lastModified = 0;
   1.735 +  aTransaction.undo = function* () {
   1.736 +    if (dateAdded == 0) {
   1.737 +      dateAdded = PlacesUtils.bookmarks.getItemDateAdded(itemId);
   1.738 +      lastModified = PlacesUtils.bookmarks.getItemLastModified(itemId);
   1.739 +    }
   1.740 +    PlacesUtils.bookmarks.removeItem(itemId);
   1.741 +    if (aOnUndo) {
   1.742 +      yield aOnUndo();
   1.743 +    }
   1.744 +  };
   1.745 +  aTransaction.redo = function* () {
   1.746 +    parentId = yield PlacesUtils.promiseItemId(aParentGUID);
   1.747 +    itemId = yield aCreateItemFunction(parentId, guid);
   1.748 +    if (aOnRedo)
   1.749 +      yield aOnRedo();
   1.750 +
   1.751 +    // aOnRedo is called first to make sure it doesn't override
   1.752 +    // lastModified.
   1.753 +    PlacesUtils.bookmarks.setItemDateAdded(itemId, dateAdded);
   1.754 +    PlacesUtils.bookmarks.setItemLastModified(itemId, lastModified);
   1.755 +  };
   1.756 +  return guid;
   1.757 +}
   1.758 +
   1.759 +/*****************************************************************************
   1.760 + * The Standard Places Transactions.
   1.761 + *
   1.762 + * See the documentation at the top of this file. The valid values for input
   1.763 + * are also documented there.
   1.764 + *****************************************************************************/
   1.765 +
   1.766 +let PT = PlacesTransactions;
   1.767 +
   1.768 +/**
   1.769 + * Transaction for creating a bookmark.
   1.770 + *
   1.771 + * Required Input Properties: uri, parentGUID.
   1.772 + * Optional Input Properties: index, title, keyword, annotations, tags.
   1.773 + *
   1.774 + * When this transaction is executed, it's resolved to the new bookmark's GUID.
   1.775 + */
   1.776 +PT.NewBookmark = DefineTransaction(["parentGUID", "uri"],
   1.777 +                                   ["index", "title", "keyword", "postData",
   1.778 +                                    "annotations", "tags"]);
   1.779 +PT.NewBookmark.prototype = Object.seal({
   1.780 +  execute: function (aParentGUID, aURI, aIndex, aTitle,
   1.781 +                     aKeyword, aPostData, aAnnos, aTags) {
   1.782 +    return ExecuteCreateItem(this, aParentGUID,
   1.783 +      function (parentId, guidToRestore = "") {
   1.784 +        let itemId = PlacesUtils.bookmarks.insertBookmark(
   1.785 +          parentId, aURI, aIndex, aTitle, guidToRestore);
   1.786 +        if (aKeyword)
   1.787 +          PlacesUtils.bookmarks.setKeywordForBookmark(itemId, aKeyword);
   1.788 +        if (aPostData)
   1.789 +          PlacesUtils.setPostDataForBookmark(itemId, aPostData);
   1.790 +        if (aAnnos)
   1.791 +          PlacesUtils.setAnnotationsForItem(itemId, aAnnos);
   1.792 +        if (aTags && aTags.length > 0) {
   1.793 +          let currentTags = PlacesUtils.tagging.getTagsForURI(aURI);
   1.794 +          aTags = [t for (t of aTags) if (currentTags.indexOf(t) == -1)];
   1.795 +          PlacesUtils.tagging.tagURI(aURI, aTags);
   1.796 +        }
   1.797 +
   1.798 +        return itemId;
   1.799 +      },
   1.800 +      function _additionalOnUndo() {
   1.801 +        if (aTags && aTags.length > 0)
   1.802 +          PlacesUtils.tagging.untagURI(aURI, aTags);
   1.803 +      });
   1.804 +  }
   1.805 +});
   1.806 +
   1.807 +/**
   1.808 + * Transaction for creating a folder.
   1.809 + *
   1.810 + * Required Input Properties: title, parentGUID.
   1.811 + * Optional Input Properties: index, annotations.
   1.812 + *
   1.813 + * When this transaction is executed, it's resolved to the new folder's GUID.
   1.814 + */
   1.815 +PT.NewFolder = DefineTransaction(["parentGUID", "title"],
   1.816 +                                 ["index", "annotations"]);
   1.817 +PT.NewFolder.prototype = Object.seal({
   1.818 +  execute: function (aParentGUID, aTitle, aIndex, aAnnos) {
   1.819 +    return ExecuteCreateItem(this,  aParentGUID,
   1.820 +      function(parentId, guidToRestore = "") {
   1.821 +        let itemId = PlacesUtils.bookmarks.createFolder(
   1.822 +          parentId, aTitle, aIndex, guidToRestore);
   1.823 +        if (aAnnos)
   1.824 +          PlacesUtils.setAnnotationsForItem(itemId, aAnnos);
   1.825 +        return itemId;
   1.826 +      });
   1.827 +  }
   1.828 +});
   1.829 +
   1.830 +/**
   1.831 + * Transaction for creating a separator.
   1.832 + *
   1.833 + * Required Input Properties: parentGUID.
   1.834 + * Optional Input Properties: index.
   1.835 + *
   1.836 + * When this transaction is executed, it's resolved to the new separator's
   1.837 + * GUID.
   1.838 + */
   1.839 +PT.NewSeparator = DefineTransaction(["parentGUID"], ["index"]);
   1.840 +PT.NewSeparator.prototype = Object.seal({
   1.841 +  execute: function (aParentGUID, aIndex) {
   1.842 +    return ExecuteCreateItem(this, aParentGUID,
   1.843 +      function (parentId, guidToRestore = "") {
   1.844 +        let itemId = PlacesUtils.bookmarks.insertSeparator(
   1.845 +          parentId, aIndex, guidToRestore);
   1.846 +        return itemId;
   1.847 +      });
   1.848 +  }
   1.849 +});
   1.850 +
   1.851 +/**
   1.852 + * Transaction for creating a live bookmark (see mozIAsyncLivemarks for the
   1.853 + * semantics).
   1.854 + *
   1.855 + * Required Input Properties: feedURI, title, parentGUID.
   1.856 + * Optional Input Properties: siteURI, index, annotations.
   1.857 + *
   1.858 + * When this transaction is executed, it's resolved to the new separators's
   1.859 + * GUID.
   1.860 + */
   1.861 +PT.NewLivemark = DefineTransaction(["feedURI", "title", "parentGUID"],
   1.862 +                                   ["siteURI", "index", "annotations"]);
   1.863 +PT.NewLivemark.prototype = Object.seal({
   1.864 +  execute: function* (aFeedURI, aTitle, aParentGUID, aSiteURI, aIndex, aAnnos) {
   1.865 +    let createItem = function* (aGUID = "") {
   1.866 +      let parentId = yield PlacesUtils.promiseItemId(aParentGUID);
   1.867 +      let livemarkInfo = {
   1.868 +        title: aTitle
   1.869 +      , feedURI: aFeedURI
   1.870 +      , parentId: parentId
   1.871 +      , index: aIndex
   1.872 +      , siteURI: aSiteURI };
   1.873 +      if (aGUID)
   1.874 +        livemarkInfo.guid = aGUID;
   1.875 +
   1.876 +      let livemark = yield PlacesUtils.livemarks.addLivemark(livemarkInfo);
   1.877 +      if (aAnnos)
   1.878 +        PlacesUtils.setAnnotationsForItem(livemark.id, aAnnos);
   1.879 +
   1.880 +      return livemark;
   1.881 +    };
   1.882 +
   1.883 +    let guid = (yield createItem()).guid;
   1.884 +    this.undo = function* () {
   1.885 +      yield PlacesUtils.livemarks.removeLivemark({ guid: guid });
   1.886 +    };
   1.887 +    this.redo = function* () {
   1.888 +      yield createItem(guid);
   1.889 +    };
   1.890 +    return guid;
   1.891 +  }
   1.892 +});
   1.893 +
   1.894 +/**
   1.895 + * Transaction for moving an item.
   1.896 + *
   1.897 + * Required Input Properties: GUID, newParentGUID.
   1.898 + * Optional Input Properties  newIndex.
   1.899 + */
   1.900 +PT.MoveItem = DefineTransaction(["GUID", "newParentGUID"], ["newIndex"]);
   1.901 +PT.MoveItem.prototype = Object.seal({
   1.902 +  execute: function* (aGUID, aNewParentGUID, aNewIndex) {
   1.903 +    let itemId = yield PlacesUtils.promiseItemId(aGUID),
   1.904 +        oldParentId = PlacesUtils.bookmarks.getFolderIdForItem(itemId),
   1.905 +        oldIndex = PlacesUtils.bookmarks.getItemIndex(itemId),
   1.906 +        newParentId = yield PlacesUtils.promiseItemId(aNewParentGUID);
   1.907 +
   1.908 +    PlacesUtils.bookmarks.moveItem(itemId, newParentId, aNewIndex);
   1.909 +
   1.910 +    let undoIndex = PlacesUtils.bookmarks.getItemIndex(itemId);
   1.911 +    this.undo = () => {
   1.912 +      // Moving down in the same parent takes in count removal of the item
   1.913 +      // so to revert positions we must move to oldIndex + 1
   1.914 +      if (newParentId == oldParentId && oldIndex > undoIndex)
   1.915 +        PlacesUtils.bookmarks.moveItem(itemId, oldParentId, oldIndex + 1);
   1.916 +      else
   1.917 +        PlacesUtils.bookmarks.moveItem(itemId, oldParentId, oldIndex);
   1.918 +    };
   1.919 +  }
   1.920 +});
   1.921 +
   1.922 +/**
   1.923 + * Transaction for setting the title for an item.
   1.924 + *
   1.925 + * Required Input Properties: GUID, title.
   1.926 + */
   1.927 +PT.EditTitle = DefineTransaction(["GUID", "title"]);
   1.928 +PT.EditTitle.prototype = Object.seal({
   1.929 +  execute: function* (aGUID, aTitle) {
   1.930 +    let itemId = yield PlacesUtils.promiseItemId(aGUID),
   1.931 +        oldTitle = PlacesUtils.bookmarks.getItemTitle(itemId);
   1.932 +    PlacesUtils.bookmarks.setItemTitle(itemId, aTitle);
   1.933 +    this.undo = () => { PlacesUtils.bookmarks.setItemTitle(itemId, oldTitle); };
   1.934 +  }
   1.935 +});
   1.936 +
   1.937 +/**
   1.938 + * Transaction for setting the URI for an item.
   1.939 + *
   1.940 + * Required Input Properties: GUID, uri.
   1.941 + */
   1.942 +PT.EditURI = DefineTransaction(["GUID", "uri"]);
   1.943 +PT.EditURI.prototype = Object.seal({
   1.944 +  execute: function* (aGUID, aURI) {
   1.945 +    let itemId = yield PlacesUtils.promiseItemId(aGUID),
   1.946 +        oldURI = PlacesUtils.bookmarks.getBookmarkURI(itemId),
   1.947 +        oldURITags = PlacesUtils.tagging.getTagsForURI(oldURI),
   1.948 +        newURIAdditionalTags = null;
   1.949 +    PlacesUtils.bookmarks.changeBookmarkURI(itemId, aURI);
   1.950 +
   1.951 +    // Move tags from old URI to new URI.
   1.952 +    if (oldURITags.length > 0) {
   1.953 +      // Only untag the old URI if this is the only bookmark.
   1.954 +      if (PlacesUtils.getBookmarksForURI(oldURI, {}).length == 0)
   1.955 +        PlacesUtils.tagging.untagURI(oldURI, oldURITags);
   1.956 +
   1.957 +      let currentNewURITags = PlacesUtils.tagging.getTagsForURI(aURI);
   1.958 +      newURIAdditionalTags = [t for (t of oldURITags)
   1.959 +                              if (currentNewURITags.indexOf(t) == -1)];
   1.960 +      if (newURIAdditionalTags)
   1.961 +        PlacesUtils.tagging.tagURI(aURI, newURIAdditionalTags);
   1.962 +    }
   1.963 +
   1.964 +    this.undo = () => {
   1.965 +      PlacesUtils.bookmarks.changeBookmarkURI(itemId, oldURI);
   1.966 +      // Move tags from new URI to old URI.
   1.967 +      if (oldURITags.length > 0) {
   1.968 +        // Only untag the new URI if this is the only bookmark.
   1.969 +        if (newURIAdditionalTags && newURIAdditionalTags.length > 0 &&
   1.970 +            PlacesUtils.getBookmarksForURI(aURI, {}).length == 0) {
   1.971 +          PlacesUtils.tagging.untagURI(aURI, newURIAdditionalTags);
   1.972 +        }
   1.973 +
   1.974 +        PlacesUtils.tagging.tagURI(oldURI, oldURITags);
   1.975 +      }
   1.976 +    };
   1.977 +  }
   1.978 +});
   1.979 +
   1.980 +/**
   1.981 + * Transaction for setting an annotation for an item.
   1.982 + *
   1.983 + * Required Input Properties: GUID, annotationObject
   1.984 + */
   1.985 +PT.SetItemAnnotation = DefineTransaction(["GUID", "annotationObject"]);
   1.986 +PT.SetItemAnnotation.prototype = {
   1.987 +  execute: function* (aGUID, aAnno) {
   1.988 +    let itemId = yield PlacesUtils.promiseItemId(aGUID), oldAnno;
   1.989 +    if (PlacesUtils.annotations.itemHasAnnotation(itemId, aAnno.name)) {
   1.990 +      // Fill the old anno if it is set.
   1.991 +      let flags = {}, expires = {};
   1.992 +      PlacesUtils.annotations.getItemAnnotationInfo(itemId, aAnno.name, flags,
   1.993 +                                                    expires, { });
   1.994 +      let value = PlacesUtils.annotations.getItemAnnotation(itemId, aAnno.name);
   1.995 +      oldAnno = { name: aAnno.name, flags: flags.value,
   1.996 +                  value: value, expires: expires.value };
   1.997 +    }
   1.998 +    else {
   1.999 +      // An unset value removes the annoation.
  1.1000 +      oldAnno = { name: aAnno.name };
  1.1001 +    }
  1.1002 +
  1.1003 +    PlacesUtils.setAnnotationsForItem(itemId, [aAnno]);
  1.1004 +    this.undo = () => { PlacesUtils.setAnnotationsForItem(itemId, [oldAnno]); };
  1.1005 +  }
  1.1006 +};
  1.1007 +
  1.1008 +/**
  1.1009 + * Transaction for setting the keyword for a bookmark.
  1.1010 + *
  1.1011 + * Required Input Properties: GUID, keyword.
  1.1012 + */
  1.1013 +PT.EditKeyword = DefineTransaction(["GUID", "keyword"]);
  1.1014 +PT.EditKeyword.prototype = Object.seal({
  1.1015 +  execute: function* (aGUID, aKeyword) {
  1.1016 +    let itemId = yield PlacesUtils.promiseItemId(aGUID),
  1.1017 +        oldKeyword = PlacesUtils.bookmarks.getKeywordForBookmark(itemId);
  1.1018 +    PlacesUtils.bookmarks.setKeywordForBookmark(itemId, aKeyword);
  1.1019 +    this.undo = () => {
  1.1020 +      PlacesUtils.bookmarks.setKeywordForBookmark(itemId, oldKeyword);
  1.1021 +    };
  1.1022 +  }
  1.1023 +});
  1.1024 +
  1.1025 +/**
  1.1026 + * Transaction for sorting a folder by name.
  1.1027 + *
  1.1028 + * Required Input Properties: GUID.
  1.1029 + */
  1.1030 +PT.SortByName = DefineTransaction(["GUID"]);
  1.1031 +PT.SortByName.prototype = {
  1.1032 +  execute: function* (aGUID) {
  1.1033 +    let itemId = yield PlacesUtils.promiseItemId(aGUID),
  1.1034 +        oldOrder = [],  // [itemId] = old index
  1.1035 +        contents = PlacesUtils.getFolderContents(itemId, false, false).root,
  1.1036 +        count = contents.childCount;
  1.1037 +
  1.1038 +    // Sort between separators.
  1.1039 +    let newOrder = [], // nodes, in the new order.
  1.1040 +        preSep   = []; // Temporary array for sorting each group of nodes.
  1.1041 +    let sortingMethod = (a, b) => {
  1.1042 +      if (PlacesUtils.nodeIsContainer(a) && !PlacesUtils.nodeIsContainer(b))
  1.1043 +        return -1;
  1.1044 +      if (!PlacesUtils.nodeIsContainer(a) && PlacesUtils.nodeIsContainer(b))
  1.1045 +        return 1;
  1.1046 +      return a.title.localeCompare(b.title);
  1.1047 +    };
  1.1048 +
  1.1049 +    for (let i = 0; i < count; ++i) {
  1.1050 +      let node = contents.getChild(i);
  1.1051 +      oldOrder[node.itemId] = i;
  1.1052 +      if (PlacesUtils.nodeIsSeparator(node)) {
  1.1053 +        if (preSep.length > 0) {
  1.1054 +          preSep.sort(sortingMethod);
  1.1055 +          newOrder = newOrder.concat(preSep);
  1.1056 +          preSep.splice(0, preSep.length);
  1.1057 +        }
  1.1058 +        newOrder.push(node);
  1.1059 +      }
  1.1060 +      else
  1.1061 +        preSep.push(node);
  1.1062 +    }
  1.1063 +    contents.containerOpen = false;
  1.1064 +
  1.1065 +    if (preSep.length > 0) {
  1.1066 +      preSep.sort(sortingMethod);
  1.1067 +      newOrder = newOrder.concat(preSep);
  1.1068 +    }
  1.1069 +
  1.1070 +    // Set the nex indexes.
  1.1071 +    let callback = {
  1.1072 +      runBatched: function() {
  1.1073 +        for (let i = 0; i < newOrder.length; ++i) {
  1.1074 +          PlacesUtils.bookmarks.setItemIndex(newOrder[i].itemId, i);
  1.1075 +        }
  1.1076 +      }
  1.1077 +    };
  1.1078 +    PlacesUtils.bookmarks.runInBatchMode(callback, null);
  1.1079 +
  1.1080 +    this.undo = () => {
  1.1081 +      let callback = {
  1.1082 +        runBatched: function() {
  1.1083 +          for (let item in oldOrder) {
  1.1084 +            PlacesUtils.bookmarks.setItemIndex(item, oldOrder[item]);
  1.1085 +          }
  1.1086 +        }
  1.1087 +      };
  1.1088 +      PlacesUtils.bookmarks.runInBatchMode(callback, null);
  1.1089 +    };
  1.1090 +  }
  1.1091 +};
  1.1092 +
  1.1093 +/**
  1.1094 + * Transaction for removing an item (any type).
  1.1095 + *
  1.1096 + * Required Input Properties: GUID.
  1.1097 + */
  1.1098 +PT.RemoveItem = DefineTransaction(["GUID"]);
  1.1099 +PT.RemoveItem.prototype = {
  1.1100 +  execute: function* (aGUID) {
  1.1101 +    const bms = PlacesUtils.bookmarks;
  1.1102 +
  1.1103 +    let itemsToRestoreOnUndo = [];
  1.1104 +    function* saveItemRestoreData(aItem, aNode = null) {
  1.1105 +      if (!aItem || !aItem.GUID)
  1.1106 +        throw new Error("invalid item object");
  1.1107 +
  1.1108 +      let itemId = aNode ?
  1.1109 +                   aNode.itemId : yield PlacesUtils.promiseItemId(aItem.GUID);
  1.1110 +      if (itemId == -1)
  1.1111 +        throw new Error("Unexpected non-bookmarks node");
  1.1112 +
  1.1113 +      aItem.itemType = function() {
  1.1114 +        if (aNode) {
  1.1115 +          switch (aNode.type) {
  1.1116 +            case aNode.RESULT_TYPE_SEPARATOR:
  1.1117 +              return bms.TYPE_SEPARATOR;
  1.1118 +            case aNode.RESULT_TYPE_URI:   // regular bookmarks
  1.1119 +            case aNode.RESULT_TYPE_FOLDER_SHORTCUT:  // place:folder= bookmarks
  1.1120 +            case aNode.RESULT_TYPE_QUERY: // smart bookmarks
  1.1121 +              return bms.TYPE_BOOKMARK;
  1.1122 +            case aNode.RESULT_TYPE_FOLDER:
  1.1123 +              return bms.TYPE_FOLDER;
  1.1124 +            default:
  1.1125 +              throw new Error("Unexpected node type");
  1.1126 +          }
  1.1127 +        }
  1.1128 +        return bms.getItemType(itemId);
  1.1129 +      }();
  1.1130 +
  1.1131 +      let node = aNode;
  1.1132 +      if (!node && aItem.itemType == bms.TYPE_FOLDER)
  1.1133 +        node = PlacesUtils.getFolderContents(itemId).root;
  1.1134 +
  1.1135 +      // dateAdded, lastModified and annotations apply to all types.
  1.1136 +      aItem.dateAdded = node ? node.dateAdded : bms.getItemDateAdded(itemId);
  1.1137 +      aItem.lastModified = node ?
  1.1138 +                           node.lastModified : bms.getItemLastModified(itemId);
  1.1139 +      aItem.annotations = PlacesUtils.getAnnotationsForItem(itemId);
  1.1140 +
  1.1141 +      // For the first-level item, we don't have the parent.
  1.1142 +      if (!aItem.parentGUID) {
  1.1143 +        let parentId     = PlacesUtils.bookmarks.getFolderIdForItem(itemId);
  1.1144 +        aItem.parentGUID = yield PlacesUtils.promiseItemGUID(parentId);
  1.1145 +        // For the first-level item, we also need the index.
  1.1146 +        // Note: node.bookmarkIndex doesn't work for root nodes.
  1.1147 +        aItem.index      = bms.getItemIndex(itemId);
  1.1148 +      }
  1.1149 +
  1.1150 +      // Separators don't have titles.
  1.1151 +      if (aItem.itemType != bms.TYPE_SEPARATOR) {
  1.1152 +        aItem.title = node ? node.title : bms.getItemTitle(itemId);
  1.1153 +
  1.1154 +        if (aItem.itemType == bms.TYPE_BOOKMARK) {
  1.1155 +          aItem.uri =
  1.1156 +            node ? NetUtil.newURI(node.uri) : bms.getBookmarkURI(itemId);
  1.1157 +          aItem.keyword = PlacesUtils.bookmarks.getKeywordForBookmark(itemId);
  1.1158 +
  1.1159 +          // This may be the last bookmark (excluding the tag-items themselves)
  1.1160 +          // for the URI, so we need to preserve the tags.
  1.1161 +          let tags = PlacesUtils.tagging.getTagsForURI(aItem.uri);;
  1.1162 +          if (tags.length > 0)
  1.1163 +            aItem.tags = tags;
  1.1164 +        }
  1.1165 +        else { // folder
  1.1166 +          // We always have the node for folders
  1.1167 +          aItem.readOnly = node.childrenReadOnly;
  1.1168 +          for (let i = 0; i < node.childCount; i++) {
  1.1169 +            let childNode = node.getChild(i);
  1.1170 +            let childItem =
  1.1171 +              { GUID: yield PlacesUtils.promiseItemGUID(childNode.itemId)
  1.1172 +              , parentGUID: aItem.GUID };
  1.1173 +            itemsToRestoreOnUndo.push(childItem);
  1.1174 +            yield saveItemRestoreData(childItem, childNode);
  1.1175 +          }
  1.1176 +          node.containerOpen = false;
  1.1177 +        }
  1.1178 +      }
  1.1179 +    }
  1.1180 +
  1.1181 +    let item = { GUID: aGUID, parentGUID: null };
  1.1182 +    itemsToRestoreOnUndo.push(item);
  1.1183 +    yield saveItemRestoreData(item);
  1.1184 +
  1.1185 +    let itemId = yield PlacesUtils.promiseItemId(aGUID);
  1.1186 +    PlacesUtils.bookmarks.removeItem(itemId);
  1.1187 +    this.undo = function() {
  1.1188 +      for (let item of itemsToRestoreOnUndo) {
  1.1189 +        let parentId = yield PlacesUtils.promiseItemId(item.parentGUID);
  1.1190 +        let index = "index" in item ?
  1.1191 +                    index : PlacesUtils.bookmarks.DEFAULT_INDEX;
  1.1192 +        let itemId;
  1.1193 +        if (item.itemType == bms.TYPE_SEPARATOR) {
  1.1194 +          itemId = bms.insertSeparator(parentId, index, item.GUID);
  1.1195 +        }
  1.1196 +        else if (item.itemType == bms.TYPE_BOOKMARK) {
  1.1197 +          itemId = bms.insertBookmark(parentId, item.uri, index, item.title,
  1.1198 +                                       item.GUID);
  1.1199 +        }
  1.1200 +        else { // folder
  1.1201 +          itemId = bms.createFolder(parentId, item.title, index, item.GUID);
  1.1202 +        }
  1.1203 +
  1.1204 +        if (item.itemType == bms.TYPE_BOOKMARK) {
  1.1205 +          if (item.keyword)
  1.1206 +            bms.setKeywordForBookmark(itemId, item.keyword);
  1.1207 +          if ("tags" in item)
  1.1208 +            PlacesUtils.tagging.tagURI(item.uri, item.tags);
  1.1209 +        }
  1.1210 +        else if (item.readOnly === true) {
  1.1211 +          bms.setFolderReadonly(itemId, true);
  1.1212 +        }
  1.1213 +
  1.1214 +        PlacesUtils.setAnnotationsForItem(itemId, item.annotations);
  1.1215 +        PlacesUtils.bookmarks.setItemDateAdded(itemId, item.dateAdded);
  1.1216 +        PlacesUtils.bookmarks.setItemLastModified(itemId, item.lastModified);
  1.1217 +      }
  1.1218 +    };
  1.1219 +  }
  1.1220 +};
  1.1221 +
  1.1222 +/**
  1.1223 + * Transaction for tagging a URI.
  1.1224 + *
  1.1225 + * Required Input Properties: uri, tags.
  1.1226 + */
  1.1227 +PT.TagURI = DefineTransaction(["uri", "tags"]);
  1.1228 +PT.TagURI.prototype = {
  1.1229 +  execute: function* (aURI, aTags) {
  1.1230 +    if (PlacesUtils.getMostRecentBookmarkForURI(aURI) == -1) {
  1.1231 +      // Tagging is only allowed for bookmarked URIs.
  1.1232 +      let unfileGUID =
  1.1233 +        yield PlacesUtils.promiseItemGUID(PlacesUtils.unfiledBookmarksFolderId);
  1.1234 +      let createTxn = TransactionsHistory.getRawTransaction(
  1.1235 +        PT.NewBookmark({ uri: aURI, tags: aTags, parentGUID: unfileGUID }));
  1.1236 +      yield createTxn.execute();
  1.1237 +      this.undo = createTxn.undo.bind(createTxn);
  1.1238 +      this.redo = createTxn.redo.bind(createTxn);
  1.1239 +    }
  1.1240 +    else {
  1.1241 +      let currentTags = PlacesUtils.tagging.getTagsForURI(aURI);
  1.1242 +      let newTags = [t for (t of aTags) if (currentTags.indexOf(t) == -1)];
  1.1243 +      PlacesUtils.tagging.tagURI(aURI, newTags);
  1.1244 +      this.undo = () => { PlacesUtils.tagging.untagURI(aURI, newTags); };
  1.1245 +      this.redo = () => { PlacesUtils.tagging.tagURI(aURI, newTags); };
  1.1246 +    }
  1.1247 +  }
  1.1248 +};
  1.1249 +
  1.1250 +/**
  1.1251 + * Transaction for removing tags from a URI.
  1.1252 + *
  1.1253 + * Required Input Properties: uri.
  1.1254 + * Optional Input Properties: tags.
  1.1255 + *
  1.1256 + * If |tags| is not set, all tags set for |uri| are removed.
  1.1257 + */
  1.1258 +PT.UntagURI = DefineTransaction(["uri"], ["tags"]);
  1.1259 +PT.UntagURI.prototype = {
  1.1260 +  execute: function* (aURI, aTags) {
  1.1261 +    let tagsSet = PlacesUtils.tagging.getTagsForURI(aURI);
  1.1262 +
  1.1263 +    if (aTags && aTags.length > 0)
  1.1264 +      aTags = [t for (t of aTags) if (tagsSet.indexOf(t) != -1)];
  1.1265 +    else
  1.1266 +      aTags = tagsSet;
  1.1267 +
  1.1268 +    PlacesUtils.tagging.untagURI(aURI, aTags);
  1.1269 +    this.undo = () => { PlacesUtils.tagging.tagURI(aURI, aTags); };
  1.1270 +    this.redo = () => { PlacesUtils.tagging.untagURI(aURI, aTags); };
  1.1271 +  }
  1.1272 +};

mercurial