From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../components/places/PlacesTransactions.sys.mjs | 1803 ++++++++++++++++++++ 1 file changed, 1803 insertions(+) create mode 100644 toolkit/components/places/PlacesTransactions.sys.mjs (limited to 'toolkit/components/places/PlacesTransactions.sys.mjs') 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"; + }, +}; -- cgit v1.2.3