summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesTransactions.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/places/PlacesTransactions.sys.mjs1723
1 files changed, 1723 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesTransactions.sys.mjs b/toolkit/components/places/PlacesTransactions.sys.mjs
new file mode 100644
index 0000000000..bf2ed16402
--- /dev/null
+++ b/toolkit/components/places/PlacesTransactions.sys.mjs
@@ -0,0 +1,1723 @@
+/* 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/. */
+
+/**
+ * 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).
+ *
+ * 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.
+ * - 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
+ *
+ * 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) 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 await 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.
+ */
+
+const TRANSACTIONS_QUEUE_TIMEOUT_MS = 240000; // 4 Mins.
+
+import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+function setTimeout(callback, ms) {
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT);
+}
+
+class TransactionsHistoryArray extends Array {
+ constructor() {
+ super();
+
+ // The index of the first undo entry (if any) - See the documentation
+ // at the top of this file.
+ this._undoPosition = 0;
+ // 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.
+ this.proxifiedToRaw = new WeakMap();
+ }
+
+ 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;
+ }
+
+ /**
+ * Proxify a transaction object for consumers.
+ * @param rawTransaction
+ * the raw transaction object.
+ * @return the proxified transaction object.
+ * @see getRawTransaction for retrieving the raw transaction.
+ */
+ proxifyTransaction(rawTransaction) {
+ let proxy = Object.freeze({
+ transact() {
+ return TransactionsManager.transact(this);
+ },
+ });
+ this.proxifiedToRaw.set(proxy, rawTransaction);
+ 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(value) {
+ return this.proxifiedToRaw.has(value);
+ }
+
+ /**
+ * 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(proxy) {
+ return this.proxifiedToRaw.get(proxy);
+ }
+
+ /**
+ * 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(proxifiedTransaction, forceNewEntry = false) {
+ if (!this.isProxifiedTransactionObject(proxifiedTransaction)) {
+ throw new Error("aProxifiedTransaction is not a proxified transaction");
+ }
+
+ if (!this.length || forceNewEntry) {
+ this.clearRedoEntries();
+ this.unshift([proxifiedTransaction]);
+ } else {
+ this[this.undoPosition].unshift(proxifiedTransaction);
+ }
+ }
+
+ /**
+ * 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) {
+ this.splice(0);
+ this._undoPosition = 0;
+ }
+ }
+}
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "TransactionsHistory",
+ () => new TransactionsHistoryArray()
+);
+
+export var PlacesTransactions = {
+ /**
+ * @see Batches in the module documentation.
+ */
+ batch(transactionsToBatch) {
+ if (Array.isArray(transactionsToBatch)) {
+ if (!transactionsToBatch.length) {
+ throw new Error("Must pass a non-empty array");
+ }
+
+ if (
+ transactionsToBatch.some(
+ o => !lazy.TransactionsHistory.isProxifiedTransactionObject(o)
+ )
+ ) {
+ throw new Error("Must pass only transaction entries");
+ }
+ return TransactionsManager.batch(async function() {
+ for (let txn of transactionsToBatch) {
+ try {
+ await txn.transact();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ });
+ }
+ if (typeof transactionsToBatch == "function") {
+ return TransactionsManager.batch(transactionsToBatch);
+ }
+
+ throw new Error("Must pass 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] undoEntries
+ * Whether or not to clear undo entries. Default: true.
+ * @param [optional] redoEntries
+ * 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(undoEntries = true, redoEntries = true) {
+ return TransactionsManager.clearTransactionsHistory(
+ undoEntries,
+ redoEntries
+ );
+ },
+
+ /**
+ * The numbers of entries in the transactions history.
+ */
+ get length() {
+ return lazy.TransactionsHistory.length;
+ },
+
+ /**
+ * Get the transaction history entry at a given index. Each entry consists
+ * of one or more transaction objects.
+ *
+ * @param index
+ * 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(index) {
+ if (!Number.isInteger(index) || index < 0 || index >= this.length) {
+ throw new Error("Invalid index");
+ }
+
+ return lazy.TransactionsHistory[index];
+ },
+
+ /**
+ * 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 lazy.TransactionsHistory.undoPosition;
+ },
+
+ /**
+ * Shortcut for accessing the top undo entry in the transaction history.
+ */
+ get topUndoEntry() {
+ return lazy.TransactionsHistory.topUndoEntry;
+ },
+
+ /**
+ * Shortcut for accessing the top redo entry in the transaction history.
+ */
+ get topRedoEntry() {
+ return lazy.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 func
+ * a function returning a promise.
+ * @return a promise that resolves once aFunc is done running. The promise
+ * "mirrors" the promise returned by aFunc.
+ */
+ enqueue(func) {
+ // If a transaction awaits on a never resolved promise, or is mistakenly
+ // nested, it could hang the transactions queue forever. Thus we timeout
+ // the execution after a meaningful amount of time, to ensure in any case
+ // we'll proceed after a while.
+ let timeoutPromise = new Promise((resolve, reject) => {
+ setTimeout(
+ () =>
+ reject(
+ new Error(
+ "PlacesTransaction timeout, most likely caused by unresolved pending work."
+ )
+ ),
+ TRANSACTIONS_QUEUE_TIMEOUT_MS
+ );
+ });
+ let promise = this._promise.then(() =>
+ Promise.race([func(), timeoutPromise])
+ );
+
+ // 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 otherPromise
+ * any promise.
+ */
+ alsoWaitFor(otherPromise) {
+ // We don't care if aPromise resolves or rejects, but just that is not
+ // pending anymore.
+ // If a transaction awaits on a never resolved promise, or is mistakenly
+ // nested, it could hang the transactions queue forever. Thus we timeout
+ // the execution after a meaningful amount of time, to ensure in any case
+ // we'll proceed after a while.
+ let timeoutPromise = new Promise((resolve, reject) => {
+ setTimeout(
+ () =>
+ reject(
+ new Error(
+ "PlacesTransaction timeout, most likely caused by unresolved pending work."
+ )
+ ),
+ TRANSACTIONS_QUEUE_TIMEOUT_MS
+ );
+ });
+ let promise = Promise.race([otherPromise, timeoutPromise]).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(txnProxy) {
+ let rawTxn = lazy.TransactionsHistory.getRawTransaction(txnProxy);
+ 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(async () => {
+ // Don't try to catch exceptions. If execute fails, we better not add the
+ // transaction to the undo stack.
+ let retval = await rawTxn.execute();
+
+ let forceNewEntry = !this._batching || !this._createdBatchEntry;
+ lazy.TransactionsHistory.add(txnProxy, forceNewEntry);
+ if (this._batching) {
+ this._createdBatchEntry = true;
+ }
+
+ this._updateCommandsOnActiveWindow();
+ return retval;
+ });
+ this._mainEnqueuer.alsoWaitFor(promise);
+ return promise;
+ },
+
+ batch(task) {
+ return this._mainEnqueuer.enqueue(async () => {
+ this._batching = true;
+ this._createdBatchEntry = false;
+ let rv;
+ try {
+ rv = await task();
+ } finally {
+ // We must enqueue clearing batching mode to ensure that any existing
+ // transactions have completed before we clear the batching mode.
+ this._mainEnqueuer.enqueue(() => {
+ this._batching = false;
+ this._createdBatchEntry = false;
+ });
+ }
+ return rv;
+ });
+ },
+
+ /**
+ * Undo the top undo entry, if any, and update the undo position accordingly.
+ */
+ undo() {
+ let promise = this._mainEnqueuer.enqueue(async () => {
+ let entry = lazy.TransactionsHistory.topUndoEntry;
+ if (!entry) {
+ return;
+ }
+
+ for (let txnProxy of entry) {
+ try {
+ await lazy.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, "Can't undo a transaction, clearing undo entries.");
+ lazy.TransactionsHistory.clearUndoEntries();
+ return;
+ }
+ }
+ lazy.TransactionsHistory._undoPosition++;
+ this._updateCommandsOnActiveWindow();
+ });
+ 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(async () => {
+ let entry = lazy.TransactionsHistory.topRedoEntry;
+ if (!entry) {
+ return;
+ }
+
+ for (let i = entry.length - 1; i >= 0; i--) {
+ let transaction = lazy.TransactionsHistory.getRawTransaction(entry[i]);
+ try {
+ if (transaction.redo) {
+ await transaction.redo();
+ } else {
+ await 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, "Can't redo a transaction, clearing redo entries.");
+ lazy.TransactionsHistory.clearRedoEntries();
+ return;
+ }
+ }
+ lazy.TransactionsHistory._undoPosition--;
+ this._updateCommandsOnActiveWindow();
+ });
+
+ this._transactEnqueuer.alsoWaitFor(promise);
+ return promise;
+ },
+
+ clearTransactionsHistory(undoEntries, redoEntries) {
+ let promise = this._mainEnqueuer.enqueue(function() {
+ if (undoEntries && redoEntries) {
+ lazy.TransactionsHistory.clearAllEntries();
+ } else if (undoEntries) {
+ lazy.TransactionsHistory.clearUndoEntries();
+ } else if (redoEntries) {
+ lazy.TransactionsHistory.clearRedoEntries();
+ } else {
+ throw new Error("either aUndoEntries or aRedoEntries should be true");
+ }
+ });
+
+ 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(requiredProps = [], optionalProps = []) {
+ for (let prop of [...requiredProps, ...optionalProps]) {
+ if (!DefineTransaction.inputProps.has(prop)) {
+ throw new Error("Property '" + prop + "' is not defined");
+ }
+ }
+
+ let ctor = function(input) {
+ // We want to support both syntaxes:
+ // let t = new PlacesTransactions.NewBookmark(),
+ // let t = PlacesTransactions.NewBookmark()
+ if (this == PlacesTransactions) {
+ return new ctor(input);
+ }
+
+ if (requiredProps.length || optionalProps.length) {
+ // Bind the input properties to the arguments of execute.
+ input = DefineTransaction.verifyInput(
+ input,
+ requiredProps,
+ optionalProps
+ );
+ this.execute = this.execute.bind(this, input);
+ }
+ return lazy.TransactionsHistory.proxifyTransaction(this);
+ };
+ return ctor;
+}
+
+function simpleValidateFunc(checkFn) {
+ return v => {
+ if (!checkFn(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");
+}
+
+function checkProperty(obj, prop, required, checkFn) {
+ if (prop in obj) {
+ return checkFn(obj[prop]);
+ }
+
+ return !required;
+}
+
+DefineTransaction.childObjectValidate = function(obj) {
+ if (
+ obj &&
+ checkProperty(obj, "title", false, v => typeof v == "string") &&
+ !("type" in obj && obj.type != PlacesUtils.bookmarks.TYPE_BOOKMARK)
+ ) {
+ obj.url = DefineTransaction.urlValidate(obj.url);
+ let validKeys = ["title", "url"];
+ if (Object.keys(obj).every(k => validKeys.includes(k))) {
+ return obj;
+ }
+ }
+ throw new Error("Invalid child object");
+};
+
+DefineTransaction.urlValidate = function(url) {
+ if (url instanceof Ci.nsIURI) {
+ return new URL(url.spec);
+ }
+ return new URL(url);
+};
+
+DefineTransaction.inputProps = new Map();
+DefineTransaction.defineInputProps = function(names, validateFn, defaultValue) {
+ for (let name of names) {
+ this.inputProps.set(name, {
+ validateValue(value) {
+ if (value === undefined) {
+ return defaultValue;
+ }
+ try {
+ return validateFn(value);
+ } catch (ex) {
+ throw new Error(`Invalid value for input property ${name}: ${ex}`);
+ }
+ },
+
+ validateInput(input, required) {
+ if (required && !(name in input)) {
+ throw new Error(`Required input property is missing: ${name}`);
+ }
+ return this.validateValue(input[name]);
+ },
+
+ isArrayProperty: false,
+ });
+ }
+};
+
+DefineTransaction.defineArrayInputProp = function(name, basePropertyName) {
+ let baseProp = this.inputProps.get(basePropertyName);
+ if (!baseProp) {
+ throw new Error(`Unknown input property: ${basePropertyName}`);
+ }
+
+ this.inputProps.set(name, {
+ validateValue(aValue) {
+ if (aValue == undefined) {
+ return [];
+ }
+
+ if (!Array.isArray(aValue)) {
+ throw new Error(`${name} input property value must be an array`);
+ }
+
+ // We must create a new array in the local scope to avoid a memory leak due
+ // to the array global object. We can't use Cu.cloneInto as that doesn't
+ // handle the URIs. Slice & map also aren't good enough, so we start off
+ // with a clean array and insert what we need into it.
+ let newArray = [];
+ for (let item of aValue) {
+ newArray.push(baseProp.validateValue(item));
+ }
+ return newArray;
+ },
+
+ // 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(input, required) {
+ if (name in input) {
+ // It's not allowed to set both though.
+ if (basePropertyName in input) {
+ throw new Error(`It is not allowed to set both ${name} and
+ ${basePropertyName} as input properties`);
+ }
+ let array = this.validateValue(input[name]);
+ if (required && !array.length) {
+ throw new Error(`Empty array passed for required input property:
+ ${name}`);
+ }
+ return array;
+ }
+ // If the property is required and it's not set as is, check if the base
+ // property is set.
+ if (required && !(basePropertyName in input)) {
+ throw new Error(`Required input property is missing: ${name}`);
+ }
+
+ if (basePropertyName in input) {
+ return [baseProp.validateValue(input[basePropertyName])];
+ }
+
+ return [];
+ },
+
+ isArrayProperty: true,
+ });
+};
+
+DefineTransaction.validatePropertyValue = function(prop, input, required) {
+ return this.inputProps.get(prop).validateInput(input, required);
+};
+
+DefineTransaction.getInputObjectForSingleValue = function(
+ input,
+ requiredProps,
+ optionalProps
+) {
+ // 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 (
+ requiredProps.length > 1 ||
+ (!requiredProps.length && optionalProps.length > 1)
+ ) {
+ throw new Error("Transaction input isn't an object");
+ }
+
+ let propName =
+ requiredProps.length == 1 ? requiredProps[0] : optionalProps[0];
+ let propValue =
+ this.inputProps.get(propName).isArrayProperty && !Array.isArray(input)
+ ? [input]
+ : input;
+ return { [propName]: propValue };
+};
+
+DefineTransaction.verifyInput = function(
+ input,
+ requiredProps = [],
+ optionalProps = []
+) {
+ if (!requiredProps.length && !optionalProps.length) {
+ return {};
+ }
+
+ // If there's just a single required/optional property, we allow passing it
+ // as is, so, for example, one could do PlacesTransactions.Remove(myGuid)
+ // rather than PlacesTransactions.Remove({ guid: myGuid}).
+ // This shortcut isn't supported for "complex" properties, like objects (note
+ // there is no use case for this at the moment anyway).
+ let isSinglePropertyInput =
+ isPrimitive(input) ||
+ Array.isArray(input) ||
+ input instanceof Ci.nsISupports;
+ if (isSinglePropertyInput) {
+ input = this.getInputObjectForSingleValue(
+ input,
+ requiredProps,
+ optionalProps
+ );
+ }
+
+ let fixedInput = {};
+ for (let prop of requiredProps) {
+ fixedInput[prop] = this.validatePropertyValue(prop, input, true);
+ }
+ for (let prop of optionalProps) {
+ 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"],
+ DefineTransaction.urlValidate,
+ null
+);
+DefineTransaction.defineInputProps(
+ ["guid", "parentGuid", "newParentGuid"],
+ DefineTransaction.guidValidate
+);
+DefineTransaction.defineInputProps(
+ ["title", "postData"],
+ DefineTransaction.strOrNullValidate,
+ null
+);
+DefineTransaction.defineInputProps(
+ ["keyword", "oldKeyword", "oldTag", "tag"],
+ DefineTransaction.strValidate,
+ ""
+);
+DefineTransaction.defineInputProps(
+ ["index", "newIndex"],
+ DefineTransaction.indexValidate,
+ PlacesUtils.bookmarks.DEFAULT_INDEX
+);
+DefineTransaction.defineInputProps(
+ ["child"],
+ DefineTransaction.childObjectValidate
+);
+DefineTransaction.defineArrayInputProp("guids", "guid");
+DefineTransaction.defineArrayInputProp("urls", "url");
+DefineTransaction.defineArrayInputProp("tags", "tag");
+DefineTransaction.defineArrayInputProp("children", "child");
+
+/**
+ * Creates items (all types) from a bookmarks tree representation, as defined
+ * in PlacesUtils.promiseBookmarksTree.
+ *
+ * @param tree
+ * the bookmarks tree object. You may pass either a bookmarks tree
+ * returned by promiseBookmarksTree, or a manually defined one.
+ * @param [optional] restoring (default: false)
+ * Whether or not the items are restored. Only in restore mode, are
+ * the guid, dateAdded and lastModified properties honored.
+ * @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}
+ * @resolves to the guid of the new item.
+ */
+// TODO: Replace most of this with insertTree.
+function createItemsFromBookmarksTree(tree, restoring = false) {
+ async function createItem(
+ item,
+ parentGuid,
+ index = PlacesUtils.bookmarks.DEFAULT_INDEX
+ ) {
+ let guid;
+ let info = { parentGuid, index };
+ if (restoring) {
+ info.guid = item.guid;
+ info.dateAdded = PlacesUtils.toDate(item.dateAdded);
+ info.lastModified = PlacesUtils.toDate(item.lastModified);
+ }
+ let shouldResetLastModified = false;
+ switch (item.type) {
+ case PlacesUtils.TYPE_X_MOZ_PLACE: {
+ info.url = item.uri;
+ if (typeof item.title == "string") {
+ info.title = item.title;
+ }
+
+ guid = (await PlacesUtils.bookmarks.insert(info)).guid;
+
+ if ("keyword" in item) {
+ let { uri: url, keyword, postData } = item;
+ await PlacesUtils.keywords.insert({ url, keyword, postData });
+ }
+ if ("tags" in item) {
+ PlacesUtils.tagging.tagURI(
+ Services.io.newURI(item.uri),
+ item.tags.split(",")
+ );
+ }
+ break;
+ }
+ case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: {
+ info.type = PlacesUtils.bookmarks.TYPE_FOLDER;
+ if (typeof item.title == "string") {
+ info.title = item.title;
+ }
+ guid = (await PlacesUtils.bookmarks.insert(info)).guid;
+ if ("children" in item) {
+ for (let child of item.children) {
+ await createItem(child, guid);
+ }
+ }
+ if (restoring) {
+ shouldResetLastModified = true;
+ }
+ break;
+ }
+ case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: {
+ info.type = PlacesUtils.bookmarks.TYPE_SEPARATOR;
+ guid = (await PlacesUtils.bookmarks.insert(info)).guid;
+ break;
+ }
+ }
+
+ if (shouldResetLastModified) {
+ let lastModified = PlacesUtils.toDate(item.lastModified);
+ await PlacesUtils.bookmarks.update({ guid, lastModified });
+ }
+
+ return guid;
+ }
+ return createItem(tree, tree.parentGuid, tree.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, tags.
+ *
+ * When this transaction is executed, it's resolved to the new bookmark's GUID.
+ */
+PT.NewBookmark = DefineTransaction(
+ ["parentGuid", "url"],
+ ["index", "title", "tags"]
+);
+PT.NewBookmark.prototype = Object.seal({
+ async execute({ parentGuid, url, index, title, tags }) {
+ let info = { parentGuid, index, url, title };
+ // Filter tags to exclude already existing ones.
+ if (tags.length) {
+ let currentTags = PlacesUtils.tagging.getTagsForURI(
+ Services.io.newURI(url.href)
+ );
+ tags = tags.filter(t => !currentTags.includes(t));
+ }
+
+ async function createItem() {
+ info = await PlacesUtils.bookmarks.insert(info);
+ if (tags.length) {
+ PlacesUtils.tagging.tagURI(Services.io.newURI(url.href), tags);
+ }
+ }
+
+ await createItem();
+
+ this.undo = async function() {
+ // Pick up the removed info so we have the accurate last-modified value.
+ await PlacesUtils.bookmarks.remove(info);
+ if (tags.length) {
+ PlacesUtils.tagging.untagURI(Services.io.newURI(url.href), tags);
+ }
+ };
+ this.redo = async function() {
+ await createItem();
+ };
+ return info.guid;
+ },
+});
+
+/**
+ * Transaction for creating a folder.
+ *
+ * Required Input Properties: title, parentGuid.
+ * Optional Input Properties: index, children
+ *
+ * When this transaction is executed, it's resolved to the new folder's GUID.
+ */
+PT.NewFolder = DefineTransaction(
+ ["parentGuid", "title"],
+ ["index", "children"]
+);
+PT.NewFolder.prototype = Object.seal({
+ async execute({ parentGuid, title, index, children }) {
+ let folderGuid;
+ let info = {
+ children: [
+ {
+ // Ensure to specify a guid to be restored on redo.
+ guid: PlacesUtils.history.makeGuid(),
+ title,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ },
+ ],
+ // insertTree uses guid as the parent for where it is being inserted
+ // into.
+ guid: parentGuid,
+ };
+
+ if (children && children.length) {
+ // Ensure to specify a guid for each child to be restored on redo.
+ info.children[0].children = children.map(c => {
+ c.guid = PlacesUtils.history.makeGuid();
+ return c;
+ });
+ }
+
+ async function createItem() {
+ // Note, insertTree returns an array, rather than the folder/child structure.
+ // For simplicity, we only get the new folder id here. This means that
+ // an undo then redo won't retain exactly the same information for all
+ // the child bookmarks, but we believe that isn't important at the moment.
+ let bmInfo = await PlacesUtils.bookmarks.insertTree(info);
+ // insertTree returns an array, but we only need to deal with the folder guid.
+ folderGuid = bmInfo[0].guid;
+
+ // Bug 1388097: insertTree doesn't handle inserting at a specific index for the folder,
+ // therefore we update the bookmark manually afterwards.
+ if (index != PlacesUtils.bookmarks.DEFAULT_INDEX) {
+ bmInfo[0].index = index;
+ bmInfo = await PlacesUtils.bookmarks.update(bmInfo[0]);
+ }
+ }
+ await createItem();
+
+ this.undo = async function() {
+ await PlacesUtils.bookmarks.remove(folderGuid);
+ };
+ this.redo = async function() {
+ await createItem();
+ };
+ return folderGuid;
+ },
+});
+
+/**
+ * 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({
+ async execute(info) {
+ info.type = PlacesUtils.bookmarks.TYPE_SEPARATOR;
+ info = await PlacesUtils.bookmarks.insert(info);
+ this.undo = PlacesUtils.bookmarks.remove.bind(PlacesUtils.bookmarks, info);
+ this.redo = PlacesUtils.bookmarks.insert.bind(PlacesUtils.bookmarks, info);
+ return info.guid;
+ },
+});
+
+/**
+ * Transaction for moving an item.
+ *
+ * Required Input Properties: guid, newParentGuid.
+ * Optional Input Properties newIndex.
+ */
+PT.Move = DefineTransaction(["guids", "newParentGuid"], ["newIndex"]);
+PT.Move.prototype = Object.seal({
+ async execute({ guids, newParentGuid, newIndex }) {
+ let originalInfos = [];
+ let index = newIndex;
+
+ for (let guid of guids) {
+ // We need to save the original data for undo.
+ let originalInfo = await PlacesUtils.bookmarks.fetch(guid);
+ if (!originalInfo) {
+ throw new Error("Cannot move a non-existent item");
+ }
+
+ originalInfos.push(originalInfo);
+ }
+
+ await PlacesUtils.bookmarks.moveToFolder(guids, newParentGuid, index);
+
+ this.undo = async function() {
+ // Undo has the potential for moving multiple bookmarks to multiple different
+ // folders and positions, which is very complicated to manage. Therefore we do
+ // individual moves one at a time and hopefully everything is put back approximately
+ // where it should be.
+ for (let info of originalInfos) {
+ await PlacesUtils.bookmarks.update(info);
+ }
+ };
+ this.redo = PlacesUtils.bookmarks.moveToFolder.bind(
+ PlacesUtils.bookmarks,
+ guids,
+ newParentGuid,
+ index
+ );
+ return guids;
+ },
+});
+
+/**
+ * Transaction for setting the title for an item.
+ *
+ * Required Input Properties: guid, title.
+ */
+PT.EditTitle = DefineTransaction(["guid", "title"]);
+PT.EditTitle.prototype = Object.seal({
+ async execute({ guid, title }) {
+ let originalInfo = await PlacesUtils.bookmarks.fetch(guid);
+ if (!originalInfo) {
+ throw new Error("cannot update a non-existent item");
+ }
+
+ let updateInfo = { guid, title };
+ updateInfo = await PlacesUtils.bookmarks.update(updateInfo);
+
+ this.undo = PlacesUtils.bookmarks.update.bind(
+ PlacesUtils.bookmarks,
+ originalInfo
+ );
+ this.redo = PlacesUtils.bookmarks.update.bind(
+ PlacesUtils.bookmarks,
+ updateInfo
+ );
+ },
+});
+
+/**
+ * Transaction for setting the URI for an item.
+ *
+ * Required Input Properties: guid, url.
+ */
+PT.EditUrl = DefineTransaction(["guid", "url"]);
+PT.EditUrl.prototype = Object.seal({
+ async execute({ guid, url }) {
+ let originalInfo = await PlacesUtils.bookmarks.fetch(guid);
+ if (!originalInfo) {
+ throw new Error("cannot update a non-existent item");
+ }
+ if (originalInfo.type != PlacesUtils.bookmarks.TYPE_BOOKMARK) {
+ throw new Error("Cannot edit url for non-bookmark items");
+ }
+
+ let uri = Services.io.newURI(url.href);
+ let originalURI = Services.io.newURI(originalInfo.url.href);
+ let originalTags = PlacesUtils.tagging.getTagsForURI(originalURI);
+ let updatedInfo = { guid, url };
+ let newURIAdditionalTags = null;
+
+ async function updateItem() {
+ updatedInfo = await PlacesUtils.bookmarks.update(updatedInfo);
+ // Move tags from the original URI to the new URI.
+ if (originalTags.length) {
+ // Untag the original URI only if this was the only bookmark.
+ if (!(await PlacesUtils.bookmarks.fetch({ url: originalInfo.url }))) {
+ PlacesUtils.tagging.untagURI(originalURI, originalTags);
+ }
+ let currentNewURITags = PlacesUtils.tagging.getTagsForURI(uri);
+ newURIAdditionalTags = originalTags.filter(
+ t => !currentNewURITags.includes(t)
+ );
+ if (newURIAdditionalTags && newURIAdditionalTags.length) {
+ PlacesUtils.tagging.tagURI(uri, newURIAdditionalTags);
+ }
+ }
+ }
+ await updateItem();
+
+ this.undo = async function() {
+ await PlacesUtils.bookmarks.update(originalInfo);
+ // Move tags from new URI to original URI.
+ if (originalTags.length) {
+ // Only untag the new URI if this is the only bookmark.
+ if (
+ newURIAdditionalTags &&
+ !!newURIAdditionalTags.length &&
+ !(await PlacesUtils.bookmarks.fetch({ url }))
+ ) {
+ PlacesUtils.tagging.untagURI(uri, newURIAdditionalTags);
+ }
+ PlacesUtils.tagging.tagURI(originalURI, originalTags);
+ }
+ };
+
+ this.redo = async function() {
+ updatedInfo = await updateItem();
+ };
+ },
+});
+
+/**
+ * Transaction for setting the keyword for a bookmark.
+ *
+ * Required Input Properties: guid, keyword.
+ * Optional Input Properties: postData, oldKeyword.
+ */
+PT.EditKeyword = DefineTransaction(
+ ["guid", "keyword"],
+ ["postData", "oldKeyword"]
+);
+PT.EditKeyword.prototype = Object.seal({
+ async execute({ guid, keyword, postData, oldKeyword }) {
+ let url;
+ let oldKeywordEntry;
+ if (oldKeyword) {
+ oldKeywordEntry = await PlacesUtils.keywords.fetch(oldKeyword);
+ url = oldKeywordEntry.url;
+ await PlacesUtils.keywords.remove(oldKeyword);
+ }
+
+ if (keyword) {
+ if (!url) {
+ url = (await PlacesUtils.bookmarks.fetch(guid)).url;
+ }
+ await PlacesUtils.keywords.insert({
+ url,
+ keyword,
+ postData: postData || (oldKeywordEntry ? oldKeywordEntry.postData : ""),
+ });
+ }
+
+ this.undo = async function() {
+ if (keyword) {
+ await PlacesUtils.keywords.remove(keyword);
+ }
+ if (oldKeywordEntry) {
+ await PlacesUtils.keywords.insert(oldKeywordEntry);
+ }
+ };
+ },
+});
+
+/**
+ * Transaction for sorting a folder by name.
+ *
+ * Required Input Properties: guid.
+ */
+PT.SortByName = DefineTransaction(["guid"]);
+PT.SortByName.prototype = {
+ async execute({ guid }) {
+ let sortingMethod = (node_a, node_b) => {
+ if (
+ PlacesUtils.nodeIsContainer(node_a) &&
+ !PlacesUtils.nodeIsContainer(node_b)
+ ) {
+ return -1;
+ }
+ if (
+ !PlacesUtils.nodeIsContainer(node_a) &&
+ PlacesUtils.nodeIsContainer(node_b)
+ ) {
+ return 1;
+ }
+ return node_a.title.localeCompare(node_b.title);
+ };
+ let oldOrderGuids = [];
+ let newOrderGuids = [];
+ let preSepNodes = [];
+
+ // This is not great, since it does main-thread IO.
+ // PromiseBookmarksTree can't be used, since it' won't stop at the first level'.
+ let root = PlacesUtils.getFolderContents(guid, false, false).root;
+ for (let i = 0; i < root.childCount; ++i) {
+ let node = root.getChild(i);
+ oldOrderGuids.push(node.bookmarkGuid);
+ if (PlacesUtils.nodeIsSeparator(node)) {
+ if (preSepNodes.length) {
+ preSepNodes.sort(sortingMethod);
+ newOrderGuids.push(...preSepNodes.map(n => n.bookmarkGuid));
+ preSepNodes = [];
+ }
+ newOrderGuids.push(node.bookmarkGuid);
+ } else {
+ preSepNodes.push(node);
+ }
+ }
+ root.containerOpen = false;
+ if (preSepNodes.length) {
+ preSepNodes.sort(sortingMethod);
+ newOrderGuids.push(...preSepNodes.map(n => n.bookmarkGuid));
+ }
+ await PlacesUtils.bookmarks.reorder(guid, newOrderGuids);
+
+ this.undo = async function() {
+ await PlacesUtils.bookmarks.reorder(guid, oldOrderGuids);
+ };
+ this.redo = async function() {
+ await PlacesUtils.bookmarks.reorder(guid, newOrderGuids);
+ };
+ },
+};
+
+/**
+ * Transaction for removing an item (any type).
+ *
+ * Required Input Properties: guids.
+ */
+PT.Remove = DefineTransaction(["guids"]);
+PT.Remove.prototype = {
+ async execute({ guids }) {
+ let removedItems = [];
+
+ for (let guid of guids) {
+ try {
+ // Although we don't strictly need to get this information for the remove,
+ // we do need it for the possibility of undo().
+ removedItems.push(await PlacesUtils.promiseBookmarksTree(guid));
+ } catch (ex) {
+ if (!ex.becauseInvalidURL) {
+ throw new Error(`Failed to get info for the guid: ${guid}: ${ex}`);
+ }
+ removedItems.push({ guid });
+ }
+ }
+
+ let removeThem = async function() {
+ if (removedItems.length) {
+ // We have to pass just the guids as although remove() accepts full
+ // info items, promiseBookmarksTree returns dateAdded and lastModified
+ // as PRTime rather than date types.
+ await PlacesUtils.bookmarks.remove(
+ removedItems.map(info => ({ guid: info.guid }))
+ );
+ }
+ };
+ await removeThem();
+
+ this.undo = async function() {
+ for (let info of removedItems) {
+ try {
+ await createItemsFromBookmarksTree(info, true);
+ } catch (ex) {
+ console.error(`Unable to undo removal of ${info.guid}`);
+ }
+ }
+ };
+ this.redo = removeThem;
+ },
+};
+
+/**
+ * Transaction for tagging urls.
+ *
+ * Required Input Properties: urls, tags.
+ */
+PT.Tag = DefineTransaction(["urls", "tags"]);
+PT.Tag.prototype = {
+ async execute({ urls, tags }) {
+ let onUndo = [],
+ onRedo = [];
+ for (let url of urls) {
+ if (!(await PlacesUtils.bookmarks.fetch({ url }))) {
+ // Tagging is only allowed for bookmarked URIs (but see 424160).
+ let createTxn = lazy.TransactionsHistory.getRawTransaction(
+ PT.NewBookmark({
+ url,
+ tags,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ })
+ );
+ await createTxn.execute();
+ onUndo.unshift(createTxn.undo.bind(createTxn));
+ onRedo.push(createTxn.redo.bind(createTxn));
+ } else {
+ let uri = Services.io.newURI(url.href);
+ let currentTags = PlacesUtils.tagging.getTagsForURI(uri);
+ let newTags = tags.filter(t => !currentTags.includes(t));
+ if (newTags.length) {
+ PlacesUtils.tagging.tagURI(uri, newTags);
+ onUndo.unshift(() => {
+ PlacesUtils.tagging.untagURI(uri, newTags);
+ });
+ onRedo.push(() => {
+ PlacesUtils.tagging.tagURI(uri, newTags);
+ });
+ }
+ }
+ }
+ this.undo = async function() {
+ for (let f of onUndo) {
+ await f();
+ }
+ };
+ this.redo = async function() {
+ for (let f of onRedo) {
+ await 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({ urls, tags }) {
+ let onUndo = [],
+ onRedo = [];
+ for (let url of urls) {
+ let uri = Services.io.newURI(url.href);
+ let tagsToRemove;
+ let tagsSet = PlacesUtils.tagging.getTagsForURI(uri);
+ if (tags.length) {
+ tagsToRemove = tags.filter(t => tagsSet.includes(t));
+ } else {
+ tagsToRemove = tagsSet;
+ }
+ if (tagsToRemove.length) {
+ PlacesUtils.tagging.untagURI(uri, tagsToRemove);
+ }
+ onUndo.unshift(() => {
+ if (tagsToRemove.length) {
+ PlacesUtils.tagging.tagURI(uri, tagsToRemove);
+ }
+ });
+ onRedo.push(() => {
+ if (tagsToRemove.length) {
+ PlacesUtils.tagging.untagURI(uri, tagsToRemove);
+ }
+ });
+ }
+ this.undo = async function() {
+ for (let f of onUndo) {
+ await f();
+ }
+ };
+ this.redo = async function() {
+ for (let f of onRedo) {
+ await f();
+ }
+ };
+ },
+};
+
+/**
+ * Transaction for renaming a tag.
+ *
+ * Required Input Properties: oldTag, tag.
+ */
+PT.RenameTag = DefineTransaction(["oldTag", "tag"]);
+PT.RenameTag.prototype = {
+ async execute({ oldTag, tag }) {
+ // For now this is implemented by untagging and tagging all the bookmarks.
+ // We should create a specialized bookmarking API to just rename the tag.
+ let onUndo = [],
+ onRedo = [];
+ let urls = new Set();
+ await PlacesUtils.bookmarks.fetch({ tags: [oldTag] }, b => urls.add(b.url));
+ if (urls.size > 0) {
+ urls = Array.from(urls);
+ let tagTxn = lazy.TransactionsHistory.getRawTransaction(
+ PT.Tag({ urls, tags: [tag] })
+ );
+ await tagTxn.execute();
+ onUndo.unshift(tagTxn.undo.bind(tagTxn));
+ onRedo.push(tagTxn.redo.bind(tagTxn));
+ let untagTxn = lazy.TransactionsHistory.getRawTransaction(
+ PT.Untag({ urls, tags: [oldTag] })
+ );
+ await untagTxn.execute();
+ onUndo.unshift(untagTxn.undo.bind(untagTxn));
+ onRedo.push(untagTxn.redo.bind(untagTxn));
+
+ // Update all the place: queries that refer to this tag.
+ let db = await PlacesUtils.promiseDBConnection();
+ let rows = await db.executeCached(
+ `
+ SELECT h.url, b.guid, b.title
+ FROM moz_places h
+ JOIN moz_bookmarks b ON b.fk = h.id
+ WHERE url_hash BETWEEN hash("place", "prefix_lo")
+ AND hash("place", "prefix_hi")
+ AND url LIKE :tagQuery
+ `,
+ { tagQuery: "%tag=%" }
+ );
+ for (let row of rows) {
+ let url = row.getResultByName("url");
+ try {
+ url = new URL(url);
+ let urlParams = new URLSearchParams(url.pathname);
+ let tags = urlParams.getAll("tag");
+ if (!tags.includes(oldTag)) {
+ continue;
+ }
+ if (tags.length > 1) {
+ // URLSearchParams cannot set more than 1 same-named param.
+ urlParams.delete("tag");
+ urlParams.set("tag", tag);
+ url = new URL(
+ url.protocol +
+ urlParams +
+ "&tag=" +
+ tags.filter(t => t != oldTag).join("&tag=")
+ );
+ } else {
+ urlParams.set("tag", tag);
+ url = new URL(url.protocol + urlParams);
+ }
+ } catch (ex) {
+ console.error(
+ "Invalid bookmark url: " + row.getResultByName("url") + ": " + ex
+ );
+ continue;
+ }
+ let guid = row.getResultByName("guid");
+ let title = row.getResultByName("title");
+
+ let editUrlTxn = lazy.TransactionsHistory.getRawTransaction(
+ PT.EditUrl({ guid, url })
+ );
+ await editUrlTxn.execute();
+ onUndo.unshift(editUrlTxn.undo.bind(editUrlTxn));
+ onRedo.push(editUrlTxn.redo.bind(editUrlTxn));
+ if (title == oldTag) {
+ let editTitleTxn = lazy.TransactionsHistory.getRawTransaction(
+ PT.EditTitle({ guid, title: tag })
+ );
+ await editTitleTxn.execute();
+ onUndo.unshift(editTitleTxn.undo.bind(editTitleTxn));
+ onRedo.push(editTitleTxn.redo.bind(editTitleTxn));
+ }
+ }
+ }
+ this.undo = async function() {
+ for (let f of onUndo) {
+ await f();
+ }
+ };
+ this.redo = async function() {
+ for (let f of onRedo) {
+ await f();
+ }
+ };
+ },
+};
+
+/**
+ * Transaction for copying an item.
+ *
+ * Required Input Properties: guid, newParentGuid
+ * Optional Input Properties: newIndex.
+ */
+PT.Copy = DefineTransaction(["guid", "newParentGuid"], ["newIndex"]);
+PT.Copy.prototype = {
+ async execute({ guid, newParentGuid, newIndex }) {
+ let creationInfo = null;
+ try {
+ creationInfo = await PlacesUtils.promiseBookmarksTree(guid);
+ } catch (ex) {
+ throw new Error(
+ "Failed to get info for the specified item (guid: " +
+ guid +
+ "). Ex: " +
+ ex
+ );
+ }
+ creationInfo.parentGuid = newParentGuid;
+ creationInfo.index = newIndex;
+
+ let newItemGuid = await createItemsFromBookmarksTree(creationInfo, false);
+ let newItemInfo = null;
+ this.undo = async function() {
+ if (!newItemInfo) {
+ newItemInfo = await PlacesUtils.promiseBookmarksTree(newItemGuid);
+ }
+ await PlacesUtils.bookmarks.remove(newItemGuid);
+ };
+ this.redo = async function() {
+ await createItemsFromBookmarksTree(newItemInfo, true);
+ };
+
+ return newItemGuid;
+ },
+};