Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this
3
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
"use strict";
6
7
var EXPORTED_SYMBOLS = ["PlacesTransactions"];
8
9
/**
10
* Overview
11
* --------
12
* This modules serves as the transactions manager for Places (hereinafter PTM).
13
* It implements all the elementary transactions for its UI commands: creating
14
* items, editing their various properties, and so forth.
15
*
16
* Note that since the effect of invoking a Places command is not limited to the
17
* window in which it was performed (e.g. a folder created in the Library may be
18
* the parent of a bookmark created in some browser window), PTM is a singleton.
19
* It's therefore unnecessary to initialize PTM in any way apart importing this
20
* module.
21
*
22
* PTM shares most of its semantics with common command pattern implementations.
23
* However, the asynchronous design of contemporary and future APIs, combined
24
* with the commitment to serialize all UI operations, does make things a little
25
* bit different. For example, when |undo| is called in order to undo the top
26
* undo entry, the caller cannot tell for sure what entry would it be, because
27
* the execution of some transactions is either in process, or enqueued to be.
28
*
29
* Also note that unlike the nsITransactionManager, for example, this API is by
30
* no means generic. That is, it cannot be used to execute anything but the
31
* elementary transactions implemented here (Please file a bug if you find
32
* anything uncovered). More-complex transactions (e.g. creating a folder and
33
* moving a bookmark into it) may be implemented as a batch (see below).
34
*
35
* A note about GUIDs and item-ids
36
* -------------------------------
37
* There's an ongoing effort (see bug 1071511) to deprecate item-ids in Places
38
* in favor of GUIDs. Both because new APIs (e.g. Bookmark.jsm) expose them to
39
* the minimum necessary, and because GUIDs play much better with implementing
40
* |redo|, this API doesn't support item-ids at all, and only accepts bookmark
41
* GUIDs, both for input (e.g. for setting the parent folder for a new bookmark)
42
* and for output (when the GUID for such a bookmark is propagated).
43
*
44
* Should you need to convert GUIDs to item-ids, use PlacesUtils.promiseItemId.
45
*
46
* Constructing transactions
47
* -------------------------
48
* At the bottom of this module you will find transactions for all Places UI
49
* commands. They are exposed as constructors set on the PlacesTransactions
50
* object (e.g. PlacesTransactions.NewFolder). The input for this constructors
51
* is taken in the form of a single argument, a plain object consisting of the
52
* properties for the transaction. Input properties may be either required or
53
* optional (for example, |keyword| is required for the EditKeyword transaction,
54
* but optional for the NewBookmark transaction).
55
*
56
* To make things simple, a given input property has the same basic meaning and
57
* valid values across all transactions which accept it in the input object.
58
* Here is a list of all supported input properties along with their expected
59
* values:
60
* - url: a URL object, an nsIURI object, or a href.
61
* - urls: an array of urls, as above.
62
* - tag - a string.
63
* - tags: an array of strings.
64
* - guid, parentGuid, newParentGuid: a valid Places GUID string.
65
* - guids: an array of valid Places GUID strings.
66
* - title: a string
67
* - index, newIndex: the position of an item in its containing folder,
68
* starting from 0.
69
* integer and PlacesUtils.bookmarks.DEFAULT_INDEX
70
*
71
* If a required property is missing in the input object (e.g. not specifying
72
* parentGuid for NewBookmark), or if the value for any of the input properties
73
* is invalid "on the surface" (e.g. a numeric value for GUID, or a string that
74
* isn't 12-characters long), the transaction constructor throws right way.
75
* More complex errors (e.g. passing a non-existent GUID for parentGuid) only
76
* reveal once the transaction is executed.
77
*
78
* Executing Transactions (the |transact| method of transactions)
79
* --------------------------------------------------------------
80
* Once a transaction is created, you must call its |transact| method for it to
81
* be executed and take effect. |transact| is an asynchronous method that takes
82
* no arguments, and returns a promise that resolves once the transaction is
83
* executed. Executing one of the transactions for creating items (NewBookmark,
84
* NewFolder, NewSeparator) resolve to the new item's GUID.
85
* There's no resolution value for other transactions.
86
* If a transaction fails to execute, |transact| rejects and the transactions
87
* history is not affected.
88
*
89
* |transact| throws if it's called more than once (successfully or not) on the
90
* same transaction object.
91
*
92
* Batches
93
* -------
94
* Sometimes it is useful to "batch" or "merge" transactions. For example,
95
* something like "Bookmark All Tabs" may be implemented as one NewFolder
96
* transaction followed by numerous NewBookmark transactions - all to be undone
97
* or redone in a single undo or redo command. Use |PlacesTransactions.batch|
98
* in such cases. It can take either an array of transactions which will be
99
* executed in the given order and later be treated a a single entry in the
100
* transactions history, or a generator function that is passed to Task.spawn,
101
* that is to "contain" the batch: once the generator function is called a batch
102
* starts, and it lasts until the asynchronous generator iteration is complete
103
* All transactions executed by |transact| during this time are to be treated as
104
* a single entry in the transactions history.
105
*
106
* In both modes, |PlacesTransactions.batch| returns a promise that is to be
107
* resolved when the batch ends. In the array-input mode, there's no resolution
108
* value. In the generator mode, the resolution value is whatever the generator
109
* function returned (the semantics are the same as in Task.spawn, basically).
110
*
111
* The array-input mode of |PlacesTransactions.batch| is useful for implementing
112
* a batch of mostly-independent transaction (for example, |paste| into a folder
113
* can be implemented as a batch of multiple NewBookmark transactions).
114
* The generator mode is useful when the resolution value of executing one
115
* transaction is the input of one more subsequent transaction.
116
*
117
* In the array-input mode, if any transactions fails to execute, the batch
118
* continues (exceptions are logged). Only transactions that were executed
119
* successfully are added to the transactions history.
120
*
121
* WARNING: "nested" batches are not supported, if you call batch while another
122
* batch is still running, the new batch is enqueued with all other PTM work
123
* and thus not run until the running batch ends. The same goes for undo, redo
124
* and clearTransactionsHistory (note batches cannot be done partially, meaning
125
* undo and redo calls that during a batch are just enqueued).
126
*
127
* *****************************************************************************
128
* IT'S PARTICULARLY IMPORTANT NOT TO await ANY PROMISE RETURNED BY ANY OF
129
* THESE METHODS (undo, redo, clearTransactionsHistory) FROM A BATCH FUNCTION.
130
* UNTIL WE FIND A WAY TO THROW IN THAT CASE (SEE BUG 1091446) DOING SO WILL
131
* COMPLETELY BREAK PTM UNTIL SHUTDOWN, NOT ALLOWING THE EXECUTION OF ANY
132
* TRANSACTION!
133
* *****************************************************************************
134
*
135
* Serialization
136
* -------------
137
* All |PlacesTransaction| operations are serialized. That is, even though the
138
* implementation is asynchronous, the order in which PlacesTransactions methods
139
* is called does guarantee the order in which they are to be invoked.
140
*
141
* The only exception to this rule is |transact| calls done during a batch (see
142
* above). |transact| calls are serialized with each other (and with undo, redo
143
* and clearTransactionsHistory), but they are, of course, not serialized with
144
* batches.
145
*
146
* The transactions-history structure
147
* ----------------------------------
148
* The transactions-history is a two-dimensional stack of transactions: the
149
* transactions are ordered in reverse to the order they were committed.
150
* It's two-dimensional because PTM allows batching transactions together for
151
* the purpose of undo or redo (see Batches above).
152
*
153
* The undoPosition property is set to the index of the top entry. If there is
154
* no entry at that index, there is nothing to undo.
155
* Entries prior to undoPosition, if any, are redo entries, the first one being
156
* the top redo entry.
157
*
158
* [ [2nd redo txn, 1st redo txn], <= 2nd redo entry
159
* [2nd redo txn, 1st redo txn], <= 1st redo entry
160
* [1st undo txn, 2nd undo txn], <= 1st undo entry
161
* [1st undo txn, 2nd undo txn] <= 2nd undo entry ]
162
* undoPostion: 2.
163
*
164
* Note that when a new entry is created, all redo entries are removed.
165
*/
166
167
const TRANSACTIONS_QUEUE_TIMEOUT_MS = 240000; // 4 Mins.
168
169
const { XPCOMUtils } = ChromeUtils.import(
171
);
172
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
173
ChromeUtils.defineModuleGetter(
174
this,
175
"PlacesUtils",
177
);
178
179
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
180
181
function setTimeout(callback, ms) {
182
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
183
timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT);
184
}
185
186
class TransactionsHistoryArray extends Array {
187
constructor() {
188
super();
189
190
// The index of the first undo entry (if any) - See the documentation
191
// at the top of this file.
192
this._undoPosition = 0;
193
// Outside of this module, the API of transactions is inaccessible, and so
194
// are any internal properties. To achieve that, transactions are proxified
195
// in their constructors. This maps the proxies to their respective raw
196
// objects.
197
this.proxifiedToRaw = new WeakMap();
198
}
199
200
get undoPosition() {
201
return this._undoPosition;
202
}
203
204
// Handy shortcuts
205
get topUndoEntry() {
206
return this.undoPosition < this.length ? this[this.undoPosition] : null;
207
}
208
get topRedoEntry() {
209
return this.undoPosition > 0 ? this[this.undoPosition - 1] : null;
210
}
211
212
/**
213
* Proxify a transaction object for consumers.
214
* @param rawTransaction
215
* the raw transaction object.
216
* @return the proxified transaction object.
217
* @see getRawTransaction for retrieving the raw transaction.
218
*/
219
proxifyTransaction(rawTransaction) {
220
let proxy = Object.freeze({
221
transact() {
222
return TransactionsManager.transact(this);
223
},
224
});
225
this.proxifiedToRaw.set(proxy, rawTransaction);
226
return proxy;
227
}
228
229
/**
230
* Check if the given object is a the proxy object for some transaction.
231
* @param aValue
232
* any JS value.
233
* @return true if aValue is the proxy object for some transaction, false
234
* otherwise.
235
*/
236
isProxifiedTransactionObject(value) {
237
return this.proxifiedToRaw.has(value);
238
}
239
240
/**
241
* Get the raw transaction for the given proxy.
242
* @param aProxy
243
* the proxy object
244
* @return the transaction proxified by aProxy; |undefined| is returned if
245
* aProxy is not a proxified transaction.
246
*/
247
getRawTransaction(proxy) {
248
return this.proxifiedToRaw.get(proxy);
249
}
250
251
/**
252
* Add a transaction either as a new entry, if forced or if there are no undo
253
* entries, or to the top undo entry.
254
*
255
* @param aProxifiedTransaction
256
* the proxified transaction object to be added to the transaction
257
* history.
258
* @param [optional] aForceNewEntry
259
* Force a new entry for the transaction. Default: false.
260
* If false, an entry will we created only if there's no undo entry
261
* to extend.
262
*/
263
add(proxifiedTransaction, forceNewEntry = false) {
264
if (!this.isProxifiedTransactionObject(proxifiedTransaction)) {
265
throw new Error("aProxifiedTransaction is not a proxified transaction");
266
}
267
268
if (!this.length || forceNewEntry) {
269
this.clearRedoEntries();
270
this.unshift([proxifiedTransaction]);
271
} else {
272
this[this.undoPosition].unshift(proxifiedTransaction);
273
}
274
}
275
276
/**
277
* Clear all undo entries.
278
*/
279
clearUndoEntries() {
280
if (this.undoPosition < this.length) {
281
this.splice(this.undoPosition);
282
}
283
}
284
285
/**
286
* Clear all redo entries.
287
*/
288
clearRedoEntries() {
289
if (this.undoPosition > 0) {
290
this.splice(0, this.undoPosition);
291
this._undoPosition = 0;
292
}
293
}
294
295
/**
296
* Clear all entries.
297
*/
298
clearAllEntries() {
299
if (this.length) {
300
this.splice(0);
301
this._undoPosition = 0;
302
}
303
}
304
}
305
306
XPCOMUtils.defineLazyGetter(
307
this,
308
"TransactionsHistory",
309
() => new TransactionsHistoryArray()
310
);
311
312
var PlacesTransactions = {
313
/**
314
* @see Batches in the module documentation.
315
*/
316
batch(transactionsToBatch) {
317
if (Array.isArray(transactionsToBatch)) {
318
if (!transactionsToBatch.length) {
319
throw new Error("Must pass a non-empty array");
320
}
321
322
if (
323
transactionsToBatch.some(
324
o => !TransactionsHistory.isProxifiedTransactionObject(o)
325
)
326
) {
327
throw new Error("Must pass only transaction entries");
328
}
329
return TransactionsManager.batch(async function() {
330
for (let txn of transactionsToBatch) {
331
try {
332
await txn.transact();
333
} catch (ex) {
334
console.error(ex);
335
}
336
}
337
});
338
}
339
if (typeof transactionsToBatch == "function") {
340
return TransactionsManager.batch(transactionsToBatch);
341
}
342
343
throw new Error("Must pass either a function or a transactions array");
344
},
345
346
/**
347
* Asynchronously undo the transaction immediately after the current undo
348
* position in the transactions history in the reverse order, if any, and
349
* adjusts the undo position.
350
*
351
* @return {Promises). The promise always resolves.
352
* @note All undo manager operations are queued. This means that transactions
353
* history may change by the time your request is fulfilled.
354
*/
355
undo() {
356
return TransactionsManager.undo();
357
},
358
359
/**
360
* Asynchronously redo the transaction immediately before the current undo
361
* position in the transactions history, if any, and adjusts the undo
362
* position.
363
*
364
* @return {Promises). The promise always resolves.
365
* @note All undo manager operations are queued. This means that transactions
366
* history may change by the time your request is fulfilled.
367
*/
368
redo() {
369
return TransactionsManager.redo();
370
},
371
372
/**
373
* Asynchronously clear the undo, redo, or all entries from the transactions
374
* history.
375
*
376
* @param [optional] undoEntries
377
* Whether or not to clear undo entries. Default: true.
378
* @param [optional] redoEntries
379
* Whether or not to clear undo entries. Default: true.
380
*
381
* @return {Promises). The promise always resolves.
382
* @throws if both aUndoEntries and aRedoEntries are false.
383
* @note All undo manager operations are queued. This means that transactions
384
* history may change by the time your request is fulfilled.
385
*/
386
clearTransactionsHistory(undoEntries = true, redoEntries = true) {
387
return TransactionsManager.clearTransactionsHistory(
388
undoEntries,
389
redoEntries
390
);
391
},
392
393
/**
394
* The numbers of entries in the transactions history.
395
*/
396
get length() {
397
return TransactionsHistory.length;
398
},
399
400
/**
401
* Get the transaction history entry at a given index. Each entry consists
402
* of one or more transaction objects.
403
*
404
* @param index
405
* the index of the entry to retrieve.
406
* @return an array of transaction objects in their undo order (that is,
407
* reversely to the order they were executed).
408
* @throw if aIndex is invalid (< 0 or >= length).
409
* @note the returned array is a clone of the history entry and is not
410
* kept in sync with the original entry if it changes.
411
*/
412
entry(index) {
413
if (!Number.isInteger(index) || index < 0 || index >= this.length) {
414
throw new Error("Invalid index");
415
}
416
417
return TransactionsHistory[index];
418
},
419
420
/**
421
* The index of the top undo entry in the transactions history.
422
* If there are no undo entries, it equals to |length|.
423
* Entries past this point
424
* Entries at and past this point are redo entries.
425
*/
426
get undoPosition() {
427
return TransactionsHistory.undoPosition;
428
},
429
430
/**
431
* Shortcut for accessing the top undo entry in the transaction history.
432
*/
433
get topUndoEntry() {
434
return TransactionsHistory.topUndoEntry;
435
},
436
437
/**
438
* Shortcut for accessing the top redo entry in the transaction history.
439
*/
440
get topRedoEntry() {
441
return TransactionsHistory.topRedoEntry;
442
},
443
};
444
445
/**
446
* Helper for serializing the calls to TransactionsManager methods. It allows
447
* us to guarantee that the order in which TransactionsManager asynchronous
448
* methods are called also enforces the order in which they're executed, and
449
* that they are never executed in parallel.
450
*
451
* In other words: Enqueuer.enqueue(aFunc1); Enqueuer.enqueue(aFunc2) is roughly
452
* the same as Task.spawn(aFunc1).then(Task.spawn(aFunc2)).
453
*/
454
function Enqueuer() {
455
this._promise = Promise.resolve();
456
}
457
Enqueuer.prototype = {
458
/**
459
* Spawn a functions once all previous functions enqueued are done running,
460
* and all promises passed to alsoWaitFor are no longer pending.
461
*
462
* @param func
463
* a function returning a promise.
464
* @return a promise that resolves once aFunc is done running. The promise
465
* "mirrors" the promise returned by aFunc.
466
*/
467
enqueue(func) {
468
// If a transaction awaits on a never resolved promise, or is mistakenly
469
// nested, it could hang the transactions queue forever. Thus we timeout
470
// the execution after a meaningful amount of time, to ensure in any case
471
// we'll proceed after a while.
472
let timeoutPromise = new Promise((resolve, reject) => {
473
setTimeout(
474
() =>
475
reject(
476
new Error(
477
"PlacesTransaction timeout, most likely caused by unresolved pending work."
478
)
479
),
480
TRANSACTIONS_QUEUE_TIMEOUT_MS
481
);
482
});
483
let promise = this._promise.then(() =>
484
Promise.race([func(), timeoutPromise])
485
);
486
487
// Propagate exceptions to the caller, but dismiss them internally.
488
this._promise = promise.catch(console.error);
489
return promise;
490
},
491
492
/**
493
* Same as above, but for a promise returned by a function that already run.
494
* This is useful, for example, for serializing transact calls with undo calls,
495
* even though transact has its own Enqueuer.
496
*
497
* @param otherPromise
498
* any promise.
499
*/
500
alsoWaitFor(otherPromise) {
501
// We don't care if aPromise resolves or rejects, but just that is not
502
// pending anymore.
503
// If a transaction awaits on a never resolved promise, or is mistakenly
504
// nested, it could hang the transactions queue forever. Thus we timeout
505
// the execution after a meaningful amount of time, to ensure in any case
506
// we'll proceed after a while.
507
let timeoutPromise = new Promise((resolve, reject) => {
508
setTimeout(
509
() =>
510
reject(
511
new Error(
512
"PlacesTransaction timeout, most likely caused by unresolved pending work."
513
)
514
),
515
TRANSACTIONS_QUEUE_TIMEOUT_MS
516
);
517
});
518
let promise = Promise.race([otherPromise, timeoutPromise]).catch(
519
console.error
520
);
521
this._promise = Promise.all([this._promise, promise]);
522
},
523
524
/**
525
* The promise for this queue.
526
*/
527
get promise() {
528
return this._promise;
529
},
530
};
531
532
var TransactionsManager = {
533
// See the documentation at the top of this file. |transact| calls are not
534
// serialized with |batch| calls.
535
_mainEnqueuer: new Enqueuer(),
536
_transactEnqueuer: new Enqueuer(),
537
538
// Is a batch in progress? set when we enter a batch function and unset when
539
// it's execution is done.
540
_batching: false,
541
542
// If a batch started, this indicates if we've already created an entry in the
543
// transactions history for the batch (i.e. if at least one transaction was
544
// executed successfully).
545
_createdBatchEntry: false,
546
547
// Transactions object should never be recycled (that is, |execute| should
548
// only be called once (or not at all) after they're constructed.
549
// This keeps track of all transactions which were executed.
550
_executedTransactions: new WeakSet(),
551
552
transact(txnProxy) {
553
let rawTxn = TransactionsHistory.getRawTransaction(txnProxy);
554
if (!rawTxn) {
555
throw new Error("|transact| was called with an unexpected object");
556
}
557
558
if (this._executedTransactions.has(rawTxn)) {
559
throw new Error("Transactions objects may not be recycled.");
560
}
561
562
// Add it in advance so one doesn't accidentally do
563
// sameTxn.transact(); sameTxn.transact();
564
this._executedTransactions.add(rawTxn);
565
566
let promise = this._transactEnqueuer.enqueue(async () => {
567
// Don't try to catch exceptions. If execute fails, we better not add the
568
// transaction to the undo stack.
569
let retval = await rawTxn.execute();
570
571
let forceNewEntry = !this._batching || !this._createdBatchEntry;
572
TransactionsHistory.add(txnProxy, forceNewEntry);
573
if (this._batching) {
574
this._createdBatchEntry = true;
575
}
576
577
this._updateCommandsOnActiveWindow();
578
return retval;
579
});
580
this._mainEnqueuer.alsoWaitFor(promise);
581
return promise;
582
},
583
584
batch(task) {
585
return this._mainEnqueuer.enqueue(async () => {
586
this._batching = true;
587
this._createdBatchEntry = false;
588
let rv;
589
try {
590
rv = await task();
591
} finally {
592
// We must enqueue clearing batching mode to ensure that any existing
593
// transactions have completed before we clear the batching mode.
594
this._mainEnqueuer.enqueue(() => {
595
this._batching = false;
596
this._createdBatchEntry = false;
597
});
598
}
599
return rv;
600
});
601
},
602
603
/**
604
* Undo the top undo entry, if any, and update the undo position accordingly.
605
*/
606
undo() {
607
let promise = this._mainEnqueuer.enqueue(async () => {
608
let entry = TransactionsHistory.topUndoEntry;
609
if (!entry) {
610
return;
611
}
612
613
for (let txnProxy of entry) {
614
try {
615
await TransactionsHistory.getRawTransaction(txnProxy).undo();
616
} catch (ex) {
617
// If one transaction is broken, it's not safe to work with any other
618
// undo entry. Report the error and clear the undo history.
619
console.error(ex, "Can't undo a transaction, clearing undo entries.");
620
TransactionsHistory.clearUndoEntries();
621
return;
622
}
623
}
624
TransactionsHistory._undoPosition++;
625
this._updateCommandsOnActiveWindow();
626
});
627
this._transactEnqueuer.alsoWaitFor(promise);
628
return promise;
629
},
630
631
/**
632
* Redo the top redo entry, if any, and update the undo position accordingly.
633
*/
634
redo() {
635
let promise = this._mainEnqueuer.enqueue(async () => {
636
let entry = TransactionsHistory.topRedoEntry;
637
if (!entry) {
638
return;
639
}
640
641
for (let i = entry.length - 1; i >= 0; i--) {
642
let transaction = TransactionsHistory.getRawTransaction(entry[i]);
643
try {
644
if (transaction.redo) {
645
await transaction.redo();
646
} else {
647
await transaction.execute();
648
}
649
} catch (ex) {
650
// If one transaction is broken, it's not safe to work with any other
651
// redo entry. Report the error and clear the undo history.
652
console.error(ex, "Can't redo a transaction, clearing redo entries.");
653
TransactionsHistory.clearRedoEntries();
654
return;
655
}
656
}
657
TransactionsHistory._undoPosition--;
658
this._updateCommandsOnActiveWindow();
659
});
660
661
this._transactEnqueuer.alsoWaitFor(promise);
662
return promise;
663
},
664
665
clearTransactionsHistory(undoEntries, redoEntries) {
666
let promise = this._mainEnqueuer.enqueue(function() {
667
if (undoEntries && redoEntries) {
668
TransactionsHistory.clearAllEntries();
669
} else if (undoEntries) {
670
TransactionsHistory.clearUndoEntries();
671
} else if (redoEntries) {
672
TransactionsHistory.clearRedoEntries();
673
} else {
674
throw new Error("either aUndoEntries or aRedoEntries should be true");
675
}
676
});
677
678
this._transactEnqueuer.alsoWaitFor(promise);
679
return promise;
680
},
681
682
// Updates commands in the undo group of the active window commands.
683
// Inactive windows commands will be updated on focus.
684
_updateCommandsOnActiveWindow() {
685
// Updating "undo" will cause a group update including "redo".
686
try {
687
let win = Services.focus.activeWindow;
688
if (win) {
689
win.updateCommands("undo");
690
}
691
} catch (ex) {
692
console.error(ex, "Couldn't update undo commands.");
693
}
694
},
695
};
696
697
/**
698
* Internal helper for defining the standard transactions and their input.
699
* It takes the required and optional properties, and generates the public
700
* constructor (which takes the input in the form of a plain object) which,
701
* when called, creates the argument-less "public" |execute| method by binding
702
* the input properties to the function arguments (required properties first,
703
* then the optional properties).
704
*
705
* If this seems confusing, look at the consumers.
706
*
707
* This magic serves two purposes:
708
* (1) It completely hides the transactions' internals from the module
709
* consumers.
710
* (2) It keeps each transaction implementation to what is about, bypassing
711
* all this bureaucracy while still validating input appropriately.
712
*/
713
function DefineTransaction(requiredProps = [], optionalProps = []) {
714
for (let prop of [...requiredProps, ...optionalProps]) {
715
if (!DefineTransaction.inputProps.has(prop)) {
716
throw new Error("Property '" + prop + "' is not defined");
717
}
718
}
719
720
let ctor = function(input) {
721
// We want to support both syntaxes:
722
// let t = new PlacesTransactions.NewBookmark(),
723
// let t = PlacesTransactions.NewBookmark()
724
if (this == PlacesTransactions) {
725
return new ctor(input);
726
}
727
728
if (requiredProps.length || optionalProps.length) {
729
// Bind the input properties to the arguments of execute.
730
input = DefineTransaction.verifyInput(
731
input,
732
requiredProps,
733
optionalProps
734
);
735
this.execute = this.execute.bind(this, input);
736
}
737
return TransactionsHistory.proxifyTransaction(this);
738
};
739
return ctor;
740
}
741
742
function simpleValidateFunc(checkFn) {
743
return v => {
744
if (!checkFn(v)) {
745
throw new Error("Invalid value");
746
}
747
return v;
748
};
749
}
750
751
DefineTransaction.strValidate = simpleValidateFunc(v => typeof v == "string");
752
DefineTransaction.strOrNullValidate = simpleValidateFunc(
753
v => typeof v == "string" || v === null
754
);
755
DefineTransaction.indexValidate = simpleValidateFunc(
756
v => Number.isInteger(v) && v >= PlacesUtils.bookmarks.DEFAULT_INDEX
757
);
758
DefineTransaction.guidValidate = simpleValidateFunc(v =>
759
/^[a-zA-Z0-9\-_]{12}$/.test(v)
760
);
761
762
function isPrimitive(v) {
763
return v === null || (typeof v != "object" && typeof v != "function");
764
}
765
766
function checkProperty(obj, prop, required, checkFn) {
767
if (prop in obj) {
768
return checkFn(obj[prop]);
769
}
770
771
return !required;
772
}
773
774
DefineTransaction.childObjectValidate = function(obj) {
775
if (
776
obj &&
777
checkProperty(obj, "title", false, v => typeof v == "string") &&
778
!("type" in obj && obj.type != PlacesUtils.bookmarks.TYPE_BOOKMARK)
779
) {
780
obj.url = DefineTransaction.urlValidate(obj.url);
781
let validKeys = ["title", "url"];
782
if (Object.keys(obj).every(k => validKeys.includes(k))) {
783
return obj;
784
}
785
}
786
throw new Error("Invalid child object");
787
};
788
789
DefineTransaction.urlValidate = function(url) {
790
if (url instanceof Ci.nsIURI) {
791
return new URL(url.spec);
792
}
793
return new URL(url);
794
};
795
796
DefineTransaction.inputProps = new Map();
797
DefineTransaction.defineInputProps = function(names, validateFn, defaultValue) {
798
for (let name of names) {
799
this.inputProps.set(name, {
800
validateValue(value) {
801
if (value === undefined) {
802
return defaultValue;
803
}
804
try {
805
return validateFn(value);
806
} catch (ex) {
807
throw new Error(`Invalid value for input property ${name}: ${ex}`);
808
}
809
},
810
811
validateInput(input, required) {
812
if (required && !(name in input)) {
813
throw new Error(`Required input property is missing: ${name}`);
814
}
815
return this.validateValue(input[name]);
816
},
817
818
isArrayProperty: false,
819
});
820
}
821
};
822
823
DefineTransaction.defineArrayInputProp = function(name, basePropertyName) {
824
let baseProp = this.inputProps.get(basePropertyName);
825
if (!baseProp) {
826
throw new Error(`Unknown input property: ${basePropertyName}`);
827
}
828
829
this.inputProps.set(name, {
830
validateValue(aValue) {
831
if (aValue == undefined) {
832
return [];
833
}
834
835
if (!Array.isArray(aValue)) {
836
throw new Error(`${name} input property value must be an array`);
837
}
838
839
// We must create a new array in the local scope to avoid a memory leak due
840
// to the array global object. We can't use Cu.cloneInto as that doesn't
841
// handle the URIs. Slice & map also aren't good enough, so we start off
842
// with a clean array and insert what we need into it.
843
let newArray = [];
844
for (let item of aValue) {
845
newArray.push(baseProp.validateValue(item));
846
}
847
return newArray;
848
},
849
850
// We allow setting either the array property itself (e.g. urls), or a
851
// single element of it (url, in that example), that is then transformed
852
// into a single-element array.
853
validateInput(input, required) {
854
if (name in input) {
855
// It's not allowed to set both though.
856
if (basePropertyName in input) {
857
throw new Error(`It is not allowed to set both ${name} and
858
${basePropertyName} as input properties`);
859
}
860
let array = this.validateValue(input[name]);
861
if (required && !array.length) {
862
throw new Error(`Empty array passed for required input property:
863
${name}`);
864
}
865
return array;
866
}
867
// If the property is required and it's not set as is, check if the base
868
// property is set.
869
if (required && !(basePropertyName in input)) {
870
throw new Error(`Required input property is missing: ${name}`);
871
}
872
873
if (basePropertyName in input) {
874
return [baseProp.validateValue(input[basePropertyName])];
875
}
876
877
return [];
878
},
879
880
isArrayProperty: true,
881
});
882
};
883
884
DefineTransaction.validatePropertyValue = function(prop, input, required) {
885
return this.inputProps.get(prop).validateInput(input, required);
886
};
887
888
DefineTransaction.getInputObjectForSingleValue = function(
889
input,
890
requiredProps,
891
optionalProps
892
) {
893
// The following input forms may be deduced from a single value:
894
// * a single required property with or without optional properties (the given
895
// value is set to the required property).
896
// * a single optional property with no required properties.
897
if (
898
requiredProps.length > 1 ||
899
(!requiredProps.length && optionalProps.length > 1)
900
) {
901
throw new Error("Transaction input isn't an object");
902
}
903
904
let propName =
905
requiredProps.length == 1 ? requiredProps[0] : optionalProps[0];
906
let propValue =
907
this.inputProps.get(propName).isArrayProperty && !Array.isArray(input)
908
? [input]
909
: input;
910
return { [propName]: propValue };
911
};
912
913
DefineTransaction.verifyInput = function(
914
input,
915
requiredProps = [],
916
optionalProps = []
917
) {
918
if (!requiredProps.length && !optionalProps.length) {
919
return {};
920
}
921
922
// If there's just a single required/optional property, we allow passing it
923
// as is, so, for example, one could do PlacesTransactions.Remove(myGuid)
924
// rather than PlacesTransactions.Remove({ guid: myGuid}).
925
// This shortcut isn't supported for "complex" properties, like objects (note
926
// there is no use case for this at the moment anyway).
927
let isSinglePropertyInput =
928
isPrimitive(input) ||
929
Array.isArray(input) ||
930
input instanceof Ci.nsISupports;
931
if (isSinglePropertyInput) {
932
input = this.getInputObjectForSingleValue(
933
input,
934
requiredProps,
935
optionalProps
936
);
937
}
938
939
let fixedInput = {};
940
for (let prop of requiredProps) {
941
fixedInput[prop] = this.validatePropertyValue(prop, input, true);
942
}
943
for (let prop of optionalProps) {
944
fixedInput[prop] = this.validatePropertyValue(prop, input, false);
945
}
946
947
return fixedInput;
948
};
949
950
// Update the documentation at the top of this module if you add or
951
// remove properties.
952
DefineTransaction.defineInputProps(
953
["url"],
954
DefineTransaction.urlValidate,
955
null
956
);
957
DefineTransaction.defineInputProps(
958
["guid", "parentGuid", "newParentGuid"],
959
DefineTransaction.guidValidate
960
);
961
DefineTransaction.defineInputProps(
962
["title", "postData"],
963
DefineTransaction.strOrNullValidate,
964
null
965
);
966
DefineTransaction.defineInputProps(
967
["keyword", "oldKeyword", "oldTag", "tag"],
968
DefineTransaction.strValidate,
969
""
970
);
971
DefineTransaction.defineInputProps(
972
["index", "newIndex"],
973
DefineTransaction.indexValidate,
974
PlacesUtils.bookmarks.DEFAULT_INDEX
975
);
976
DefineTransaction.defineInputProps(
977
["child"],
978
DefineTransaction.childObjectValidate
979
);
980
DefineTransaction.defineArrayInputProp("guids", "guid");
981
DefineTransaction.defineArrayInputProp("urls", "url");
982
DefineTransaction.defineArrayInputProp("tags", "tag");
983
DefineTransaction.defineArrayInputProp("children", "child");
984
985
/**
986
* Creates items (all types) from a bookmarks tree representation, as defined
987
* in PlacesUtils.promiseBookmarksTree.
988
*
989
* @param tree
990
* the bookmarks tree object. You may pass either a bookmarks tree
991
* returned by promiseBookmarksTree, or a manually defined one.
992
* @param [optional] restoring (default: false)
993
* Whether or not the items are restored. Only in restore mode, are
994
* the guid, dateAdded and lastModified properties honored.
995
* @note the id, root and charset properties of items in aBookmarksTree are
996
* always ignored. The index property is ignored for all items but the
997
* root one.
998
* @return {Promise}
999
* @resolves to the guid of the new item.
1000
*/
1001
// TODO: Replace most of this with insertTree.
1002
function createItemsFromBookmarksTree(tree, restoring = false) {
1003
async function createItem(
1004
item,
1005
parentGuid,
1006
index = PlacesUtils.bookmarks.DEFAULT_INDEX
1007
) {
1008
let guid;
1009
let info = { parentGuid, index };
1010
if (restoring) {
1011
info.guid = item.guid;
1012
info.dateAdded = PlacesUtils.toDate(item.dateAdded);
1013
info.lastModified = PlacesUtils.toDate(item.lastModified);
1014
}
1015
let shouldResetLastModified = false;
1016
switch (item.type) {
1017
case PlacesUtils.TYPE_X_MOZ_PLACE: {
1018
info.url = item.uri;
1019
if (typeof item.title == "string") {
1020
info.title = item.title;
1021
}
1022
1023
guid = (await PlacesUtils.bookmarks.insert(info)).guid;
1024
1025
if ("keyword" in item) {
1026
let { uri: url, keyword, postData } = item;
1027
await PlacesUtils.keywords.insert({ url, keyword, postData });
1028
}
1029
if ("tags" in item) {
1030
PlacesUtils.tagging.tagURI(
1031
Services.io.newURI(item.uri),
1032
item.tags.split(",")
1033
);
1034
}
1035
break;
1036
}
1037
case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: {
1038
info.type = PlacesUtils.bookmarks.TYPE_FOLDER;
1039
if (typeof item.title == "string") {
1040
info.title = item.title;
1041
}
1042
guid = (await PlacesUtils.bookmarks.insert(info)).guid;
1043
if ("children" in item) {
1044
for (let child of item.children) {
1045
await createItem(child, guid);
1046
}
1047
}
1048
if (restoring) {
1049
shouldResetLastModified = true;
1050
}
1051
break;
1052
}
1053
case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: {
1054
info.type = PlacesUtils.bookmarks.TYPE_SEPARATOR;
1055
guid = (await PlacesUtils.bookmarks.insert(info)).guid;
1056
break;
1057
}
1058
}
1059
1060
if (shouldResetLastModified) {
1061
let lastModified = PlacesUtils.toDate(item.lastModified);
1062
await PlacesUtils.bookmarks.update({ guid, lastModified });
1063
}
1064
1065
return guid;
1066
}
1067
return createItem(tree, tree.parentGuid, tree.index);
1068
}
1069
1070
/** ***************************************************************************
1071
* The Standard Places Transactions.
1072
*
1073
* See the documentation at the top of this file. The valid values for input
1074
* are also documented there.
1075
*****************************************************************************/
1076
1077
var PT = PlacesTransactions;
1078
1079
/**
1080
* Transaction for creating a bookmark.
1081
*
1082
* Required Input Properties: url, parentGuid.
1083
* Optional Input Properties: index, title, keyword, tags.
1084
*
1085
* When this transaction is executed, it's resolved to the new bookmark's GUID.
1086
*/
1087
PT.NewBookmark = DefineTransaction(
1088
["parentGuid", "url"],
1089
["index", "title", "tags"]
1090
);
1091
PT.NewBookmark.prototype = Object.seal({
1092
async execute({ parentGuid, url, index, title, tags }) {
1093
let info = { parentGuid, index, url, title };
1094
// Filter tags to exclude already existing ones.
1095
if (tags.length) {
1096
let currentTags = PlacesUtils.tagging.getTagsForURI(
1097
Services.io.newURI(url.href)
1098
);
1099
tags = tags.filter(t => !currentTags.includes(t));
1100
}
1101
1102
async function createItem() {
1103
info = await PlacesUtils.bookmarks.insert(info);
1104
if (tags.length) {
1105
PlacesUtils.tagging.tagURI(Services.io.newURI(url.href), tags);
1106
}
1107
}
1108
1109
await createItem();
1110
1111
this.undo = async function() {
1112
// Pick up the removed info so we have the accurate last-modified value.
1113
await PlacesUtils.bookmarks.remove(info);
1114
if (tags.length) {
1115
PlacesUtils.tagging.untagURI(Services.io.newURI(url.href), tags);
1116
}
1117
};
1118
this.redo = async function() {
1119
await createItem();
1120
};
1121
return info.guid;
1122
},
1123
});
1124
1125
/**
1126
* Transaction for creating a folder.
1127
*
1128
* Required Input Properties: title, parentGuid.
1129
* Optional Input Properties: index, children
1130
*
1131
* When this transaction is executed, it's resolved to the new folder's GUID.
1132
*/
1133
PT.NewFolder = DefineTransaction(
1134
["parentGuid", "title"],
1135
["index", "children"]
1136
);
1137
PT.NewFolder.prototype = Object.seal({
1138
async execute({ parentGuid, title, index, children }) {
1139
let folderGuid;
1140
let info = {
1141
children: [
1142
{
1143
// Ensure to specify a guid to be restored on redo.
1144
guid: PlacesUtils.history.makeGuid(),
1145
title,
1146
type: PlacesUtils.bookmarks.TYPE_FOLDER,
1147
},
1148
],
1149
// insertTree uses guid as the parent for where it is being inserted
1150
// into.
1151
guid: parentGuid,
1152
};
1153
1154
if (children && children.length) {
1155
// Ensure to specify a guid for each child to be restored on redo.
1156
info.children[0].children = children.map(c => {
1157
c.guid = PlacesUtils.history.makeGuid();
1158
return c;
1159
});
1160
}
1161
1162
async function createItem() {
1163
// Note, insertTree returns an array, rather than the folder/child structure.
1164
// For simplicity, we only get the new folder id here. This means that
1165
// an undo then redo won't retain exactly the same information for all
1166
// the child bookmarks, but we believe that isn't important at the moment.
1167
let bmInfo = await PlacesUtils.bookmarks.insertTree(info);
1168
// insertTree returns an array, but we only need to deal with the folder guid.
1169
folderGuid = bmInfo[0].guid;
1170
1171
// Bug 1388097: insertTree doesn't handle inserting at a specific index for the folder,
1172
// therefore we update the bookmark manually afterwards.
1173
if (index != PlacesUtils.bookmarks.DEFAULT_INDEX) {
1174
bmInfo[0].index = index;
1175
bmInfo = await PlacesUtils.bookmarks.update(bmInfo[0]);
1176
}
1177
}
1178
await createItem();
1179
1180
this.undo = async function() {
1181
await PlacesUtils.bookmarks.remove(folderGuid);
1182
};
1183
this.redo = async function() {
1184
await createItem();
1185
};
1186
return folderGuid;
1187
},
1188
});
1189
1190
/**
1191
* Transaction for creating a separator.
1192
*
1193
* Required Input Properties: parentGuid.
1194
* Optional Input Properties: index.
1195
*
1196
* When this transaction is executed, it's resolved to the new separator's
1197
* GUID.
1198
*/
1199
PT.NewSeparator = DefineTransaction(["parentGuid"], ["index"]);
1200
PT.NewSeparator.prototype = Object.seal({
1201
async execute(info) {
1202
info.type = PlacesUtils.bookmarks.TYPE_SEPARATOR;
1203
info = await PlacesUtils.bookmarks.insert(info);
1204
this.undo = PlacesUtils.bookmarks.remove.bind(PlacesUtils.bookmarks, info);
1205
this.redo = PlacesUtils.bookmarks.insert.bind(PlacesUtils.bookmarks, info);
1206
return info.guid;
1207
},
1208
});
1209
1210
/**
1211
* Transaction for moving an item.
1212
*
1213
* Required Input Properties: guid, newParentGuid.
1214
* Optional Input Properties newIndex.
1215
*/
1216
PT.Move = DefineTransaction(["guids", "newParentGuid"], ["newIndex"]);
1217
PT.Move.prototype = Object.seal({
1218
async execute({ guids, newParentGuid, newIndex }) {
1219
let originalInfos = [];
1220
let index = newIndex;
1221
1222
for (let guid of guids) {
1223
// We need to save the original data for undo.
1224
let originalInfo = await PlacesUtils.bookmarks.fetch(guid);
1225
if (!originalInfo) {
1226
throw new Error("Cannot move a non-existent item");
1227
}
1228
1229
originalInfos.push(originalInfo);
1230
}
1231
1232
await PlacesUtils.bookmarks.moveToFolder(guids, newParentGuid, index);
1233
1234
this.undo = async function() {
1235
// Undo has the potential for moving multiple bookmarks to multiple different
1236
// folders and positions, which is very complicated to manage. Therefore we do
1237
// individual moves one at a time and hopefully everything is put back approximately
1238
// where it should be.
1239
for (let info of originalInfos) {
1240
await PlacesUtils.bookmarks.update(info);
1241
}
1242
};
1243
this.redo = PlacesUtils.bookmarks.moveToFolder.bind(
1244
PlacesUtils.bookmarks,
1245
guids,
1246
newParentGuid,
1247
index
1248
);
1249
return guids;
1250
},
1251
});
1252
1253
/**
1254
* Transaction for setting the title for an item.
1255
*
1256
* Required Input Properties: guid, title.
1257
*/
1258
PT.EditTitle = DefineTransaction(["guid", "title"]);
1259
PT.EditTitle.prototype = Object.seal({
1260
async execute({ guid, title }) {
1261
let originalInfo = await PlacesUtils.bookmarks.fetch(guid);
1262
if (!originalInfo) {
1263
throw new Error("cannot update a non-existent item");
1264
}
1265
1266
let updateInfo = { guid, title };
1267
updateInfo = await PlacesUtils.bookmarks.update(updateInfo);
1268
1269
this.undo = PlacesUtils.bookmarks.update.bind(
1270
PlacesUtils.bookmarks,
1271
originalInfo
1272
);
1273
this.redo = PlacesUtils.bookmarks.update.bind(
1274
PlacesUtils.bookmarks,
1275
updateInfo
1276
);
1277
},
1278
});
1279
1280
/**
1281
* Transaction for setting the URI for an item.
1282
*
1283
* Required Input Properties: guid, url.
1284
*/
1285
PT.EditUrl = DefineTransaction(["guid", "url"]);
1286
PT.EditUrl.prototype = Object.seal({
1287
async execute({ guid, url }) {
1288
let originalInfo = await PlacesUtils.bookmarks.fetch(guid);
1289
if (!originalInfo) {
1290
throw new Error("cannot update a non-existent item");
1291
}
1292
if (originalInfo.type != PlacesUtils.bookmarks.TYPE_BOOKMARK) {
1293
throw new Error("Cannot edit url for non-bookmark items");
1294
}
1295
1296
let uri = Services.io.newURI(url.href);
1297
let originalURI = Services.io.newURI(originalInfo.url.href);
1298
let originalTags = PlacesUtils.tagging.getTagsForURI(originalURI);
1299
let updatedInfo = { guid, url };
1300
let newURIAdditionalTags = null;
1301
1302
async function updateItem() {
1303
updatedInfo = await PlacesUtils.bookmarks.update(updatedInfo);
1304
// Move tags from the original URI to the new URI.
1305
if (originalTags.length) {
1306
// Untag the original URI only if this was the only bookmark.
1307
if (!(await PlacesUtils.bookmarks.fetch({ url: originalInfo.url }))) {
1308
PlacesUtils.tagging.untagURI(originalURI, originalTags);
1309
}
1310
let currentNewURITags = PlacesUtils.tagging.getTagsForURI(uri);
1311
newURIAdditionalTags = originalTags.filter(
1312
t => !currentNewURITags.includes(t)
1313
);
1314
if (newURIAdditionalTags && newURIAdditionalTags.length) {
1315
PlacesUtils.tagging.tagURI(uri, newURIAdditionalTags);
1316
}
1317
}
1318
}
1319
await updateItem();
1320
1321
this.undo = async function() {
1322
await PlacesUtils.bookmarks.update(originalInfo);
1323
// Move tags from new URI to original URI.
1324
if (originalTags.length) {
1325
// Only untag the new URI if this is the only bookmark.
1326
if (
1327
newURIAdditionalTags &&
1328
!!newURIAdditionalTags.length &&
1329
!(await PlacesUtils.bookmarks.fetch({ url }))
1330
) {
1331
PlacesUtils.tagging.untagURI(uri, newURIAdditionalTags);
1332
}
1333
PlacesUtils.tagging.tagURI(originalURI, originalTags);
1334
}
1335
};
1336
1337
this.redo = async function() {
1338
updatedInfo = await updateItem();
1339
};
1340
},
1341
});
1342
1343
/**
1344
* Transaction for setting the keyword for a bookmark.
1345
*
1346
* Required Input Properties: guid, keyword.
1347
* Optional Input Properties: postData, oldKeyword.
1348
*/
1349
PT.EditKeyword = DefineTransaction(
1350
["guid", "keyword"],
1351
["postData", "oldKeyword"]
1352
);
1353
PT.EditKeyword.prototype = Object.seal({
1354
async execute({ guid, keyword, postData, oldKeyword }) {
1355
let url;
1356
let oldKeywordEntry;
1357
if (oldKeyword) {
1358
oldKeywordEntry = await PlacesUtils.keywords.fetch(oldKeyword);
1359
url = oldKeywordEntry.url;
1360
await PlacesUtils.keywords.remove(oldKeyword);
1361
}
1362
1363
if (keyword) {
1364
if (!url) {
1365
url = (await PlacesUtils.bookmarks.fetch(guid)).url;
1366
}
1367
await PlacesUtils.keywords.insert({
1368
url,
1369
keyword,
1370
postData: postData || (oldKeywordEntry ? oldKeywordEntry.postData : ""),
1371
});
1372
}
1373
1374
this.undo = async function() {
1375
if (keyword) {
1376
await PlacesUtils.keywords.remove(keyword);
1377
}
1378
if (oldKeywordEntry) {
1379
await PlacesUtils.keywords.insert(oldKeywordEntry);
1380
}
1381
};
1382
},
1383
});
1384
1385
/**
1386
* Transaction for sorting a folder by name.
1387
*
1388
* Required Input Properties: guid.
1389
*/
1390
PT.SortByName = DefineTransaction(["guid"]);
1391
PT.SortByName.prototype = {
1392
async execute({ guid }) {
1393
let sortingMethod = (node_a, node_b) => {
1394
if (
1395
PlacesUtils.nodeIsContainer(node_a) &&
1396
!PlacesUtils.nodeIsContainer(node_b)
1397
) {
1398
return -1;
1399
}
1400
if (
1401
!PlacesUtils.nodeIsContainer(node_a) &&
1402
PlacesUtils.nodeIsContainer(node_b)
1403
) {
1404
return 1;
1405
}
1406
return node_a.title.localeCompare(node_b.title);
1407
};
1408
let oldOrderGuids = [];
1409
let newOrderGuids = [];
1410
let preSepNodes = [];
1411
1412
// This is not great, since it does main-thread IO.
1413
// PromiseBookmarksTree can't be used, since it' won't stop at the first level'.
1414
let root = PlacesUtils.getFolderContents(guid, false, false).root;
1415
for (let i = 0; i < root.childCount; ++i) {
1416
let node = root.getChild(i);
1417
oldOrderGuids.push(node.bookmarkGuid);
1418
if (PlacesUtils.nodeIsSeparator(node)) {
1419
if (preSepNodes.length) {
1420
preSepNodes.sort(sortingMethod);
1421
newOrderGuids.push(...preSepNodes.map(n => n.bookmarkGuid));
1422
preSepNodes = [];
1423
}
1424
newOrderGuids.push(node.bookmarkGuid);
1425
} else {
1426
preSepNodes.push(node);
1427
}
1428
}
1429
root.containerOpen = false;
1430
if (preSepNodes.length) {
1431
preSepNodes.sort(sortingMethod);
1432
newOrderGuids.push(...preSepNodes.map(n => n.bookmarkGuid));
1433
}
1434
await PlacesUtils.bookmarks.reorder(guid, newOrderGuids);
1435
1436
this.undo = async function() {
1437
await PlacesUtils.bookmarks.reorder(guid, oldOrderGuids);
1438
};
1439
this.redo = async function() {
1440
await PlacesUtils.bookmarks.reorder(guid, newOrderGuids);
1441
};
1442
},
1443
};
1444
1445
/**
1446
* Transaction for removing an item (any type).
1447
*
1448
* Required Input Properties: guids.
1449
*/
1450
PT.Remove = DefineTransaction(["guids"]);
1451
PT.Remove.prototype = {
1452
async execute({ guids }) {
1453
let removedItems = [];
1454
1455
for (let guid of guids) {
1456
try {
1457
// Although we don't strictly need to get this information for the remove,
1458
// we do need it for the possibility of undo().
1459
removedItems.push(await PlacesUtils.promiseBookmarksTree(guid));
1460
} catch (ex) {
1461
if (!ex.becauseInvalidURL) {
1462
throw new Error(`Failed to get info for the guid: ${guid}: ${ex}`);
1463
}
1464
removedItems.push({ guid });
1465
}
1466
}
1467
1468
let removeThem = async function() {
1469
if (removedItems.length) {
1470
// We have to pass just the guids as although remove() accepts full
1471
// info items, promiseBookmarksTree returns dateAdded and lastModified
1472
// as PRTime rather than date types.
1473
await PlacesUtils.bookmarks.remove(
1474
removedItems.map(info => ({ guid: info.guid }))
1475
);
1476
}
1477
};
1478
await removeThem();
1479
1480
this.undo = async function() {
1481
for (let info of removedItems) {
1482
try {
1483
await createItemsFromBookmarksTree(info, true);
1484
} catch (ex) {
1485
Cu.reportError(`Unable to undo removal of ${info.guid}`);
1486
}
1487
}
1488
};
1489
this.redo = removeThem;
1490
},
1491
};
1492
1493
/**
1494
* Transaction for tagging urls.
1495
*
1496
* Required Input Properties: urls, tags.
1497
*/
1498
PT.Tag = DefineTransaction(["urls", "tags"]);
1499
PT.Tag.prototype = {
1500
async execute({ urls, tags }) {
1501
let onUndo = [],
1502
onRedo = [];
1503
for (let url of urls) {
1504
if (!(await PlacesUtils.bookmarks.fetch({ url }))) {
1505
// Tagging is only allowed for bookmarked URIs (but see 424160).
1506
let createTxn = TransactionsHistory.getRawTransaction(
1507
PT.NewBookmark({
1508
url,
1509
tags,
1510
parentGuid: PlacesUtils.bookmarks.unfiledGuid,
1511
})
1512
);
1513
await createTxn.execute();
1514
onUndo.unshift(createTxn.undo.bind(createTxn));
1515
onRedo.push(createTxn.redo.bind(createTxn));
1516
} else {
1517
let uri = Services.io.newURI(url.href);
1518
let currentTags = PlacesUtils.tagging.getTagsForURI(uri);
1519
let newTags = tags.filter(t => !currentTags.includes(t));
1520
if (newTags.length) {
1521
PlacesUtils.tagging.tagURI(uri, newTags);
1522
onUndo.unshift(() => {
1523
PlacesUtils.tagging.untagURI(uri, newTags);
1524
});
1525
onRedo.push(() => {
1526
PlacesUtils.tagging.tagURI(uri, newTags);
1527
});
1528
}
1529
}
1530
}
1531
this.undo = async function() {
1532
for (let f of onUndo) {
1533
await f();
1534
}
1535
};
1536
this.redo = async function() {
1537
for (let f of onRedo) {
1538
await f();
1539
}
1540
};
1541
},
1542
};
1543
1544
/**
1545
* Transaction for removing tags from a URI.
1546
*
1547
* Required Input Properties: urls.
1548
* Optional Input Properties: tags.
1549
*
1550
* If |tags| is not set, all tags set for |url| are removed.
1551
*/
1552
PT.Untag = DefineTransaction(["urls"], ["tags"]);
1553
PT.Untag.prototype = {
1554
execute({ urls, tags }) {
1555
let onUndo = [],
1556
onRedo = [];
1557
for (let url of urls) {
1558
let uri = Services.io.newURI(url.href);
1559
let tagsToRemove;
1560
let tagsSet = PlacesUtils.tagging.getTagsForURI(uri);
1561
if (tags.length) {
1562
tagsToRemove = tags.filter(t => tagsSet.includes(t));
1563
} else {
1564
tagsToRemove = tagsSet;
1565
}
1566
if (tagsToRemove.length) {
1567
PlacesUtils.tagging.untagURI(uri, tagsToRemove);
1568
}
1569
onUndo.unshift(() => {
1570
if (tagsToRemove.length) {
1571
PlacesUtils.tagging.tagURI(uri, tagsToRemove);
1572
}
1573
});
1574
onRedo.push(() => {
1575
if (tagsToRemove.length) {
1576
PlacesUtils.tagging.untagURI(uri, tagsToRemove);
1577
}
1578
});
1579
}
1580
this.undo = async function() {
1581
for (let f of onUndo) {
1582
await f();
1583
}
1584
};
1585
this.redo = async function() {
1586
for (let f of onRedo) {
1587
await f();
1588
}
1589
};
1590
},
1591
};
1592
1593
/**
1594
* Transaction for renaming a tag.
1595
*
1596
* Required Input Properties: oldTag, tag.
1597
*/
1598
PT.RenameTag = DefineTransaction(["oldTag", "tag"]);
1599
PT.RenameTag.prototype = {
1600
async execute({ oldTag, tag }) {
1601
// For now this is implemented by untagging and tagging all the bookmarks.
1602
// We should create a specialized bookmarking API to just rename the tag.
1603
let onUndo = [],
1604
onRedo = [];
1605
let urls = new Set();
1606
await PlacesUtils.bookmarks.fetch({ tags: [oldTag] }, b => urls.add(b.url));
1607
if (urls.size > 0) {
1608
urls = Array.from(urls);
1609
let tagTxn = TransactionsHistory.getRawTransaction(
1610
PT.Tag({ urls, tags: [tag] })
1611
);
1612
await tagTxn.execute();
1613
onUndo.unshift(tagTxn.undo.bind(tagTxn));
1614
onRedo.push(tagTxn.redo.bind(tagTxn));
1615
let untagTxn = TransactionsHistory.getRawTransaction(
1616
PT.Untag({ urls, tags: [oldTag] })
1617
);
1618
await untagTxn.execute();
1619
onUndo.unshift(untagTxn.undo.bind(untagTxn));
1620
onRedo.push(untagTxn.redo.bind(untagTxn));
1621
1622
// Update all the place: queries that refer to this tag.
1623
let db = await PlacesUtils.promiseDBConnection();
1624
let rows = await db.executeCached(
1625
`
1626
SELECT h.url, b.guid, b.title
1627
FROM moz_places h
1628
JOIN moz_bookmarks b ON b.fk = h.id
1629
WHERE url_hash BETWEEN hash("place", "prefix_lo")
1630
AND hash("place", "prefix_hi")
1631
AND url LIKE :tagQuery
1632
`,
1633
{ tagQuery: "%tag=%" }
1634
);
1635
for (let row of rows) {
1636
let url = row.getResultByName("url");
1637
try {
1638
url = new URL(url);
1639
let urlParams = new URLSearchParams(url.pathname);
1640
let tags = urlParams.getAll("tag");
1641
if (!tags.includes(oldTag)) {
1642
continue;
1643
}
1644
if (tags.length > 1) {
1645
// URLSearchParams cannot set more than 1 same-named param.
1646
urlParams.delete("tag");
1647
urlParams.set("tag", tag);
1648
url = new URL(
1649
url.protocol +
1650
urlParams +
1651
"&tag=" +
1652
tags.filter(t => t != oldTag).join("&tag=")
1653
);
1654
} else {
1655
urlParams.set("tag", tag);
1656
url = new URL(url.protocol + urlParams);
1657
}
1658
} catch (ex) {
1659
Cu.reportError(
1660
"Invalid bookmark url: " + row.getResultByName("url") + ": " + ex
1661
);
1662
continue;
1663
}
1664
let guid = row.getResultByName("guid");
1665
let title = row.getResultByName("title");
1666
1667
let editUrlTxn = TransactionsHistory.getRawTransaction(
1668
PT.EditUrl({ guid, url })
1669
);
1670
await editUrlTxn.execute();
1671
onUndo.unshift(editUrlTxn.undo.bind(editUrlTxn));
1672
onRedo.push(editUrlTxn.redo.bind(editUrlTxn));
1673
if (title == oldTag) {
1674
let editTitleTxn = TransactionsHistory.getRawTransaction(
1675
PT.EditTitle({ guid, title: tag })
1676
);
1677
await editTitleTxn.execute();
1678
onUndo.unshift(editTitleTxn.undo.bind(editTitleTxn));
1679
onRedo.push(editTitleTxn.redo.bind(editTitleTxn));
1680
}
1681
}
1682
}
1683
this.undo = async function() {
1684
for (let f of onUndo) {
1685
await f();
1686
}
1687
};
1688
this.redo = async function() {
1689
for (let f of onRedo) {
1690
await f();
1691
}
1692
};
1693
},
1694
};
1695
1696
/**
1697
* Transaction for copying an item.
1698
*
1699
* Required Input Properties: guid, newParentGuid
1700
* Optional Input Properties: newIndex.
1701
*/
1702
PT.Copy = DefineTransaction(["guid", "newParentGuid"], ["newIndex"]);
1703
PT.Copy.prototype = {
1704
async execute({ guid, newParentGuid, newIndex }) {
1705
let creationInfo = null;
1706
try {
1707
creationInfo = await PlacesUtils.promiseBookmarksTree(guid);
1708
} catch (ex) {
1709
throw new Error(
1710
"Failed to get info for the specified item (guid: " +
1711
guid +
1712
"). Ex: " +
1713
ex
1714
);
1715
}
1716
creationInfo.parentGuid = newParentGuid;
1717
creationInfo.index = newIndex;
1718
1719
let newItemGuid = await createItemsFromBookmarksTree(creationInfo, false);
1720
let newItemInfo = null;
1721
this.undo = async function() {
1722
if (!newItemInfo) {
1723
newItemInfo = await PlacesUtils.promiseBookmarksTree(newItemGuid);
1724
}
1725
await PlacesUtils.bookmarks.remove(newItemGuid);
1726
};
1727
this.redo = async function() {
1728
await createItemsFromBookmarksTree(newItemInfo, true);
1729
};
1730
1731
return newItemGuid;
1732
},
1733
};