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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5"use strict"; 6 7/** 8 * This module provides an asynchronous API for managing bookmarks. 9 * 10 * Bookmarks are organized in a tree structure, and include URLs, folders and 11 * separators. Multiple bookmarks for the same URL are allowed. 12 * 13 * Note that if you are handling bookmarks operations in the UI, you should 14 * not use this API directly, but rather use PlacesTransactions.jsm, so that 15 * any operation is undo/redo-able. 16 * 17 * Each bookmark-item is represented by an object having the following 18 * properties: 19 * 20 * - guid (string) 21 * The globally unique identifier of the item. 22 * - parentGuid (string) 23 * The globally unique identifier of the folder containing the item. 24 * This will be an empty string for the Places root folder. 25 * - index (number) 26 * The 0-based position of the item in the parent folder. 27 * - dateAdded (Date) 28 * The time at which the item was added. 29 * - lastModified (Date) 30 * The time at which the item was last modified. 31 * - type (number) 32 * The item's type, either TYPE_BOOKMARK, TYPE_FOLDER or TYPE_SEPARATOR. 33 * 34 * The following properties are only valid for URLs or folders. 35 * 36 * - title (string) 37 * The item's title, if any. Empty titles and null titles are considered 38 * the same. Titles longer than DB_TITLE_LENGTH_MAX will be truncated. 39 * 40 * The following properties are only valid for URLs: 41 * 42 * - url (URL, href or nsIURI) 43 * The item's URL. Note that while input objects can contains either 44 * an URL object, an href string, or an nsIURI, output objects will always 45 * contain an URL object. 46 * An URL cannot be longer than DB_URL_LENGTH_MAX, methods will throw if a 47 * longer value is provided. 48 * 49 * Each successful operation notifies through the nsINavBookmarksObserver 50 * interface. To listen to such notifications you must register using 51 * nsINavBookmarksService addObserver and removeObserver methods. 52 * Note that bookmark addition or order changes won't notify bookmark-moved for 53 * items that have their indexes changed. 54 * Similarly, lastModified changes not done explicitly (like changing another 55 * property) won't fire an onItemChanged notification for the lastModified 56 * property. 57 * @see nsINavBookmarkObserver 58 */ 59 60var EXPORTED_SYMBOLS = ["Bookmarks"]; 61 62const { XPCOMUtils } = ChromeUtils.import( 63 "resource://gre/modules/XPCOMUtils.jsm" 64); 65 66XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); 67 68ChromeUtils.defineModuleGetter( 69 this, 70 "NetUtil", 71 "resource://gre/modules/NetUtil.jsm" 72); 73ChromeUtils.defineModuleGetter( 74 this, 75 "PlacesUtils", 76 "resource://gre/modules/PlacesUtils.jsm" 77); 78ChromeUtils.defineModuleGetter( 79 this, 80 "PlacesSyncUtils", 81 "resource://gre/modules/PlacesSyncUtils.jsm" 82); 83 84// This is an helper to temporarily cover the need to know the tags folder 85// itemId until bug 424160 is fixed. This exists so that startup paths won't 86// pay the price to initialize the bookmarks service just to fetch this value. 87// If the method is already initing the bookmarks service for other reasons 88// (most of the writing methods will invoke getObservers() already) it can 89// directly use the PlacesUtils.tagsFolderId property. 90var gTagsFolderId; 91async function promiseTagsFolderId() { 92 if (gTagsFolderId) { 93 return gTagsFolderId; 94 } 95 let db = await PlacesUtils.promiseDBConnection(); 96 let rows = await db.execute( 97 "SELECT id FROM moz_bookmarks WHERE guid = :guid", 98 { guid: Bookmarks.tagsGuid } 99 ); 100 return (gTagsFolderId = rows[0].getResultByName("id")); 101} 102 103const MATCH_ANYWHERE_UNMODIFIED = 104 Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED; 105const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; 106 107var Bookmarks = Object.freeze({ 108 /** 109 * Item's type constants. 110 * These should stay consistent with nsINavBookmarksService.idl 111 */ 112 TYPE_BOOKMARK: 1, 113 TYPE_FOLDER: 2, 114 TYPE_SEPARATOR: 3, 115 116 /** 117 * Sync status constants, stored for each item. 118 */ 119 SYNC_STATUS: { 120 UNKNOWN: Ci.nsINavBookmarksService.SYNC_STATUS_UNKNOWN, 121 NEW: Ci.nsINavBookmarksService.SYNC_STATUS_NEW, 122 NORMAL: Ci.nsINavBookmarksService.SYNC_STATUS_NORMAL, 123 }, 124 125 /** 126 * Default index used to append a bookmark-item at the end of a folder. 127 * This should stay consistent with nsINavBookmarksService.idl 128 */ 129 DEFAULT_INDEX: -1, 130 131 /** 132 * Maximum length of a tag. 133 * Any tag above this length is rejected. 134 */ 135 MAX_TAG_LENGTH: 100, 136 137 /** 138 * Bookmark change source constants, passed as optional properties and 139 * forwarded to observers. See nsINavBookmarksService.idl for an explanation. 140 */ 141 SOURCES: { 142 DEFAULT: Ci.nsINavBookmarksService.SOURCE_DEFAULT, 143 SYNC: Ci.nsINavBookmarksService.SOURCE_SYNC, 144 IMPORT: Ci.nsINavBookmarksService.SOURCE_IMPORT, 145 SYNC_REPARENT_REMOVED_FOLDER_CHILDREN: 146 Ci.nsINavBookmarksService.SOURCE_SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, 147 RESTORE: Ci.nsINavBookmarksService.SOURCE_RESTORE, 148 RESTORE_ON_STARTUP: Ci.nsINavBookmarksService.SOURCE_RESTORE_ON_STARTUP, 149 }, 150 151 /** 152 * Special GUIDs associated with bookmark roots. 153 * It's guaranteed that the roots will always have these guids. 154 */ 155 rootGuid: "root________", 156 menuGuid: "menu________", 157 toolbarGuid: "toolbar_____", 158 unfiledGuid: "unfiled_____", 159 mobileGuid: "mobile______", 160 161 // With bug 424160, tags will stop being bookmarks, thus this root will 162 // be removed. Do not rely on this, rather use the tagging service API. 163 tagsGuid: "tags________", 164 165 /** 166 * The GUIDs of the user content root folders that we support, for easy access 167 * as a set. 168 */ 169 userContentRoots: [ 170 "toolbar_____", 171 "menu________", 172 "unfiled_____", 173 "mobile______", 174 ], 175 176 /** 177 * GUIDs associated with virtual queries that are used for displaying bookmark 178 * folders in the left pane. 179 */ 180 virtualMenuGuid: "menu_______v", 181 virtualToolbarGuid: "toolbar____v", 182 virtualUnfiledGuid: "unfiled____v", 183 virtualMobileGuid: "mobile_____v", 184 185 /** 186 * Checks if a guid is a virtual root. 187 * 188 * @param {String} guid The guid of the item to look for. 189 * @returns {Boolean} true if guid is a virtual root, false otherwise. 190 */ 191 isVirtualRootItem(guid) { 192 return ( 193 guid == PlacesUtils.bookmarks.virtualMenuGuid || 194 guid == PlacesUtils.bookmarks.virtualToolbarGuid || 195 guid == PlacesUtils.bookmarks.virtualUnfiledGuid || 196 guid == PlacesUtils.bookmarks.virtualMobileGuid 197 ); 198 }, 199 200 /** 201 * Returns the title to use on the UI for a bookmark item. Root folders 202 * in the database don't store fully localised versions of the title. To 203 * get those this function should be called. 204 * 205 * Hence, this function should only be called if a root folder object is 206 * likely to be displayed to the user. 207 * 208 * @param {Object} info An object representing a bookmark-item. 209 * @returns {String} The correct string. 210 * @throws {Error} If the guid in PlacesUtils.bookmarks.userContentRoots is 211 * not supported. 212 */ 213 getLocalizedTitle(info) { 214 if (!PlacesUtils.bookmarks.userContentRoots.includes(info.guid)) { 215 return info.title; 216 } 217 218 switch (info.guid) { 219 case PlacesUtils.bookmarks.toolbarGuid: 220 return PlacesUtils.getString("BookmarksToolbarFolderTitle"); 221 case PlacesUtils.bookmarks.menuGuid: 222 return PlacesUtils.getString("BookmarksMenuFolderTitle"); 223 case PlacesUtils.bookmarks.unfiledGuid: 224 return PlacesUtils.getString("OtherBookmarksFolderTitle"); 225 case PlacesUtils.bookmarks.mobileGuid: 226 return PlacesUtils.getString("MobileBookmarksFolderTitle"); 227 default: 228 throw new Error( 229 `Unsupported guid ${info.guid} passed to getLocalizedTitle!` 230 ); 231 } 232 }, 233 234 /** 235 * Inserts a bookmark-item into the bookmarks tree. 236 * 237 * For creating a bookmark, the following set of properties is required: 238 * - type 239 * - parentGuid 240 * - url, only for bookmarked URLs 241 * 242 * If an index is not specified, it defaults to appending. 243 * It's also possible to pass a non-existent GUID to force creation of an 244 * item with the given GUID, but unless you have a very sound reason, such as 245 * an undo manager implementation or synchronization, don't do that. 246 * 247 * Note that any known properties that don't apply to the specific item type 248 * cause an exception. 249 * 250 * @param info 251 * object representing a bookmark-item. 252 * 253 * @return {Promise} resolved when the creation is complete. 254 * @resolves to an object representing the created bookmark. 255 * @rejects if it's not possible to create the requested bookmark. 256 * @throws if the arguments are invalid. 257 */ 258 insert(info) { 259 let now = new Date(); 260 let addedTime = (info && info.dateAdded) || now; 261 let modTime = addedTime; 262 if (addedTime > now) { 263 modTime = now; 264 } 265 let insertInfo = validateBookmarkObject("Bookmarks.jsm: insert", info, { 266 type: { defaultValue: this.TYPE_BOOKMARK }, 267 index: { defaultValue: this.DEFAULT_INDEX }, 268 url: { 269 requiredIf: b => b.type == this.TYPE_BOOKMARK, 270 validIf: b => b.type == this.TYPE_BOOKMARK, 271 }, 272 parentGuid: { 273 required: true, 274 // Inserting into the root folder is not allowed. 275 validIf: b => b.parentGuid != this.rootGuid, 276 }, 277 title: { 278 defaultValue: "", 279 validIf: b => 280 b.type == this.TYPE_BOOKMARK || 281 b.type == this.TYPE_FOLDER || 282 b.title === "", 283 }, 284 dateAdded: { defaultValue: addedTime }, 285 lastModified: { 286 defaultValue: modTime, 287 validIf: b => 288 b.lastModified >= now || 289 (b.dateAdded && b.lastModified >= b.dateAdded), 290 }, 291 source: { defaultValue: this.SOURCES.DEFAULT }, 292 }); 293 294 return (async () => { 295 // Ensure the parent exists. 296 let parent = await fetchBookmark({ guid: insertInfo.parentGuid }); 297 if (!parent) { 298 throw new Error("parentGuid must be valid"); 299 } 300 301 // Set index in the appending case. 302 if ( 303 insertInfo.index == this.DEFAULT_INDEX || 304 insertInfo.index > parent._childCount 305 ) { 306 insertInfo.index = parent._childCount; 307 } 308 309 let item = await insertBookmark(insertInfo, parent); 310 311 // We need the itemId to notify, though once the switch to guids is 312 // complete we may stop using it. 313 let itemId = await PlacesUtils.promiseItemId(item.guid); 314 315 // Pass tagging information for the observers to skip over these notifications when needed. 316 let isTagging = parent._parentId == PlacesUtils.tagsFolderId; 317 let isTagsFolder = parent._id == PlacesUtils.tagsFolderId; 318 let url = ""; 319 if (item.type == Bookmarks.TYPE_BOOKMARK) { 320 url = item.url.href; 321 } 322 323 const notifications = [ 324 new PlacesBookmarkAddition({ 325 id: itemId, 326 url, 327 itemType: item.type, 328 parentId: parent._id, 329 index: item.index, 330 title: item.title, 331 dateAdded: item.dateAdded, 332 guid: item.guid, 333 parentGuid: item.parentGuid, 334 source: item.source, 335 isTagging: isTagging || isTagsFolder, 336 }), 337 ]; 338 339 // If it's a tag, notify bookmark-tags-changed event to all bookmarks for this URL. 340 if (isTagging) { 341 const tags = PlacesUtils.tagging.getTagsForURI(NetUtil.newURI(url)); 342 343 for (let entry of await fetchBookmarksByURL(item, { 344 concurrent: true, 345 })) { 346 notifications.push( 347 new PlacesBookmarkTags({ 348 id: entry._id, 349 itemType: entry.type, 350 url, 351 guid: entry.guid, 352 parentGuid: entry.parentGuid, 353 tags, 354 lastModified: entry.lastModified, 355 source: item.source, 356 isTagging: false, 357 }) 358 ); 359 } 360 } 361 362 PlacesObservers.notifyListeners(notifications); 363 364 // Remove non-enumerable properties. 365 delete item.source; 366 return Object.assign({}, item); 367 })(); 368 }, 369 370 /** 371 * Inserts a bookmark-tree into the existing bookmarks tree. 372 * 373 * All the specified folders and bookmarks will be inserted as new, even 374 * if duplicates. There's no merge support at this time. 375 * 376 * The input should be of the form: 377 * { 378 * guid: "<some-existing-guid-to-use-as-parent>", 379 * source: "<some valid source>", (optional) 380 * children: [ 381 * ... valid bookmark objects. 382 * ] 383 * } 384 * 385 * Children will be appended to any existing children of the parent 386 * that is specified. The source specified on the root of the tree 387 * will be used for all the items inserted. Any indices or custom parentGuids 388 * set on children will be ignored and overwritten. 389 * 390 * @param {Object} tree 391 * object representing a tree of bookmark items to insert. 392 * @param {Object} options [optional] 393 * object with properties representing options. Current options are: 394 * - fixupOrSkipInvalidEntries: makes the insert more lenient to 395 * mistakes in the input tree. Properties of an entry that are 396 * fixable will be corrected, otherwise the entry will be skipped. 397 * This is particularly convenient for import/restore operations, 398 * but should not be abused for common inserts, since it may hide 399 * bugs in the calling code. 400 * 401 * @return {Promise} resolved when the creation is complete. 402 * @resolves to an array of objects representing the created bookmark(s). 403 * @rejects if it's not possible to create the requested bookmark. 404 * @throws if the arguments are invalid. 405 */ 406 insertTree(tree, options) { 407 if (!tree || typeof tree != "object") { 408 throw new Error("Should be provided a valid tree object."); 409 } 410 if (!Array.isArray(tree.children) || !tree.children.length) { 411 throw new Error("Should have a non-zero number of children to insert."); 412 } 413 if (!PlacesUtils.isValidGuid(tree.guid)) { 414 throw new Error( 415 `The parent guid is not valid (${tree.guid} ${tree.title}).` 416 ); 417 } 418 if (tree.guid == this.rootGuid) { 419 throw new Error("Can't insert into the root."); 420 } 421 if (tree.guid == this.tagsGuid) { 422 throw new Error("Can't use insertTree to insert tags."); 423 } 424 if ( 425 tree.hasOwnProperty("source") && 426 !Object.values(this.SOURCES).includes(tree.source) 427 ) { 428 throw new Error("Can't use source value " + tree.source); 429 } 430 if (options && typeof options != "object") { 431 throw new Error("Options should be a valid object"); 432 } 433 let fixupOrSkipInvalidEntries = 434 options && !!options.fixupOrSkipInvalidEntries; 435 436 // Serialize the tree into an array of items to insert into the db. 437 let insertInfos = []; 438 let urlsThatMightNeedPlaces = []; 439 440 // We want to use the same 'last added' time for all the entries 441 // we import (so they won't differ by a few ms based on where 442 // they are in the tree, and so we don't needlessly construct 443 // multiple dates). 444 let fallbackLastAdded = new Date(); 445 446 const { TYPE_BOOKMARK, TYPE_FOLDER, SOURCES } = this; 447 448 // Reuse the 'source' property for all the entries. 449 let source = tree.source || SOURCES.DEFAULT; 450 451 // This is recursive. 452 function appendInsertionInfoForInfoArray(infos, indexToUse, parentGuid) { 453 // We want to keep the index of items that will be inserted into the root 454 // NULL, and then use a subquery to select the right index, to avoid 455 // races where other consumers might add items between when we determine 456 // the index and when we insert. However, the validator does not allow 457 // NULL values in in the index, so we fake it while validating and then 458 // correct later. Keep track of whether we're doing this: 459 let shouldUseNullIndices = false; 460 if (indexToUse === null) { 461 shouldUseNullIndices = true; 462 indexToUse = 0; 463 } 464 465 // When a folder gets an item added, its last modified date is updated 466 // to be equal to the date we added the item (if that date is newer). 467 // Because we're inserting a tree, we keep track of this date for the 468 // loop, updating it for inserted items as well as from any subfolders 469 // we insert. 470 let lastAddedForParent = new Date(0); 471 for (let info of infos) { 472 // Ensure to use the same date for dateAdded and lastModified, even if 473 // dateAdded may be imposed by the caller. 474 let time = (info && info.dateAdded) || fallbackLastAdded; 475 let insertInfo = { 476 guid: { defaultValue: PlacesUtils.history.makeGuid() }, 477 type: { defaultValue: TYPE_BOOKMARK }, 478 url: { 479 requiredIf: b => b.type == TYPE_BOOKMARK, 480 validIf: b => b.type == TYPE_BOOKMARK, 481 }, 482 parentGuid: { replaceWith: parentGuid }, // Set the correct parent guid. 483 title: { 484 defaultValue: "", 485 validIf: b => 486 b.type == TYPE_BOOKMARK || 487 b.type == TYPE_FOLDER || 488 b.title === "", 489 }, 490 dateAdded: { 491 defaultValue: time, 492 validIf: b => !b.lastModified || b.dateAdded <= b.lastModified, 493 }, 494 lastModified: { 495 defaultValue: time, 496 validIf: b => 497 (!b.dateAdded && b.lastModified >= time) || 498 (b.dateAdded && b.lastModified >= b.dateAdded), 499 }, 500 index: { replaceWith: indexToUse++ }, 501 source: { replaceWith: source }, 502 keyword: { validIf: b => b.type == TYPE_BOOKMARK }, 503 charset: { validIf: b => b.type == TYPE_BOOKMARK }, 504 postData: { validIf: b => b.type == TYPE_BOOKMARK }, 505 tags: { validIf: b => b.type == TYPE_BOOKMARK }, 506 children: { 507 validIf: b => b.type == TYPE_FOLDER && Array.isArray(b.children), 508 }, 509 }; 510 if (fixupOrSkipInvalidEntries) { 511 insertInfo.guid.fixup = b => 512 (b.guid = PlacesUtils.history.makeGuid()); 513 insertInfo.dateAdded.fixup = insertInfo.lastModified.fixup = b => 514 (b.lastModified = b.dateAdded = fallbackLastAdded); 515 } 516 try { 517 insertInfo = validateBookmarkObject( 518 "Bookmarks.jsm: insertTree", 519 info, 520 insertInfo 521 ); 522 } catch (ex) { 523 if (fixupOrSkipInvalidEntries) { 524 indexToUse--; 525 continue; 526 } else { 527 throw ex; 528 } 529 } 530 531 if (shouldUseNullIndices) { 532 insertInfo.index = null; 533 } 534 // Store the URL if this is a bookmark, so we can ensure we create an 535 // entry in moz_places for it. 536 if (insertInfo.type == Bookmarks.TYPE_BOOKMARK) { 537 urlsThatMightNeedPlaces.push(insertInfo.url); 538 } 539 540 insertInfos.push(insertInfo); 541 // Process any children. We have to use info.children here rather than 542 // insertInfo.children because validateBookmarkObject doesn't copy over 543 // the children ref, as the default bookmark validators object doesn't 544 // know about children. 545 if (info.children) { 546 // start children of this item off at index 0. 547 let childrenLastAdded = appendInsertionInfoForInfoArray( 548 info.children, 549 0, 550 insertInfo.guid 551 ); 552 if (childrenLastAdded > insertInfo.lastModified) { 553 insertInfo.lastModified = childrenLastAdded; 554 } 555 if (childrenLastAdded > lastAddedForParent) { 556 lastAddedForParent = childrenLastAdded; 557 } 558 } 559 560 // Ensure we track what time to update the parent to. 561 if (insertInfo.dateAdded > lastAddedForParent) { 562 lastAddedForParent = insertInfo.dateAdded; 563 } 564 } 565 return lastAddedForParent; 566 } 567 568 // We want to validate synchronously, but we can't know the index at which 569 // we're inserting into the parent. We just use NULL instead, 570 // and the SQL query with which we insert will update it as necessary. 571 let lastAddedForParent = appendInsertionInfoForInfoArray( 572 tree.children, 573 null, 574 tree.guid 575 ); 576 577 // appendInsertionInfoForInfoArray will remove invalid items and may leave 578 // us with nothing to insert, if so, just return early. 579 if (!insertInfos.length) { 580 return []; 581 } 582 583 return (async function() { 584 let treeParent = await fetchBookmark({ guid: tree.guid }); 585 if (!treeParent) { 586 throw new Error("The parent you specified doesn't exist."); 587 } 588 589 if (treeParent._parentId == PlacesUtils.tagsFolderId) { 590 throw new Error("Can't use insertTree to insert tags."); 591 } 592 593 await insertBookmarkTree( 594 insertInfos, 595 source, 596 treeParent, 597 urlsThatMightNeedPlaces, 598 lastAddedForParent 599 ); 600 601 // Now update the indices of root items in the objects we return. 602 // These may be wrong if someone else modified the table between 603 // when we fetched the parent and inserted our items, but the actual 604 // inserts will have been correct, and we don't want to query the DB 605 // again if we don't have to. bug 1347230 covers improving this. 606 let rootIndex = treeParent._childCount; 607 for (let insertInfo of insertInfos) { 608 if (insertInfo.parentGuid == tree.guid) { 609 insertInfo.index += rootIndex++; 610 } 611 } 612 // We need the itemIds to notify, though once the switch to guids is 613 // complete we may stop using them. 614 let itemIdMap = await PlacesUtils.promiseManyItemIds( 615 insertInfos.map(info => info.guid) 616 ); 617 618 let notifications = []; 619 for (let i = 0; i < insertInfos.length; i++) { 620 let item = insertInfos[i]; 621 let itemId = itemIdMap.get(item.guid); 622 // For sub-folders, we need to make sure their children have the correct parent ids. 623 let parentId; 624 if (item.parentGuid === treeParent.guid) { 625 // This is a direct child of the tree parent, so we can use the 626 // existing parent's id. 627 parentId = treeParent._id; 628 } else { 629 // This is a parent folder that's been updated, so we need to 630 // use the new item id. 631 parentId = itemIdMap.get(item.parentGuid); 632 } 633 634 let url = ""; 635 if (item.type == Bookmarks.TYPE_BOOKMARK) { 636 url = item.url instanceof URL ? item.url.href : item.url; 637 } 638 639 notifications.push( 640 new PlacesBookmarkAddition({ 641 id: itemId, 642 url, 643 itemType: item.type, 644 parentId, 645 index: item.index, 646 title: item.title, 647 dateAdded: item.dateAdded, 648 guid: item.guid, 649 parentGuid: item.parentGuid, 650 source: item.source, 651 isTagging: false, 652 }) 653 ); 654 655 try { 656 await handleBookmarkItemSpecialData(itemId, item); 657 } catch (ex) { 658 // This is not critical, regardless the bookmark has been created 659 // and we should continue notifying the next ones. 660 Cu.reportError( 661 `An error occured while handling special bookmark data: ${ex}` 662 ); 663 } 664 665 // Remove non-enumerable properties. 666 delete item.source; 667 668 insertInfos[i] = Object.assign({}, item); 669 } 670 671 if (notifications.length) { 672 PlacesObservers.notifyListeners(notifications); 673 } 674 675 return insertInfos; 676 })(); 677 }, 678 679 /** 680 * Updates a bookmark-item. 681 * 682 * Only set the properties which should be changed (undefined properties 683 * won't be taken into account). 684 * Moreover, the item's type or dateAdded cannot be changed, since they are 685 * immutable after creation. Trying to change them will reject. 686 * 687 * Note that any known properties that don't apply to the specific item type 688 * cause an exception. 689 * 690 * @param info 691 * object representing a bookmark-item, as defined above. 692 * 693 * @return {Promise} resolved when the update is complete. 694 * @resolves to an object representing the updated bookmark. 695 * @rejects if it's not possible to update the given bookmark. 696 * @throws if the arguments are invalid. 697 */ 698 update(info) { 699 // The info object is first validated here to ensure it's consistent, then 700 // it's compared to the existing item to remove any properties that don't 701 // need to be updated. 702 let updateInfo = validateBookmarkObject("Bookmarks.jsm: update", info, { 703 guid: { required: true }, 704 index: { 705 requiredIf: b => b.hasOwnProperty("parentGuid"), 706 validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX, 707 }, 708 parentGuid: { validIf: b => b.parentGuid != this.rootGuid }, 709 source: { defaultValue: this.SOURCES.DEFAULT }, 710 }); 711 712 // There should be at last one more property in addition to guid and source. 713 if (Object.keys(updateInfo).length < 3) { 714 throw new Error("Not enough properties to update"); 715 } 716 717 return (async () => { 718 // Ensure the item exists. 719 let item = await fetchBookmark(updateInfo); 720 if (!item) { 721 throw new Error("No bookmarks found for the provided GUID"); 722 } 723 if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type) { 724 throw new Error("The bookmark type cannot be changed"); 725 } 726 727 // Remove any property that will stay the same. 728 removeSameValueProperties(updateInfo, item); 729 // Check if anything should still be updated. 730 if (Object.keys(updateInfo).length < 3) { 731 // Remove non-enumerable properties. 732 return Object.assign({}, item); 733 } 734 const now = new Date(); 735 let lastModifiedDefault = now; 736 // In the case where `dateAdded` is specified, but `lastModified` is not, 737 // we only update `lastModified` if it is older than the new `dateAdded`. 738 if (!("lastModified" in updateInfo) && "dateAdded" in updateInfo) { 739 lastModifiedDefault = new Date( 740 Math.max(item.lastModified, updateInfo.dateAdded) 741 ); 742 } 743 updateInfo = validateBookmarkObject("Bookmarks.jsm: update", updateInfo, { 744 url: { validIf: () => item.type == this.TYPE_BOOKMARK }, 745 title: { 746 validIf: () => 747 [this.TYPE_BOOKMARK, this.TYPE_FOLDER].includes(item.type), 748 }, 749 lastModified: { 750 defaultValue: lastModifiedDefault, 751 validIf: b => 752 b.lastModified >= now || 753 b.lastModified >= (b.dateAdded || item.dateAdded), 754 }, 755 dateAdded: { defaultValue: item.dateAdded }, 756 }); 757 758 return PlacesUtils.withConnectionWrapper( 759 "Bookmarks.jsm: update", 760 async db => { 761 let parent; 762 if (updateInfo.hasOwnProperty("parentGuid")) { 763 if (PlacesUtils.isRootItem(item.guid)) { 764 throw new Error("It's not possible to move Places root folders."); 765 } 766 if (item.type == this.TYPE_FOLDER) { 767 // Make sure we are not moving a folder into itself or one of its 768 // descendants. 769 let rows = await db.executeCached( 770 `WITH RECURSIVE 771 descendants(did) AS ( 772 VALUES(:id) 773 UNION ALL 774 SELECT id FROM moz_bookmarks 775 JOIN descendants ON parent = did 776 WHERE type = :type 777 ) 778 SELECT guid FROM moz_bookmarks 779 WHERE id IN descendants 780 `, 781 { id: item._id, type: this.TYPE_FOLDER } 782 ); 783 if ( 784 rows 785 .map(r => r.getResultByName("guid")) 786 .includes(updateInfo.parentGuid) 787 ) { 788 throw new Error( 789 "Cannot insert a folder into itself or one of its descendants" 790 ); 791 } 792 } 793 794 parent = await fetchBookmark({ guid: updateInfo.parentGuid }); 795 if (!parent) { 796 throw new Error("No bookmarks found for the provided parentGuid"); 797 } 798 } 799 800 if (updateInfo.hasOwnProperty("index")) { 801 if (PlacesUtils.isRootItem(item.guid)) { 802 throw new Error("It's not possible to move Places root folders."); 803 } 804 // If at this point we don't have a parent yet, we are moving into 805 // the same container. Thus we know it exists. 806 if (!parent) { 807 parent = await fetchBookmark({ guid: item.parentGuid }); 808 } 809 810 if ( 811 updateInfo.index >= parent._childCount || 812 updateInfo.index == this.DEFAULT_INDEX 813 ) { 814 updateInfo.index = parent._childCount; 815 816 // Fix the index when moving within the same container. 817 if (parent.guid == item.parentGuid) { 818 updateInfo.index--; 819 } 820 } 821 } 822 823 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 824 info.source 825 ); 826 827 let updatedItem = await db.executeTransaction(async function() { 828 let updatedItem = await updateBookmark( 829 db, 830 updateInfo, 831 item, 832 item.index, 833 parent, 834 syncChangeDelta 835 ); 836 if (parent) { 837 await setAncestorsLastModified( 838 db, 839 parent.guid, 840 updatedItem.lastModified, 841 syncChangeDelta 842 ); 843 } 844 return updatedItem; 845 }); 846 847 if ( 848 item.type == this.TYPE_BOOKMARK && 849 item.url.href != updatedItem.url.href 850 ) { 851 // ...though we don't wait for the calculation. 852 updateFrecency(db, [item.url, updatedItem.url]).catch( 853 Cu.reportError 854 ); 855 } 856 857 const notifications = []; 858 859 // For lastModified, we only care about the original input, since we 860 // should not notify implicit lastModified changes. 861 if ( 862 (info.hasOwnProperty("lastModified") && 863 updateInfo.hasOwnProperty("lastModified") && 864 item.lastModified != updatedItem.lastModified) || 865 (info.hasOwnProperty("dateAdded") && 866 updateInfo.hasOwnProperty("dateAdded") && 867 item.dateAdded != updatedItem.dateAdded) 868 ) { 869 let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; 870 if (!isTagging) { 871 if (!parent) { 872 parent = await fetchBookmark({ guid: updatedItem.parentGuid }); 873 } 874 isTagging = parent.parentGuid === Bookmarks.tagsGuid; 875 } 876 877 notifications.push( 878 new PlacesBookmarkTime({ 879 id: updatedItem._id, 880 itemType: updatedItem.type, 881 url: updatedItem.url?.href, 882 guid: updatedItem.guid, 883 parentGuid: updatedItem.parentGuid, 884 dateAdded: updatedItem.dateAdded, 885 lastModified: updatedItem.lastModified, 886 source: updatedItem.source, 887 isTagging, 888 }) 889 ); 890 } 891 892 if (updateInfo.hasOwnProperty("title")) { 893 let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; 894 if (!isTagging) { 895 if (!parent) { 896 parent = await fetchBookmark({ guid: updatedItem.parentGuid }); 897 } 898 isTagging = parent.parentGuid === Bookmarks.tagsGuid; 899 } 900 901 notifications.push( 902 new PlacesBookmarkTitle({ 903 id: updatedItem._id, 904 itemType: updatedItem.type, 905 url: updatedItem.url?.href, 906 guid: updatedItem.guid, 907 parentGuid: updatedItem.parentGuid, 908 title: updatedItem.title, 909 lastModified: updatedItem.lastModified, 910 source: updatedItem.source, 911 isTagging, 912 }) 913 ); 914 915 // If we're updating a tag, we must notify all the tagged bookmarks 916 // about the change. 917 if (isTagging) { 918 for (let entry of await fetchBookmarksByTags( 919 { tags: [updatedItem.title] }, 920 { concurrent: true } 921 )) { 922 const tags = PlacesUtils.tagging.getTagsForURI( 923 NetUtil.newURI(entry.url) 924 ); 925 notifications.push( 926 new PlacesBookmarkTags({ 927 id: entry._id, 928 itemType: entry.type, 929 url: entry.url, 930 guid: entry.guid, 931 parentGuid: entry.parentGuid, 932 tags, 933 lastModified: entry.lastModified, 934 source: updatedItem.source, 935 isTagging: false, 936 }) 937 ); 938 } 939 } 940 } 941 if (updateInfo.hasOwnProperty("url")) { 942 await PlacesUtils.keywords.reassign( 943 item.url, 944 updatedItem.url, 945 updatedItem.source 946 ); 947 948 let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; 949 if (!isTagging) { 950 if (!parent) { 951 parent = await fetchBookmark({ guid: updatedItem.parentGuid }); 952 } 953 isTagging = parent.parentGuid === Bookmarks.tagsGuid; 954 } 955 956 notifications.push( 957 new PlacesBookmarkUrl({ 958 id: updatedItem._id, 959 itemType: updatedItem.type, 960 url: updatedItem.url.href, 961 guid: updatedItem.guid, 962 parentGuid: updatedItem.parentGuid, 963 source: updatedItem.source, 964 isTagging, 965 lastModified: updatedItem.lastModified, 966 }) 967 ); 968 } 969 // If the item was moved, notify bookmark-moved. 970 if ( 971 item.parentGuid != updatedItem.parentGuid || 972 item.index != updatedItem.index 973 ) { 974 notifications.push( 975 new PlacesBookmarkMoved({ 976 id: updatedItem._id, 977 itemType: updatedItem.type, 978 url: updatedItem.url && updatedItem.url.href, 979 guid: updatedItem.guid, 980 parentGuid: updatedItem.parentGuid, 981 source: updatedItem.source, 982 index: updatedItem.index, 983 oldParentGuid: item.parentGuid, 984 oldIndex: item.index, 985 isTagging: 986 updatedItem.parentGuid === Bookmarks.tagsGuid || 987 parent.parentGuid === Bookmarks.tagsGuid, 988 }) 989 ); 990 } 991 992 if (notifications.length) { 993 PlacesObservers.notifyListeners(notifications); 994 } 995 996 // Remove non-enumerable properties. 997 delete updatedItem.source; 998 return Object.assign({}, updatedItem); 999 } 1000 ); 1001 })(); 1002 }, 1003 1004 /** 1005 * Moves multiple bookmark-items to a specific folder. 1006 * 1007 * If you are only updating/moving a single bookmark, use update() instead. 1008 * 1009 * @param {Array} guids 1010 * An array of GUIDs representing the bookmarks to move. 1011 * @param {String} parentGuid 1012 * Optional, the parent GUID to move the bookmarks to. 1013 * @param {Integer} index 1014 * The index to move the bookmarks to. If this is -1, the bookmarks 1015 * will be appended to the folder. 1016 * @param {Integer} source 1017 * One of the Bookmarks.SOURCES.* options, representing the source of 1018 * this change. 1019 * 1020 * @return {Promise} resolved when the move is complete. 1021 * @resolves to an array of objects representing the moved bookmarks. 1022 * @rejects if it's not possible to move the given bookmark(s). 1023 * @throws if the arguments are invalid. 1024 */ 1025 moveToFolder(guids, parentGuid, index, source) { 1026 if (!Array.isArray(guids) || guids.length < 1) { 1027 throw new Error("guids should be an array of at least one item"); 1028 } 1029 if (!guids.every(guid => PlacesUtils.isValidGuid(guid))) { 1030 throw new Error("Expected only valid GUIDs to be passed."); 1031 } 1032 if (parentGuid && !PlacesUtils.isValidGuid(parentGuid)) { 1033 throw new Error("parentGuid should be a valid GUID"); 1034 } 1035 if (parentGuid == PlacesUtils.bookmarks.rootGuid) { 1036 throw new Error("Cannot move bookmarks into root."); 1037 } 1038 if (typeof index != "number" || index < this.DEFAULT_INDEX) { 1039 throw new Error( 1040 `index should be a number greater than ${this.DEFAULT_INDEX}` 1041 ); 1042 } 1043 1044 if (!source) { 1045 source = this.SOURCES.DEFAULT; 1046 } 1047 1048 return (async () => { 1049 let updateInfos = []; 1050 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 1051 source 1052 ); 1053 1054 await PlacesUtils.withConnectionWrapper( 1055 "Bookmarks.jsm: moveToFolder", 1056 async db => { 1057 const lastModified = new Date(); 1058 1059 let targetParentGuid = parentGuid || undefined; 1060 1061 for (let guid of guids) { 1062 // Ensure the item exists. 1063 let existingItem = await fetchBookmark({ guid }, { db }); 1064 if (!existingItem) { 1065 throw new Error("No bookmarks found for the provided GUID"); 1066 } 1067 1068 if (parentGuid) { 1069 // We're moving to a different folder. 1070 if (existingItem.type == this.TYPE_FOLDER) { 1071 // Make sure we are not moving a folder into itself or one of its 1072 // descendants. 1073 let rows = await db.executeCached( 1074 `WITH RECURSIVE 1075 descendants(did) AS ( 1076 VALUES(:id) 1077 UNION ALL 1078 SELECT id FROM moz_bookmarks 1079 JOIN descendants ON parent = did 1080 WHERE type = :type 1081 ) 1082 SELECT guid FROM moz_bookmarks 1083 WHERE id IN descendants 1084 `, 1085 { id: existingItem._id, type: this.TYPE_FOLDER } 1086 ); 1087 if ( 1088 rows.map(r => r.getResultByName("guid")).includes(parentGuid) 1089 ) { 1090 throw new Error( 1091 "Cannot insert a folder into itself or one of its descendants" 1092 ); 1093 } 1094 } 1095 } else if (!targetParentGuid) { 1096 targetParentGuid = existingItem.parentGuid; 1097 } else if (existingItem.parentGuid != targetParentGuid) { 1098 throw new Error( 1099 "All bookmarks should be in the same folder if no parent is specified" 1100 ); 1101 } 1102 1103 updateInfos.push({ existingItem, currIndex: existingItem.index }); 1104 } 1105 1106 let newParent = await fetchBookmark( 1107 { guid: targetParentGuid }, 1108 { db } 1109 ); 1110 1111 if (newParent._grandParentId == PlacesUtils.tagsFolderId) { 1112 throw new Error("Can't move to a tags folder"); 1113 } 1114 1115 let newParentChildCount = newParent._childCount; 1116 1117 await db.executeTransaction(async () => { 1118 // Now that we have all the existing items, we can do the actual updates. 1119 for (let i = 0; i < updateInfos.length; i++) { 1120 let info = updateInfos[i]; 1121 if (index != this.DEFAULT_INDEX) { 1122 // If we're dropping on the same folder, then we may need to adjust 1123 // the index to insert at the correct place. 1124 if (info.existingItem.parentGuid == newParent.guid) { 1125 if (index > info.existingItem.index) { 1126 // If we're dragging down, we need to go one lower to insert at 1127 // the real point as moving the element changes the index of 1128 // everything below by 1. 1129 index--; 1130 } else if (index == info.existingItem.index) { 1131 // This isn't moving so we skip it, but copy the data so we have 1132 // an easy way for the notifications to check. 1133 info.updatedItem = { ...info.existingItem }; 1134 continue; 1135 } 1136 } 1137 } 1138 1139 // Never let the index go higher than the max count of the folder. 1140 if (index == this.DEFAULT_INDEX || index >= newParentChildCount) { 1141 index = newParentChildCount; 1142 1143 // If this is moving within the same folder, then we need to drop the 1144 // index by one to compensate for "removing" it, then re-inserting. 1145 if (info.existingItem.parentGuid == newParent.guid) { 1146 index--; 1147 } 1148 } 1149 1150 info.updatedItem = await updateBookmark( 1151 db, 1152 { lastModified, index }, 1153 info.existingItem, 1154 info.currIndex, 1155 newParent, 1156 syncChangeDelta 1157 ); 1158 info.newParent = newParent; 1159 1160 // For items moving within the same folder, we have to keep track 1161 // of their indexes. Otherwise we run the risk of not correctly 1162 // updating the indexes of other items in the folder. 1163 // This section simulates the database write in moveBookmark, which 1164 // allows us to avoid re-reading the database. 1165 if (info.existingItem.parentGuid == newParent.guid) { 1166 let sign = index < info.currIndex ? 1 : -1; 1167 for (let j = 0; j < updateInfos.length; j++) { 1168 if (j == i) { 1169 continue; 1170 } 1171 if ( 1172 updateInfos[j].currIndex >= 1173 Math.min(info.currIndex, index) && 1174 updateInfos[j].currIndex <= Math.max(info.currIndex, index) 1175 ) { 1176 updateInfos[j].currIndex += sign; 1177 } 1178 } 1179 } 1180 info.currIndex = index; 1181 1182 // We only bump the parent count if we're moving from a different folder. 1183 if (info.existingItem.parentGuid != newParent.guid) { 1184 newParentChildCount++; 1185 } 1186 index++; 1187 } 1188 1189 await setAncestorsLastModified( 1190 db, 1191 newParent.guid, 1192 lastModified, 1193 syncChangeDelta 1194 ); 1195 }); 1196 } 1197 ); 1198 1199 const notifications = []; 1200 1201 // Updates complete, time to notify everyone. 1202 for (let { updatedItem, existingItem, newParent } of updateInfos) { 1203 // If the item was moved, notify bookmark-moved. 1204 // We use the updatedItem.index here, rather than currIndex, as the views 1205 // need to know where we inserted the item as opposed to where it ended 1206 // up. 1207 if ( 1208 existingItem.parentGuid != updatedItem.parentGuid || 1209 existingItem.index != updatedItem.index 1210 ) { 1211 notifications.push( 1212 new PlacesBookmarkMoved({ 1213 id: updatedItem._id, 1214 itemType: updatedItem.type, 1215 url: existingItem.url, 1216 guid: updatedItem.guid, 1217 parentGuid: updatedItem.parentGuid, 1218 source, 1219 index: updatedItem.index, 1220 oldParentGuid: existingItem.parentGuid, 1221 oldIndex: existingItem.index, 1222 isTagging: 1223 updatedItem.parentGuid === Bookmarks.tagsGuid || 1224 newParent.parentGuid === Bookmarks.tagsGuid, 1225 }) 1226 ); 1227 } 1228 // Remove non-enumerable properties. 1229 delete updatedItem.source; 1230 } 1231 1232 if (notifications.length) { 1233 PlacesObservers.notifyListeners(notifications); 1234 } 1235 1236 return updateInfos.map(updateInfo => 1237 Object.assign({}, updateInfo.updatedItem) 1238 ); 1239 })(); 1240 }, 1241 1242 /** 1243 * Removes one or more bookmark-items. 1244 * 1245 * @param guidOrInfo This may be: 1246 * - The globally unique identifier of the item to remove 1247 * - an object representing the item, as defined above 1248 * - an array of objects representing the items to be removed 1249 * @param {Object} [options={}] 1250 * Additional options that can be passed to the function. 1251 * Currently supports the following properties: 1252 * - preventRemovalOfNonEmptyFolders: Causes an exception to be 1253 * thrown when attempting to remove a folder that is not empty. 1254 * - source: The change source, forwarded to all bookmark observers. 1255 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 1256 * 1257 * @return {Promise} 1258 * @resolves when the removal is complete 1259 * @rejects if the provided guid doesn't match any existing bookmark. 1260 * @throws if the arguments are invalid. 1261 */ 1262 remove(guidOrInfo, options = {}) { 1263 let infos = guidOrInfo; 1264 if (!infos) { 1265 throw new Error("Input should be a valid object"); 1266 } 1267 if (!Array.isArray(guidOrInfo)) { 1268 if (typeof guidOrInfo != "object") { 1269 infos = [{ guid: guidOrInfo }]; 1270 } else { 1271 infos = [guidOrInfo]; 1272 } 1273 } 1274 1275 if (!("source" in options)) { 1276 options.source = Bookmarks.SOURCES.DEFAULT; 1277 } 1278 1279 let removeInfos = []; 1280 for (let info of infos) { 1281 // Disallow removing the root folders. 1282 if ( 1283 [ 1284 Bookmarks.rootGuid, 1285 Bookmarks.menuGuid, 1286 Bookmarks.toolbarGuid, 1287 Bookmarks.unfiledGuid, 1288 Bookmarks.tagsGuid, 1289 Bookmarks.mobileGuid, 1290 ].includes(info.guid) 1291 ) { 1292 throw new Error("It's not possible to remove Places root folders."); 1293 } 1294 1295 // Even if we ignore any other unneeded property, we still validate any 1296 // known property to reduce likelihood of hidden bugs. 1297 let removeInfo = validateBookmarkObject("Bookmarks.jsm: remove", info); 1298 removeInfos.push(removeInfo); 1299 } 1300 1301 return (async function() { 1302 let removeItems = []; 1303 for (let info of removeInfos) { 1304 // We must be able to remove a bookmark even if it has an invalid url. 1305 // In that case the item won't have a url property. 1306 let item = await fetchBookmark(info, { ignoreInvalidURLs: true }); 1307 if (!item) { 1308 throw new Error("No bookmarks found for the provided GUID."); 1309 } 1310 1311 removeItems.push(item); 1312 } 1313 1314 await removeBookmarks(removeItems, options); 1315 1316 // Notify bookmark-removed to listeners. 1317 let notifications = []; 1318 1319 for (let item of removeItems) { 1320 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 1321 let url = ""; 1322 if (item.type == Bookmarks.TYPE_BOOKMARK) { 1323 url = item.hasOwnProperty("url") ? item.url.href : null; 1324 } 1325 1326 notifications.push( 1327 new PlacesBookmarkRemoved({ 1328 id: item._id, 1329 url, 1330 itemType: item.type, 1331 parentId: item._parentId, 1332 index: item.index, 1333 guid: item.guid, 1334 parentGuid: item.parentGuid, 1335 source: options.source, 1336 isTagging: isUntagging, 1337 isDescendantRemoval: false, 1338 }) 1339 ); 1340 1341 if (isUntagging) { 1342 const tags = PlacesUtils.tagging.getTagsForURI(NetUtil.newURI(url)); 1343 for (let entry of await fetchBookmarksByURL(item, { 1344 concurrent: true, 1345 })) { 1346 notifications.push( 1347 new PlacesBookmarkTags({ 1348 id: entry._id, 1349 itemType: entry.type, 1350 url, 1351 guid: entry.guid, 1352 parentGuid: entry.parentGuid, 1353 tags, 1354 lastModified: entry.lastModified, 1355 source: options.source, 1356 isTagging: false, 1357 }) 1358 ); 1359 } 1360 } 1361 } 1362 1363 PlacesObservers.notifyListeners(notifications); 1364 })(); 1365 }, 1366 1367 /** 1368 * Removes ALL bookmarks, resetting the bookmarks storage to an empty tree. 1369 * 1370 * Note that roots are preserved, only their children will be removed. 1371 * 1372 * @param {Object} [options={}] 1373 * Additional options. Currently supports the following properties: 1374 * - source: The change source, forwarded to all bookmark observers. 1375 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 1376 * 1377 * @return {Promise} resolved when the removal is complete. 1378 * @resolves once the removal is complete. 1379 */ 1380 eraseEverything(options = {}) { 1381 if (!options.source) { 1382 options.source = Bookmarks.SOURCES.DEFAULT; 1383 } 1384 1385 return PlacesUtils.withConnectionWrapper( 1386 "Bookmarks.jsm: eraseEverything", 1387 async function(db) { 1388 let urls; 1389 1390 await db.executeTransaction(async function() { 1391 urls = await removeFoldersContents( 1392 db, 1393 Bookmarks.userContentRoots, 1394 options 1395 ); 1396 const time = PlacesUtils.toPRTime(new Date()); 1397 const syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 1398 options.source 1399 ); 1400 for (let folderGuid of Bookmarks.userContentRoots) { 1401 await db.executeCached( 1402 `UPDATE moz_bookmarks SET lastModified = :time, 1403 syncChangeCounter = syncChangeCounter + :syncChangeDelta 1404 WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid ) 1405 `, 1406 { folderGuid, time, syncChangeDelta } 1407 ); 1408 } 1409 1410 await PlacesSyncUtils.bookmarks.resetSyncMetadata(db, options.source); 1411 }); 1412 1413 // We don't wait for the frecency calculation. 1414 if (urls && urls.length) { 1415 await PlacesUtils.keywords.eraseEverything(); 1416 updateFrecency(db, urls).catch(Cu.reportError); 1417 } 1418 } 1419 ); 1420 }, 1421 1422 /** 1423 * Returns a list of recently bookmarked items. 1424 * Only includes actual bookmarks. Excludes folders, separators and queries. 1425 * 1426 * @param {integer} numberOfItems 1427 * The maximum number of bookmark items to return. 1428 * 1429 * @return {Promise} resolved when the listing is complete. 1430 * @resolves to an array of recent bookmark-items. 1431 * @rejects if an error happens while querying. 1432 */ 1433 getRecent(numberOfItems) { 1434 if (numberOfItems === undefined) { 1435 throw new Error("numberOfItems argument is required"); 1436 } 1437 if (!typeof numberOfItems === "number" || numberOfItems % 1 !== 0) { 1438 throw new Error("numberOfItems argument must be an integer"); 1439 } 1440 if (numberOfItems <= 0) { 1441 throw new Error("numberOfItems argument must be greater than zero"); 1442 } 1443 1444 return fetchRecentBookmarks(numberOfItems); 1445 }, 1446 1447 /** 1448 * Fetches information about a bookmark-item. 1449 * 1450 * REMARK: any successful call to this method resolves to a single 1451 * bookmark-item (or null), even when multiple bookmarks may exist 1452 * (e.g. fetching by url). If you wish to retrieve all of the 1453 * bookmarks for a given match, use the callback instead. 1454 * 1455 * Input can be either a guid or an object with one, and only one, of these 1456 * filtering properties set: 1457 * - guid 1458 * retrieves the item with the specified guid. 1459 * - parentGuid and index 1460 * retrieves the item by its position. 1461 * - url 1462 * retrieves the most recent bookmark having the given URL. 1463 * To retrieve ALL of the bookmarks for that URL, you must pass in an 1464 * onResult callback, that will be invoked once for each found bookmark. 1465 * - guidPrefix 1466 * retrieves the most recent item with the specified guid prefix. 1467 * To retrieve ALL of the bookmarks for that guid prefix, you must pass 1468 * in an onResult callback, that will be invoked once for each bookmark. 1469 * - tags 1470 * Retrieves the most recent item with all the specified tags. 1471 * The tags are matched in a case-insensitive way. 1472 * To retrieve ALL of the bookmarks having these tags, pass in an 1473 * onResult callback, that will be invoked once for each bookmark. 1474 * Note, there can be multiple bookmarks for the same url, if you need 1475 * unique tagged urls you can filter duplicates by accumulating in a Set. 1476 * 1477 * @param guidOrInfo 1478 * The globally unique identifier of the item to fetch, or an 1479 * object representing it, as defined above. 1480 * @param onResult [optional] 1481 * Callback invoked for each found bookmark. 1482 * @param options [optional] 1483 * an optional object whose properties describe options for the fetch: 1484 * - concurrent: fetches concurrently to any writes, returning results 1485 * faster. On the negative side, it may return stale 1486 * information missing the currently ongoing write. 1487 * - includePath: additionally fetches the path for the bookmarks. 1488 * This is a potentially expensive operation. When 1489 * set to true, the path property is set on results 1490 * containing an array of {title, guid} objects 1491 * ordered from root to leaf. 1492 * 1493 * @return {Promise} resolved when the fetch is complete. 1494 * @resolves to an object representing the found item, as described above, or 1495 * an array of such objects. if no item is found, the returned 1496 * promise is resolved to null. 1497 * @rejects if an error happens while fetching. 1498 * @throws if the arguments are invalid. 1499 * 1500 * @note Any unknown property in the info object is ignored. Known properties 1501 * may be overwritten. 1502 */ 1503 fetch(guidOrInfo, onResult = null, options = {}) { 1504 if (onResult && typeof onResult != "function") { 1505 throw new Error("onResult callback must be a valid function"); 1506 } 1507 let info = guidOrInfo; 1508 if (!info) { 1509 throw new Error("Input should be a valid object"); 1510 } 1511 if (typeof info != "object") { 1512 info = { guid: guidOrInfo }; 1513 } else if (Object.keys(info).length == 1) { 1514 // Just a faster code path. 1515 if ( 1516 !["url", "guid", "parentGuid", "index", "guidPrefix", "tags"].includes( 1517 Object.keys(info)[0] 1518 ) 1519 ) { 1520 throw new Error(`Unexpected number of conditions provided: 0`); 1521 } 1522 } else { 1523 // Only one condition at a time can be provided. 1524 let conditionsCount = [ 1525 v => v.hasOwnProperty("guid"), 1526 v => v.hasOwnProperty("parentGuid") && v.hasOwnProperty("index"), 1527 v => v.hasOwnProperty("url"), 1528 v => v.hasOwnProperty("guidPrefix"), 1529 v => v.hasOwnProperty("tags"), 1530 ].reduce((old, fn) => (old + fn(info)) | 0, 0); 1531 if (conditionsCount != 1) { 1532 throw new Error( 1533 `Unexpected number of conditions provided: ${conditionsCount}` 1534 ); 1535 } 1536 } 1537 1538 // Create a new options object with just the support properties, because 1539 // we may augment it and hand it down to other methods. 1540 options = { 1541 concurrent: !!options.concurrent, 1542 includePath: !!options.includePath, 1543 }; 1544 1545 let behavior = {}; 1546 if (info.hasOwnProperty("parentGuid") || info.hasOwnProperty("index")) { 1547 behavior = { 1548 parentGuid: { requiredIf: b => b.hasOwnProperty("index") }, 1549 index: { 1550 validIf: b => 1551 (typeof b.index == "number" && b.index >= 0) || 1552 b.index == this.DEFAULT_INDEX, 1553 }, 1554 }; 1555 } 1556 1557 // Even if we ignore any other unneeded property, we still validate any 1558 // known property to reduce likelihood of hidden bugs. 1559 let fetchInfo = validateBookmarkObject( 1560 "Bookmarks.jsm: fetch", 1561 info, 1562 behavior 1563 ); 1564 1565 return (async function() { 1566 let results; 1567 if (fetchInfo.hasOwnProperty("url")) { 1568 results = await fetchBookmarksByURL(fetchInfo, options); 1569 } else if (fetchInfo.hasOwnProperty("guid")) { 1570 results = await fetchBookmark(fetchInfo, options); 1571 } else if (fetchInfo.hasOwnProperty("parentGuid")) { 1572 if (fetchInfo.hasOwnProperty("index")) { 1573 results = await fetchBookmarkByPosition(fetchInfo, options); 1574 } else { 1575 results = await fetchBookmarksByParentGUID(fetchInfo, options); 1576 } 1577 } else if (fetchInfo.hasOwnProperty("guidPrefix")) { 1578 results = await fetchBookmarksByGUIDPrefix(fetchInfo, options); 1579 } else if (fetchInfo.hasOwnProperty("tags")) { 1580 results = await fetchBookmarksByTags(fetchInfo, options); 1581 } 1582 1583 if (!results) { 1584 return null; 1585 } 1586 1587 if (!Array.isArray(results)) { 1588 results = [results]; 1589 } 1590 // Remove non-enumerable properties. 1591 results = results.map(r => Object.assign({}, r)); 1592 1593 if (options.includePath) { 1594 for (let result of results) { 1595 let folderPath = await retrieveFullBookmarkPath(result.parentGuid); 1596 if (folderPath) { 1597 result.path = folderPath; 1598 } 1599 } 1600 } 1601 1602 // Ideally this should handle an incremental behavior and thus be invoked 1603 // while we fetch. Though, the likelihood of 2 or more bookmarks for the 1604 // same match is very low, so it's not worth the added code complication. 1605 if (onResult) { 1606 for (let result of results) { 1607 try { 1608 onResult(result); 1609 } catch (ex) { 1610 Cu.reportError(ex); 1611 } 1612 } 1613 } 1614 1615 return results[0]; 1616 })(); 1617 }, 1618 1619 /** 1620 * Retrieves an object representation of a bookmark-item, along with all of 1621 * its descendants, if any. 1622 * 1623 * Each node in the tree is an object that extends the item representation 1624 * described above with some additional properties: 1625 * 1626 * - [deprecated] id (number) 1627 * the item's id. Defined only if aOptions.includeItemIds is set. 1628 * - annos (array) 1629 * the item's annotations. This is not set if there are no annotations 1630 * set for the item. 1631 * 1632 * The root object of the tree also has the following properties set: 1633 * - itemsCount (number, not enumerable) 1634 * the number of items, including the root item itself, which are 1635 * represented in the resolved object. 1636 * 1637 * Bookmarked URLs may also have the following properties: 1638 * - tags (string) 1639 * csv string of the bookmark's tags, if any. 1640 * - charset (string) 1641 * the last known charset of the bookmark, if any. 1642 * - iconurl (URL) 1643 * the bookmark's favicon URL, if any. 1644 * 1645 * Folders may also have the following properties: 1646 * - children (array) 1647 * the folder's children information, each of them having the same set of 1648 * properties as above. 1649 * 1650 * @param [optional] guid 1651 * the topmost item to be queried. If it's not passed, the Places 1652 * root folder is queried: that is, you get a representation of the 1653 * entire bookmarks hierarchy. 1654 * @param [optional] options 1655 * Options for customizing the query behavior, in the form of an 1656 * object with any of the following properties: 1657 * - excludeItemsCallback: a function for excluding items, along with 1658 * their descendants. Given an item object (that has everything set 1659 * apart its potential children data), it should return true if the 1660 * item should be excluded. Once an item is excluded, the function 1661 * isn't called for any of its descendants. This isn't called for 1662 * the root item. 1663 * WARNING: since the function may be called for each item, using 1664 * this option can slow down the process significantly if the 1665 * callback does anything that's not relatively trivial. It is 1666 * highly recommended to avoid any synchronous I/O or DB queries. 1667 * - includeItemIds: opt-in to include the deprecated id property. 1668 * Use it if you must. It'll be removed once the switch to guids is 1669 * complete. 1670 * 1671 * @return {Promise} resolved when the fetch is complete. 1672 * @resolves to an object that represents either a single item or a 1673 * bookmarks tree. if guid points to a non-existent item, the 1674 * returned promise is resolved to null. 1675 * @rejects if an error happens while fetching. 1676 * @throws if the arguments are invalid. 1677 */ 1678 // TODO must implement these methods yet: 1679 // PlacesUtils.promiseBookmarksTree() 1680 fetchTree(guid = "", options = {}) { 1681 throw new Error("Not yet implemented"); 1682 }, 1683 1684 /** 1685 * Fetch all the existing tags, sorted alphabetically. 1686 * @return {Promise} resolves to an array of objects representing tags, when 1687 * fetching is complete. 1688 * Each object looks like { 1689 * name: the name of the tag, 1690 * count: number of bookmarks with this tag 1691 * } 1692 */ 1693 async fetchTags() { 1694 // TODO: Once the tagging API is implemented in Bookmarks.jsm, we can cache 1695 // the list of tags, instead of querying every time. 1696 let db = await PlacesUtils.promiseDBConnection(); 1697 let rows = await db.executeCached( 1698 ` 1699 SELECT b.title AS name, count(*) AS count 1700 FROM moz_bookmarks b 1701 JOIN moz_bookmarks p ON b.parent = p.id 1702 JOIN moz_bookmarks c ON c.parent = b.id 1703 WHERE p.guid = :tagsGuid 1704 GROUP BY name 1705 ORDER BY name COLLATE nocase ASC 1706 `, 1707 { tagsGuid: this.tagsGuid } 1708 ); 1709 return rows.map(r => ({ 1710 name: r.getResultByName("name"), 1711 count: r.getResultByName("count"), 1712 })); 1713 }, 1714 1715 /** 1716 * Reorders contents of a folder based on a provided array of GUIDs. 1717 * 1718 * @param parentGuid 1719 * The globally unique identifier of the folder whose contents should 1720 * be reordered. 1721 * @param orderedChildrenGuids 1722 * Ordered array of the children's GUIDs. If this list contains 1723 * non-existing entries they will be ignored. If the list is 1724 * incomplete, and the current child list is already in order with 1725 * respect to orderedChildrenGuids, no change is made. Otherwise, the 1726 * new items are appended but maintain their current order relative to 1727 * eachother. 1728 * @param {Object} [options={}] 1729 * Additional options. Currently supports the following properties: 1730 * - lastModified: The last modified time to use for the folder and 1731 reordered children. Defaults to the current time. 1732 * - source: The change source, forwarded to all bookmark observers. 1733 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 1734 * 1735 * @return {Promise} resolved when reordering is complete. 1736 * @rejects if an error happens while reordering. 1737 * @throws if the arguments are invalid. 1738 */ 1739 reorder(parentGuid, orderedChildrenGuids, options = {}) { 1740 let info = { guid: parentGuid }; 1741 info = validateBookmarkObject("Bookmarks.jsm: reorder", info, { 1742 guid: { required: true }, 1743 }); 1744 1745 if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length) { 1746 throw new Error("Must provide a sorted array of children GUIDs."); 1747 } 1748 try { 1749 orderedChildrenGuids.forEach(PlacesUtils.BOOKMARK_VALIDATORS.guid); 1750 } catch (ex) { 1751 throw new Error("Invalid GUID found in the sorted children array."); 1752 } 1753 1754 options.source = 1755 "source" in options 1756 ? PlacesUtils.BOOKMARK_VALIDATORS.source(options.source) 1757 : Bookmarks.SOURCES.DEFAULT; 1758 options.lastModified = 1759 "lastModified" in options 1760 ? PlacesUtils.BOOKMARK_VALIDATORS.lastModified(options.lastModified) 1761 : new Date(); 1762 1763 return (async () => { 1764 let parent = await fetchBookmark(info); 1765 if (!parent || parent.type != this.TYPE_FOLDER) { 1766 throw new Error("No folder found for the provided GUID."); 1767 } 1768 1769 let sortedChildren = await reorderChildren( 1770 parent, 1771 orderedChildrenGuids, 1772 options 1773 ); 1774 1775 const notifications = []; 1776 1777 // Note that child.index is the old index. 1778 for (let i = 0; i < sortedChildren.length; ++i) { 1779 let child = sortedChildren[i]; 1780 notifications.push( 1781 new PlacesBookmarkMoved({ 1782 id: child._id, 1783 itemType: child.type, 1784 url: child.url && child.url.href, 1785 guid: child.guid, 1786 parentGuid: child.parentGuid, 1787 source: options.source, 1788 index: i, 1789 oldParentGuid: child.parentGuid, 1790 oldIndex: child.index, 1791 isTagging: 1792 child.parentGuid === Bookmarks.tagsGuid || 1793 parent.parentGuid === Bookmarks.tagsGuid, 1794 }) 1795 ); 1796 } 1797 1798 if (notifications.length) { 1799 PlacesObservers.notifyListeners(notifications); 1800 } 1801 })(); 1802 }, 1803 1804 /** 1805 * Searches a list of bookmark-items by a search term, url or title. 1806 * 1807 * IMPORTANT: 1808 * This is intended as an interim API for the web-extensions implementation. 1809 * It will be removed as soon as we have a new querying API. 1810 * 1811 * Note also that this used to exclude separators but no longer does so. 1812 * 1813 * If you just want to search bookmarks by URL, use .fetch() instead. 1814 * 1815 * @param query 1816 * Either a string to use as search term, or an object 1817 * containing any of these keys: query, title or url with the 1818 * corresponding string to match as value. 1819 * The url property can be either a string or an nsIURI. 1820 * 1821 * @return {Promise} resolved when the search is complete. 1822 * @resolves to an array of found bookmark-items. 1823 * @rejects if an error happens while searching. 1824 * @throws if the arguments are invalid. 1825 * 1826 * @note Any unknown property in the query object is ignored. 1827 * Known properties may be overwritten. 1828 */ 1829 search(query) { 1830 if (!query) { 1831 throw new Error("Query object is required"); 1832 } 1833 if (typeof query === "string") { 1834 query = { query }; 1835 } 1836 if (typeof query !== "object") { 1837 throw new Error("Query must be an object or a string"); 1838 } 1839 if (query.query && typeof query.query !== "string") { 1840 throw new Error("Query option must be a string"); 1841 } 1842 if (query.title && typeof query.title !== "string") { 1843 throw new Error("Title option must be a string"); 1844 } 1845 1846 if (query.url) { 1847 if (typeof query.url === "string" || query.url instanceof URL) { 1848 query.url = new URL(query.url).href; 1849 } else if (query.url instanceof Ci.nsIURI) { 1850 query.url = query.url.spec; 1851 } else { 1852 throw new Error("Url option must be a string or a URL object"); 1853 } 1854 } 1855 1856 return queryBookmarks(query); 1857 }, 1858}); 1859 1860// Globals. 1861 1862// Update implementation. 1863 1864/** 1865 * Updates a single bookmark in the database. This should be called from within 1866 * a transaction. 1867 * 1868 * @param {Object} db The pre-existing database connection. 1869 * @param {Object} info A bookmark-item structure with new properties. 1870 * @param {Object} item A bookmark-item structure representing the existing bookmark. 1871 * @param {Integer} oldIndex The index of the item in the old parent. 1872 * @param {Object} newParent The new parent folder (note: this may be the same as) 1873 * the existing folder. 1874 * @param {Integer} syncChangeDelta The change delta to be applied. 1875 */ 1876async function updateBookmark( 1877 db, 1878 info, 1879 item, 1880 oldIndex, 1881 newParent, 1882 syncChangeDelta 1883) { 1884 let tuples = new Map(); 1885 tuples.set("lastModified", { 1886 value: PlacesUtils.toPRTime(info.lastModified), 1887 }); 1888 if (info.hasOwnProperty("title")) { 1889 tuples.set("title", { 1890 value: info.title, 1891 fragment: `title = NULLIF(:title, '')`, 1892 }); 1893 } 1894 if (info.hasOwnProperty("dateAdded")) { 1895 tuples.set("dateAdded", { value: PlacesUtils.toPRTime(info.dateAdded) }); 1896 } 1897 1898 if (info.hasOwnProperty("url")) { 1899 // Ensure a page exists in moz_places for this URL. 1900 await maybeInsertPlace(db, info.url); 1901 // Update tuples for the update query. 1902 tuples.set("url", { 1903 value: info.url.href, 1904 fragment: 1905 "fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)", 1906 }); 1907 } 1908 1909 let newIndex = info.hasOwnProperty("index") ? info.index : item.index; 1910 if (newParent) { 1911 // For simplicity, update the index regardless. 1912 tuples.set("position", { value: newIndex }); 1913 1914 // For moving within the same parent, we've already updated the indexes. 1915 if (newParent.guid == item.parentGuid) { 1916 // Moving inside the original container. 1917 // When moving "up", add 1 to each index in the interval. 1918 // Otherwise when moving down, we subtract 1. 1919 // Only the parent needs a sync change, which is handled in 1920 // `setAncestorsLastModified`. 1921 await db.executeCached( 1922 `UPDATE moz_bookmarks 1923 SET position = CASE WHEN :newIndex < :currIndex 1924 THEN position + 1 1925 ELSE position - 1 1926 END 1927 WHERE parent = :newParentId 1928 AND position BETWEEN :lowIndex AND :highIndex 1929 `, 1930 { 1931 newIndex, 1932 currIndex: oldIndex, 1933 newParentId: newParent._id, 1934 lowIndex: Math.min(oldIndex, newIndex), 1935 highIndex: Math.max(oldIndex, newIndex), 1936 } 1937 ); 1938 } else { 1939 // Moving across different containers. In this case, both parents and 1940 // the child need sync changes. `setAncestorsLastModified`, below and in 1941 // `update` and `moveToFolder`, handles the parents. The `needsSyncChange` 1942 // check below handles the child. 1943 tuples.set("parent", { value: newParent._id }); 1944 await db.executeCached( 1945 `UPDATE moz_bookmarks SET position = position - 1 1946 WHERE parent = :oldParentId 1947 AND position >= :oldIndex 1948 `, 1949 { oldParentId: item._parentId, oldIndex } 1950 ); 1951 await db.executeCached( 1952 `UPDATE moz_bookmarks SET position = position + 1 1953 WHERE parent = :newParentId 1954 AND position >= :newIndex 1955 `, 1956 { newParentId: newParent._id, newIndex } 1957 ); 1958 1959 await setAncestorsLastModified( 1960 db, 1961 item.parentGuid, 1962 info.lastModified, 1963 syncChangeDelta 1964 ); 1965 } 1966 } 1967 1968 if (syncChangeDelta) { 1969 // Sync stores child indices in the parent's record, so we only bump the 1970 // item's counter if we're updating at least one more property in 1971 // addition to the index, last modified time, and dateAdded. 1972 let sizeThreshold = 1; 1973 if (newIndex != oldIndex) { 1974 ++sizeThreshold; 1975 } 1976 if (tuples.has("dateAdded")) { 1977 ++sizeThreshold; 1978 } 1979 let needsSyncChange = tuples.size > sizeThreshold; 1980 if (needsSyncChange) { 1981 tuples.set("syncChangeDelta", { 1982 value: syncChangeDelta, 1983 fragment: "syncChangeCounter = syncChangeCounter + :syncChangeDelta", 1984 }); 1985 } 1986 } 1987 1988 let isTagging = item._grandParentId == PlacesUtils.tagsFolderId; 1989 if (isTagging) { 1990 // If we're updating a tag entry, bump the sync change counter for 1991 // bookmarks with the tagged URL. 1992 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 1993 db, 1994 item.url, 1995 syncChangeDelta 1996 ); 1997 if (info.hasOwnProperty("url")) { 1998 // Changing the URL of a tag entry is equivalent to untagging the 1999 // old URL and tagging the new one, so we bump the change counter 2000 // for the new URL here. 2001 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 2002 db, 2003 info.url, 2004 syncChangeDelta 2005 ); 2006 } 2007 } 2008 2009 let isChangingTagFolder = item._parentId == PlacesUtils.tagsFolderId; 2010 if (isChangingTagFolder && syncChangeDelta) { 2011 // If we're updating a tag folder (for example, changing a tag's title), 2012 // bump the change counter for all tagged bookmarks. 2013 await db.executeCached( 2014 ` 2015 UPDATE moz_bookmarks SET 2016 syncChangeCounter = syncChangeCounter + :syncChangeDelta 2017 WHERE type = :type AND 2018 fk = (SELECT fk FROM moz_bookmarks WHERE parent = :parent) 2019 `, 2020 { syncChangeDelta, type: Bookmarks.TYPE_BOOKMARK, parent: item._id } 2021 ); 2022 } 2023 2024 await db.executeCached( 2025 `UPDATE moz_bookmarks 2026 SET ${Array.from(tuples.keys()) 2027 .map(v => tuples.get(v).fragment || `${v} = :${v}`) 2028 .join(", ")} 2029 WHERE guid = :guid 2030 `, 2031 Object.assign( 2032 { guid: item.guid }, 2033 [...tuples.entries()].reduce((p, c) => { 2034 p[c[0]] = c[1].value; 2035 return p; 2036 }, {}) 2037 ) 2038 ); 2039 2040 if (newParent) { 2041 if (newParent.guid == item.parentGuid) { 2042 // Mark all affected separators as changed 2043 // Also bumps the change counter if the item itself is a separator 2044 const startIndex = Math.min(newIndex, oldIndex); 2045 await adjustSeparatorsSyncCounter( 2046 db, 2047 newParent._id, 2048 startIndex, 2049 syncChangeDelta 2050 ); 2051 } else { 2052 // Mark all affected separators as changed 2053 await adjustSeparatorsSyncCounter( 2054 db, 2055 item._parentId, 2056 oldIndex, 2057 syncChangeDelta 2058 ); 2059 await adjustSeparatorsSyncCounter( 2060 db, 2061 newParent._id, 2062 newIndex, 2063 syncChangeDelta 2064 ); 2065 } 2066 // Remove the Sync orphan annotation from reparented items. We don't 2067 // notify annotation observers about this because this is a temporary, 2068 // internal anno that's only used by Sync. 2069 await db.executeCached( 2070 `DELETE FROM moz_items_annos 2071 WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes 2072 WHERE name = :orphanAnno) AND 2073 item_id = :id`, 2074 { orphanAnno: PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO, id: item._id } 2075 ); 2076 } 2077 2078 // If the parent changed, update related non-enumerable properties. 2079 let additionalParentInfo = {}; 2080 if (newParent) { 2081 additionalParentInfo.parentGuid = newParent.guid; 2082 Object.defineProperty(additionalParentInfo, "_parentId", { 2083 value: newParent._id, 2084 enumerable: false, 2085 }); 2086 Object.defineProperty(additionalParentInfo, "_grandParentId", { 2087 value: newParent._parentId, 2088 enumerable: false, 2089 }); 2090 } 2091 2092 return mergeIntoNewObject(item, info, additionalParentInfo); 2093} 2094 2095// Insert implementation. 2096 2097function insertBookmark(item, parent) { 2098 return PlacesUtils.withConnectionWrapper( 2099 "Bookmarks.jsm: insertBookmark", 2100 async function(db) { 2101 // If a guid was not provided, generate one, so we won't need to fetch the 2102 // bookmark just after having created it. 2103 let hasExistingGuid = item.hasOwnProperty("guid"); 2104 if (!hasExistingGuid) { 2105 item.guid = PlacesUtils.history.makeGuid(); 2106 } 2107 2108 let isTagging = parent._parentId == PlacesUtils.tagsFolderId; 2109 2110 await db.executeTransaction(async function transaction() { 2111 if (item.type == Bookmarks.TYPE_BOOKMARK) { 2112 // Ensure a page exists in moz_places for this URL. 2113 // The IGNORE conflict can trigger on `guid`. 2114 await maybeInsertPlace(db, item.url); 2115 } 2116 2117 // Adjust indices. 2118 await db.executeCached( 2119 `UPDATE moz_bookmarks SET position = position + 1 2120 WHERE parent = :parent 2121 AND position >= :index 2122 `, 2123 { parent: parent._id, index: item.index } 2124 ); 2125 2126 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 2127 item.source 2128 ); 2129 let syncStatus = PlacesSyncUtils.bookmarks.determineInitialSyncStatus( 2130 item.source 2131 ); 2132 2133 // Insert the bookmark into the database. 2134 await db.executeCached( 2135 `INSERT INTO moz_bookmarks (fk, type, parent, position, title, 2136 dateAdded, lastModified, guid, 2137 syncChangeCounter, syncStatus) 2138 VALUES (CASE WHEN :url ISNULL THEN NULL ELSE (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) END, 2139 :type, :parent, :index, NULLIF(:title, ''), :date_added, 2140 :last_modified, :guid, :syncChangeCounter, :syncStatus) 2141 `, 2142 { 2143 url: item.hasOwnProperty("url") ? item.url.href : null, 2144 type: item.type, 2145 parent: parent._id, 2146 index: item.index, 2147 title: item.title, 2148 date_added: PlacesUtils.toPRTime(item.dateAdded), 2149 last_modified: PlacesUtils.toPRTime(item.lastModified), 2150 guid: item.guid, 2151 syncChangeCounter: syncChangeDelta, 2152 syncStatus, 2153 } 2154 ); 2155 2156 // Mark all affected separators as changed 2157 await adjustSeparatorsSyncCounter( 2158 db, 2159 parent._id, 2160 item.index + 1, 2161 syncChangeDelta 2162 ); 2163 2164 if (hasExistingGuid) { 2165 // Remove stale tombstones if we're reinserting an item. 2166 await db.executeCached( 2167 `DELETE FROM moz_bookmarks_deleted WHERE guid = :guid`, 2168 { guid: item.guid } 2169 ); 2170 } 2171 2172 if (isTagging) { 2173 // New tag entry; bump the change counter for bookmarks with the 2174 // tagged URL. 2175 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 2176 db, 2177 item.url, 2178 syncChangeDelta 2179 ); 2180 } 2181 2182 await setAncestorsLastModified( 2183 db, 2184 item.parentGuid, 2185 item.dateAdded, 2186 syncChangeDelta 2187 ); 2188 }); 2189 2190 // If not a tag recalculate frecency... 2191 if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) { 2192 // ...though we don't wait for the calculation. 2193 updateFrecency(db, [item.url]).catch(Cu.reportError); 2194 } 2195 2196 return item; 2197 } 2198 ); 2199} 2200 2201function insertBookmarkTree(items, source, parent, urls, lastAddedForParent) { 2202 return PlacesUtils.withConnectionWrapper( 2203 "Bookmarks.jsm: insertBookmarkTree", 2204 async function(db) { 2205 await db.executeTransaction(async function transaction() { 2206 await maybeInsertManyPlaces(db, urls); 2207 2208 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 2209 source 2210 ); 2211 let syncStatus = PlacesSyncUtils.bookmarks.determineInitialSyncStatus( 2212 source 2213 ); 2214 2215 let rootId = parent._id; 2216 2217 items = items.map(item => ({ 2218 url: item.url && item.url.href, 2219 type: item.type, 2220 parentGuid: item.parentGuid, 2221 index: item.index, 2222 title: item.title, 2223 date_added: PlacesUtils.toPRTime(item.dateAdded), 2224 last_modified: PlacesUtils.toPRTime(item.lastModified), 2225 guid: item.guid, 2226 syncChangeCounter: syncChangeDelta, 2227 syncStatus, 2228 rootId, 2229 })); 2230 await db.executeCached( 2231 `INSERT INTO moz_bookmarks (fk, type, parent, position, title, 2232 dateAdded, lastModified, guid, 2233 syncChangeCounter, syncStatus) 2234 VALUES (CASE WHEN :url ISNULL THEN NULL ELSE (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) END, :type, 2235 (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), 2236 IFNULL(:index, (SELECT count(*) FROM moz_bookmarks WHERE parent = :rootId)), 2237 NULLIF(:title, ''), :date_added, :last_modified, :guid, 2238 :syncChangeCounter, :syncStatus)`, 2239 items 2240 ); 2241 2242 // Remove stale tombstones for new items. 2243 for (let chunk of PlacesUtils.chunkArray(items, db.variableLimit)) { 2244 await db.executeCached( 2245 `DELETE FROM moz_bookmarks_deleted 2246 WHERE guid IN (${sqlBindPlaceholders(chunk)})`, 2247 chunk.map(item => item.guid) 2248 ); 2249 } 2250 2251 await setAncestorsLastModified( 2252 db, 2253 parent.guid, 2254 lastAddedForParent, 2255 syncChangeDelta 2256 ); 2257 }); 2258 2259 // We don't wait for the frecency calculation. 2260 updateFrecency(db, urls).catch(Cu.reportError); 2261 2262 return items; 2263 } 2264 ); 2265} 2266 2267/** 2268 * Handles special data on a bookmark, e.g. annotations, keywords, tags, charsets, 2269 * inserting the data into the appropriate place. 2270 * 2271 * @param {Integer} itemId The ID of the item within the bookmarks database. 2272 * @param {Object} item The bookmark item with possible special data to be inserted. 2273 */ 2274async function handleBookmarkItemSpecialData(itemId, item) { 2275 if ("keyword" in item && item.keyword) { 2276 try { 2277 await PlacesUtils.keywords.insert({ 2278 keyword: item.keyword, 2279 url: item.url, 2280 postData: item.postData, 2281 source: item.source, 2282 }); 2283 } catch (ex) { 2284 Cu.reportError( 2285 `Failed to insert keyword "${item.keyword} for ${item.url}": ${ex}` 2286 ); 2287 } 2288 } 2289 if ("tags" in item) { 2290 try { 2291 PlacesUtils.tagging.tagURI( 2292 NetUtil.newURI(item.url), 2293 item.tags, 2294 item.source 2295 ); 2296 } catch (ex) { 2297 // Invalid tag child, skip it. 2298 Cu.reportError( 2299 `Unable to set tags "${item.tags.join(", ")}" for ${item.url}: ${ex}` 2300 ); 2301 } 2302 } 2303 if ("charset" in item && item.charset) { 2304 try { 2305 // UTF-8 is the default. If we are passed the value then set it to null, 2306 // to ensure any charset is removed from the database. 2307 let charset = item.charset; 2308 if (item.charset.toLowerCase() == "utf-8") { 2309 charset = null; 2310 } 2311 2312 await PlacesUtils.history.update({ 2313 url: item.url, 2314 annotations: new Map([[PlacesUtils.CHARSET_ANNO, charset]]), 2315 }); 2316 } catch (ex) { 2317 Cu.reportError( 2318 `Failed to set charset "${item.charset}" for ${item.url}: ${ex}` 2319 ); 2320 } 2321 } 2322} 2323 2324// Query implementation. 2325 2326async function queryBookmarks(info) { 2327 let queryParams = { 2328 tags_folder: await promiseTagsFolderId(), 2329 }; 2330 // We're searching for bookmarks, so exclude tags. 2331 let queryString = "WHERE b.parent <> :tags_folder"; 2332 queryString += " AND p.parent <> :tags_folder"; 2333 2334 if (info.title) { 2335 queryString += " AND b.title = :title"; 2336 queryParams.title = info.title; 2337 } 2338 2339 if (info.url) { 2340 queryString += " AND h.url_hash = hash(:url) AND h.url = :url"; 2341 queryParams.url = info.url; 2342 } 2343 2344 if (info.query) { 2345 queryString += 2346 " AND AUTOCOMPLETE_MATCH(:query, h.url, b.title, NULL, NULL, 1, 1, NULL, :matchBehavior, :searchBehavior, NULL) "; 2347 queryParams.query = info.query; 2348 queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED; 2349 queryParams.searchBehavior = BEHAVIOR_BOOKMARK; 2350 } 2351 2352 return PlacesUtils.withConnectionWrapper( 2353 "Bookmarks.jsm: queryBookmarks", 2354 async function(db) { 2355 // _id, _childCount, _grandParentId and _parentId fields 2356 // are required to be in the result by the converting function 2357 // hence setting them to NULL 2358 let rows = await db.executeCached( 2359 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2360 b.dateAdded, b.lastModified, b.type, 2361 IFNULL(b.title, '') AS title, h.url AS url, b.parent, p.parent, 2362 NULL AS _id, 2363 NULL AS _childCount, 2364 NULL AS _grandParentId, 2365 NULL AS _parentId, 2366 NULL AS _syncStatus 2367 FROM moz_bookmarks b 2368 LEFT JOIN moz_bookmarks p ON p.id = b.parent 2369 LEFT JOIN moz_places h ON h.id = b.fk 2370 ${queryString} 2371 `, 2372 queryParams 2373 ); 2374 2375 return rowsToItemsArray(rows); 2376 } 2377 ); 2378} 2379 2380/** 2381 * Internal fetch implementation. 2382 * @param {object} info 2383 * The bookmark item to remove. 2384 * @param {object} options 2385 * An options object supporting the following properties: 2386 * @param {object} [options.concurrent] 2387 * Whether to use the concurrent read-only connection. 2388 * @param {object} [options.db] 2389 * A specific connection to be used. 2390 * @param {object} [options.ignoreInvalidURLs] 2391 * Whether invalid URLs should be ignored or throw an exception. 2392 * 2393 */ 2394async function fetchBookmark(info, options = {}) { 2395 let query = async function(db) { 2396 let rows = await db.executeCached( 2397 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2398 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2399 h.url AS url, b.id AS _id, b.parent AS _parentId, 2400 (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount, 2401 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 2402 FROM moz_bookmarks b 2403 LEFT JOIN moz_bookmarks p ON p.id = b.parent 2404 LEFT JOIN moz_places h ON h.id = b.fk 2405 WHERE b.guid = :guid 2406 `, 2407 { guid: info.guid } 2408 ); 2409 2410 return rows.length 2411 ? rowsToItemsArray(rows, !!options.ignoreInvalidURLs)[0] 2412 : null; 2413 }; 2414 if (options.concurrent) { 2415 let db = await PlacesUtils.promiseDBConnection(); 2416 return query(db); 2417 } 2418 if (options.db) { 2419 return query(options.db); 2420 } 2421 return PlacesUtils.withConnectionWrapper( 2422 "Bookmarks.jsm: fetchBookmark", 2423 query 2424 ); 2425} 2426 2427async function fetchBookmarkByPosition(info, options = {}) { 2428 let query = async function(db) { 2429 let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index; 2430 let rows = await db.executeCached( 2431 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2432 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2433 h.url AS url, b.id AS _id, b.parent AS _parentId, 2434 (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount, 2435 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 2436 FROM moz_bookmarks b 2437 LEFT JOIN moz_bookmarks p ON p.id = b.parent 2438 LEFT JOIN moz_places h ON h.id = b.fk 2439 WHERE p.guid = :parentGuid 2440 AND b.position = IFNULL(:index, (SELECT count(*) - 1 2441 FROM moz_bookmarks 2442 WHERE parent = p.id)) 2443 `, 2444 { parentGuid: info.parentGuid, index } 2445 ); 2446 2447 return rows.length ? rowsToItemsArray(rows)[0] : null; 2448 }; 2449 if (options.concurrent) { 2450 let db = await PlacesUtils.promiseDBConnection(); 2451 return query(db); 2452 } 2453 return PlacesUtils.withConnectionWrapper( 2454 "Bookmarks.jsm: fetchBookmarkByPosition", 2455 query 2456 ); 2457} 2458 2459async function fetchBookmarksByTags(info, options = {}) { 2460 let query = async function(db) { 2461 let rows = await db.executeCached( 2462 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2463 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2464 h.url AS url, b.id AS _id, b.parent AS _parentId, 2465 NULL AS _childCount, 2466 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 2467 FROM moz_bookmarks b 2468 JOIN moz_bookmarks p ON p.id = b.parent 2469 JOIN moz_bookmarks g ON g.id = p.parent 2470 JOIN moz_places h ON h.id = b.fk 2471 WHERE g.guid <> ? AND b.fk IN ( 2472 SELECT b2.fk FROM moz_bookmarks b2 2473 JOIN moz_bookmarks p2 ON p2.id = b2.parent 2474 JOIN moz_bookmarks g2 ON g2.id = p2.parent 2475 WHERE g2.guid = ? 2476 AND lower(p2.title) IN ( 2477 ${new Array(info.tags.length).fill("?").join(",")} 2478 ) 2479 GROUP BY b2.fk HAVING count(*) = ${info.tags.length} 2480 ) 2481 ORDER BY b.lastModified DESC 2482 `, 2483 [Bookmarks.tagsGuid, Bookmarks.tagsGuid].concat( 2484 info.tags.map(t => t.toLowerCase()) 2485 ) 2486 ); 2487 2488 return rows.length ? rowsToItemsArray(rows) : null; 2489 }; 2490 2491 if (options.concurrent) { 2492 let db = await PlacesUtils.promiseDBConnection(); 2493 return query(db); 2494 } 2495 return PlacesUtils.withConnectionWrapper( 2496 "Bookmarks.jsm: fetchBookmarksByTags", 2497 query 2498 ); 2499} 2500 2501async function fetchBookmarksByGUIDPrefix(info, options = {}) { 2502 let query = async function(db) { 2503 let rows = await db.executeCached( 2504 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2505 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2506 h.url AS url, b.id AS _id, b.parent AS _parentId, 2507 NULL AS _childCount, 2508 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 2509 FROM moz_bookmarks b 2510 LEFT JOIN moz_bookmarks p ON p.id = b.parent 2511 LEFT JOIN moz_places h ON h.id = b.fk 2512 WHERE b.guid LIKE :guidPrefix 2513 ORDER BY b.lastModified DESC 2514 `, 2515 { guidPrefix: info.guidPrefix + "%" } 2516 ); 2517 2518 return rows.length ? rowsToItemsArray(rows) : null; 2519 }; 2520 2521 if (options.concurrent) { 2522 let db = await PlacesUtils.promiseDBConnection(); 2523 return query(db); 2524 } 2525 return PlacesUtils.withConnectionWrapper( 2526 "Bookmarks.jsm: fetchBookmarksByGUIDPrefix", 2527 query 2528 ); 2529} 2530 2531async function fetchBookmarksByURL(info, options = {}) { 2532 let query = async function(db) { 2533 let tagsFolderId = await promiseTagsFolderId(); 2534 let rows = await db.executeCached( 2535 `/* do not warn (bug no): not worth to add an index */ 2536 SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2537 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2538 h.url AS url, b.id AS _id, b.parent AS _parentId, 2539 NULL AS _childCount, /* Unused for now */ 2540 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 2541 FROM moz_bookmarks b 2542 JOIN moz_bookmarks p ON p.id = b.parent 2543 JOIN moz_places h ON h.id = b.fk 2544 WHERE h.url_hash = hash(:url) AND h.url = :url 2545 AND _grandParentId <> :tagsFolderId 2546 ORDER BY b.lastModified DESC 2547 `, 2548 { url: info.url.href, tagsFolderId } 2549 ); 2550 2551 return rows.length ? rowsToItemsArray(rows) : null; 2552 }; 2553 2554 if (options.concurrent) { 2555 let db = await PlacesUtils.promiseDBConnection(); 2556 return query(db); 2557 } 2558 return PlacesUtils.withConnectionWrapper( 2559 "Bookmarks.jsm: fetchBookmarksByURL", 2560 query 2561 ); 2562} 2563 2564async function fetchBookmarksByParentGUID(info, options = {}) { 2565 let query = async function(db) { 2566 let rows = await db.executeCached( 2567 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2568 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2569 h.url AS url, 2570 NULL AS _id, 2571 NULL AS _parentId, 2572 NULL AS _childCount, 2573 NULL AS _grandParentId, 2574 NULL AS _syncStatus 2575 FROM moz_bookmarks b 2576 LEFT JOIN moz_bookmarks p ON p.id = b.parent 2577 LEFT JOIN moz_places h ON h.id = b.fk 2578 WHERE p.guid = :parentGuid 2579 ORDER BY b.position ASC 2580 `, 2581 { parentGuid: info.parentGuid } 2582 ); 2583 2584 return rows.length ? rowsToItemsArray(rows) : null; 2585 }; 2586 2587 if (options.concurrent) { 2588 let db = await PlacesUtils.promiseDBConnection(); 2589 return query(db); 2590 } 2591 return PlacesUtils.withConnectionWrapper( 2592 "Bookmarks.jsm: fetchBookmarksByParentGUID", 2593 query 2594 ); 2595} 2596 2597function fetchRecentBookmarks(numberOfItems) { 2598 return PlacesUtils.withConnectionWrapper( 2599 "Bookmarks.jsm: fetchRecentBookmarks", 2600 async function(db) { 2601 let tagsFolderId = await promiseTagsFolderId(); 2602 let rows = await db.executeCached( 2603 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2604 b.dateAdded, b.lastModified, b.type, 2605 IFNULL(b.title, '') AS title, h.url AS url, NULL AS _id, 2606 NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId, 2607 NULL AS _syncStatus 2608 FROM moz_bookmarks b 2609 JOIN moz_bookmarks p ON p.id = b.parent 2610 JOIN moz_places h ON h.id = b.fk 2611 WHERE p.parent <> :tagsFolderId 2612 AND b.type = :type 2613 AND url_hash NOT BETWEEN hash("place", "prefix_lo") 2614 AND hash("place", "prefix_hi") 2615 ORDER BY b.dateAdded DESC, b.ROWID DESC 2616 LIMIT :numberOfItems 2617 `, 2618 { 2619 tagsFolderId, 2620 type: Bookmarks.TYPE_BOOKMARK, 2621 numberOfItems, 2622 } 2623 ); 2624 2625 return rows.length ? rowsToItemsArray(rows) : []; 2626 } 2627 ); 2628} 2629 2630async function fetchBookmarksByParent(db, info) { 2631 let rows = await db.executeCached( 2632 `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', 2633 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, 2634 h.url AS url, b.id AS _id, b.parent AS _parentId, 2635 (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount, 2636 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 2637 FROM moz_bookmarks b 2638 LEFT JOIN moz_bookmarks p ON p.id = b.parent 2639 LEFT JOIN moz_places h ON h.id = b.fk 2640 WHERE p.guid = :parentGuid 2641 ORDER BY b.position ASC 2642 `, 2643 { parentGuid: info.parentGuid } 2644 ); 2645 2646 return rowsToItemsArray(rows); 2647} 2648 2649// Remove implementation. 2650 2651function removeBookmarks(items, options) { 2652 return PlacesUtils.withConnectionWrapper( 2653 "Bookmarks.jsm: removeBookmarks", 2654 async function(db) { 2655 let urls = []; 2656 2657 await db.executeTransaction(async function transaction() { 2658 // We use a map for its de-duplication properties. 2659 let parents = new Map(); 2660 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 2661 options.source 2662 ); 2663 2664 for (let item of items) { 2665 parents.set(item.parentGuid, item._parentId); 2666 2667 // If it's a folder, remove its contents first. 2668 if (item.type == Bookmarks.TYPE_FOLDER) { 2669 if ( 2670 options.preventRemovalOfNonEmptyFolders && 2671 item._childCount > 0 2672 ) { 2673 throw new Error("Cannot remove a non-empty folder."); 2674 } 2675 urls = urls.concat( 2676 await removeFoldersContents(db, [item.guid], options) 2677 ); 2678 } 2679 } 2680 2681 for (let chunk of PlacesUtils.chunkArray(items, db.variableLimit)) { 2682 // We don't go through the annotations service for this cause otherwise 2683 // we'd get a pointless onItemChanged notification and it would also 2684 // set lastModified to an unexpected value. 2685 await removeAnnotationsForItems(db, chunk); 2686 2687 // Remove the bookmarks. 2688 await db.executeCached( 2689 `DELETE FROM moz_bookmarks 2690 WHERE guid IN (${sqlBindPlaceholders(chunk)})`, 2691 chunk.map(item => item.guid) 2692 ); 2693 } 2694 2695 for (let [parentGuid, parentId] of parents.entries()) { 2696 // Now recalculate the positions. 2697 await db.executeCached( 2698 `WITH positions(id, pos, seq) AS ( 2699 SELECT id, position AS pos, 2700 (row_number() OVER (ORDER BY position)) - 1 AS seq 2701 FROM moz_bookmarks 2702 WHERE parent = :parentId 2703 ) 2704 UPDATE moz_bookmarks 2705 SET position = (SELECT seq FROM positions WHERE positions.id = moz_bookmarks.id) 2706 WHERE id IN (SELECT id FROM positions WHERE seq <> pos) 2707 `, 2708 { parentId } 2709 ); 2710 2711 // Mark this parent as changed. 2712 await setAncestorsLastModified( 2713 db, 2714 parentGuid, 2715 new Date(), 2716 syncChangeDelta 2717 ); 2718 } 2719 2720 for (let i = 0; i < items.length; i++) { 2721 const item = items[i]; 2722 // For the notifications, we may need to adjust indexes if there are more 2723 // than one of the same item in the folder. This makes sure that we notify 2724 // the index of the item when it was removed, rather than the original index. 2725 for (let j = i + 1; j < items.length; j++) { 2726 if ( 2727 items[j]._parentId == item._parentId && 2728 items[j].index > item.index 2729 ) { 2730 items[j].index--; 2731 } 2732 } 2733 if (item._grandParentId == PlacesUtils.tagsFolderId) { 2734 // If we're removing a tag entry, increment the change counter for all 2735 // bookmarks with the tagged URL. 2736 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 2737 db, 2738 item.url, 2739 syncChangeDelta 2740 ); 2741 } 2742 2743 await adjustSeparatorsSyncCounter( 2744 db, 2745 item._parentId, 2746 item.index, 2747 syncChangeDelta 2748 ); 2749 } 2750 2751 // Write tombstones for the removed items. 2752 await insertTombstones(db, items, syncChangeDelta); 2753 }); 2754 2755 // Update the frecencies outside of the transaction, excluding tags, so that 2756 // the updates can progress in the background. 2757 urls = urls.concat( 2758 items 2759 .filter(item => { 2760 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 2761 return !isUntagging && "url" in item; 2762 }) 2763 .map(item => item.url) 2764 ); 2765 2766 if (urls.length) { 2767 await PlacesUtils.keywords.removeFromURLsIfNotBookmarked(urls); 2768 updateFrecency(db, urls).catch(Cu.reportError); 2769 } 2770 } 2771 ); 2772} 2773 2774// Reorder implementation. 2775 2776function reorderChildren(parent, orderedChildrenGuids, options) { 2777 return PlacesUtils.withConnectionWrapper( 2778 "Bookmarks.jsm: reorderChildren", 2779 db => 2780 db.executeTransaction(async function() { 2781 // Select all of the direct children for the given parent. 2782 let children = await fetchBookmarksByParent(db, { 2783 parentGuid: parent.guid, 2784 }); 2785 if (!children.length) { 2786 return []; 2787 } 2788 2789 // Maps of GUIDs to indices for fast lookups in the comparator function. 2790 let guidIndices = new Map(); 2791 let currentIndices = new Map(); 2792 for (let i = 0; i < orderedChildrenGuids.length; ++i) { 2793 let guid = orderedChildrenGuids[i]; 2794 guidIndices.set(guid, i); 2795 } 2796 2797 // If we got an incomplete list but everything we have is in the right 2798 // order, we do nothing. 2799 let needReorder = true; 2800 let requestedChildIndices = []; 2801 for (let i = 0; i < children.length; ++i) { 2802 // Take the opportunity to build currentIndices here, since we already 2803 // are iterating over the children array. 2804 currentIndices.set(children[i].guid, i); 2805 2806 if (guidIndices.has(children[i].guid)) { 2807 let index = guidIndices.get(children[i].guid); 2808 requestedChildIndices.push(index); 2809 } 2810 } 2811 2812 if (requestedChildIndices.length) { 2813 needReorder = false; 2814 for (let i = 1; i < requestedChildIndices.length; ++i) { 2815 if (requestedChildIndices[i - 1] > requestedChildIndices[i]) { 2816 needReorder = true; 2817 break; 2818 } 2819 } 2820 } 2821 2822 if (needReorder) { 2823 // Reorder the children array according to the specified order, provided 2824 // GUIDs come first, others are appended in somehow random order. 2825 children.sort((a, b) => { 2826 // This works provided fetchBookmarksByParent returns sorted children. 2827 if (!guidIndices.has(a.guid) && !guidIndices.has(b.guid)) { 2828 return currentIndices.get(a.guid) < currentIndices.get(b.guid) 2829 ? -1 2830 : 1; 2831 } 2832 if (!guidIndices.has(a.guid)) { 2833 return 1; 2834 } 2835 if (!guidIndices.has(b.guid)) { 2836 return -1; 2837 } 2838 return guidIndices.get(a.guid) < guidIndices.get(b.guid) ? -1 : 1; 2839 }); 2840 2841 // Update the bookmarks position now. If any unknown guid have been 2842 // inserted meanwhile, its position will be set to -position, and we'll 2843 // handle it later. 2844 // To do the update in a single step, we build a VALUES (guid, position) 2845 // table. We then use count() in the sorting table to avoid skipping values 2846 // when no more existing GUIDs have been provided. 2847 let valuesTable = children 2848 .map((child, i) => `("${child.guid}", ${i})`) 2849 .join(); 2850 await db.execute( 2851 `WITH sorting(g, p) AS ( 2852 VALUES ${valuesTable} 2853 ) 2854 UPDATE moz_bookmarks SET 2855 position = ( 2856 SELECT CASE count(*) WHEN 0 THEN -position 2857 ELSE count(*) - 1 2858 END 2859 FROM sorting a 2860 JOIN sorting b ON b.p <= a.p 2861 WHERE a.g = guid 2862 ), 2863 lastModified = :lastModified 2864 WHERE parent = :parentId 2865 `, 2866 { 2867 parentId: parent._id, 2868 lastModified: PlacesUtils.toPRTime(options.lastModified), 2869 } 2870 ); 2871 2872 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 2873 options.source 2874 ); 2875 await setAncestorsLastModified( 2876 db, 2877 parent.guid, 2878 options.lastModified, 2879 syncChangeDelta 2880 ); 2881 2882 // Update position of items that could have been inserted in the meanwhile. 2883 // Since this can happen rarely and it's only done for schema coherence 2884 // resonds, we won't notify about these changes. 2885 await db.executeCached( 2886 `CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger 2887 AFTER UPDATE OF position ON moz_bookmarks 2888 WHEN NEW.position = -1 2889 BEGIN 2890 UPDATE moz_bookmarks 2891 SET position = (SELECT MAX(position) FROM moz_bookmarks 2892 WHERE parent = NEW.parent) + 2893 (SELECT count(*) FROM moz_bookmarks 2894 WHERE parent = NEW.parent 2895 AND position BETWEEN OLD.position AND -1) 2896 WHERE guid = NEW.guid; 2897 END 2898 ` 2899 ); 2900 2901 await db.executeCached( 2902 `UPDATE moz_bookmarks SET position = -1 WHERE position < 0` 2903 ); 2904 2905 await db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`); 2906 } 2907 2908 // Remove the Sync orphan annotation from the reordered children, so that 2909 // Sync doesn't try to reparent them once it sees the original parents. We 2910 // only do this for explicitly ordered children, to avoid removing orphan 2911 // annos set by Sync. 2912 let possibleOrphanIds = []; 2913 for (let child of children) { 2914 if (guidIndices.has(child.guid)) { 2915 possibleOrphanIds.push(child._id); 2916 } 2917 } 2918 await db.executeCached( 2919 `DELETE FROM moz_items_annos 2920 WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes 2921 WHERE name = :orphanAnno) AND 2922 item_id IN (${possibleOrphanIds.join(", ")})`, 2923 { orphanAnno: PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO } 2924 ); 2925 2926 return children; 2927 }) 2928 ); 2929} 2930 2931// Helpers. 2932 2933/** 2934 * Merges objects into a new object, included non-enumerable properties. 2935 * 2936 * @param sources 2937 * source objects to merge. 2938 * @return a new object including all properties from the source objects. 2939 */ 2940function mergeIntoNewObject(...sources) { 2941 let dest = {}; 2942 for (let src of sources) { 2943 for (let prop of Object.getOwnPropertyNames(src)) { 2944 Object.defineProperty( 2945 dest, 2946 prop, 2947 Object.getOwnPropertyDescriptor(src, prop) 2948 ); 2949 } 2950 } 2951 return dest; 2952} 2953 2954/** 2955 * Remove properties that have the same value across two bookmark objects. 2956 * 2957 * @param dest 2958 * destination bookmark object. 2959 * @param src 2960 * source bookmark object. 2961 * @return a cleaned up bookmark object. 2962 * @note "guid" is never removed. 2963 */ 2964function removeSameValueProperties(dest, src) { 2965 for (let prop in dest) { 2966 let remove = false; 2967 switch (prop) { 2968 case "lastModified": 2969 case "dateAdded": 2970 remove = 2971 src.hasOwnProperty(prop) && 2972 dest[prop].getTime() == src[prop].getTime(); 2973 break; 2974 case "url": 2975 remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href; 2976 break; 2977 default: 2978 remove = dest[prop] == src[prop]; 2979 } 2980 if (remove && prop != "guid") { 2981 delete dest[prop]; 2982 } 2983 } 2984} 2985 2986/** 2987 * Convert an array of mozIStorageRow objects to an array of bookmark objects. 2988 * 2989 * @param {Array} rows 2990 * the array of mozIStorageRow objects. 2991 * @param {Boolean} ignoreInvalidURLs 2992 * whether to ignore invalid urls (leaving the url property undefined) 2993 * or throw. 2994 * @return an array of bookmark objects. 2995 */ 2996function rowsToItemsArray(rows, ignoreInvalidURLs = false) { 2997 return rows.map(row => { 2998 let item = {}; 2999 for (let prop of ["guid", "index", "type", "title"]) { 3000 item[prop] = row.getResultByName(prop); 3001 } 3002 for (let prop of ["dateAdded", "lastModified"]) { 3003 let value = row.getResultByName(prop); 3004 if (value) { 3005 item[prop] = PlacesUtils.toDate(value); 3006 } 3007 } 3008 let parentGuid = row.getResultByName("parentGuid"); 3009 if (parentGuid) { 3010 item.parentGuid = parentGuid; 3011 } 3012 let url = row.getResultByName("url"); 3013 if (url) { 3014 try { 3015 item.url = new URL(url); 3016 } catch (ex) { 3017 if (!ignoreInvalidURLs) { 3018 throw ex; 3019 } 3020 } 3021 } 3022 3023 for (let prop of [ 3024 "_id", 3025 "_parentId", 3026 "_childCount", 3027 "_grandParentId", 3028 "_syncStatus", 3029 ]) { 3030 let val = row.getResultByName(prop); 3031 if (val !== null) { 3032 // These properties should not be returned to the API consumer, thus 3033 // they are non-enumerable and removed through Object.assign just before 3034 // the object is returned. 3035 // Configurable is set to support mergeIntoNewObject overwrites. 3036 Object.defineProperty(item, prop, { 3037 value: val, 3038 enumerable: false, 3039 configurable: true, 3040 }); 3041 } 3042 } 3043 3044 return item; 3045 }); 3046} 3047 3048function validateBookmarkObject(name, input, behavior) { 3049 return PlacesUtils.validateItemProperties( 3050 name, 3051 PlacesUtils.BOOKMARK_VALIDATORS, 3052 input, 3053 behavior 3054 ); 3055} 3056 3057/** 3058 * Updates frecency for a list of URLs. 3059 * 3060 * @param db 3061 * the Sqlite.jsm connection handle. 3062 * @param urls 3063 * the array of URLs to update. 3064 */ 3065var updateFrecency = async function(db, urls) { 3066 let hrefs = urls.map(url => url.href); 3067 // We just use the hashes, since updating a few additional urls won't hurt. 3068 for (let chunk of PlacesUtils.chunkArray(hrefs, db.variableLimit)) { 3069 await db.execute( 3070 `UPDATE moz_places 3071 SET hidden = (url_hash BETWEEN hash("place", "prefix_lo") AND hash("place", "prefix_hi")), 3072 frecency = CALCULATE_FRECENCY(id) 3073 WHERE url_hash IN (${sqlBindPlaceholders(chunk, "hash(", ")")})`, 3074 chunk 3075 ); 3076 } 3077 3078 // Trigger frecency updates for all affected origins. 3079 await db.executeCached(`DELETE FROM moz_updateoriginsupdate_temp`); 3080 3081 PlacesObservers.notifyListeners([new PlacesRanking()]); 3082}; 3083 3084/** 3085 * Removes any orphan annotation entries. 3086 * 3087 * @param db 3088 * the Sqlite.jsm connection handle. 3089 */ 3090var removeOrphanAnnotations = async function(db) { 3091 await db.executeCached( 3092 `DELETE FROM moz_items_annos 3093 WHERE id IN (SELECT a.id from moz_items_annos a 3094 LEFT JOIN moz_bookmarks b ON a.item_id = b.id 3095 WHERE b.id ISNULL) 3096 ` 3097 ); 3098 await db.executeCached( 3099 `DELETE FROM moz_anno_attributes 3100 WHERE id IN (SELECT n.id from moz_anno_attributes n 3101 LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id 3102 LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id 3103 WHERE a1.id ISNULL AND a2.id ISNULL) 3104 ` 3105 ); 3106}; 3107 3108/** 3109 * Removes annotations for a given item. 3110 * 3111 * @param db 3112 * the Sqlite.jsm connection handle. 3113 * @param items 3114 * The items for which to remove annotations. 3115 */ 3116var removeAnnotationsForItems = async function(db, items) { 3117 // Remove the annotations. 3118 let itemIds = items.map(item => item._id); 3119 await db.executeCached( 3120 `DELETE FROM moz_items_annos 3121 WHERE item_id IN (${sqlBindPlaceholders(itemIds)})`, 3122 itemIds 3123 ); 3124 await db.executeCached( 3125 `DELETE FROM moz_anno_attributes 3126 WHERE id IN (SELECT n.id from moz_anno_attributes n 3127 LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id 3128 LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id 3129 WHERE a1.id ISNULL AND a2.id ISNULL) 3130 ` 3131 ); 3132}; 3133 3134/** 3135 * Updates lastModified for all the ancestors of a given folder GUID. 3136 * 3137 * @param db 3138 * the Sqlite.jsm connection handle. 3139 * @param folderGuid 3140 * the GUID of the folder whose ancestors should be updated. 3141 * @param time 3142 * a Date object to use for the update. 3143 * 3144 * @note the folder itself is also updated. 3145 */ 3146var setAncestorsLastModified = async function( 3147 db, 3148 folderGuid, 3149 time, 3150 syncChangeDelta 3151) { 3152 await db.executeCached( 3153 `WITH RECURSIVE 3154 ancestors(aid) AS ( 3155 SELECT id FROM moz_bookmarks WHERE guid = :guid 3156 UNION ALL 3157 SELECT parent FROM moz_bookmarks 3158 JOIN ancestors ON id = aid 3159 WHERE type = :type 3160 ) 3161 UPDATE moz_bookmarks SET lastModified = :time 3162 WHERE id IN ancestors 3163 `, 3164 { 3165 guid: folderGuid, 3166 type: Bookmarks.TYPE_FOLDER, 3167 time: PlacesUtils.toPRTime(time), 3168 } 3169 ); 3170 3171 if (syncChangeDelta) { 3172 // Flag the folder as having a change. 3173 await db.executeCached( 3174 ` 3175 UPDATE moz_bookmarks SET 3176 syncChangeCounter = syncChangeCounter + :syncChangeDelta 3177 WHERE guid = :guid`, 3178 { guid: folderGuid, syncChangeDelta } 3179 ); 3180 } 3181}; 3182 3183/** 3184 * Remove all descendants of one or more bookmark folders. 3185 * 3186 * @param {Object} db 3187 * the Sqlite.jsm connection handle. 3188 * @param {Array} folderGuids 3189 * array of folder guids. 3190 * @return {Array} 3191 * An array of urls that will need to be updated for frecency. These 3192 * are returned rather than updated immediately so that the caller 3193 * can decide when they need to be updated - they do not need to 3194 * stop this function from completing. 3195 */ 3196var removeFoldersContents = async function(db, folderGuids, options) { 3197 let syncChangeDelta = PlacesSyncUtils.bookmarks.determineSyncChangeDelta( 3198 options.source 3199 ); 3200 3201 let itemsRemoved = []; 3202 for (let folderGuid of folderGuids) { 3203 let rows = await db.executeCached( 3204 `WITH RECURSIVE 3205 descendants(did) AS ( 3206 SELECT b.id FROM moz_bookmarks b 3207 JOIN moz_bookmarks p ON b.parent = p.id 3208 WHERE p.guid = :folderGuid 3209 UNION ALL 3210 SELECT id FROM moz_bookmarks 3211 JOIN descendants ON parent = did 3212 ) 3213 SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index', 3214 b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded, 3215 b.lastModified, IFNULL(b.title, '') AS title, 3216 p.parent AS _grandParentId, NULL AS _childCount, 3217 b.syncStatus AS _syncStatus 3218 FROM descendants 3219 /* The usage of CROSS JOIN is not random, it tells the optimizer 3220 to retain the original rows order, so the hierarchy is respected */ 3221 CROSS JOIN moz_bookmarks b ON did = b.id 3222 JOIN moz_bookmarks p ON p.id = b.parent 3223 LEFT JOIN moz_places h ON b.fk = h.id`, 3224 { folderGuid } 3225 ); 3226 3227 itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows, true)); 3228 3229 await db.executeCached( 3230 `WITH RECURSIVE 3231 descendants(did) AS ( 3232 SELECT b.id FROM moz_bookmarks b 3233 JOIN moz_bookmarks p ON b.parent = p.id 3234 WHERE p.guid = :folderGuid 3235 UNION ALL 3236 SELECT id FROM moz_bookmarks 3237 JOIN descendants ON parent = did 3238 ) 3239 DELETE FROM moz_bookmarks WHERE id IN descendants`, 3240 { folderGuid } 3241 ); 3242 } 3243 3244 // Write tombstones for removed items. 3245 await insertTombstones(db, itemsRemoved, syncChangeDelta); 3246 3247 // Bump the change counter for all tagged bookmarks when removing tag 3248 // folders. 3249 await addSyncChangesForRemovedTagFolders(db, itemsRemoved, syncChangeDelta); 3250 3251 // Cleanup orphans. 3252 await removeOrphanAnnotations(db); 3253 3254 // TODO (Bug 1087576): this may leave orphan tags behind. 3255 3256 // Send onItemRemoved notifications to listeners. 3257 // TODO (Bug 1087580): for the case of eraseEverything, this should send a 3258 // single clear bookmarks notification rather than notifying for each 3259 // bookmark. 3260 3261 // Notify listeners in reverse order to serve children before parents. 3262 let { source = Bookmarks.SOURCES.DEFAULT } = options; 3263 let notifications = []; 3264 for (let item of itemsRemoved.reverse()) { 3265 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 3266 let url = ""; 3267 if (item.type == Bookmarks.TYPE_BOOKMARK) { 3268 url = item.hasOwnProperty("url") ? item.url.href : null; 3269 } 3270 notifications.push( 3271 new PlacesBookmarkRemoved({ 3272 id: item._id, 3273 url, 3274 parentId: item._parentId, 3275 index: item.index, 3276 itemType: item.type, 3277 guid: item.guid, 3278 parentGuid: item.parentGuid, 3279 source, 3280 isTagging: isUntagging, 3281 isDescendantRemoval: !PlacesUtils.bookmarks.userContentRoots.includes( 3282 item.parentGuid 3283 ), 3284 }) 3285 ); 3286 3287 if (isUntagging) { 3288 const tags = PlacesUtils.tagging.getTagsForURI(NetUtil.newURI(url)); 3289 for (let entry of await fetchBookmarksByURL(item, true)) { 3290 notifications.push( 3291 new PlacesBookmarkTags({ 3292 id: entry._id, 3293 itemType: entry.type, 3294 url, 3295 guid: entry.guid, 3296 parentGuid: entry.parentGuid, 3297 tags, 3298 lastModified: entry.lastModified, 3299 source, 3300 isTagging: false, 3301 }) 3302 ); 3303 } 3304 } 3305 } 3306 3307 if (notifications.length) { 3308 PlacesObservers.notifyListeners(notifications); 3309 } 3310 3311 return itemsRemoved.filter(item => "url" in item).map(item => item.url); 3312}; 3313 3314/** 3315 * Tries to insert a new place if it doesn't exist yet. 3316 * @param url 3317 * A valid URL object. 3318 * @return {Promise} resolved when the operation is complete. 3319 */ 3320async function maybeInsertPlace(db, url) { 3321 // The IGNORE conflict can trigger on `guid`. 3322 await db.executeCached( 3323 `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) 3324 VALUES (:url, hash(:url), :rev_host, 0, :frecency, 3325 IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), 3326 GENERATE_GUID())) 3327 `, 3328 { 3329 url: url.href, 3330 rev_host: PlacesUtils.getReversedHost(url), 3331 frecency: url.protocol == "place:" ? 0 : -1, 3332 } 3333 ); 3334 await db.executeCached("DELETE FROM moz_updateoriginsinsert_temp"); 3335} 3336 3337/** 3338 * Tries to insert a new place if it doesn't exist yet. 3339 * @param db 3340 * The database to use 3341 * @param urls 3342 * An array with all the url objects to insert. 3343 * @return {Promise} resolved when the operation is complete. 3344 */ 3345async function maybeInsertManyPlaces(db, urls) { 3346 await db.executeCached( 3347 `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) VALUES 3348 (:url, hash(:url), :rev_host, 0, :frecency, 3349 IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :maybeguid))`, 3350 urls.map(url => ({ 3351 url: url.href, 3352 rev_host: PlacesUtils.getReversedHost(url), 3353 frecency: url.protocol == "place:" ? 0 : -1, 3354 maybeguid: PlacesUtils.history.makeGuid(), 3355 })) 3356 ); 3357 await db.executeCached("DELETE FROM moz_updateoriginsinsert_temp"); 3358} 3359 3360// Indicates whether we should write a tombstone for an item that has been 3361// uploaded to the server. We ignore "NEW" and "UNKNOWN" items: "NEW" items 3362// haven't been uploaded yet, and "UNKNOWN" items need a full reconciliation 3363// with the server. 3364function needsTombstone(item) { 3365 return item._syncStatus == Bookmarks.SYNC_STATUS.NORMAL; 3366} 3367 3368// Inserts tombstones for removed synced items. 3369function insertTombstones(db, itemsRemoved, syncChangeDelta) { 3370 if (!syncChangeDelta) { 3371 return Promise.resolve(); 3372 } 3373 let syncedItems = itemsRemoved.filter(needsTombstone); 3374 if (!syncedItems.length) { 3375 return Promise.resolve(); 3376 } 3377 let dateRemoved = PlacesUtils.toPRTime(Date.now()); 3378 let valuesTable = syncedItems 3379 .map( 3380 item => `( 3381 ${JSON.stringify(item.guid)}, 3382 ${dateRemoved} 3383 )` 3384 ) 3385 .join(","); 3386 return db.execute(` 3387 INSERT INTO moz_bookmarks_deleted (guid, dateRemoved) 3388 VALUES ${valuesTable}`); 3389} 3390 3391// Bumps the change counter for all bookmarks with URLs referenced in removed 3392// tag folders. 3393var addSyncChangesForRemovedTagFolders = async function( 3394 db, 3395 itemsRemoved, 3396 syncChangeDelta 3397) { 3398 if (!syncChangeDelta) { 3399 return; 3400 } 3401 for (let item of itemsRemoved) { 3402 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 3403 if (isUntagging) { 3404 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 3405 db, 3406 item.url, 3407 syncChangeDelta 3408 ); 3409 } 3410 } 3411}; 3412 3413function adjustSeparatorsSyncCounter( 3414 db, 3415 parentId, 3416 startIndex, 3417 syncChangeDelta 3418) { 3419 if (!syncChangeDelta) { 3420 return Promise.resolve(); 3421 } 3422 3423 return db.executeCached( 3424 ` 3425 UPDATE moz_bookmarks 3426 SET syncChangeCounter = syncChangeCounter + :delta 3427 WHERE parent = :parent AND position >= :start_index 3428 AND type = :item_type 3429 `, 3430 { 3431 delta: syncChangeDelta, 3432 parent: parentId, 3433 start_index: startIndex, 3434 item_type: Bookmarks.TYPE_SEPARATOR, 3435 } 3436 ); 3437} 3438 3439/** 3440 * Generates a list of "?" SQL bindings based on input array length. 3441 * @param {array} values an array of values. 3442 * @param {string} [prefix] a string to prefix to the placeholder. 3443 * @param {string} [suffix] a string to suffix to the placeholder. 3444 * @returns {string} placeholders is a string made of question marks and commas, 3445 * one per value. 3446 */ 3447function sqlBindPlaceholders(values, prefix = "", suffix = "") { 3448 return new Array(values.length).fill(prefix + "?" + suffix).join(","); 3449} 3450 3451/** 3452 * Return the full path, from parent to root folder, of a bookmark. 3453 * 3454 * @param guid 3455 * The globally unique identifier of the item to determine the full 3456 * bookmark path for. 3457 * @param options [optional] 3458 * an optional object whose properties describe options for the query: 3459 * - concurrent: Queries concurrently to any writes, returning results 3460 * faster. On the negative side, it may return stale 3461 * information missing the currently ongoing write. 3462 * - db: A specific connection to be used. 3463 * @return {Promise} resolved when the query is complete. 3464 * @resolves to an array of {guid, title} objects that represent the full path 3465 * from parent to root for the passed in bookmark. 3466 * @rejects if an error happens while querying. 3467 */ 3468async function retrieveFullBookmarkPath(guid, options = {}) { 3469 let query = async function(db) { 3470 let rows = await db.executeCached( 3471 `WITH RECURSIVE parents(guid, _id, _parent, title) AS 3472 (SELECT guid, id AS _id, parent AS _parent, 3473 IFNULL(title, '') AS title 3474 FROM moz_bookmarks 3475 WHERE guid = :pguid 3476 UNION ALL 3477 SELECT b.guid, b.id AS _id, b.parent AS _parent, 3478 IFNULL(b.title, '') AS title 3479 FROM moz_bookmarks b 3480 INNER JOIN parents ON b.id=parents._parent) 3481 SELECT * FROM parents WHERE guid != :rootGuid; 3482 `, 3483 { pguid: guid, rootGuid: PlacesUtils.bookmarks.rootGuid } 3484 ); 3485 3486 return rows.reverse().map(r => ({ 3487 guid: r.getResultByName("guid"), 3488 title: r.getResultByName("title"), 3489 })); 3490 }; 3491 3492 if (options.concurrent) { 3493 let db = await PlacesUtils.promiseDBConnection(); 3494 return query(db); 3495 } 3496 return PlacesUtils.withConnectionWrapper( 3497 "Bookmarks.jsm: retrieveFullBookmarkPath", 3498 query 3499 ); 3500} 3501