-
Notifications
You must be signed in to change notification settings - Fork 54
/
PlacesSyncUtils.jsm
2374 lines (2154 loc) · 86 KB
/
PlacesSyncUtils.jsm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* 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/. */
"use strict";
var EXPORTED_SYMBOLS = ["PlacesSyncUtils"];
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "URLSearchParams"]);
ChromeUtils.defineModuleGetter(this, "Log",
"resource://gre/modules/Log.jsm");
ChromeUtils.defineModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
/**
* This module exports functions for Sync to use when applying remote
* records. The calls are similar to those in `Bookmarks.jsm` and
* `nsINavBookmarksService`, with special handling for
* tags, keywords, synced annotations, and missing parents.
*/
var PlacesSyncUtils = {
/**
* Auxiliary generator function that yields an array in chunks
*
* @param array
* @param chunkLength
* @yields {Array}
* New Array with the next chunkLength elements of array.
* If the array has less than chunkLength elements, yields all of them
*/
* chunkArray(array, chunkLength) {
if (!array.length || chunkLength <= 0) {
return;
}
let startIndex = 0;
while (startIndex < array.length) {
yield array.slice(startIndex, startIndex + chunkLength);
startIndex += chunkLength;
}
},
};
const { SOURCE_SYNC } = Ci.nsINavBookmarksService;
const MICROSECONDS_PER_SECOND = 1000000;
const SQLITE_MAX_VARIABLE_NUMBER = 999;
const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks";
// These are defined as lazy getters to defer initializing the bookmarks
// service until it's needed.
XPCOMUtils.defineLazyGetter(this, "ROOT_RECORD_ID_TO_GUID", () => ({
menu: PlacesUtils.bookmarks.menuGuid,
places: PlacesUtils.bookmarks.rootGuid,
tags: PlacesUtils.bookmarks.tagsGuid,
toolbar: PlacesUtils.bookmarks.toolbarGuid,
unfiled: PlacesUtils.bookmarks.unfiledGuid,
mobile: PlacesUtils.bookmarks.mobileGuid,
}));
XPCOMUtils.defineLazyGetter(this, "ROOT_GUID_TO_RECORD_ID", () => ({
[PlacesUtils.bookmarks.menuGuid]: "menu",
[PlacesUtils.bookmarks.rootGuid]: "places",
[PlacesUtils.bookmarks.tagsGuid]: "tags",
[PlacesUtils.bookmarks.toolbarGuid]: "toolbar",
[PlacesUtils.bookmarks.unfiledGuid]: "unfiled",
[PlacesUtils.bookmarks.mobileGuid]: "mobile",
}));
XPCOMUtils.defineLazyGetter(this, "ROOTS", () =>
Object.keys(ROOT_RECORD_ID_TO_GUID)
);
const HistorySyncUtils = PlacesSyncUtils.history = Object.freeze({
SYNC_ID_META_KEY: "sync/history/syncId",
LAST_SYNC_META_KEY: "sync/history/lastSync",
/**
* Returns the current history sync ID, or `""` if one isn't set.
*/
getSyncId() {
return PlacesUtils.metadata.get(
HistorySyncUtils.SYNC_ID_META_KEY, "");
},
/**
* Assigns a new sync ID. This is called when we sync for the first time with
* a new account, and when we're the first to sync after a node reassignment.
*
* @return {Promise} resolved once the ID has been updated.
* @resolves to the new sync ID.
*/
resetSyncId() {
return PlacesUtils.withConnectionWrapper(
"HistorySyncUtils: resetSyncId",
function(db) {
let newSyncId = PlacesUtils.history.makeGuid();
return db.executeTransaction(async function() {
await setHistorySyncId(db, newSyncId);
return newSyncId;
});
}
);
},
/**
* Ensures that the existing local sync ID, if any, is up-to-date with the
* server. This is called when we sync with an existing account.
*
* @param newSyncId
* The server's sync ID.
* @return {Promise} resolved once the ID has been updated.
*/
async ensureCurrentSyncId(newSyncId) {
if (!newSyncId || typeof newSyncId != "string") {
throw new TypeError("Invalid new history sync ID");
}
await PlacesUtils.withConnectionWrapper(
"HistorySyncUtils: ensureCurrentSyncId",
async function(db) {
let existingSyncId = await PlacesUtils.metadata.getWithConnection(
db, HistorySyncUtils.SYNC_ID_META_KEY, "");
if (existingSyncId == newSyncId) {
HistorySyncLog.trace("History sync ID up-to-date",
{ existingSyncId });
return;
}
HistorySyncLog.info("History sync ID changed; resetting metadata",
{ existingSyncId, newSyncId });
await db.executeTransaction(function() {
return setHistorySyncId(db, newSyncId);
});
}
);
},
/**
* Returns the last sync time, in seconds, for the history collection, or 0
* if history has never synced before.
*/
async getLastSync() {
let lastSync = await PlacesUtils.metadata.get(
HistorySyncUtils.LAST_SYNC_META_KEY, 0);
return lastSync / 1000;
},
/**
* Updates the history collection last sync time.
*
* @param lastSyncSeconds
* The collection last sync time, in seconds, as a number or string.
*/
async setLastSync(lastSyncSeconds) {
let lastSync = Math.floor(lastSyncSeconds * 1000);
if (!Number.isInteger(lastSync)) {
throw new TypeError("Invalid history last sync timestamp");
}
await PlacesUtils.metadata.set(HistorySyncUtils.LAST_SYNC_META_KEY,
lastSync);
},
/**
* Removes all history visits and pages from the database. Sync calls this
* method when it receives a command from a remote client to wipe all stored
* data.
*
* @return {Promise} resolved once all pages and visits have been removed.
*/
async wipe() {
await PlacesUtils.history.clear();
await HistorySyncUtils.reset();
},
/**
* Removes the sync ID and last sync time for the history collection. Unlike
* `wipe`, this keeps all existing history pages and visits.
*
* @return {Promise} resolved once the metadata have been removed.
*/
reset() {
return PlacesUtils.metadata.delete(HistorySyncUtils.SYNC_ID_META_KEY,
HistorySyncUtils.LAST_SYNC_META_KEY);
},
/**
* Clamps a history visit date between the current date and the earliest
* sensible date.
*
* @param {Date} visitDate
* The visit date.
* @return {Date} The clamped visit date.
*/
clampVisitDate(visitDate) {
let currentDate = new Date();
if (visitDate > currentDate) {
return currentDate;
}
if (visitDate < BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) {
return new Date(BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP);
}
return visitDate;
},
/**
* Fetches the frecency for the URL provided
*
* @param url
* @returns {Number} The frecency of the given url
*/
async fetchURLFrecency(url) {
let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.executeCached(`
SELECT frecency
FROM moz_places
WHERE url_hash = hash(:url) AND url = :url
LIMIT 1`,
{ url: canonicalURL.href }
);
return rows.length ? rows[0].getResultByName("frecency") : -1;
},
/**
* Filters syncable places from a collection of places guids.
*
* @param guids
*
* @returns {Array} new Array with the guids that aren't syncable
*/
async determineNonSyncableGuids(guids) {
// Filter out hidden pages and `TRANSITION_FRAMED_LINK` visits. These are
// excluded when rendering the history menu, so we use the same constraints
// for Sync. We also don't want to sync `TRANSITION_EMBED` visits, but those
// aren't stored in the database.
let db = await PlacesUtils.promiseDBConnection();
let nonSyncableGuids = [];
for (let chunk of PlacesSyncUtils.chunkArray(guids, SQLITE_MAX_VARIABLE_NUMBER)) {
let rows = await db.execute(`
SELECT DISTINCT p.guid FROM moz_places p
JOIN moz_historyvisits v ON p.id = v.place_id
WHERE p.guid IN (${new Array(chunk.length).fill("?").join(",")}) AND
(p.hidden = 1 OR v.visit_type IN (0,
${PlacesUtils.history.TRANSITION_FRAMED_LINK}))
`, chunk);
nonSyncableGuids = nonSyncableGuids.concat(rows.map(row => row.getResultByName("guid")));
}
return nonSyncableGuids;
},
/**
* Change the guid of the given uri
*
* @param uri
* @param guid
*/
changeGuid(uri, guid) {
let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(uri);
let validatedGuid = PlacesUtils.BOOKMARK_VALIDATORS.guid(guid);
return PlacesUtils.withConnectionWrapper("PlacesSyncUtils.history: changeGuid",
async function(db) {
await db.executeCached(`
UPDATE moz_places
SET guid = :guid
WHERE url_hash = hash(:page_url) AND url = :page_url`,
{guid: validatedGuid, page_url: canonicalURL.href});
});
},
/**
* Fetch the last 20 visits (date and type of it) corresponding to a given url
*
* @param url
* @returns {Array} Each element of the Array is an object with members: date and type
*/
async fetchVisitsForURL(url) {
let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.executeCached(`
SELECT visit_type type, visit_date date
FROM moz_historyvisits
JOIN moz_places h ON h.id = place_id
WHERE url_hash = hash(:url) AND url = :url
ORDER BY date DESC LIMIT 20`, { url: canonicalURL.href }
);
return rows.map(row => {
let visitDate = row.getResultByName("date");
let visitType = row.getResultByName("type");
return { date: visitDate, type: visitType };
});
},
/**
* Fetches the guid of a uri
*
* @param uri
* @returns {String} The guid of the given uri
*/
async fetchGuidForURL(url) {
let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.executeCached(`
SELECT guid
FROM moz_places
WHERE url_hash = hash(:page_url) AND url = :page_url`,
{ page_url: canonicalURL.href }
);
if (rows.length == 0) {
return null;
}
return rows[0].getResultByName("guid");
},
/**
* Fetch information about a guid (url, title and frecency)
*
* @param guid
* @returns {Object} Object with three members: url, title and frecency of the given guid
*/
async fetchURLInfoForGuid(guid) {
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.executeCached(`
SELECT url, IFNULL(title, "") AS title, frecency
FROM moz_places
WHERE guid = :guid`,
{ guid }
);
if (rows.length === 0) {
return null;
}
return {
url: rows[0].getResultByName("url"),
title: rows[0].getResultByName("title"),
frecency: rows[0].getResultByName("frecency"),
};
},
/**
* Get all URLs filtered by the limit and since members of the options object.
*
* @param options
* Options object with two members, since and limit. Both of them must be provided
* @returns {Array} - Up to limit number of URLs starting from the date provided by since
*/
async getAllURLs(options) {
// Check that the limit property is finite number.
if (!Number.isFinite(options.limit)) {
throw new Error("The number provided in options.limit is not finite.");
}
// Check that the since property is of type Date.
if (!options.since || Object.prototype.toString.call(options.since) != "[object Date]") {
throw new Error("The property since of the options object must be of type Date.");
}
let db = await PlacesUtils.promiseDBConnection();
let sinceInMicroseconds = PlacesUtils.toPRTime(options.since);
let rows = await db.executeCached(`
SELECT DISTINCT p.url
FROM moz_places p
JOIN moz_historyvisits v ON p.id = v.place_id
WHERE p.last_visit_date > :cutoff_date AND
p.hidden = 0 AND
v.visit_type NOT IN (0,
${PlacesUtils.history.TRANSITION_FRAMED_LINK})
ORDER BY frecency DESC
LIMIT :max_results`,
{ cutoff_date: sinceInMicroseconds, max_results: options.limit }
);
return rows.map(row => row.getResultByName("url"));
},
});
const BookmarkSyncUtils = PlacesSyncUtils.bookmarks = Object.freeze({
SYNC_PARENT_ANNO: "sync/parent",
SYNC_ID_META_KEY: "sync/bookmarks/syncId",
LAST_SYNC_META_KEY: "sync/bookmarks/lastSync",
WIPE_REMOTE_META_KEY: "sync/bookmarks/wipeRemote",
// Jan 23, 1993 in milliseconds since 1970. Corresponds roughly to the release
// of the original NCSA Mosiac. We can safely assume that any dates before
// this time are invalid.
EARLIEST_BOOKMARK_TIMESTAMP: Date.UTC(1993, 0, 23),
KINDS: {
BOOKMARK: "bookmark",
QUERY: "query",
FOLDER: "folder",
LIVEMARK: "livemark",
SEPARATOR: "separator",
},
get ROOTS() {
return ROOTS;
},
/**
* Returns the current bookmarks sync ID, or `""` if one isn't set.
*/
getSyncId() {
return PlacesUtils.metadata.get(
BookmarkSyncUtils.SYNC_ID_META_KEY, "");
},
/**
* Indicates if the bookmarks engine should erase all bookmarks on the server
* and all other clients, because the user manually restored their bookmarks
* from a backup on this client.
*/
async shouldWipeRemote() {
let shouldWipeRemote = await PlacesUtils.metadata.get(
BookmarkSyncUtils.WIPE_REMOTE_META_KEY, false);
return !!shouldWipeRemote;
},
/**
* Assigns a new sync ID, bumps the change counter, and flags all items as
* "NEW" for upload. This is called when we sync for the first time with a
* new account, when we're the first to sync after a node reassignment, and
* on the first sync after a manual restore.
*
* @return {Promise} resolved once the ID and all items have been updated.
* @resolves to the new sync ID.
*/
resetSyncId() {
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: resetSyncId",
function(db) {
let newSyncId = PlacesUtils.history.makeGuid();
return db.executeTransaction(async function() {
await setBookmarksSyncId(db, newSyncId);
await resetAllSyncStatuses(db,
PlacesUtils.bookmarks.SYNC_STATUS.NEW);
return newSyncId;
});
}
);
},
/**
* Ensures that the existing local sync ID, if any, is up-to-date with the
* server. This is called when we sync with an existing account.
*
* We always take the server's sync ID. If we don't have an existing ID,
* we're either syncing for the first time with an existing account, or Places
* has automatically restored from a backup. If the sync IDs don't match,
* we're likely syncing after a node reassignment, where another client
* uploaded their bookmarks first.
*
* @param newSyncId
* The server's sync ID.
* @return {Promise} resolved once the ID and all items have been updated.
*/
async ensureCurrentSyncId(newSyncId) {
if (!newSyncId || typeof newSyncId != "string") {
throw new TypeError("Invalid new bookmarks sync ID");
}
await PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: ensureCurrentSyncId",
async function(db) {
let existingSyncId = await PlacesUtils.metadata.getWithConnection(
db, BookmarkSyncUtils.SYNC_ID_META_KEY, "");
// If we don't have a sync ID, take the server's without resetting
// sync statuses.
if (!existingSyncId) {
BookmarkSyncLog.info("Taking new bookmarks sync ID", { newSyncId });
await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId));
return;
}
// If the existing sync ID matches the server, great!
if (existingSyncId == newSyncId) {
BookmarkSyncLog.trace("Bookmarks sync ID up-to-date",
{ existingSyncId });
return;
}
// Otherwise, we have a sync ID, but it doesn't match, so we were likely
// node reassigned. Take the server's sync ID and reset all items to
// "UNKNOWN" so that we can merge.
BookmarkSyncLog.info("Bookmarks sync ID changed; resetting sync " +
"statuses", { existingSyncId, newSyncId });
await db.executeTransaction(async function() {
await setBookmarksSyncId(db, newSyncId);
await resetAllSyncStatuses(db,
PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN);
});
}
);
},
/**
* Returns the last sync time, in seconds, for the bookmarks collection, or 0
* if bookmarks have never synced before.
*/
async getLastSync() {
let lastSync = await PlacesUtils.metadata.get(
BookmarkSyncUtils.LAST_SYNC_META_KEY, 0);
return lastSync / 1000;
},
/**
* Updates the bookmarks collection last sync time.
*
* @param lastSyncSeconds
* The collection last sync time, in seconds, as a number or string.
*/
async setLastSync(lastSyncSeconds) {
let lastSync = Math.floor(lastSyncSeconds * 1000);
if (!Number.isInteger(lastSync)) {
throw new TypeError("Invalid bookmarks last sync timestamp");
}
await PlacesUtils.metadata.set(BookmarkSyncUtils.LAST_SYNC_META_KEY,
lastSync);
},
/**
* Resets Sync metadata for bookmarks in Places. This function behaves
* differently depending on the change source, and may be called from
* `PlacesSyncUtils.bookmarks.reset` or
* `PlacesUtils.bookmarks.eraseEverything`.
*
* - RESTORE: The user is restoring from a backup. Drop the sync ID, last
* sync time, and tombstones; reset sync statuses for remaining items to
* "NEW"; then set a flag to wipe the server and all other clients. On the
* next sync, we'll replace their bookmarks with ours.
*
* - RESTORE_ON_STARTUP: Places is automatically restoring from a backup to
* recover from a corrupt database. The sync ID, last sync time, and
* tombstones don't exist, since we don't back them up; reset sync statuses
* for the roots to "UNKNOWN"; but don't wipe the server. On the next sync,
* we'll merge the restored bookmarks with the ones on the server.
*
* - SYNC: Either another client told us to erase our bookmarks
* (`PlacesSyncUtils.bookmarks.wipe`), or the user disconnected Sync
* (`PlacesSyncUtils.bookmarks.reset`). In both cases, drop the existing
* sync ID, last sync time, and tombstones; reset sync statuses for
* remaining items to "NEW"; and don't wipe the server.
*
* @param db
* the Sqlite.jsm connection handle.
* @param source
* the change source constant.
*/
async resetSyncMetadata(db, source) {
if (![ PlacesUtils.bookmarks.SOURCES.RESTORE,
PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
PlacesUtils.bookmarks.SOURCES.SYNC ].includes(source)) {
return;
}
// Remove the sync ID and last sync time in all cases.
await PlacesUtils.metadata.deleteWithConnection(db,
BookmarkSyncUtils.SYNC_ID_META_KEY,
BookmarkSyncUtils.LAST_SYNC_META_KEY);
// If we're manually restoring from a backup, wipe the server and other
// clients, so that we replace their bookmarks with the restored tree. If
// we're automatically restoring to recover from a corrupt database, don't
// wipe; we want to merge the restored tree with the one on the server.
await PlacesUtils.metadata.setWithConnection(db,
BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
source == PlacesUtils.bookmarks.SOURCES.RESTORE);
// Reset change counters and sync statuses for roots and remaining
// items, and drop tombstones.
let syncStatus =
source == PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ?
PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN :
PlacesUtils.bookmarks.SYNC_STATUS.NEW;
await resetAllSyncStatuses(db, syncStatus);
},
/**
* Converts a Places GUID to a Sync record ID. Record IDs are identical to
* Places GUIDs for all items except roots.
*/
guidToRecordId(guid) {
return ROOT_GUID_TO_RECORD_ID[guid] || guid;
},
/**
* Converts a Sync record ID to a Places GUID.
*/
recordIdToGuid(recordId) {
return ROOT_RECORD_ID_TO_GUID[recordId] || recordId;
},
/**
* Fetches the record IDs for a folder's children, ordered by their position
* within the folder.
*/
fetchChildRecordIds(parentRecordId) {
PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: fetchChildRecordIds", async function(db) {
let childGuids = await fetchChildGuids(db, parentGuid);
return childGuids.map(guid =>
BookmarkSyncUtils.guidToRecordId(guid)
);
}
);
},
/**
* Returns an array of `{ recordId, syncable }` tuples for all items in
* `requestedRecordIds`. If any requested ID is a folder, all its descendants
* will be included. Ancestors of non-syncable items are not included; if
* any are missing on the server, the requesting client will need to make
* another repair request.
*
* Sync calls this method to respond to incoming bookmark repair requests
* and upload items that are missing on the server.
*/
fetchRecordIdsForRepair(requestedRecordIds) {
let requestedGuids = requestedRecordIds.map(BookmarkSyncUtils.recordIdToGuid);
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: fetchRecordIdsForRepair", async function(db) {
let rows = await db.executeCached(`
WITH RECURSIVE
syncedItems(id) AS (
SELECT b.id FROM moz_bookmarks b
WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
'mobile______')
UNION ALL
SELECT b.id FROM moz_bookmarks b
JOIN syncedItems s ON b.parent = s.id
),
descendants(id) AS (
SELECT b.id FROM moz_bookmarks b
WHERE b.guid IN (${requestedGuids.map(guid => JSON.stringify(guid)).join(",")})
UNION ALL
SELECT b.id FROM moz_bookmarks b
JOIN descendants d ON d.id = b.parent
)
SELECT b.guid, s.id NOT NULL AS syncable
FROM descendants d
JOIN moz_bookmarks b ON b.id = d.id
LEFT JOIN syncedItems s ON s.id = d.id
`);
return rows.map(row => {
let recordId = BookmarkSyncUtils.guidToRecordId(row.getResultByName("guid"));
let syncable = !!row.getResultByName("syncable");
return { recordId, syncable };
});
}
);
},
/**
* Migrates an array of `{ recordId, modified }` tuples from the old JSON-based
* tracker to the new sync change counter. `modified` is when the change was
* added to the old tracker, in milliseconds.
*
* Sync calls this method before the first bookmark sync after the Places
* schema migration.
*/
migrateOldTrackerEntries(entries) {
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: migrateOldTrackerEntries", function(db) {
return db.executeTransaction(async function() {
// Mark all existing bookmarks as synced, and clear their change
// counters to avoid a full upload on the next sync. Note that
// this means we'll miss changes made between startup and the first
// post-migration sync, as well as changes made on a new release
// channel that weren't synced before the user downgraded. This is
// unfortunate, but no worse than the behavior of the old tracker.
//
// We also likely have bookmarks that don't exist on the server,
// because the old tracker missed them. We'll eventually fix the
// server once we decide on a repair strategy.
await db.executeCached(`
WITH RECURSIVE
syncedItems(id) AS (
SELECT b.id FROM moz_bookmarks b
WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
'mobile______')
UNION ALL
SELECT b.id FROM moz_bookmarks b
JOIN syncedItems s ON b.parent = s.id
)
UPDATE moz_bookmarks SET
syncStatus = :syncStatus,
syncChangeCounter = 0
WHERE id IN syncedItems`,
{ syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL });
await db.executeCached(`DELETE FROM moz_bookmarks_deleted`);
await db.executeCached(`CREATE TEMP TABLE moz_bookmarks_tracked (
guid TEXT PRIMARY KEY,
time INTEGER
)`);
try {
for (let { recordId, modified } of entries) {
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
if (!PlacesUtils.isValidGuid(guid)) {
BookmarkSyncLog.warn(`migrateOldTrackerEntries: Ignoring ` +
`change for invalid item ${guid}`);
continue;
}
let time = PlacesUtils.toPRTime(Number.isFinite(modified) ?
modified : Date.now());
await db.executeCached(`
INSERT OR IGNORE INTO moz_bookmarks_tracked (guid, time)
VALUES (:guid, :time)`,
{ guid, time });
}
// Bump the change counter for existing tracked items.
await db.executeCached(`
INSERT OR REPLACE INTO moz_bookmarks (id, fk, type, parent,
position, title,
dateAdded, lastModified,
guid, syncChangeCounter,
syncStatus)
SELECT b.id, b.fk, b.type, b.parent, b.position, b.title,
b.dateAdded, MAX(b.lastModified, t.time), b.guid,
b.syncChangeCounter + 1, b.syncStatus
FROM moz_bookmarks b
JOIN moz_bookmarks_tracked t ON b.guid = t.guid`);
// Insert tombstones for nonexistent tracked items, using the most
// recent deletion date for more accurate reconciliation. We assume
// the tracked item belongs to a synced root.
await db.executeCached(`
INSERT OR REPLACE INTO moz_bookmarks_deleted (guid, dateRemoved)
SELECT t.guid, MAX(IFNULL((SELECT dateRemoved FROM moz_bookmarks_deleted
WHERE guid = t.guid), 0), t.time)
FROM moz_bookmarks_tracked t
LEFT JOIN moz_bookmarks b ON t.guid = b.guid
WHERE b.guid IS NULL`);
} finally {
await db.executeCached(`DROP TABLE moz_bookmarks_tracked`);
}
});
}
);
},
/**
* Reorders a folder's children, based on their order in the array of sync
* IDs.
*
* Sync uses this method to reorder all synced children after applying all
* incoming records.
*
* @return {Promise} resolved when reordering is complete.
* @rejects if an error happens while reordering.
* @throws if the arguments are invalid.
*/
order(parentRecordId, childRecordIds) {
PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
if (!childRecordIds.length) {
return undefined;
}
let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
if (parentGuid == PlacesUtils.bookmarks.rootGuid) {
// Reordering roots doesn't make sense, but Sync will do this on the
// first sync.
return undefined;
}
let orderedChildrenGuids = childRecordIds.map(BookmarkSyncUtils.recordIdToGuid);
return PlacesUtils.bookmarks.reorder(parentGuid, orderedChildrenGuids,
{ source: SOURCE_SYNC });
},
/**
* Resolves to true if there are known sync changes.
*/
havePendingChanges() {
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: havePendingChanges", async function(db) {
let rows = await db.executeCached(`
WITH RECURSIVE
syncedItems(id, guid, syncChangeCounter) AS (
SELECT b.id, b.guid, b.syncChangeCounter
FROM moz_bookmarks b
WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
'mobile______')
UNION ALL
SELECT b.id, b.guid, b.syncChangeCounter
FROM moz_bookmarks b
JOIN syncedItems s ON b.parent = s.id
),
changedItems(guid) AS (
SELECT guid FROM syncedItems
WHERE syncChangeCounter >= 1
UNION ALL
SELECT guid FROM moz_bookmarks_deleted
)
SELECT EXISTS(SELECT guid FROM changedItems) AS haveChanges`);
return !!rows[0].getResultByName("haveChanges");
}
);
},
/**
* Returns a changeset containing local bookmark changes since the last sync.
*
* @return {Promise} resolved once all items have been fetched.
* @resolves to an object containing records for changed bookmarks, keyed by
* the record ID.
* @see pullSyncChanges for the implementation, and markChangesAsSyncing for
* an explanation of why we update the sync status.
*/
pullChanges() {
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: pullChanges", pullSyncChanges);
},
/**
* Updates the sync status of all "NEW" bookmarks to "NORMAL", so that Sync
* can recover correctly after an interrupted sync.
*
* @param changeRecords
* A changeset containing sync change records, as returned by
* `pullChanges`.
* @return {Promise} resolved once all records have been updated.
*/
markChangesAsSyncing(changeRecords) {
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: markChangesAsSyncing",
db => markChangesAsSyncing(db, changeRecords)
);
},
/**
* Decrements the sync change counter, updates the sync status, and cleans up
* tombstones for successfully synced items. Sync calls this method at the
* end of each bookmark sync.
*
* @param changeRecords
* A changeset containing sync change records, as returned by
* `pullChanges`.
* @return {Promise} resolved once all records have been updated.
*/
pushChanges(changeRecords) {
return PlacesUtils.withConnectionWrapper(
"BookmarkSyncUtils: pushChanges", async function(db) {
let skippedCount = 0;
let weakCount = 0;
let updateParams = [];
let tombstoneGuidsToRemove = [];
for (let recordId in changeRecords) {
// Validate change records to catch coding errors.
let changeRecord = validateChangeRecord(
"BookmarkSyncUtils: pushChanges",
changeRecords[recordId], {
tombstone: { required: true },
counter: { required: true },
synced: { required: true },
}
);
// Skip weakly uploaded records.
if (!changeRecord.counter) {
weakCount++;
continue;
}
// Sync sets the `synced` flag for reconciled or successfully
// uploaded items. If upload failed, ignore the change; we'll
// try again on the next sync.
if (!changeRecord.synced) {
skippedCount++;
continue;
}
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
if (changeRecord.tombstone) {
tombstoneGuidsToRemove.push(guid);
} else {
updateParams.push({
guid,
syncChangeDelta: changeRecord.counter,
syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
});
}
}
// Reduce the change counter and update the sync status for
// reconciled and uploaded items. If the bookmark was updated
// during the sync, its change counter will still be > 0 for the
// next sync.
if (updateParams.length || tombstoneGuidsToRemove.length) {
await db.executeTransaction(async function() {
if (updateParams.length) {
await db.executeCached(`
UPDATE moz_bookmarks
SET syncChangeCounter = MAX(syncChangeCounter - :syncChangeDelta, 0),
syncStatus = :syncStatus
WHERE guid = :guid`,
updateParams);
// and if there are *both* bookmarks and tombstones for these
// items, we nuke the tombstones.
// This should be unlikely, but bad if it happens.
let dupedGuids = updateParams.map(({ guid }) => guid);
await removeUndeletedTombstones(db, dupedGuids);
}
await removeTombstones(db, tombstoneGuidsToRemove);
});
}
BookmarkSyncLog.debug(`pushChanges: Processed change records`,
{ weak: weakCount,
skipped: skippedCount,
updated: updateParams.length });
}
);
},
/**
* Removes items from the database. Sync buffers incoming tombstones, and
* calls this method to apply them at the end of each sync. Deletion
* happens in three steps:
*
* 1. Remove all non-folder items. Deleting a folder on a remote client
* uploads tombstones for the folder and its children at the time of
* deletion. This preserves any new children we've added locally since
* the last sync.
* 2. Reparent remaining children to the tombstoned folder's parent. This
* bumps the change counter for the children and their new parent.
* 3. Remove the tombstoned folder. Because we don't do this in a
* transaction, the user might move new items into the folder before we
* can remove it. In that case, we keep the folder and upload the new
* subtree to the server.
*
* See the comment above `BookmarksStore::deletePending` for the details on
* why delete works the way it does.
*/
remove(recordIds) {
if (!recordIds.length) {
return null;
}
return PlacesUtils.withConnectionWrapper("BookmarkSyncUtils: remove",
async function(db) {
let folderGuids = [];
for (let recordId of recordIds) {
if (recordId in ROOT_RECORD_ID_TO_GUID) {
BookmarkSyncLog.warn(`remove: Refusing to remove root ${recordId}`);
continue;
}
let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid);
if (!bookmarkItem) {
BookmarkSyncLog.trace(`remove: Item ${guid} already removed`);
continue;
}
let kind = await getKindForItem(db, bookmarkItem);
if (kind == BookmarkSyncUtils.KINDS.FOLDER) {
folderGuids.push(bookmarkItem.guid);
continue;
}
let wasRemoved = await deleteSyncedAtom(bookmarkItem);
if (wasRemoved) {
BookmarkSyncLog.trace(`remove: Removed item ${guid} with ` +
`kind ${kind}`);
}
}
for (let guid of folderGuids) {
let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid);
if (!bookmarkItem) {
BookmarkSyncLog.trace(`remove: Folder ${guid} already removed`);
continue;
}
let wasRemoved = await deleteSyncedFolder(db, bookmarkItem);
if (wasRemoved) {
BookmarkSyncLog.trace(`remove: Removed folder ${bookmarkItem.guid}`);
}
}
// TODO (Bug 1313890): Refactor the bookmarks engine to pull change records
// before uploading, instead of returning records to merge into the engine's
// initial changeset.
return pullSyncChanges(db);
}
);
},
/**
* Increments the change counter of a non-folder item and its parent. Sync
* calls this method to override a remote deletion for an item that's changed
* locally.
*
* @param recordId
* The record ID to revive.