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 7var EXPORTED_SYMBOLS = ["PlacesSyncUtils"]; 8 9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 10const { XPCOMUtils } = ChromeUtils.import( 11 "resource://gre/modules/XPCOMUtils.jsm" 12); 13 14XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "URLSearchParams"]); 15 16ChromeUtils.defineModuleGetter(this, "Log", "resource://gre/modules/Log.jsm"); 17ChromeUtils.defineModuleGetter( 18 this, 19 "PlacesUtils", 20 "resource://gre/modules/PlacesUtils.jsm" 21); 22 23/** 24 * This module exports functions for Sync to use when applying remote 25 * records. The calls are similar to those in `Bookmarks.jsm` and 26 * `nsINavBookmarksService`, with special handling for 27 * tags, keywords, synced annotations, and missing parents. 28 */ 29var PlacesSyncUtils = {}; 30 31const { SOURCE_SYNC } = Ci.nsINavBookmarksService; 32 33const MICROSECONDS_PER_SECOND = 1000000; 34 35const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks"; 36 37// These are defined as lazy getters to defer initializing the bookmarks 38// service until it's needed. 39XPCOMUtils.defineLazyGetter(this, "ROOT_RECORD_ID_TO_GUID", () => ({ 40 menu: PlacesUtils.bookmarks.menuGuid, 41 places: PlacesUtils.bookmarks.rootGuid, 42 tags: PlacesUtils.bookmarks.tagsGuid, 43 toolbar: PlacesUtils.bookmarks.toolbarGuid, 44 unfiled: PlacesUtils.bookmarks.unfiledGuid, 45 mobile: PlacesUtils.bookmarks.mobileGuid, 46})); 47 48XPCOMUtils.defineLazyGetter(this, "ROOT_GUID_TO_RECORD_ID", () => ({ 49 [PlacesUtils.bookmarks.menuGuid]: "menu", 50 [PlacesUtils.bookmarks.rootGuid]: "places", 51 [PlacesUtils.bookmarks.tagsGuid]: "tags", 52 [PlacesUtils.bookmarks.toolbarGuid]: "toolbar", 53 [PlacesUtils.bookmarks.unfiledGuid]: "unfiled", 54 [PlacesUtils.bookmarks.mobileGuid]: "mobile", 55})); 56 57XPCOMUtils.defineLazyGetter(this, "ROOTS", () => 58 Object.keys(ROOT_RECORD_ID_TO_GUID) 59); 60 61// Gets the history transition values we ignore and do not sync, as a 62// string, which is a comma-separated set of values - ie, something which can 63// be used with sqlite's IN operator. Does *not* includes the parens. 64XPCOMUtils.defineLazyGetter(this, "IGNORED_TRANSITIONS_AS_SQL_LIST", () => 65 // * We don't sync `TRANSITION_FRAMED_LINK` visits - these are excluded when 66 // rendering the history menu, so we use the same constraints for Sync. 67 // * We don't sync `TRANSITION_DOWNLOAD` because it makes no sense to see 68 // these on other devices - the downloaded file can not exist. 69 // * We don't want to sync TRANSITION_EMBED visits, but these aren't 70 // stored in the DB, so no need to specify them. 71 // * 0 is invalid, and hopefully don't exist, but let's exclude it anyway. 72 // Array.toString() semantics are well defined and exactly what we need, so.. 73 [ 74 0, 75 PlacesUtils.history.TRANSITION_FRAMED_LINK, 76 PlacesUtils.history.TRANSITION_DOWNLOAD, 77 ].toString() 78); 79 80const HistorySyncUtils = (PlacesSyncUtils.history = Object.freeze({ 81 SYNC_ID_META_KEY: "sync/history/syncId", 82 LAST_SYNC_META_KEY: "sync/history/lastSync", 83 84 /** 85 * Returns the current history sync ID, or `""` if one isn't set. 86 */ 87 getSyncId() { 88 return PlacesUtils.metadata.get(HistorySyncUtils.SYNC_ID_META_KEY, ""); 89 }, 90 91 /** 92 * Assigns a new sync ID. This is called when we sync for the first time with 93 * a new account, and when we're the first to sync after a node reassignment. 94 * 95 * @return {Promise} resolved once the ID has been updated. 96 * @resolves to the new sync ID. 97 */ 98 resetSyncId() { 99 return PlacesUtils.withConnectionWrapper( 100 "HistorySyncUtils: resetSyncId", 101 function(db) { 102 let newSyncId = PlacesUtils.history.makeGuid(); 103 return db.executeTransaction(async function() { 104 await setHistorySyncId(db, newSyncId); 105 return newSyncId; 106 }); 107 } 108 ); 109 }, 110 111 /** 112 * Ensures that the existing local sync ID, if any, is up-to-date with the 113 * server. This is called when we sync with an existing account. 114 * 115 * @param newSyncId 116 * The server's sync ID. 117 * @return {Promise} resolved once the ID has been updated. 118 */ 119 async ensureCurrentSyncId(newSyncId) { 120 if (!newSyncId || typeof newSyncId != "string") { 121 throw new TypeError("Invalid new history sync ID"); 122 } 123 await PlacesUtils.withConnectionWrapper( 124 "HistorySyncUtils: ensureCurrentSyncId", 125 async function(db) { 126 let existingSyncId = await PlacesUtils.metadata.getWithConnection( 127 db, 128 HistorySyncUtils.SYNC_ID_META_KEY, 129 "" 130 ); 131 132 if (existingSyncId == newSyncId) { 133 HistorySyncLog.trace("History sync ID up-to-date", { 134 existingSyncId, 135 }); 136 return; 137 } 138 139 HistorySyncLog.info("History sync ID changed; resetting metadata", { 140 existingSyncId, 141 newSyncId, 142 }); 143 await db.executeTransaction(function() { 144 return setHistorySyncId(db, newSyncId); 145 }); 146 } 147 ); 148 }, 149 150 /** 151 * Returns the last sync time, in seconds, for the history collection, or 0 152 * if history has never synced before. 153 */ 154 async getLastSync() { 155 let lastSync = await PlacesUtils.metadata.get( 156 HistorySyncUtils.LAST_SYNC_META_KEY, 157 0 158 ); 159 return lastSync / 1000; 160 }, 161 162 /** 163 * Updates the history collection last sync time. 164 * 165 * @param lastSyncSeconds 166 * The collection last sync time, in seconds, as a number or string. 167 */ 168 async setLastSync(lastSyncSeconds) { 169 let lastSync = Math.floor(lastSyncSeconds * 1000); 170 if (!Number.isInteger(lastSync)) { 171 throw new TypeError("Invalid history last sync timestamp"); 172 } 173 await PlacesUtils.metadata.set( 174 HistorySyncUtils.LAST_SYNC_META_KEY, 175 lastSync 176 ); 177 }, 178 179 /** 180 * Removes all history visits and pages from the database. Sync calls this 181 * method when it receives a command from a remote client to wipe all stored 182 * data. 183 * 184 * @return {Promise} resolved once all pages and visits have been removed. 185 */ 186 async wipe() { 187 await PlacesUtils.history.clear(); 188 await HistorySyncUtils.reset(); 189 }, 190 191 /** 192 * Removes the sync ID and last sync time for the history collection. Unlike 193 * `wipe`, this keeps all existing history pages and visits. 194 * 195 * @return {Promise} resolved once the metadata have been removed. 196 */ 197 reset() { 198 return PlacesUtils.metadata.delete( 199 HistorySyncUtils.SYNC_ID_META_KEY, 200 HistorySyncUtils.LAST_SYNC_META_KEY 201 ); 202 }, 203 204 /** 205 * Clamps a history visit date between the current date and the earliest 206 * sensible date. 207 * 208 * @param {Date} visitDate 209 * The visit date. 210 * @return {Date} The clamped visit date. 211 */ 212 clampVisitDate(visitDate) { 213 let currentDate = new Date(); 214 if (visitDate > currentDate) { 215 return currentDate; 216 } 217 if (visitDate < BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) { 218 return new Date(BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP); 219 } 220 return visitDate; 221 }, 222 223 /** 224 * Fetches the frecency for the URL provided 225 * 226 * @param url 227 * @returns {Number} The frecency of the given url 228 */ 229 async fetchURLFrecency(url) { 230 let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url); 231 232 let db = await PlacesUtils.promiseDBConnection(); 233 let rows = await db.executeCached( 234 ` 235 SELECT frecency 236 FROM moz_places 237 WHERE url_hash = hash(:url) AND url = :url 238 LIMIT 1`, 239 { url: canonicalURL.href } 240 ); 241 242 return rows.length ? rows[0].getResultByName("frecency") : -1; 243 }, 244 245 /** 246 * Filters syncable places from a collection of places guids. 247 * 248 * @param guids 249 * 250 * @returns {Array} new Array with the guids that aren't syncable 251 */ 252 async determineNonSyncableGuids(guids) { 253 // Filter out hidden pages and transitions that we don't sync. 254 let db = await PlacesUtils.promiseDBConnection(); 255 let nonSyncableGuids = []; 256 for (let chunk of PlacesUtils.chunkArray(guids, db.variableLimit)) { 257 let rows = await db.execute( 258 ` 259 SELECT DISTINCT p.guid FROM moz_places p 260 JOIN moz_historyvisits v ON p.id = v.place_id 261 WHERE p.guid IN (${new Array(chunk.length).fill("?").join(",")}) AND 262 (p.hidden = 1 OR v.visit_type IN (${IGNORED_TRANSITIONS_AS_SQL_LIST})) 263 `, 264 chunk 265 ); 266 nonSyncableGuids = nonSyncableGuids.concat( 267 rows.map(row => row.getResultByName("guid")) 268 ); 269 } 270 return nonSyncableGuids; 271 }, 272 273 /** 274 * Change the guid of the given uri 275 * 276 * @param uri 277 * @param guid 278 */ 279 changeGuid(uri, guid) { 280 let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(uri); 281 let validatedGuid = PlacesUtils.BOOKMARK_VALIDATORS.guid(guid); 282 return PlacesUtils.withConnectionWrapper( 283 "PlacesSyncUtils.history: changeGuid", 284 async function(db) { 285 await db.executeCached( 286 ` 287 UPDATE moz_places 288 SET guid = :guid 289 WHERE url_hash = hash(:page_url) AND url = :page_url`, 290 { guid: validatedGuid, page_url: canonicalURL.href } 291 ); 292 } 293 ); 294 }, 295 296 /** 297 * Fetch the last 20 visits (date and type of it) corresponding to a given url 298 * 299 * @param url 300 * @returns {Array} Each element of the Array is an object with members: date and type 301 */ 302 async fetchVisitsForURL(url) { 303 let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url); 304 let db = await PlacesUtils.promiseDBConnection(); 305 let rows = await db.executeCached( 306 ` 307 SELECT visit_type type, visit_date date 308 FROM moz_historyvisits 309 JOIN moz_places h ON h.id = place_id 310 WHERE url_hash = hash(:url) AND url = :url 311 ORDER BY date DESC LIMIT 20`, 312 { url: canonicalURL.href } 313 ); 314 return rows.map(row => { 315 let visitDate = row.getResultByName("date"); 316 let visitType = row.getResultByName("type"); 317 return { date: visitDate, type: visitType }; 318 }); 319 }, 320 321 /** 322 * Fetches the guid of a uri 323 * 324 * @param uri 325 * @returns {String} The guid of the given uri 326 */ 327 async fetchGuidForURL(url) { 328 let canonicalURL = PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url); 329 let db = await PlacesUtils.promiseDBConnection(); 330 let rows = await db.executeCached( 331 ` 332 SELECT guid 333 FROM moz_places 334 WHERE url_hash = hash(:page_url) AND url = :page_url`, 335 { page_url: canonicalURL.href } 336 ); 337 if (!rows.length) { 338 return null; 339 } 340 return rows[0].getResultByName("guid"); 341 }, 342 343 /** 344 * Fetch information about a guid (url, title and frecency) 345 * 346 * @param guid 347 * @returns {Object} Object with three members: url, title and frecency of the given guid 348 */ 349 async fetchURLInfoForGuid(guid) { 350 let db = await PlacesUtils.promiseDBConnection(); 351 let rows = await db.executeCached( 352 ` 353 SELECT url, IFNULL(title, '') AS title, frecency 354 FROM moz_places 355 WHERE guid = :guid`, 356 { guid } 357 ); 358 if (rows.length === 0) { 359 return null; 360 } 361 return { 362 url: rows[0].getResultByName("url"), 363 title: rows[0].getResultByName("title"), 364 frecency: rows[0].getResultByName("frecency"), 365 }; 366 }, 367 368 /** 369 * Get all URLs filtered by the limit and since members of the options object. 370 * 371 * @param options 372 * Options object with two members, since and limit. Both of them must be provided 373 * @returns {Array} - Up to limit number of URLs starting from the date provided by since 374 * 375 * Note that some visit types are explicitly excluded - downloads and framed 376 * links. 377 */ 378 async getAllURLs(options) { 379 // Check that the limit property is finite number. 380 if (!Number.isFinite(options.limit)) { 381 throw new Error("The number provided in options.limit is not finite."); 382 } 383 // Check that the since property is of type Date. 384 if ( 385 !options.since || 386 Object.prototype.toString.call(options.since) != "[object Date]" 387 ) { 388 throw new Error( 389 "The property since of the options object must be of type Date." 390 ); 391 } 392 let db = await PlacesUtils.promiseDBConnection(); 393 let sinceInMicroseconds = PlacesUtils.toPRTime(options.since); 394 let rows = await db.executeCached( 395 ` 396 SELECT DISTINCT p.url 397 FROM moz_places p 398 JOIN moz_historyvisits v ON p.id = v.place_id 399 WHERE p.last_visit_date > :cutoff_date AND 400 p.hidden = 0 AND 401 v.visit_type NOT IN (${IGNORED_TRANSITIONS_AS_SQL_LIST}) 402 ORDER BY frecency DESC 403 LIMIT :max_results`, 404 { cutoff_date: sinceInMicroseconds, max_results: options.limit } 405 ); 406 return rows.map(row => row.getResultByName("url")); 407 }, 408})); 409 410const BookmarkSyncUtils = (PlacesSyncUtils.bookmarks = Object.freeze({ 411 SYNC_PARENT_ANNO: "sync/parent", 412 413 SYNC_ID_META_KEY: "sync/bookmarks/syncId", 414 LAST_SYNC_META_KEY: "sync/bookmarks/lastSync", 415 WIPE_REMOTE_META_KEY: "sync/bookmarks/wipeRemote", 416 417 // Jan 23, 1993 in milliseconds since 1970. Corresponds roughly to the release 418 // of the original NCSA Mosiac. We can safely assume that any dates before 419 // this time are invalid. 420 EARLIEST_BOOKMARK_TIMESTAMP: Date.UTC(1993, 0, 23), 421 422 KINDS: { 423 BOOKMARK: "bookmark", 424 QUERY: "query", 425 FOLDER: "folder", 426 LIVEMARK: "livemark", 427 SEPARATOR: "separator", 428 }, 429 430 get ROOTS() { 431 return ROOTS; 432 }, 433 434 /** 435 * Returns the current bookmarks sync ID, or `""` if one isn't set. 436 */ 437 getSyncId() { 438 return PlacesUtils.metadata.get(BookmarkSyncUtils.SYNC_ID_META_KEY, ""); 439 }, 440 441 /** 442 * Indicates if the bookmarks engine should erase all bookmarks on the server 443 * and all other clients, because the user manually restored their bookmarks 444 * from a backup on this client. 445 */ 446 async shouldWipeRemote() { 447 let shouldWipeRemote = await PlacesUtils.metadata.get( 448 BookmarkSyncUtils.WIPE_REMOTE_META_KEY, 449 false 450 ); 451 return !!shouldWipeRemote; 452 }, 453 454 /** 455 * Assigns a new sync ID, bumps the change counter, and flags all items as 456 * "NEW" for upload. This is called when we sync for the first time with a 457 * new account, when we're the first to sync after a node reassignment, and 458 * on the first sync after a manual restore. 459 * 460 * @return {Promise} resolved once the ID and all items have been updated. 461 * @resolves to the new sync ID. 462 */ 463 resetSyncId() { 464 return PlacesUtils.withConnectionWrapper( 465 "BookmarkSyncUtils: resetSyncId", 466 function(db) { 467 let newSyncId = PlacesUtils.history.makeGuid(); 468 return db.executeTransaction(async function() { 469 await setBookmarksSyncId(db, newSyncId); 470 await resetAllSyncStatuses(db, PlacesUtils.bookmarks.SYNC_STATUS.NEW); 471 return newSyncId; 472 }); 473 } 474 ); 475 }, 476 477 /** 478 * Ensures that the existing local sync ID, if any, is up-to-date with the 479 * server. This is called when we sync with an existing account. 480 * 481 * We always take the server's sync ID. If we don't have an existing ID, 482 * we're either syncing for the first time with an existing account, or Places 483 * has automatically restored from a backup. If the sync IDs don't match, 484 * we're likely syncing after a node reassignment, where another client 485 * uploaded their bookmarks first. 486 * 487 * @param newSyncId 488 * The server's sync ID. 489 * @return {Promise} resolved once the ID and all items have been updated. 490 */ 491 async ensureCurrentSyncId(newSyncId) { 492 if (!newSyncId || typeof newSyncId != "string") { 493 throw new TypeError("Invalid new bookmarks sync ID"); 494 } 495 await PlacesUtils.withConnectionWrapper( 496 "BookmarkSyncUtils: ensureCurrentSyncId", 497 async function(db) { 498 let existingSyncId = await PlacesUtils.metadata.getWithConnection( 499 db, 500 BookmarkSyncUtils.SYNC_ID_META_KEY, 501 "" 502 ); 503 504 // If we don't have a sync ID, take the server's without resetting 505 // sync statuses. 506 if (!existingSyncId) { 507 BookmarkSyncLog.info("Taking new bookmarks sync ID", { newSyncId }); 508 await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId)); 509 return; 510 } 511 512 // If the existing sync ID matches the server, great! 513 if (existingSyncId == newSyncId) { 514 BookmarkSyncLog.trace("Bookmarks sync ID up-to-date", { 515 existingSyncId, 516 }); 517 return; 518 } 519 520 // Otherwise, we have a sync ID, but it doesn't match, so we were likely 521 // node reassigned. Take the server's sync ID and reset all items to 522 // "UNKNOWN" so that we can merge. 523 BookmarkSyncLog.info( 524 "Bookmarks sync ID changed; resetting sync statuses", 525 { existingSyncId, newSyncId } 526 ); 527 await db.executeTransaction(async function() { 528 await setBookmarksSyncId(db, newSyncId); 529 await resetAllSyncStatuses( 530 db, 531 PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN 532 ); 533 }); 534 } 535 ); 536 }, 537 538 /** 539 * Returns the last sync time, in seconds, for the bookmarks collection, or 0 540 * if bookmarks have never synced before. 541 */ 542 async getLastSync() { 543 let lastSync = await PlacesUtils.metadata.get( 544 BookmarkSyncUtils.LAST_SYNC_META_KEY, 545 0 546 ); 547 return lastSync / 1000; 548 }, 549 550 /** 551 * Updates the bookmarks collection last sync time. 552 * 553 * @param lastSyncSeconds 554 * The collection last sync time, in seconds, as a number or string. 555 */ 556 async setLastSync(lastSyncSeconds) { 557 let lastSync = Math.floor(lastSyncSeconds * 1000); 558 if (!Number.isInteger(lastSync)) { 559 throw new TypeError("Invalid bookmarks last sync timestamp"); 560 } 561 await PlacesUtils.metadata.set( 562 BookmarkSyncUtils.LAST_SYNC_META_KEY, 563 lastSync 564 ); 565 }, 566 567 /** 568 * Resets Sync metadata for bookmarks in Places. This function behaves 569 * differently depending on the change source, and may be called from 570 * `PlacesSyncUtils.bookmarks.reset` or 571 * `PlacesUtils.bookmarks.eraseEverything`. 572 * 573 * - RESTORE: The user is restoring from a backup. Drop the sync ID, last 574 * sync time, and tombstones; reset sync statuses for remaining items to 575 * "NEW"; then set a flag to wipe the server and all other clients. On the 576 * next sync, we'll replace their bookmarks with ours. 577 * 578 * - RESTORE_ON_STARTUP: Places is automatically restoring from a backup to 579 * recover from a corrupt database. The sync ID, last sync time, and 580 * tombstones don't exist, since we don't back them up; reset sync statuses 581 * for the roots to "UNKNOWN"; but don't wipe the server. On the next sync, 582 * we'll merge the restored bookmarks with the ones on the server. 583 * 584 * - SYNC: Either another client told us to erase our bookmarks 585 * (`PlacesSyncUtils.bookmarks.wipe`), or the user disconnected Sync 586 * (`PlacesSyncUtils.bookmarks.reset`). In both cases, drop the existing 587 * sync ID, last sync time, and tombstones; reset sync statuses for 588 * remaining items to "NEW"; and don't wipe the server. 589 * 590 * @param db 591 * the Sqlite.jsm connection handle. 592 * @param source 593 * the change source constant. 594 */ 595 async resetSyncMetadata(db, source) { 596 if ( 597 ![ 598 PlacesUtils.bookmarks.SOURCES.RESTORE, 599 PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, 600 PlacesUtils.bookmarks.SOURCES.SYNC, 601 ].includes(source) 602 ) { 603 return; 604 } 605 606 // Remove the sync ID and last sync time in all cases. 607 await PlacesUtils.metadata.deleteWithConnection( 608 db, 609 BookmarkSyncUtils.SYNC_ID_META_KEY, 610 BookmarkSyncUtils.LAST_SYNC_META_KEY 611 ); 612 613 // If we're manually restoring from a backup, wipe the server and other 614 // clients, so that we replace their bookmarks with the restored tree. If 615 // we're automatically restoring to recover from a corrupt database, don't 616 // wipe; we want to merge the restored tree with the one on the server. 617 await PlacesUtils.metadata.setWithConnection( 618 db, 619 BookmarkSyncUtils.WIPE_REMOTE_META_KEY, 620 source == PlacesUtils.bookmarks.SOURCES.RESTORE 621 ); 622 623 // Reset change counters and sync statuses for roots and remaining 624 // items, and drop tombstones. 625 let syncStatus = 626 source == PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP 627 ? PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN 628 : PlacesUtils.bookmarks.SYNC_STATUS.NEW; 629 await resetAllSyncStatuses(db, syncStatus); 630 }, 631 632 /** 633 * Converts a Places GUID to a Sync record ID. Record IDs are identical to 634 * Places GUIDs for all items except roots. 635 */ 636 guidToRecordId(guid) { 637 return ROOT_GUID_TO_RECORD_ID[guid] || guid; 638 }, 639 640 /** 641 * Converts a Sync record ID to a Places GUID. 642 */ 643 recordIdToGuid(recordId) { 644 return ROOT_RECORD_ID_TO_GUID[recordId] || recordId; 645 }, 646 647 /** 648 * Fetches the record IDs for a folder's children, ordered by their position 649 * within the folder. 650 * Used only be tests - but that includes tps, so it lives here. 651 */ 652 fetchChildRecordIds(parentRecordId) { 653 PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId); 654 let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId); 655 656 return PlacesUtils.withConnectionWrapper( 657 "BookmarkSyncUtils: fetchChildRecordIds", 658 async function(db) { 659 let childGuids = await fetchChildGuids(db, parentGuid); 660 return childGuids.map(guid => BookmarkSyncUtils.guidToRecordId(guid)); 661 } 662 ); 663 }, 664 665 /** 666 * Migrates an array of `{ recordId, modified }` tuples from the old JSON-based 667 * tracker to the new sync change counter. `modified` is when the change was 668 * added to the old tracker, in milliseconds. 669 * 670 * Sync calls this method before the first bookmark sync after the Places 671 * schema migration. 672 */ 673 migrateOldTrackerEntries(entries) { 674 return PlacesUtils.withConnectionWrapper( 675 "BookmarkSyncUtils: migrateOldTrackerEntries", 676 function(db) { 677 return db.executeTransaction(async function() { 678 // Mark all existing bookmarks as synced, and clear their change 679 // counters to avoid a full upload on the next sync. Note that 680 // this means we'll miss changes made between startup and the first 681 // post-migration sync, as well as changes made on a new release 682 // channel that weren't synced before the user downgraded. This is 683 // unfortunate, but no worse than the behavior of the old tracker. 684 // 685 // We also likely have bookmarks that don't exist on the server, 686 // because the old tracker missed them. We'll eventually fix the 687 // server once we decide on a repair strategy. 688 await db.executeCached( 689 ` 690 WITH RECURSIVE 691 syncedItems(id) AS ( 692 SELECT b.id FROM moz_bookmarks b 693 WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', 694 'mobile______') 695 UNION ALL 696 SELECT b.id FROM moz_bookmarks b 697 JOIN syncedItems s ON b.parent = s.id 698 ) 699 UPDATE moz_bookmarks SET 700 syncStatus = :syncStatus, 701 syncChangeCounter = 0 702 WHERE id IN syncedItems`, 703 { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } 704 ); 705 706 await db.executeCached(`DELETE FROM moz_bookmarks_deleted`); 707 708 await db.executeCached(`CREATE TEMP TABLE moz_bookmarks_tracked ( 709 guid TEXT PRIMARY KEY, 710 time INTEGER 711 )`); 712 713 try { 714 for (let { recordId, modified } of entries) { 715 let guid = BookmarkSyncUtils.recordIdToGuid(recordId); 716 if (!PlacesUtils.isValidGuid(guid)) { 717 BookmarkSyncLog.warn( 718 `migrateOldTrackerEntries: Ignoring ` + 719 `change for invalid item ${guid}` 720 ); 721 continue; 722 } 723 let time = PlacesUtils.toPRTime( 724 Number.isFinite(modified) ? modified : Date.now() 725 ); 726 await db.executeCached( 727 ` 728 INSERT OR IGNORE INTO moz_bookmarks_tracked (guid, time) 729 VALUES (:guid, :time)`, 730 { guid, time } 731 ); 732 } 733 734 // Bump the change counter for existing tracked items. 735 await db.executeCached(` 736 INSERT OR REPLACE INTO moz_bookmarks (id, fk, type, parent, 737 position, title, 738 dateAdded, lastModified, 739 guid, syncChangeCounter, 740 syncStatus) 741 SELECT b.id, b.fk, b.type, b.parent, b.position, b.title, 742 b.dateAdded, MAX(b.lastModified, t.time), b.guid, 743 b.syncChangeCounter + 1, b.syncStatus 744 FROM moz_bookmarks b 745 JOIN moz_bookmarks_tracked t ON b.guid = t.guid`); 746 747 // Insert tombstones for nonexistent tracked items, using the most 748 // recent deletion date for more accurate reconciliation. We assume 749 // the tracked item belongs to a synced root. 750 await db.executeCached(` 751 INSERT OR REPLACE INTO moz_bookmarks_deleted (guid, dateRemoved) 752 SELECT t.guid, MAX(IFNULL((SELECT dateRemoved FROM moz_bookmarks_deleted 753 WHERE guid = t.guid), 0), t.time) 754 FROM moz_bookmarks_tracked t 755 LEFT JOIN moz_bookmarks b ON t.guid = b.guid 756 WHERE b.guid IS NULL`); 757 } finally { 758 await db.executeCached(`DROP TABLE moz_bookmarks_tracked`); 759 } 760 }); 761 } 762 ); 763 }, 764 765 /** 766 * Reorders a folder's children, based on their order in the array of sync 767 * IDs. 768 * 769 * Sync uses this method to reorder all synced children after applying all 770 * incoming records. 771 * 772 * @return {Promise} resolved when reordering is complete. 773 * @rejects if an error happens while reordering. 774 * @throws if the arguments are invalid. 775 */ 776 order(parentRecordId, childRecordIds) { 777 PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId); 778 if (!childRecordIds.length) { 779 return undefined; 780 } 781 let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId); 782 if (parentGuid == PlacesUtils.bookmarks.rootGuid) { 783 // Reordering roots doesn't make sense, but Sync will do this on the 784 // first sync. 785 return undefined; 786 } 787 let orderedChildrenGuids = childRecordIds.map( 788 BookmarkSyncUtils.recordIdToGuid 789 ); 790 return PlacesUtils.bookmarks.reorder(parentGuid, orderedChildrenGuids, { 791 source: SOURCE_SYNC, 792 }); 793 }, 794 795 /** 796 * Resolves to true if there are known sync changes. 797 */ 798 havePendingChanges() { 799 return PlacesUtils.withConnectionWrapper( 800 "BookmarkSyncUtils: havePendingChanges", 801 async function(db) { 802 let rows = await db.executeCached(` 803 WITH RECURSIVE 804 syncedItems(id, guid, syncChangeCounter) AS ( 805 SELECT b.id, b.guid, b.syncChangeCounter 806 FROM moz_bookmarks b 807 WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', 808 'mobile______') 809 UNION ALL 810 SELECT b.id, b.guid, b.syncChangeCounter 811 FROM moz_bookmarks b 812 JOIN syncedItems s ON b.parent = s.id 813 ), 814 changedItems(guid) AS ( 815 SELECT guid FROM syncedItems 816 WHERE syncChangeCounter >= 1 817 UNION ALL 818 SELECT guid FROM moz_bookmarks_deleted 819 ) 820 SELECT EXISTS(SELECT guid FROM changedItems) AS haveChanges`); 821 return !!rows[0].getResultByName("haveChanges"); 822 } 823 ); 824 }, 825 826 /** 827 * Returns a changeset containing local bookmark changes since the last sync. 828 * 829 * @return {Promise} resolved once all items have been fetched. 830 * @resolves to an object containing records for changed bookmarks, keyed by 831 * the record ID. 832 * @see pullSyncChanges for the implementation, and markChangesAsSyncing for 833 * an explanation of why we update the sync status. 834 */ 835 pullChanges() { 836 return PlacesUtils.withConnectionWrapper( 837 "BookmarkSyncUtils: pullChanges", 838 pullSyncChanges 839 ); 840 }, 841 842 /** 843 * Updates the sync status of all "NEW" bookmarks to "NORMAL", so that Sync 844 * can recover correctly after an interrupted sync. 845 * 846 * @param changeRecords 847 * A changeset containing sync change records, as returned by 848 * `pullChanges`. 849 * @return {Promise} resolved once all records have been updated. 850 */ 851 markChangesAsSyncing(changeRecords) { 852 return PlacesUtils.withConnectionWrapper( 853 "BookmarkSyncUtils: markChangesAsSyncing", 854 db => markChangesAsSyncing(db, changeRecords) 855 ); 856 }, 857 858 /** 859 * Decrements the sync change counter, updates the sync status, and cleans up 860 * tombstones for successfully synced items. Sync calls this method at the 861 * end of each bookmark sync. 862 * 863 * @param changeRecords 864 * A changeset containing sync change records, as returned by 865 * `pullChanges`. 866 * @return {Promise} resolved once all records have been updated. 867 */ 868 pushChanges(changeRecords) { 869 return PlacesUtils.withConnectionWrapper( 870 "BookmarkSyncUtils: pushChanges", 871 async function(db) { 872 let skippedCount = 0; 873 let weakCount = 0; 874 let updateParams = []; 875 let tombstoneGuidsToRemove = []; 876 877 for (let recordId in changeRecords) { 878 // Validate change records to catch coding errors. 879 let changeRecord = validateChangeRecord( 880 "BookmarkSyncUtils: pushChanges", 881 changeRecords[recordId], 882 { 883 tombstone: { required: true }, 884 counter: { required: true }, 885 synced: { required: true }, 886 } 887 ); 888 889 // Skip weakly uploaded records. 890 if (!changeRecord.counter) { 891 weakCount++; 892 continue; 893 } 894 895 // Sync sets the `synced` flag for reconciled or successfully 896 // uploaded items. If upload failed, ignore the change; we'll 897 // try again on the next sync. 898 if (!changeRecord.synced) { 899 skippedCount++; 900 continue; 901 } 902 903 let guid = BookmarkSyncUtils.recordIdToGuid(recordId); 904 if (changeRecord.tombstone) { 905 tombstoneGuidsToRemove.push(guid); 906 } else { 907 updateParams.push({ 908 guid, 909 syncChangeDelta: changeRecord.counter, 910 syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, 911 }); 912 } 913 } 914 915 // Reduce the change counter and update the sync status for 916 // reconciled and uploaded items. If the bookmark was updated 917 // during the sync, its change counter will still be > 0 for the 918 // next sync. 919 if (updateParams.length || tombstoneGuidsToRemove.length) { 920 await db.executeTransaction(async function() { 921 if (updateParams.length) { 922 await db.executeCached( 923 ` 924 UPDATE moz_bookmarks 925 SET syncChangeCounter = MAX(syncChangeCounter - :syncChangeDelta, 0), 926 syncStatus = :syncStatus 927 WHERE guid = :guid`, 928 updateParams 929 ); 930 // and if there are *both* bookmarks and tombstones for these 931 // items, we nuke the tombstones. 932 // This should be unlikely, but bad if it happens. 933 let dupedGuids = updateParams.map(({ guid }) => guid); 934 await removeUndeletedTombstones(db, dupedGuids); 935 } 936 await removeTombstones(db, tombstoneGuidsToRemove); 937 }); 938 } 939 940 BookmarkSyncLog.debug(`pushChanges: Processed change records`, { 941 weak: weakCount, 942 skipped: skippedCount, 943 updated: updateParams.length, 944 }); 945 } 946 ); 947 }, 948 949 /** 950 * Removes items from the database. Sync buffers incoming tombstones, and 951 * calls this method to apply them at the end of each sync. Deletion 952 * happens in three steps: 953 * 954 * 1. Remove all non-folder items. Deleting a folder on a remote client 955 * uploads tombstones for the folder and its children at the time of 956 * deletion. This preserves any new children we've added locally since 957 * the last sync. 958 * 2. Reparent remaining children to the tombstoned folder's parent. This 959 * bumps the change counter for the children and their new parent. 960 * 3. Remove the tombstoned folder. Because we don't do this in a 961 * transaction, the user might move new items into the folder before we 962 * can remove it. In that case, we keep the folder and upload the new 963 * subtree to the server. 964 * 965 * See the comment above `BookmarksStore::deletePending` for the details on 966 * why delete works the way it does. 967 */ 968 remove(recordIds) { 969 if (!recordIds.length) { 970 return null; 971 } 972 973 return PlacesUtils.withConnectionWrapper( 974 "BookmarkSyncUtils: remove", 975 async function(db) { 976 let folderGuids = []; 977 for (let recordId of recordIds) { 978 if (recordId in ROOT_RECORD_ID_TO_GUID) { 979 BookmarkSyncLog.warn(`remove: Refusing to remove root ${recordId}`); 980 continue; 981 } 982 let guid = BookmarkSyncUtils.recordIdToGuid(recordId); 983 let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid); 984 if (!bookmarkItem) { 985 BookmarkSyncLog.trace(`remove: Item ${guid} already removed`); 986 continue; 987 } 988 let kind = await getKindForItem(db, bookmarkItem); 989 if (kind == BookmarkSyncUtils.KINDS.FOLDER) { 990 folderGuids.push(bookmarkItem.guid); 991 continue; 992 } 993 let wasRemoved = await deleteSyncedAtom(bookmarkItem); 994 if (wasRemoved) { 995 BookmarkSyncLog.trace( 996 `remove: Removed item ${guid} with kind ${kind}` 997 ); 998 } 999 } 1000 1001 for (let guid of folderGuids) { 1002 let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid); 1003 if (!bookmarkItem) { 1004 BookmarkSyncLog.trace(`remove: Folder ${guid} already removed`); 1005 continue; 1006 } 1007 let wasRemoved = await deleteSyncedFolder(db, bookmarkItem); 1008 if (wasRemoved) { 1009 BookmarkSyncLog.trace( 1010 `remove: Removed folder ${bookmarkItem.guid}` 1011 ); 1012 } 1013 } 1014 1015 // TODO (Bug 1313890): Refactor the bookmarks engine to pull change records 1016 // before uploading, instead of returning records to merge into the engine's 1017 // initial changeset. 1018 return pullSyncChanges(db); 1019 } 1020 ); 1021 }, 1022 1023 /** 1024 * Returns true for record IDs that are considered roots. 1025 */ 1026 isRootRecordID(id) { 1027 return ROOT_RECORD_ID_TO_GUID.hasOwnProperty(id); 1028 }, 1029 1030 /** 1031 * Removes all bookmarks and tombstones from the database. Sync calls this 1032 * method when it receives a command from a remote client to wipe all stored 1033 * data. 1034 * 1035 * @return {Promise} resolved once all items have been removed. 1036 */ 1037 wipe() { 1038 return PlacesUtils.bookmarks.eraseEverything({ 1039 source: SOURCE_SYNC, 1040 }); 1041 }, 1042 1043 /** 1044 * Marks all bookmarks as "NEW" and removes all tombstones. Unlike `wipe`, 1045 * this keeps all existing bookmarks, and only clears their sync change 1046 * tracking info. 1047 * 1048 * @return {Promise} resolved once all items have been updated. 1049 */ 1050 reset() { 1051 return PlacesUtils.withConnectionWrapper( 1052 "BookmarkSyncUtils: reset", 1053 function(db) { 1054 return db.executeTransaction(async function() { 1055 await BookmarkSyncUtils.resetSyncMetadata(db, SOURCE_SYNC); 1056 }); 1057 } 1058 ); 1059 }, 1060 1061 /** 1062 * Fetches a Sync bookmark object for an item in the tree. 1063 * 1064 * Should only be used by SYNC TESTS. 1065 * We should remove this in bug XXXXXX, updating the tests to use 1066 * PlacesUtils.bookmarks.fetch. 1067 * 1068 * The object contains 1069 * the following properties, depending on the item's kind: 1070 * 1071 * - kind (all): A string representing the item's kind. 1072 * - recordId (all): The item's record ID. 1073 * - parentRecordId (all): The record ID of the item's parent. 1074 * - parentTitle (all): The title of the item's parent, used for de-duping. 1075 * Omitted for the Places root and parents with empty titles. 1076 * - dateAdded (all): Timestamp in milliseconds, when the bookmark was added 1077 * or created on a remote device if known. 1078 * - title ("bookmark", "folder", "query"): The item's title. 1079 * Omitted if empty. 1080 * - url ("bookmark", "query"): The item's URL. 1081 * - tags ("bookmark", "query"): An array containing the item's tags. 1082 * - keyword ("bookmark"): The bookmark's keyword, if one exists. 1083 * - childRecordIds ("folder"): An array containing the record IDs of the item's 1084 * children, used to determine child order. 1085 * - folder ("query"): The tag folder name, if this is a tag query. 1086 * - index ("separator"): The separator's position within its parent. 1087 */ 1088 async fetch(recordId) { 1089 let guid = BookmarkSyncUtils.recordIdToGuid(recordId); 1090 let bookmarkItem = await PlacesUtils.bookmarks.fetch(guid); 1091 if (!bookmarkItem) { 1092 return null; 1093 } 1094 return PlacesUtils.withConnectionWrapper( 1095 "BookmarkSyncUtils: fetch", 1096 async function(db) { 1097 // Convert the Places bookmark object to a Sync bookmark and add 1098 // kind-specific properties. Titles are required for bookmarks, 1099 // and folders; optional for queries, and omitted for separators. 1100 let kind = await getKindForItem(db, bookmarkItem); 1101 let item; 1102 switch (kind) { 1103 case BookmarkSyncUtils.KINDS.BOOKMARK: 1104 item = await fetchBookmarkItem(db, bookmarkItem); 1105 break; 1106 1107 case BookmarkSyncUtils.KINDS.QUERY: 1108 item = await fetchQueryItem(db, bookmarkItem); 1109 break; 1110 1111 case BookmarkSyncUtils.KINDS.FOLDER: 1112 item = await fetchFolderItem(db, bookmarkItem); 1113 break; 1114 1115 case BookmarkSyncUtils.KINDS.SEPARATOR: 1116 item = await placesBookmarkToSyncBookmark(db, bookmarkItem); 1117 item.index = bookmarkItem.index; 1118 break; 1119 1120 default: 1121 throw new Error(`Unknown bookmark kind: ${kind}`); 1122 } 1123 1124 // Sync uses the parent title for de-duping. All Sync bookmark objects 1125 // except the Places root should have this property. 1126 if (bookmarkItem.parentGuid) { 1127 let parent = await PlacesUtils.bookmarks.fetch( 1128 bookmarkItem.parentGuid 1129 ); 1130 item.parentTitle = parent.title || ""; 1131 } 1132 1133 return item; 1134 } 1135 ); 1136 }, 1137 1138 /** 1139 * Returns the sync change counter increment for a change source constant. 1140 */ 1141 determineSyncChangeDelta(source) { 1142 // Don't bump the change counter when applying changes made by Sync, to 1143 // avoid sync loops. 1144 return source == PlacesUtils.bookmarks.SOURCES.SYNC ? 0 : 1; 1145 }, 1146 1147 /** 1148 * Returns the sync status for a new item inserted by a change source. 1149 */ 1150 determineInitialSyncStatus(source) { 1151 if (source == PlacesUtils.bookmarks.SOURCES.SYNC) { 1152 // Incoming bookmarks are "NORMAL", since they already exist on the server. 1153 return PlacesUtils.bookmarks.SYNC_STATUS.NORMAL; 1154 } 1155 if (source == PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP) { 1156 // If the user restores from a backup, or Places automatically recovers 1157 // from a corrupt database, all prior sync tracking is lost. Setting the 1158 // status to "UNKNOWN" allows Sync to reconcile restored bookmarks with 1159 // those on the server. 1160 return PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN; 1161 } 1162 // For all other sources, mark items as "NEW". We'll update their statuses 1163 // to "NORMAL" after the first sync. 1164 return PlacesUtils.bookmarks.SYNC_STATUS.NEW; 1165 }, 1166 1167 /** 1168 * An internal helper that bumps the change counter for all bookmarks with 1169 * a given URL. This is used to update bookmarks when adding or changing a 1170 * tag or keyword entry. 1171 * 1172 * @param db 1173 * the Sqlite.jsm connection handle. 1174 * @param url 1175 * the bookmark URL object. 1176 * @param syncChangeDelta 1177 * the sync change counter increment. 1178 * @return {Promise} resolved when the counters have been updated. 1179 */ 1180 addSyncChangesForBookmarksWithURL(db, url, syncChangeDelta) { 1181 if (!url || !syncChangeDelta) { 1182 return Promise.resolve(); 1183 } 1184 return db.executeCached( 1185 ` 1186 UPDATE moz_bookmarks 1187 SET syncChangeCounter = syncChangeCounter + :syncChangeDelta 1188 WHERE type = :type AND 1189 fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND 1190 url = :url)`, 1191 { 1192 syncChangeDelta, 1193 type: PlacesUtils.bookmarks.TYPE_BOOKMARK, 1194 url: url.href, 1195 } 1196 ); 1197 }, 1198 1199 async removeLivemark(livemarkInfo) { 1200 let info = validateSyncBookmarkObject( 1201 "BookmarkSyncUtils: removeLivemark", 1202 livemarkInfo, 1203 { 1204 kind: { 1205 required: true, 1206 validIf: b => b.kind == BookmarkSyncUtils.KINDS.LIVEMARK, 1207 }, 1208 recordId: { required: true }, 1209 parentRecordId: { required: true }, 1210 } 1211 ); 1212 1213 let guid = BookmarkSyncUtils.recordIdToGuid(info.recordId); 1214 let parentGuid = BookmarkSyncUtils.recordIdToGuid(info.parentRecordId); 1215 1216 return PlacesUtils.withConnectionWrapper( 1217 "BookmarkSyncUtils: removeLivemark", 1218 async function(db) { 1219 if (await GUIDMissing(guid)) { 1220 // If the livemark doesn't exist in the database, insert a tombstone 1221 // and bump its parent's change counter to ensure it's removed from 1222 // the server in the current sync. 1223 await db.executeTransaction(async function() { 1224 await db.executeCached( 1225 ` 1226 UPDATE moz_bookmarks SET 1227 syncChangeCounter = syncChangeCounter + 1 1228 WHERE guid = :parentGuid`, 1229 { parentGuid } 1230 ); 1231 1232 await db.executeCached( 1233 ` 1234 INSERT OR IGNORE INTO moz_bookmarks_deleted (guid, dateRemoved) 1235 VALUES (:guid, ${PlacesUtils.toPRTime(Date.now())})`, 1236 { guid } 1237 ); 1238 }); 1239 } else { 1240 await PlacesUtils.bookmarks.remove({ 1241 guid, 1242 // `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for 1243 // the child and its new parent, without incrementing the bookmark 1244 // tracker's score. 1245 source: 1246 PlacesUtils.bookmarks.SOURCES 1247 .SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, 1248 }); 1249 } 1250 1251 return pullSyncChanges(db, [guid, parentGuid]); 1252 } 1253 ); 1254 }, 1255 1256 /** 1257 * Returns `0` if no sensible timestamp could be found. 1258 * Otherwise, returns the earliest sensible timestamp between `existingMillis` 1259 * and `serverMillis`. 1260 */ 1261 ratchetTimestampBackwards( 1262 existingMillis, 1263 serverMillis, 1264 lowerBound = BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP 1265 ) { 1266 const possible = [+existingMillis, +serverMillis].filter( 1267 n => !isNaN(n) && n > lowerBound 1268 ); 1269 if (!possible.length) { 1270 return 0; 1271 } 1272 return Math.min(...possible); 1273 }, 1274 1275 /** 1276 * Rebuilds the left pane query for the mobile root under "All Bookmarks" if 1277 * necessary. Sync calls this method at the end of each bookmark sync. This 1278 * code should eventually move to `PlacesUIUtils#maybeRebuildLeftPane`; see 1279 * bug 647605. 1280 * 1281 * - If there are no mobile bookmarks, the query will not be created, or 1282 * will be removed if it already exists. 1283 * - If there are mobile bookmarks, the query will be created if it doesn't 1284 * exist, or will be updated with the correct title and URL otherwise. 1285 */ 1286 async ensureMobileQuery() { 1287 let db = await PlacesUtils.promiseDBConnection(); 1288 1289 let mobileChildGuids = await fetchChildGuids( 1290 db, 1291 PlacesUtils.bookmarks.mobileGuid 1292 ); 1293 let hasMobileBookmarks = !!mobileChildGuids.length; 1294 1295 Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, hasMobileBookmarks); 1296 }, 1297 1298 /** 1299 * Fetches an array of GUIDs for items that have an annotation set with the 1300 * given value. 1301 */ 1302 async fetchGuidsWithAnno(anno, val) { 1303 let db = await PlacesUtils.promiseDBConnection(); 1304 return fetchGuidsWithAnno(db, anno, val); 1305 }, 1306})); 1307 1308PlacesSyncUtils.test = {}; 1309PlacesSyncUtils.test.bookmarks = Object.freeze({ 1310 /** 1311 * Inserts a synced bookmark into the tree. Only SYNC TESTS should call this 1312 * method; other callers should use `PlacesUtils.bookmarks.insert`. 1313 * 1314 * It is in this file rather than a test-only file because it makes use of 1315 * other internal functions here, so moving is not trivial - see bug 1662602. 1316 * 1317 * The following properties are supported: 1318 * - kind: Required. 1319 * - guid: Required. 1320 * - parentGuid: Required. 1321 * - url: Required for bookmarks. 1322 * - tags: An optional array of tag strings. 1323 * - keyword: An optional keyword string. 1324 * 1325 * Sync doesn't set the index, since it appends and reorders children 1326 * after applying all incoming items. 1327 * 1328 * @param info 1329 * object representing a synced bookmark. 1330 * 1331 * @return {Promise} resolved when the creation is complete. 1332 * @resolves to an object representing the created bookmark. 1333 * @rejects if it's not possible to create the requested bookmark. 1334 * @throws if the arguments are invalid. 1335 */ 1336 insert(info) { 1337 let insertInfo = validateNewBookmark("BookmarkTestUtils: insert", info); 1338 1339 return PlacesUtils.withConnectionWrapper( 1340 "BookmarkTestUtils: insert", 1341 async db => { 1342 // If we're inserting a tag query, make sure the tag exists and fix the 1343 // folder ID to refer to the local tag folder. 1344 insertInfo = await updateTagQueryFolder(db, insertInfo); 1345 1346 let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo); 1347 let bookmarkItem = await PlacesUtils.bookmarks.insert(bookmarkInfo); 1348 let newItem = await insertBookmarkMetadata( 1349 db, 1350 bookmarkItem, 1351 insertInfo 1352 ); 1353 1354 return newItem; 1355 } 1356 ); 1357 }, 1358}); 1359 1360XPCOMUtils.defineLazyGetter(this, "HistorySyncLog", () => { 1361 return Log.repository.getLogger("Sync.Engine.History.HistorySyncUtils"); 1362}); 1363 1364XPCOMUtils.defineLazyGetter(this, "BookmarkSyncLog", () => { 1365 // Use a sub-log of the bookmarks engine, so setting the level for that 1366 // engine also adjust the level of this log. 1367 return Log.repository.getLogger("Sync.Engine.Bookmarks.BookmarkSyncUtils"); 1368}); 1369 1370function validateSyncBookmarkObject(name, input, behavior) { 1371 return PlacesUtils.validateItemProperties( 1372 name, 1373 PlacesUtils.SYNC_BOOKMARK_VALIDATORS, 1374 input, 1375 behavior 1376 ); 1377} 1378 1379// Validates a sync change record as returned by `pullChanges` and passed to 1380// `pushChanges`. 1381function validateChangeRecord(name, changeRecord, behavior) { 1382 return PlacesUtils.validateItemProperties( 1383 name, 1384 PlacesUtils.SYNC_CHANGE_RECORD_VALIDATORS, 1385 changeRecord, 1386 behavior 1387 ); 1388} 1389 1390// Similar to the private `fetchBookmarksByParent` implementation in 1391// `Bookmarks.jsm`. 1392var fetchChildGuids = async function(db, parentGuid) { 1393 let rows = await db.executeCached( 1394 ` 1395 SELECT guid 1396 FROM moz_bookmarks 1397 WHERE parent = ( 1398 SELECT id FROM moz_bookmarks WHERE guid = :parentGuid 1399 ) 1400 ORDER BY position`, 1401 { parentGuid } 1402 ); 1403 return rows.map(row => row.getResultByName("guid")); 1404}; 1405 1406// A helper for whenever we want to know if a GUID doesn't exist in the places 1407// database. Primarily used to detect orphans on incoming records. 1408var GUIDMissing = async function(guid) { 1409 try { 1410 await PlacesUtils.promiseItemId(guid); 1411 return false; 1412 } catch (ex) { 1413 if (ex.message == "no item found for the given GUID") { 1414 return true; 1415 } 1416 throw ex; 1417 } 1418}; 1419 1420// Legacy tag queries may use a `place:` URL that refers to the tag folder ID. 1421// When we apply a synced tag query from a remote client, we need to update the 1422// URL to point to the local tag. 1423function updateTagQueryFolder(db, info) { 1424 if ( 1425 info.kind != BookmarkSyncUtils.KINDS.QUERY || 1426 !info.folder || 1427 !info.url || 1428 info.url.protocol != "place:" 1429 ) { 1430 return info; 1431 } 1432 1433 let params = new URLSearchParams(info.url.pathname); 1434 let type = +params.get("type"); 1435 if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) { 1436 return info; 1437 } 1438 1439 BookmarkSyncLog.debug( 1440 `updateTagQueryFolder: Tag query folder: ${info.folder}` 1441 ); 1442 1443 // Rewrite the query to directly reference the tag. 1444 params.delete("queryType"); 1445 params.delete("type"); 1446 params.delete("folder"); 1447 params.set("tag", info.folder); 1448 info.url = new URL(info.url.protocol + params); 1449 return info; 1450} 1451 1452// Keywords are a 1 to 1 mapping between strings and pairs of (URL, postData). 1453// (the postData is not synced, so we ignore it). Sync associates keywords with 1454// bookmarks, which is not really accurate. -- We might already have a keyword 1455// with that name, or we might already have another bookmark with that URL with 1456// a different keyword, etc. 1457// 1458// If we don't handle those cases by removing the conflicting keywords first, 1459// the insertion will fail, and the keywords will either be wrong, or missing. 1460// This function handles those cases. 1461function removeConflictingKeywords(bookmarkURL, newKeyword) { 1462 return PlacesUtils.withConnectionWrapper( 1463 "BookmarkSyncUtils: removeConflictingKeywords", 1464 async function(db) { 1465 let entryForURL = await PlacesUtils.keywords.fetch({ 1466 url: bookmarkURL.href, 1467 }); 1468 if (entryForURL && entryForURL.keyword !== newKeyword) { 1469 await PlacesUtils.keywords.remove({ 1470 keyword: entryForURL.keyword, 1471 source: SOURCE_SYNC, 1472 }); 1473 // This will cause us to reupload this record for this sync, but 1474 // without it, we will risk data corruption. 1475 await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL( 1476 db, 1477 entryForURL.url, 1478 1 1479 ); 1480 } 1481 if (!newKeyword) { 1482 return; 1483 } 1484 let entryForNewKeyword = await PlacesUtils.keywords.fetch({ 1485 keyword: newKeyword, 1486 }); 1487 if (entryForNewKeyword) { 1488 await PlacesUtils.keywords.remove({ 1489 keyword: entryForNewKeyword.keyword, 1490 source: SOURCE_SYNC, 1491 }); 1492 await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL( 1493 db, 1494 entryForNewKeyword.url, 1495 1 1496 ); 1497 } 1498 } 1499 ); 1500} 1501 1502// Sets annotations, keywords, and tags on a new bookmark. Returns a Sync 1503// bookmark object. 1504async function insertBookmarkMetadata(db, bookmarkItem, insertInfo) { 1505 let newItem = await placesBookmarkToSyncBookmark(db, bookmarkItem); 1506 1507 try { 1508 newItem.tags = tagItem(bookmarkItem, insertInfo.tags); 1509 } catch (ex) { 1510 BookmarkSyncLog.warn( 1511 `insertBookmarkMetadata: Error tagging item ${insertInfo.recordId}`, 1512 ex 1513 ); 1514 } 1515 1516 if (insertInfo.keyword) { 1517 await removeConflictingKeywords(bookmarkItem.url, insertInfo.keyword); 1518 await PlacesUtils.keywords.insert({ 1519 keyword: insertInfo.keyword, 1520 url: bookmarkItem.url.href, 1521 source: SOURCE_SYNC, 1522 }); 1523 newItem.keyword = insertInfo.keyword; 1524 } 1525 1526 return newItem; 1527} 1528 1529// Determines the Sync record kind for an existing bookmark. 1530async function getKindForItem(db, item) { 1531 switch (item.type) { 1532 case PlacesUtils.bookmarks.TYPE_FOLDER: { 1533 return BookmarkSyncUtils.KINDS.FOLDER; 1534 } 1535 case PlacesUtils.bookmarks.TYPE_BOOKMARK: 1536 return item.url.protocol == "place:" 1537 ? BookmarkSyncUtils.KINDS.QUERY 1538 : BookmarkSyncUtils.KINDS.BOOKMARK; 1539 1540 case PlacesUtils.bookmarks.TYPE_SEPARATOR: 1541 return BookmarkSyncUtils.KINDS.SEPARATOR; 1542 } 1543 return null; 1544} 1545 1546// Returns the `nsINavBookmarksService` bookmark type constant for a Sync 1547// record kind. 1548function getTypeForKind(kind) { 1549 switch (kind) { 1550 case BookmarkSyncUtils.KINDS.BOOKMARK: 1551 case BookmarkSyncUtils.KINDS.QUERY: 1552 return PlacesUtils.bookmarks.TYPE_BOOKMARK; 1553 1554 case BookmarkSyncUtils.KINDS.FOLDER: 1555 return PlacesUtils.bookmarks.TYPE_FOLDER; 1556 1557 case BookmarkSyncUtils.KINDS.SEPARATOR: 1558 return PlacesUtils.bookmarks.TYPE_SEPARATOR; 1559 } 1560 throw new Error(`Unknown bookmark kind: ${kind}`); 1561} 1562 1563function validateNewBookmark(name, info) { 1564 let insertInfo = validateSyncBookmarkObject(name, info, { 1565 kind: { required: true }, 1566 recordId: { required: true }, 1567 url: { 1568 requiredIf: b => 1569 [ 1570 BookmarkSyncUtils.KINDS.BOOKMARK, 1571 BookmarkSyncUtils.KINDS.QUERY, 1572 ].includes(b.kind), 1573 validIf: b => 1574 [ 1575 BookmarkSyncUtils.KINDS.BOOKMARK, 1576 BookmarkSyncUtils.KINDS.QUERY, 1577 ].includes(b.kind), 1578 }, 1579 parentRecordId: { required: true }, 1580 title: { 1581 validIf: b => 1582 [ 1583 BookmarkSyncUtils.KINDS.BOOKMARK, 1584 BookmarkSyncUtils.KINDS.QUERY, 1585 BookmarkSyncUtils.KINDS.FOLDER, 1586 ].includes(b.kind) || b.title === "", 1587 }, 1588 query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }, 1589 folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }, 1590 tags: { 1591 validIf: b => 1592 [ 1593 BookmarkSyncUtils.KINDS.BOOKMARK, 1594 BookmarkSyncUtils.KINDS.QUERY, 1595 ].includes(b.kind), 1596 }, 1597 keyword: { 1598 validIf: b => 1599 [ 1600 BookmarkSyncUtils.KINDS.BOOKMARK, 1601 BookmarkSyncUtils.KINDS.QUERY, 1602 ].includes(b.kind), 1603 }, 1604 dateAdded: { required: false }, 1605 }); 1606 1607 return insertInfo; 1608} 1609 1610async function fetchGuidsWithAnno(db, anno, val) { 1611 let rows = await db.executeCached( 1612 ` 1613 SELECT b.guid FROM moz_items_annos a 1614 JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id 1615 JOIN moz_bookmarks b ON b.id = a.item_id 1616 WHERE n.name = :anno AND 1617 a.content = :val`, 1618 { anno, val } 1619 ); 1620 return rows.map(row => row.getResultByName("guid")); 1621} 1622 1623function tagItem(item, tags) { 1624 if (!item.url) { 1625 return []; 1626 } 1627 1628 // Remove leading and trailing whitespace, then filter out empty tags. 1629 let newTags = tags ? tags.map(tag => tag.trim()).filter(Boolean) : []; 1630 1631 // Removing the last tagged item will also remove the tag. To preserve 1632 // tag IDs, we temporarily tag a dummy URI, ensuring the tags exist. 1633 let dummyURI = PlacesUtils.toURI("about:weave#BStore_tagURI"); 1634 let bookmarkURI = PlacesUtils.toURI(item.url.href); 1635 if (newTags && newTags.length) { 1636 PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC); 1637 } 1638 PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC); 1639 if (newTags && newTags.length) { 1640 PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC); 1641 } 1642 PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC); 1643 1644 return newTags; 1645} 1646 1647// Converts a Places bookmark to a Sync bookmark. This function maps Places 1648// GUIDs to record IDs and filters out extra Places properties like date added, 1649// last modified, and index. 1650async function placesBookmarkToSyncBookmark(db, bookmarkItem) { 1651 let item = {}; 1652 1653 for (let prop in bookmarkItem) { 1654 switch (prop) { 1655 // Record IDs are identical to Places GUIDs for all items except roots. 1656 case "guid": 1657 item.recordId = BookmarkSyncUtils.guidToRecordId(bookmarkItem.guid); 1658 break; 1659 1660 case "parentGuid": 1661 item.parentRecordId = BookmarkSyncUtils.guidToRecordId( 1662 bookmarkItem.parentGuid 1663 ); 1664 break; 1665 1666 // Sync uses kinds instead of types, which distinguish between folders, 1667 // livemarks, bookmarks, and queries. 1668 case "type": 1669 item.kind = await getKindForItem(db, bookmarkItem); 1670 break; 1671 1672 case "title": 1673 case "url": 1674 item[prop] = bookmarkItem[prop]; 1675 break; 1676 1677 case "dateAdded": 1678 item[prop] = new Date(bookmarkItem[prop]).getTime(); 1679 break; 1680 } 1681 } 1682 1683 return item; 1684} 1685 1686// Converts a Sync bookmark object to a Places bookmark or livemark object. 1687// This function maps record IDs to Places GUIDs, and filters out extra Sync 1688// properties like keywords, tags. Returns an object that can be passed to 1689// `PlacesUtils.bookmarks.{insert, update}`. 1690function syncBookmarkToPlacesBookmark(info) { 1691 let bookmarkInfo = { 1692 source: SOURCE_SYNC, 1693 }; 1694 1695 for (let prop in info) { 1696 switch (prop) { 1697 case "kind": 1698 bookmarkInfo.type = getTypeForKind(info.kind); 1699 break; 1700 1701 // Convert record IDs to Places GUIDs for roots. 1702 case "recordId": 1703 bookmarkInfo.guid = BookmarkSyncUtils.recordIdToGuid(info.recordId); 1704 break; 1705 1706 case "dateAdded": 1707 bookmarkInfo.dateAdded = new Date(info.dateAdded); 1708 break; 1709 1710 case "parentRecordId": 1711 bookmarkInfo.parentGuid = BookmarkSyncUtils.recordIdToGuid( 1712 info.parentRecordId 1713 ); 1714 // Instead of providing an index, Sync reorders children at the end of 1715 // the sync using `BookmarkSyncUtils.order`. We explicitly specify the 1716 // default index here to prevent `PlacesUtils.bookmarks.update` from 1717 // throwing. 1718 bookmarkInfo.index = PlacesUtils.bookmarks.DEFAULT_INDEX; 1719 break; 1720 1721 case "title": 1722 case "url": 1723 bookmarkInfo[prop] = info[prop]; 1724 break; 1725 } 1726 } 1727 1728 return bookmarkInfo; 1729} 1730 1731// Creates and returns a Sync bookmark object containing the bookmark's 1732// tags, keyword. 1733var fetchBookmarkItem = async function(db, bookmarkItem) { 1734 let item = await placesBookmarkToSyncBookmark(db, bookmarkItem); 1735 1736 if (!item.title) { 1737 item.title = ""; 1738 } 1739 1740 item.tags = PlacesUtils.tagging.getTagsForURI( 1741 PlacesUtils.toURI(bookmarkItem.url) 1742 ); 1743 1744 let keywordEntry = await PlacesUtils.keywords.fetch({ 1745 url: bookmarkItem.url, 1746 }); 1747 if (keywordEntry) { 1748 item.keyword = keywordEntry.keyword; 1749 } 1750 1751 return item; 1752}; 1753 1754// Creates and returns a Sync bookmark object containing the folder's children. 1755async function fetchFolderItem(db, bookmarkItem) { 1756 let item = await placesBookmarkToSyncBookmark(db, bookmarkItem); 1757 1758 if (!item.title) { 1759 item.title = ""; 1760 } 1761 1762 let childGuids = await fetchChildGuids(db, bookmarkItem.guid); 1763 item.childRecordIds = childGuids.map(guid => 1764 BookmarkSyncUtils.guidToRecordId(guid) 1765 ); 1766 1767 return item; 1768} 1769 1770// Creates and returns a Sync bookmark object containing the query's tag 1771// folder name. 1772async function fetchQueryItem(db, bookmarkItem) { 1773 let item = await placesBookmarkToSyncBookmark(db, bookmarkItem); 1774 1775 let params = new URLSearchParams(bookmarkItem.url.pathname); 1776 let tags = params.getAll("tag"); 1777 if (tags.length == 1) { 1778 item.folder = tags[0]; 1779 } 1780 1781 return item; 1782} 1783 1784function addRowToChangeRecords(row, changeRecords) { 1785 let guid = row.getResultByName("guid"); 1786 if (!guid) { 1787 throw new Error(`Changed item missing GUID`); 1788 } 1789 let isTombstone = !!row.getResultByName("tombstone"); 1790 let recordId = BookmarkSyncUtils.guidToRecordId(guid); 1791 if (recordId in changeRecords) { 1792 let existingRecord = changeRecords[recordId]; 1793 if (existingRecord.tombstone == isTombstone) { 1794 // Should never happen: `moz_bookmarks.guid` has a unique index, and 1795 // `moz_bookmarks_deleted.guid` is the primary key. 1796 throw new Error(`Duplicate item or tombstone ${recordId} in changeset`); 1797 } 1798 if (!existingRecord.tombstone && isTombstone) { 1799 // Don't replace undeleted items with tombstones... 1800 BookmarkSyncLog.warn( 1801 "addRowToChangeRecords: Ignoring tombstone for undeleted item", 1802 recordId 1803 ); 1804 return; 1805 } 1806 // ...But replace undeleted tombstones with items. 1807 BookmarkSyncLog.warn( 1808 "addRowToChangeRecords: Replacing tombstone for undeleted item", 1809 recordId 1810 ); 1811 } 1812 let modifiedAsPRTime = row.getResultByName("modified"); 1813 let modified = modifiedAsPRTime / MICROSECONDS_PER_SECOND; 1814 if (Number.isNaN(modified) || modified <= 0) { 1815 BookmarkSyncLog.error( 1816 "addRowToChangeRecords: Invalid modified date for " + recordId, 1817 modifiedAsPRTime 1818 ); 1819 modified = 0; 1820 } 1821 changeRecords[recordId] = { 1822 modified, 1823 counter: row.getResultByName("syncChangeCounter"), 1824 status: row.getResultByName("syncStatus"), 1825 tombstone: isTombstone, 1826 synced: false, 1827 }; 1828} 1829 1830/** 1831 * Queries the database for synced bookmarks and tombstones, and returns a 1832 * changeset for the Sync bookmarks engine. 1833 * 1834 * @param db 1835 * The Sqlite.jsm connection handle. 1836 * @param forGuids 1837 * Fetch Sync tracking information for only the requested GUIDs. 1838 * @return {Promise} resolved once all items have been fetched. 1839 * @resolves to an object containing records for changed bookmarks, keyed by 1840 * the record ID. 1841 */ 1842var pullSyncChanges = async function(db, forGuids = []) { 1843 let changeRecords = {}; 1844 1845 let itemConditions = ["syncChangeCounter >= 1"]; 1846 let tombstoneConditions = ["1 = 1"]; 1847 if (forGuids.length) { 1848 let restrictToGuids = `guid IN (${forGuids 1849 .map(guid => JSON.stringify(guid)) 1850 .join(",")})`; 1851 itemConditions.push(restrictToGuids); 1852 tombstoneConditions.push(restrictToGuids); 1853 } 1854 1855 let rows = await db.executeCached( 1856 ` 1857 WITH RECURSIVE 1858 syncedItems(id, guid, modified, syncChangeCounter, syncStatus) AS ( 1859 SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus 1860 FROM moz_bookmarks b 1861 WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', 1862 'mobile______') 1863 UNION ALL 1864 SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus 1865 FROM moz_bookmarks b 1866 JOIN syncedItems s ON b.parent = s.id 1867 ) 1868 SELECT guid, modified, syncChangeCounter, syncStatus, 0 AS tombstone 1869 FROM syncedItems 1870 WHERE ${itemConditions.join(" AND ")} 1871 UNION ALL 1872 SELECT guid, dateRemoved AS modified, 1 AS syncChangeCounter, 1873 :deletedSyncStatus, 1 AS tombstone 1874 FROM moz_bookmarks_deleted 1875 WHERE ${tombstoneConditions.join(" AND ")}`, 1876 { deletedSyncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } 1877 ); 1878 for (let row of rows) { 1879 addRowToChangeRecords(row, changeRecords); 1880 } 1881 1882 return changeRecords; 1883}; 1884 1885// Moves a synced folder's remaining children to its parent, and deletes the 1886// folder if it's empty. 1887async function deleteSyncedFolder(db, bookmarkItem) { 1888 // At this point, any member in the folder that remains is either a folder 1889 // pending deletion (which we'll get to in this function), or an item that 1890 // should not be deleted. To avoid deleting these items, we first move them 1891 // to the parent of the folder we're about to delete. 1892 let childGuids = await fetchChildGuids(db, bookmarkItem.guid); 1893 if (!childGuids.length) { 1894 // No children -- just delete the folder. 1895 return deleteSyncedAtom(bookmarkItem); 1896 } 1897 1898 if (BookmarkSyncLog.level <= Log.Level.Trace) { 1899 BookmarkSyncLog.trace( 1900 `deleteSyncedFolder: Moving ${JSON.stringify(childGuids)} children of ` + 1901 `"${bookmarkItem.guid}" to grandparent 1902 "${BookmarkSyncUtils.guidToRecordId(bookmarkItem.parentGuid)}" before ` + 1903 `deletion` 1904 ); 1905 } 1906 1907 // Move children out of the parent and into the grandparent 1908 for (let guid of childGuids) { 1909 await PlacesUtils.bookmarks.update({ 1910 guid, 1911 parentGuid: bookmarkItem.parentGuid, 1912 index: PlacesUtils.bookmarks.DEFAULT_INDEX, 1913 // `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for 1914 // the child and its new parent, without incrementing the bookmark 1915 // tracker's score. 1916 // 1917 // We intentionally don't check if the child is one we'll remove later, 1918 // so it's possible we'll bump the change counter of the closest living 1919 // ancestor when it's not needed. This avoids inconsistency if removal 1920 // is interrupted, since we don't run this operation in a transaction. 1921 source: 1922 PlacesUtils.bookmarks.SOURCES.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, 1923 }); 1924 } 1925 1926 // Delete the (now empty) parent 1927 try { 1928 await PlacesUtils.bookmarks.remove(bookmarkItem.guid, { 1929 preventRemovalOfNonEmptyFolders: true, 1930 // We don't want to bump the change counter for this deletion, because 1931 // a tombstone for the folder is already on the server. 1932 source: SOURCE_SYNC, 1933 }); 1934 } catch (e) { 1935 // We failed, probably because someone added something to this folder 1936 // between when we got the children and now (or the database is corrupt, 1937 // or something else happened...) This is unlikely, but possible. To 1938 // avoid corruption in this case, we need to reupload the record to the 1939 // server. 1940 // 1941 // (Ideally this whole operation would be done in a transaction, and this 1942 // wouldn't be possible). 1943 BookmarkSyncLog.trace( 1944 `deleteSyncedFolder: Error removing parent ` + 1945 `${bookmarkItem.guid} after reparenting children`, 1946 e 1947 ); 1948 return false; 1949 } 1950 1951 return true; 1952} 1953 1954// Removes a synced bookmark or empty folder from the database. 1955var deleteSyncedAtom = async function(bookmarkItem) { 1956 try { 1957 await PlacesUtils.bookmarks.remove(bookmarkItem.guid, { 1958 preventRemovalOfNonEmptyFolders: true, 1959 source: SOURCE_SYNC, 1960 }); 1961 } catch (ex) { 1962 // Likely already removed. 1963 BookmarkSyncLog.trace( 1964 `deleteSyncedAtom: Error removing ` + bookmarkItem.guid, 1965 ex 1966 ); 1967 return false; 1968 } 1969 1970 return true; 1971}; 1972 1973/** 1974 * Updates the sync status on all "NEW" and "UNKNOWN" bookmarks to "NORMAL". 1975 * 1976 * We do this when pulling changes instead of in `pushChanges` to make sure 1977 * we write tombstones if a new item is deleted after an interrupted sync. (For 1978 * example, if a "NEW" record is uploaded or reconciled, then the app is closed 1979 * before Sync calls `pushChanges`). 1980 */ 1981function markChangesAsSyncing(db, changeRecords) { 1982 let unsyncedGuids = []; 1983 for (let recordId in changeRecords) { 1984 if (changeRecords[recordId].tombstone) { 1985 continue; 1986 } 1987 if ( 1988 changeRecords[recordId].status == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL 1989 ) { 1990 continue; 1991 } 1992 let guid = BookmarkSyncUtils.recordIdToGuid(recordId); 1993 unsyncedGuids.push(JSON.stringify(guid)); 1994 } 1995 if (!unsyncedGuids.length) { 1996 return Promise.resolve(); 1997 } 1998 return db.execute( 1999 ` 2000 UPDATE moz_bookmarks 2001 SET syncStatus = :syncStatus 2002 WHERE guid IN (${unsyncedGuids.join(",")})`, 2003 { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } 2004 ); 2005} 2006 2007/** 2008 * Removes tombstones for successfully synced items. 2009 * 2010 * @return {Promise} 2011 */ 2012var removeTombstones = function(db, guids) { 2013 if (!guids.length) { 2014 return Promise.resolve(); 2015 } 2016 return db.execute(` 2017 DELETE FROM moz_bookmarks_deleted 2018 WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})`); 2019}; 2020 2021/** 2022 * Removes tombstones for successfully synced items where the specified GUID 2023 * exists in *both* the bookmarks and tombstones tables. 2024 * 2025 * @return {Promise} 2026 */ 2027var removeUndeletedTombstones = function(db, guids) { 2028 if (!guids.length) { 2029 return Promise.resolve(); 2030 } 2031 // sqlite can't join in a DELETE, so we use a subquery. 2032 return db.execute(` 2033 DELETE FROM moz_bookmarks_deleted 2034 WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")}) 2035 AND guid IN (SELECT guid from moz_bookmarks)`); 2036}; 2037 2038// Sets the history sync ID and clears the last sync time. 2039async function setHistorySyncId(db, newSyncId) { 2040 await PlacesUtils.metadata.setWithConnection( 2041 db, 2042 HistorySyncUtils.SYNC_ID_META_KEY, 2043 newSyncId 2044 ); 2045 2046 await PlacesUtils.metadata.deleteWithConnection( 2047 db, 2048 HistorySyncUtils.LAST_SYNC_META_KEY 2049 ); 2050} 2051 2052// Sets the bookmarks sync ID and clears the last sync time. 2053async function setBookmarksSyncId(db, newSyncId) { 2054 await PlacesUtils.metadata.setWithConnection( 2055 db, 2056 BookmarkSyncUtils.SYNC_ID_META_KEY, 2057 newSyncId 2058 ); 2059 2060 await PlacesUtils.metadata.deleteWithConnection( 2061 db, 2062 BookmarkSyncUtils.LAST_SYNC_META_KEY, 2063 BookmarkSyncUtils.WIPE_REMOTE_META_KEY 2064 ); 2065} 2066 2067// Bumps the change counter and sets the given sync status for all bookmarks, 2068// removes all orphan annos, and drops stale tombstones. 2069async function resetAllSyncStatuses(db, syncStatus) { 2070 await db.execute( 2071 ` 2072 UPDATE moz_bookmarks 2073 SET syncChangeCounter = 1, 2074 syncStatus = :syncStatus`, 2075 { syncStatus } 2076 ); 2077 2078 // The orphan anno isn't meaningful after a restore, disconnect, or node 2079 // reassignment. 2080 await db.execute( 2081 ` 2082 DELETE FROM moz_items_annos 2083 WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes 2084 WHERE name = :orphanAnno)`, 2085 { orphanAnno: BookmarkSyncUtils.SYNC_PARENT_ANNO } 2086 ); 2087 2088 // Drop stale tombstones. 2089 await db.execute("DELETE FROM moz_bookmarks_deleted"); 2090} 2091