summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesTransactions.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/places/PlacesTransactions.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/places/PlacesTransactions.sys.mjs')
-rw-r--r--toolkit/components/places/PlacesTransactions.sys.mjs1803
1 files changed, 1803 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..0c9accd3ba
--- /dev/null
+++ b/toolkit/components/places/PlacesTransactions.sys.mjs
@@ -0,0 +1,1803 @@
+/* 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).
+ *
+ * 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 takes an array of transactions which will be executed in the given order
+ * and later be treated as a single entry in the transactions history.
+ * If a transaction depends on the results from a previous one, it can be
+ * replaced by a function that will be invoked with an array of results
+ * accumulated from the previous transactions, indexed in the same positions.
+ * The function should return the transaction to execute. For example:
+ *
+ * let transactions = [
+ * // Returns the GUID of the new bookmark.
+ * PlacesTransactions.NewBookmark({
+ * parentGuid: "someGUID",
+ * title: "someTitle",
+ * url: "https://www.mozilla.org/""
+ * }),
+ * previousResults => PlacesTransactions.EditKeyword({
+ * // Get the GUID from the result of transactions[0].
+ * guid: previousResults[0],
+ * keyword: "someKeyword",
+ * },
+ * ];
+ *
+ * `PlacesTransactions.batch()` returns a promise resolved when the batch ends.
+ * The resolution value is an array with all the transaction return values
+ * indexed like the original transactions. So, for example, if a transaction
+ * returns an array of GUIDs, to get a list of all the created GUIDs for all the
+ * transactions one could use .flat() to flatten the array.
+ *
+ * If any transactions fails to execute, the batch continues (exceptions are
+ * logged) and the result of that transactions will be set to undefined.
+ * Only transactions that were executed successfully are added to the
+ * transactions history as part of the batch.
+ *
+ * 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+// Use a single queue bookmarks transaction manager. This pref exists as an
+// emergency switch-off, it will go away in the future.
+const prefs = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ prefs,
+ "USE_SINGLE_QUEUE",
+ "places.bookmarks.useSingleQueueTransactionManager",
+ true
+);
+
+import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";
+
+function setTimeout(callback, ms) {
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT);
+}
+
+const lazy = {};
+ChromeUtils.defineLazyGetter(lazy, "logger", function () {
+ return console.createInstance({
+ prefix: "PlacesTransactions",
+ maxLogLevel: Services.prefs.getCharPref(
+ "places.transactions.logLevel",
+ "Error"
+ ),
+ });
+});
+
+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(inBatch, batchIndex) {
+ return TransactionsManager.transact(this, inBatch, batchIndex);
+ },
+ toString() {
+ return rawTransaction.toString();
+ },
+ });
+ 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();
+ lazy.logger.debug(`Adding transaction: ${proxifiedTransaction}`);
+ this.unshift([proxifiedTransaction]);
+ } else {
+ lazy.logger.debug(`Adding transaction: ${proxifiedTransaction}`);
+ this[this.undoPosition].unshift(proxifiedTransaction);
+ }
+ }
+
+ /**
+ * Clear all undo entries.
+ */
+ clearUndoEntries() {
+ lazy.logger.debug("Clearing undo entries");
+ if (this.undoPosition < this.length) {
+ this.splice(this.undoPosition);
+ }
+ }
+
+ /**
+ * Clear all redo entries.
+ */
+ clearRedoEntries() {
+ lazy.logger.debug("Clearing redo entries");
+ if (this.undoPosition > 0) {
+ this.splice(0, this.undoPosition);
+ this._undoPosition = 0;
+ }
+ }
+
+ /**
+ * Clear all entries.
+ */
+ clearAllEntries() {
+ lazy.logger.debug("Clearing all entries");
+ if (this.length) {
+ this.splice(0);
+ this._undoPosition = 0;
+ }
+ }
+}
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "TransactionsHistory",
+ () => new TransactionsHistoryArray()
+);
+
+export var PlacesTransactions = {
+ /**
+ * @see Batches in the module documentation.
+ */
+ batch(transactionsToBatch, batchName) {
+ if (!Array.isArray(transactionsToBatch) || !transactionsToBatch.length) {
+ throw new Error("Must pass a non-empty array");
+ }
+ if (
+ transactionsToBatch.some(
+ o =>
+ !lazy.TransactionsHistory.isProxifiedTransactionObject(o) &&
+ typeof o != "function"
+ )
+ ) {
+ throw new Error("Must pass only transactions or functions");
+ }
+ lazy.logger.debug(
+ `Batch ${batchName}: ${transactionsToBatch.length} transactions`
+ );
+ return TransactionsManager.batch(async function () {
+ lazy.logger.debug(`Batch ${batchName}: executing transactions`);
+ let accumulatedResults = [];
+ for (let txn of transactionsToBatch) {
+ try {
+ if (typeof txn == "function") {
+ txn = txn(accumulatedResults);
+ }
+ accumulatedResults.push(
+ await txn.transact(true, accumulatedResults.length)
+ );
+ } catch (ex) {
+ // TODO Bug 1865631: handle these errors better, currently we just
+ // continue, that works for non-dependent transactions, but will
+ // skip most of the work for functions depending on previous results.
+ // Moreover in both cases we should notify the user about the problem.
+ accumulatedResults.push(undefined);
+ // Using console.error() here sometimes fails, due to unknown XPC
+ // wrappers reasons, so just use our logger.
+ lazy.logger.error(`Failed to execute batched transaction: ${ex}`);
+ }
+ }
+ return accumulatedResults;
+ });
+ },
+
+ /**
+ * 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() {
+ lazy.logger.debug("undo() was invoked");
+ 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() {
+ lazy.logger.debug("redo() was invoked");
+ 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) {
+ lazy.logger.debug("clearTransactionsHistory() was invoked");
+ 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(name) {
+ this._promise = Promise.resolve();
+ this._name = name;
+}
+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) {
+ lazy.logger.debug(`${this._name} enqueing`);
+ // 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(lazy.logger.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 {Promise} otherPromise
+ * any promise.
+ * @param {string} source
+ * source for logging purposes
+ */
+ alsoWaitFor(otherPromise, source) {
+ lazy.logger.debug(`${this._name} alsoWaitFor: ${source}`);
+ // 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("MainEnqueuer"),
+ _transactEnqueuer: new Enqueuer("TransactEnqueuer"),
+
+ // 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(),
+
+ /**
+ * Execute a proxified transaction.
+ *
+ * @param {object} txnProxy The proxified transaction to execute.
+ * @param {boolean} [inBatch] Whether the transaction is part of a batch.
+ * @param {integer} [batchIndex] The index of the transaction in the batch array.
+ * @returns {Promise} resolved to the transaction return value once complete.
+ */
+ transact(txnProxy, inBatch = false, batchIndex = undefined) {
+ 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.");
+ }
+
+ lazy.logger.debug(`transact() enqueue: ${txnProxy}`);
+
+ // Add it in advance so one doesn't accidentally do
+ // sameTxn.transact(); sameTxn.transact();
+ this._executedTransactions.add(rawTxn);
+
+ let task = async () => {
+ lazy.logger.debug(`transact execute(): ${txnProxy}`);
+ // 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 = !inBatch || batchIndex === 0;
+ lazy.TransactionsHistory.add(txnProxy, forceNewEntry);
+
+ this._updateCommandsOnActiveWindow();
+ return retval;
+ };
+
+ if (prefs.USE_SINGLE_QUEUE) {
+ return task();
+ }
+
+ let promise = this._transactEnqueuer.enqueue(task);
+ this._mainEnqueuer.alsoWaitFor(promise, "transact");
+ return promise;
+ },
+
+ batch(task) {
+ return this._mainEnqueuer.enqueue(task);
+ },
+
+ /**
+ * Undo the top undo entry, if any, and update the undo position accordingly.
+ */
+ undo() {
+ let promise = this._mainEnqueuer.enqueue(async () => {
+ lazy.logger.debug("Undo execute");
+ 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();
+ });
+ if (!prefs.USE_SINGLE_QUEUE) {
+ this._transactEnqueuer.alsoWaitFor(promise, "undo");
+ }
+ return promise;
+ },
+
+ /**
+ * Redo the top redo entry, if any, and update the undo position accordingly.
+ */
+ redo() {
+ let promise = this._mainEnqueuer.enqueue(async () => {
+ lazy.logger.debug("Redo execute");
+ 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();
+ });
+ if (!prefs.USE_SINGLE_QUEUE) {
+ this._transactEnqueuer.alsoWaitFor(promise, "redo");
+ }
+ return promise;
+ },
+
+ clearTransactionsHistory(undoEntries, redoEntries) {
+ let promise = this._mainEnqueuer.enqueue(function () {
+ lazy.logger.debug(`ClearTransactionsHistory execute`);
+ 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");
+ }
+ });
+
+ if (!prefs.USE_SINGLE_QUEUE) {
+ this._transactEnqueuer.alsoWaitFor(promise, "clearTransactionsHistory");
+ }
+ 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 URL.fromURI(url);
+ }
+ 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(url.URI);
+ tags = tags.filter(t => !currentTags.includes(t));
+ }
+
+ async function createItem() {
+ info = await PlacesUtils.bookmarks.insert(info);
+ if (tags.length) {
+ PlacesUtils.tagging.tagURI(url.URI, 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(url.URI, tags);
+ }
+ };
+ this.redo = async function () {
+ await createItem();
+ };
+ return info.guid;
+ },
+ toString() {
+ return "NewBookmark";
+ },
+});
+
+/**
+ * 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;
+ },
+ toString() {
+ return "NewFolder";
+ },
+});
+
+/**
+ * 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;
+ },
+ toString() {
+ return "NewSeparator";
+ },
+});
+
+/**
+ * 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;
+ },
+ toString() {
+ return "Move";
+ },
+});
+
+/**
+ * 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
+ );
+ },
+ toString() {
+ return "EditTitle";
+ },
+});
+
+/**
+ * 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 = url.URI;
+ let originalURI = originalInfo.url.URI;
+ 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();
+ };
+ },
+ toString() {
+ return "EditUrl";
+ },
+});
+
+/**
+ * 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);
+ }
+ };
+ },
+ toString() {
+ return "EditKeyword";
+ },
+});
+
+/**
+ * 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);
+ };
+ },
+ toString() {
+ return "SortByName";
+ },
+};
+
+/**
+ * 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;
+ },
+ toString() {
+ return "Remove";
+ },
+};
+
+/**
+ * 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 = url.URI;
+ 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();
+ }
+ };
+ },
+ toString() {
+ return "Tag";
+ },
+};
+
+/**
+ * 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 = url.URI;
+ 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();
+ }
+ };
+ },
+ toString() {
+ return "Untag";
+ },
+};
+
+/**
+ * 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();
+ }
+ };
+ },
+ toString() {
+ return "RenameTag";
+ },
+};
+
+/**
+ * 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;
+ },
+ toString() {
+ return "Copy";
+ },
+};