Source code

Revision control

Copy as Markdown

Other Tools

/* 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. Bookmarks.sys.mjs) 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",
* }),
* 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 PlacesUtils.getLogger({ prefix: "Transactions" });
});
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";
},
};