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 onItemMoved 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 62Cu.importGlobalProperties(["URL"]); 63 64ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); 65ChromeUtils.defineModuleGetter(this, "Services", 66 "resource://gre/modules/Services.jsm"); 67ChromeUtils.defineModuleGetter(this, "NetUtil", 68 "resource://gre/modules/NetUtil.jsm"); 69ChromeUtils.defineModuleGetter(this, "Sqlite", 70 "resource://gre/modules/Sqlite.jsm"); 71ChromeUtils.defineModuleGetter(this, "PlacesUtils", 72 "resource://gre/modules/PlacesUtils.jsm"); 73ChromeUtils.defineModuleGetter(this, "PlacesSyncUtils", 74 "resource://gre/modules/PlacesSyncUtils.jsm"); 75 76// This is an helper to temporarily cover the need to know the tags folder 77// itemId until bug 424160 is fixed. This exists so that startup paths won't 78// pay the price to initialize the bookmarks service just to fetch this value. 79// If the method is already initing the bookmarks service for other reasons 80// (most of the writing methods will invoke getObservers() already) it can 81// directly use the PlacesUtils.tagsFolderId property. 82var gTagsFolderId; 83async function promiseTagsFolderId() { 84 if (gTagsFolderId) 85 return gTagsFolderId; 86 let db = await PlacesUtils.promiseDBConnection(); 87 let rows = await db.execute( 88 "SELECT id FROM moz_bookmarks WHERE guid = :guid", 89 { guid: Bookmarks.tagsGuid } 90 ); 91 return gTagsFolderId = rows[0].getResultByName("id"); 92} 93 94const MATCH_ANYWHERE_UNMODIFIED = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED; 95const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; 96const SQLITE_MAX_VARIABLE_NUMBER = 999; 97 98var Bookmarks = Object.freeze({ 99 /** 100 * Item's type constants. 101 * These should stay consistent with nsINavBookmarksService.idl 102 */ 103 TYPE_BOOKMARK: 1, 104 TYPE_FOLDER: 2, 105 TYPE_SEPARATOR: 3, 106 107 /** 108 * Sync status constants, stored for each item. 109 */ 110 SYNC_STATUS: { 111 UNKNOWN: Ci.nsINavBookmarksService.SYNC_STATUS_UNKNOWN, 112 NEW: Ci.nsINavBookmarksService.SYNC_STATUS_NEW, 113 NORMAL: Ci.nsINavBookmarksService.SYNC_STATUS_NORMAL, 114 }, 115 116 /** 117 * Default index used to append a bookmark-item at the end of a folder. 118 * This should stay consistent with nsINavBookmarksService.idl 119 */ 120 DEFAULT_INDEX: -1, 121 122 /** 123 * Bookmark change source constants, passed as optional properties and 124 * forwarded to observers. See nsINavBookmarksService.idl for an explanation. 125 */ 126 SOURCES: { 127 DEFAULT: Ci.nsINavBookmarksService.SOURCE_DEFAULT, 128 SYNC: Ci.nsINavBookmarksService.SOURCE_SYNC, 129 IMPORT: Ci.nsINavBookmarksService.SOURCE_IMPORT, 130 IMPORT_REPLACE: Ci.nsINavBookmarksService.SOURCE_IMPORT_REPLACE, 131 SYNC_REPARENT_REMOVED_FOLDER_CHILDREN: Ci.nsINavBookmarksService.SOURCE_SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, 132 }, 133 134 /** 135 * Special GUIDs associated with bookmark roots. 136 * It's guaranteed that the roots will always have these guids. 137 */ 138 rootGuid: "root________", 139 menuGuid: "menu________", 140 toolbarGuid: "toolbar_____", 141 unfiledGuid: "unfiled_____", 142 mobileGuid: "mobile______", 143 144 // With bug 424160, tags will stop being bookmarks, thus this root will 145 // be removed. Do not rely on this, rather use the tagging service API. 146 tagsGuid: "tags________", 147 148 /** 149 * The GUIDs of the user content root folders that we support, for easy access 150 * as a set. 151 */ 152 userContentRoots: ["toolbar_____", "menu________", "unfiled_____", "mobile______"], 153 154 /** 155 * GUIDs associated with virtual queries that are used for display in the left 156 * pane. 157 */ 158 virtualMenuGuid: "menu_______v", 159 virtualToolbarGuid: "toolbar____v", 160 virtualUnfiledGuid: "unfiled___v", 161 virtualMobileGuid: "mobile____v", 162 163 /** 164 * Checks if a guid is a virtual root. 165 * 166 * @param {String} guid The guid of the item to look for. 167 * @returns {Boolean} true if guid is a virtual root, false otherwise. 168 */ 169 isVirtualRootItem(guid) { 170 return guid == PlacesUtils.bookmarks.virtualMenuGuid || 171 guid == PlacesUtils.bookmarks.virtualToolbarGuid || 172 guid == PlacesUtils.bookmarks.virtualUnfiledGuid || 173 guid == PlacesUtils.bookmarks.virtualMobileGuid; 174 }, 175 176 /** 177 * Returns the title to use on the UI for a bookmark item. Root folders 178 * in the database don't store fully localised versions of the title. To 179 * get those this function should be called. 180 * 181 * Hence, this function should only be called if a root folder object is 182 * likely to be displayed to the user. 183 * 184 * @param {Object} info An object representing a bookmark-item. 185 * @returns {String} The correct string. 186 * @throws {Error} If the guid in PlacesUtils.bookmarks.userContentRoots is 187 * not supported. 188 */ 189 getLocalizedTitle(info) { 190 if (!PlacesUtils.bookmarks.userContentRoots.includes(info.guid)) { 191 return info.title; 192 } 193 194 switch (info.guid) { 195 case PlacesUtils.bookmarks.toolbarGuid: 196 return PlacesUtils.getString("BookmarksToolbarFolderTitle"); 197 case PlacesUtils.bookmarks.menuGuid: 198 return PlacesUtils.getString("BookmarksMenuFolderTitle"); 199 case PlacesUtils.bookmarks.unfiledGuid: 200 return PlacesUtils.getString("OtherBookmarksFolderTitle"); 201 case PlacesUtils.bookmarks.mobileGuid: 202 return PlacesUtils.getString("MobileBookmarksFolderTitle"); 203 default: 204 throw new Error(`Unsupported guid ${info.guid} passed to getLocalizedTitle!`); 205 } 206 }, 207 208 /** 209 * Inserts a bookmark-item into the bookmarks tree. 210 * 211 * For creating a bookmark, the following set of properties is required: 212 * - type 213 * - parentGuid 214 * - url, only for bookmarked URLs 215 * 216 * If an index is not specified, it defaults to appending. 217 * It's also possible to pass a non-existent GUID to force creation of an 218 * item with the given GUID, but unless you have a very sound reason, such as 219 * an undo manager implementation or synchronization, don't do that. 220 * 221 * Note that any known properties that don't apply to the specific item type 222 * cause an exception. 223 * 224 * @param info 225 * object representing a bookmark-item. 226 * 227 * @return {Promise} resolved when the creation is complete. 228 * @resolves to an object representing the created bookmark. 229 * @rejects if it's not possible to create the requested bookmark. 230 * @throws if the arguments are invalid. 231 */ 232 insert(info) { 233 let now = new Date(); 234 let addedTime = (info && info.dateAdded) || now; 235 let modTime = addedTime; 236 if (addedTime > now) { 237 modTime = now; 238 } 239 let insertInfo = validateBookmarkObject("Bookmarks.jsm: insert", info, 240 { type: { defaultValue: this.TYPE_BOOKMARK }, 241 index: { defaultValue: this.DEFAULT_INDEX }, 242 url: { requiredIf: b => b.type == this.TYPE_BOOKMARK, 243 validIf: b => b.type == this.TYPE_BOOKMARK }, 244 parentGuid: { required: true }, 245 title: { defaultValue: "", 246 validIf: b => b.type == this.TYPE_BOOKMARK || 247 b.type == this.TYPE_FOLDER || 248 b.title === "" }, 249 dateAdded: { defaultValue: addedTime }, 250 lastModified: { defaultValue: modTime, 251 validIf: b => b.lastModified >= now || (b.dateAdded && b.lastModified >= b.dateAdded) }, 252 source: { defaultValue: this.SOURCES.DEFAULT } 253 }); 254 255 return (async () => { 256 // Ensure the parent exists. 257 let parent = await fetchBookmark({ guid: insertInfo.parentGuid }); 258 if (!parent) 259 throw new Error("parentGuid must be valid"); 260 261 // Set index in the appending case. 262 if (insertInfo.index == this.DEFAULT_INDEX || 263 insertInfo.index > parent._childCount) { 264 insertInfo.index = parent._childCount; 265 } 266 267 let item = await insertBookmark(insertInfo, parent); 268 269 // Notify onItemAdded to listeners. 270 let observers = PlacesUtils.bookmarks.getObservers(); 271 // We need the itemId to notify, though once the switch to guids is 272 // complete we may stop using it. 273 let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; 274 let itemId = await PlacesUtils.promiseItemId(item.guid); 275 276 // Pass tagging information for the observers to skip over these notifications when needed. 277 let isTagging = parent._parentId == PlacesUtils.tagsFolderId; 278 let isTagsFolder = parent._id == PlacesUtils.tagsFolderId; 279 notify(observers, "onItemAdded", [ itemId, parent._id, item.index, 280 item.type, uri, item.title, 281 PlacesUtils.toPRTime(item.dateAdded), item.guid, 282 item.parentGuid, item.source ], 283 { isTagging: isTagging || isTagsFolder }); 284 285 // If it's a tag, notify OnItemChanged to all bookmarks for this URL. 286 if (isTagging) { 287 for (let entry of (await fetchBookmarksByURL(item, true))) { 288 notify(observers, "onItemChanged", [ entry._id, "tags", false, "", 289 PlacesUtils.toPRTime(entry.lastModified), 290 entry.type, entry._parentId, 291 entry.guid, entry.parentGuid, 292 "", item.source ]); 293 } 294 } 295 296 // Remove non-enumerable properties. 297 delete item.source; 298 return Object.assign({}, item); 299 })(); 300 }, 301 302 303 /** 304 * Inserts a bookmark-tree into the existing bookmarks tree. 305 * 306 * All the specified folders and bookmarks will be inserted as new, even 307 * if duplicates. There's no merge support at this time. 308 * 309 * The input should be of the form: 310 * { 311 * guid: "<some-existing-guid-to-use-as-parent>", 312 * source: "<some valid source>", (optional) 313 * children: [ 314 * ... valid bookmark objects. 315 * ] 316 * } 317 * 318 * Children will be appended to any existing children of the parent 319 * that is specified. The source specified on the root of the tree 320 * will be used for all the items inserted. Any indices or custom parentGuids 321 * set on children will be ignored and overwritten. 322 * 323 * @param {Object} tree 324 * object representing a tree of bookmark items to insert. 325 * @param {Object} options [optional] 326 * object with properties representing options. Current options are: 327 * - fixupOrSkipInvalidEntries: makes the insert more lenient to 328 * mistakes in the input tree. Properties of an entry that are 329 * fixable will be corrected, otherwise the entry will be skipped. 330 * This is particularly convenient for import/restore operations, 331 * but should not be abused for common inserts, since it may hide 332 * bugs in the calling code. 333 * 334 * @return {Promise} resolved when the creation is complete. 335 * @resolves to an object representing the created bookmark. 336 * @rejects if it's not possible to create the requested bookmark. 337 * @throws if the arguments are invalid. 338 */ 339 insertTree(tree, options) { 340 if (!tree || typeof tree != "object") { 341 throw new Error("Should be provided a valid tree object."); 342 } 343 if (!Array.isArray(tree.children) || !tree.children.length) { 344 throw new Error("Should have a non-zero number of children to insert."); 345 } 346 if (!PlacesUtils.isValidGuid(tree.guid)) { 347 throw new Error(`The parent guid is not valid (${tree.guid} ${tree.title}).`); 348 } 349 if (tree.guid == this.rootGuid) { 350 throw new Error("Can't insert into the root."); 351 } 352 if (tree.guid == this.tagsGuid) { 353 throw new Error("Can't use insertTree to insert tags."); 354 } 355 if (tree.hasOwnProperty("source") && 356 !Object.values(this.SOURCES).includes(tree.source)) { 357 throw new Error("Can't use source value " + tree.source); 358 } 359 if (options && typeof options != "object") { 360 throw new Error("Options should be a valid object"); 361 } 362 let fixupOrSkipInvalidEntries = options && !!options.fixupOrSkipInvalidEntries; 363 364 // Serialize the tree into an array of items to insert into the db. 365 let insertInfos = []; 366 let insertLivemarkInfos = []; 367 let urlsThatMightNeedPlaces = []; 368 369 // We want to use the same 'last added' time for all the entries 370 // we import (so they won't differ by a few ms based on where 371 // they are in the tree, and so we don't needlessly construct 372 // multiple dates). 373 let fallbackLastAdded = new Date(); 374 375 const {TYPE_BOOKMARK, TYPE_FOLDER, SOURCES} = this; 376 377 // Reuse the 'source' property for all the entries. 378 let source = tree.source || SOURCES.DEFAULT; 379 380 // This is recursive. 381 function appendInsertionInfoForInfoArray(infos, indexToUse, parentGuid) { 382 // We want to keep the index of items that will be inserted into the root 383 // NULL, and then use a subquery to select the right index, to avoid 384 // races where other consumers might add items between when we determine 385 // the index and when we insert. However, the validator does not allow 386 // NULL values in in the index, so we fake it while validating and then 387 // correct later. Keep track of whether we're doing this: 388 let shouldUseNullIndices = false; 389 if (indexToUse === null) { 390 shouldUseNullIndices = true; 391 indexToUse = 0; 392 } 393 394 // When a folder gets an item added, its last modified date is updated 395 // to be equal to the date we added the item (if that date is newer). 396 // Because we're inserting a tree, we keep track of this date for the 397 // loop, updating it for inserted items as well as from any subfolders 398 // we insert. 399 let lastAddedForParent = new Date(0); 400 for (let info of infos) { 401 // Ensure to use the same date for dateAdded and lastModified, even if 402 // dateAdded may be imposed by the caller. 403 let time = (info && info.dateAdded) || fallbackLastAdded; 404 let insertInfo = { 405 guid: { defaultValue: PlacesUtils.history.makeGuid() }, 406 type: { defaultValue: TYPE_BOOKMARK }, 407 url: { requiredIf: b => b.type == TYPE_BOOKMARK, 408 validIf: b => b.type == TYPE_BOOKMARK }, 409 parentGuid: { replaceWith: parentGuid }, // Set the correct parent guid. 410 title: { defaultValue: "", 411 validIf: b => b.type == TYPE_BOOKMARK || 412 b.type == TYPE_FOLDER || 413 b.title === "" }, 414 dateAdded: { defaultValue: time, 415 validIf: b => !b.lastModified || 416 b.dateAdded <= b.lastModified }, 417 lastModified: { defaultValue: time, 418 validIf: b => (!b.dateAdded && b.lastModified >= time) || 419 (b.dateAdded && b.lastModified >= b.dateAdded) }, 420 index: { replaceWith: indexToUse++ }, 421 source: { replaceWith: source }, 422 annos: {}, 423 keyword: { validIf: b => b.type == TYPE_BOOKMARK }, 424 charset: { validIf: b => b.type == TYPE_BOOKMARK }, 425 postData: { validIf: b => b.type == TYPE_BOOKMARK }, 426 tags: { validIf: b => b.type == TYPE_BOOKMARK }, 427 children: { validIf: b => b.type == TYPE_FOLDER && Array.isArray(b.children) } 428 }; 429 if (fixupOrSkipInvalidEntries) { 430 insertInfo.guid.fixup = b => b.guid = PlacesUtils.history.makeGuid(); 431 insertInfo.dateAdded.fixup = insertInfo.lastModified.fixup = 432 b => b.lastModified = b.dateAdded = fallbackLastAdded; 433 } 434 try { 435 insertInfo = validateBookmarkObject("Bookmarks.jsm: insertTree", info, insertInfo); 436 } catch (ex) { 437 if (fixupOrSkipInvalidEntries) { 438 indexToUse--; 439 continue; 440 } else { 441 throw ex; 442 } 443 } 444 445 if (shouldUseNullIndices) { 446 insertInfo.index = null; 447 } 448 // Store the URL if this is a bookmark, so we can ensure we create an 449 // entry in moz_places for it. 450 if (insertInfo.type == Bookmarks.TYPE_BOOKMARK) { 451 urlsThatMightNeedPlaces.push(insertInfo.url); 452 } 453 454 // As we don't track indexes for children of root folders, and we 455 // insert livemarks separately, we create a temporary placeholder in 456 // the bookmarks, and later we'll replace it by the real livemark. 457 if (isLivemark(insertInfo)) { 458 // Make the current insertInfo item a placeholder. 459 let livemarkInfo = Object.assign({}, insertInfo); 460 461 // Delete the annotations that make it a livemark. 462 delete insertInfo.annos; 463 464 // Now save the livemark info for later. 465 insertLivemarkInfos.push(livemarkInfo); 466 } 467 468 insertInfos.push(insertInfo); 469 // Process any children. We have to use info.children here rather than 470 // insertInfo.children because validateBookmarkObject doesn't copy over 471 // the children ref, as the default bookmark validators object doesn't 472 // know about children. 473 if (info.children) { 474 // start children of this item off at index 0. 475 let childrenLastAdded = appendInsertionInfoForInfoArray(info.children, 0, insertInfo.guid); 476 if (childrenLastAdded > insertInfo.lastModified) { 477 insertInfo.lastModified = childrenLastAdded; 478 } 479 if (childrenLastAdded > lastAddedForParent) { 480 lastAddedForParent = childrenLastAdded; 481 } 482 } 483 484 // Ensure we track what time to update the parent to. 485 if (insertInfo.dateAdded > lastAddedForParent) { 486 lastAddedForParent = insertInfo.dateAdded; 487 } 488 } 489 return lastAddedForParent; 490 } 491 492 // We want to validate synchronously, but we can't know the index at which 493 // we're inserting into the parent. We just use NULL instead, 494 // and the SQL query with which we insert will update it as necessary. 495 let lastAddedForParent = appendInsertionInfoForInfoArray(tree.children, null, tree.guid); 496 497 return (async function() { 498 let treeParent = await fetchBookmark({ guid: tree.guid }); 499 if (!treeParent) { 500 throw new Error("The parent you specified doesn't exist."); 501 } 502 503 if (treeParent._parentId == PlacesUtils.tagsFolderId) { 504 throw new Error("Can't use insertTree to insert tags."); 505 } 506 507 await insertBookmarkTree(insertInfos, source, treeParent, 508 urlsThatMightNeedPlaces, lastAddedForParent); 509 510 for (let info of insertLivemarkInfos) { 511 try { 512 await insertLivemarkData(info); 513 } catch (ex) { 514 // This can arguably fail, if some of the livemarks data is invalid. 515 if (fixupOrSkipInvalidEntries) { 516 // The placeholder should have been removed at this point, thus we 517 // can avoid to notify about it. 518 let placeholderIndex = insertInfos.findIndex(item => item.guid == info.guid); 519 if (placeholderIndex != -1) { 520 insertInfos.splice(placeholderIndex, 1); 521 } 522 } else { 523 // Throw if we're not lenient to input mistakes. 524 throw ex; 525 } 526 } 527 } 528 529 // Now update the indices of root items in the objects we return. 530 // These may be wrong if someone else modified the table between 531 // when we fetched the parent and inserted our items, but the actual 532 // inserts will have been correct, and we don't want to query the DB 533 // again if we don't have to. bug 1347230 covers improving this. 534 let rootIndex = treeParent._childCount; 535 for (let insertInfo of insertInfos) { 536 if (insertInfo.parentGuid == tree.guid) { 537 insertInfo.index += rootIndex++; 538 } 539 } 540 // We need the itemIds to notify, though once the switch to guids is 541 // complete we may stop using them. 542 let itemIdMap = await PlacesUtils.promiseManyItemIds(insertInfos.map(info => info.guid)); 543 // Notify onItemAdded to listeners. 544 let observers = PlacesUtils.bookmarks.getObservers(); 545 for (let i = 0; i < insertInfos.length; i++) { 546 let item = insertInfos[i]; 547 let itemId = itemIdMap.get(item.guid); 548 let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; 549 // For sub-folders, we need to make sure their children have the correct parent ids. 550 let parentId; 551 if (item.parentGuid === treeParent.guid) { 552 // This is a direct child of the tree parent, so we can use the 553 // existing parent's id. 554 parentId = treeParent._id; 555 } else { 556 // This is a parent folder that's been updated, so we need to 557 // use the new item id. 558 parentId = itemIdMap.get(item.parentGuid); 559 } 560 561 notify(observers, "onItemAdded", [ itemId, parentId, item.index, 562 item.type, uri, item.title, 563 PlacesUtils.toPRTime(item.dateAdded), item.guid, 564 item.parentGuid, item.source ], 565 { isTagging: false }); 566 // Note, annotations for livemark data are deleted from insertInfo 567 // within appendInsertionInfoForInfoArray, so we won't be duplicating 568 // the insertions here. 569 try { 570 await handleBookmarkItemSpecialData(itemId, item); 571 } catch (ex) { 572 // This is not critical, regardless the bookmark has been created 573 // and we should continue notifying the next ones. 574 Cu.reportError(`An error occured while handling special bookmark data: ${ex}`); 575 } 576 577 // Remove non-enumerable properties. 578 delete item.source; 579 580 insertInfos[i] = Object.assign({}, item); 581 } 582 return insertInfos; 583 })(); 584 }, 585 586 /** 587 * Updates a bookmark-item. 588 * 589 * Only set the properties which should be changed (undefined properties 590 * won't be taken into account). 591 * Moreover, the item's type or dateAdded cannot be changed, since they are 592 * immutable after creation. Trying to change them will reject. 593 * 594 * Note that any known properties that don't apply to the specific item type 595 * cause an exception. 596 * 597 * @param info 598 * object representing a bookmark-item, as defined above. 599 * 600 * @return {Promise} resolved when the update is complete. 601 * @resolves to an object representing the updated bookmark. 602 * @rejects if it's not possible to update the given bookmark. 603 * @throws if the arguments are invalid. 604 */ 605 update(info) { 606 // The info object is first validated here to ensure it's consistent, then 607 // it's compared to the existing item to remove any properties that don't 608 // need to be updated. 609 let updateInfo = validateBookmarkObject("Bookmarks.jsm: update", info, 610 { guid: { required: true }, 611 index: { requiredIf: b => b.hasOwnProperty("parentGuid"), 612 validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX }, 613 source: { defaultValue: this.SOURCES.DEFAULT } 614 }); 615 616 // There should be at last one more property in addition to guid and source. 617 if (Object.keys(updateInfo).length < 3) 618 throw new Error("Not enough properties to update"); 619 620 return (async () => { 621 // Ensure the item exists. 622 let item = await fetchBookmark(updateInfo); 623 if (!item) 624 throw new Error("No bookmarks found for the provided GUID"); 625 if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type) 626 throw new Error("The bookmark type cannot be changed"); 627 628 // Remove any property that will stay the same. 629 removeSameValueProperties(updateInfo, item); 630 // Check if anything should still be updated. 631 if (Object.keys(updateInfo).length < 3) { 632 // Remove non-enumerable properties. 633 return Object.assign({}, item); 634 } 635 const now = new Date(); 636 let lastModifiedDefault = now; 637 // In the case where `dateAdded` is specified, but `lastModified` is not, 638 // we only update `lastModified` if it is older than the new `dateAdded`. 639 if (!("lastModified" in updateInfo) && 640 "dateAdded" in updateInfo) { 641 lastModifiedDefault = new Date(Math.max(item.lastModified, updateInfo.dateAdded)); 642 } 643 updateInfo = validateBookmarkObject("Bookmarks.jsm: update", updateInfo, 644 { url: { validIf: () => item.type == this.TYPE_BOOKMARK }, 645 title: { validIf: () => [ this.TYPE_BOOKMARK, 646 this.TYPE_FOLDER ].includes(item.type) }, 647 lastModified: { defaultValue: lastModifiedDefault, 648 validIf: b => b.lastModified >= now || 649 b.lastModified >= (b.dateAdded || item.dateAdded) }, 650 dateAdded: { defaultValue: item.dateAdded } 651 }); 652 653 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: update", 654 async db => { 655 let parent; 656 if (updateInfo.hasOwnProperty("parentGuid")) { 657 if (PlacesUtils.isRootItem(item.guid)) { 658 throw new Error("It's not possible to move Places root folders."); 659 } 660 if (item.type == this.TYPE_FOLDER) { 661 // Make sure we are not moving a folder into itself or one of its 662 // descendants. 663 let rows = await db.executeCached( 664 `WITH RECURSIVE 665 descendants(did) AS ( 666 VALUES(:id) 667 UNION ALL 668 SELECT id FROM moz_bookmarks 669 JOIN descendants ON parent = did 670 WHERE type = :type 671 ) 672 SELECT guid FROM moz_bookmarks 673 WHERE id IN descendants 674 `, { id: item._id, type: this.TYPE_FOLDER }); 675 if (rows.map(r => r.getResultByName("guid")).includes(updateInfo.parentGuid)) 676 throw new Error("Cannot insert a folder into itself or one of its descendants"); 677 } 678 679 parent = await fetchBookmark({ guid: updateInfo.parentGuid }); 680 if (!parent) 681 throw new Error("No bookmarks found for the provided parentGuid"); 682 } 683 684 if (updateInfo.hasOwnProperty("index")) { 685 if (PlacesUtils.isRootItem(item.guid)) { 686 throw new Error("It's not possible to move Places root folders."); 687 } 688 // If at this point we don't have a parent yet, we are moving into 689 // the same container. Thus we know it exists. 690 if (!parent) 691 parent = await fetchBookmark({ guid: item.parentGuid }); 692 693 if (updateInfo.index >= parent._childCount || 694 updateInfo.index == this.DEFAULT_INDEX) { 695 updateInfo.index = parent._childCount; 696 697 // Fix the index when moving within the same container. 698 if (parent.guid == item.parentGuid) 699 updateInfo.index--; 700 } 701 } 702 703 let updatedItem = await updateBookmark(updateInfo, item, parent); 704 705 if (item.type == this.TYPE_BOOKMARK && 706 item.url.href != updatedItem.url.href) { 707 // ...though we don't wait for the calculation. 708 updateFrecency(db, [item.url]).catch(Cu.reportError); 709 updateFrecency(db, [updatedItem.url]).catch(Cu.reportError); 710 } 711 712 // Notify onItemChanged to listeners. 713 let observers = PlacesUtils.bookmarks.getObservers(); 714 // For lastModified, we only care about the original input, since we 715 // should not notify implciit lastModified changes. 716 if (info.hasOwnProperty("lastModified") && 717 updateInfo.hasOwnProperty("lastModified") && 718 item.lastModified != updatedItem.lastModified) { 719 notify(observers, "onItemChanged", [ updatedItem._id, "lastModified", 720 false, 721 `${PlacesUtils.toPRTime(updatedItem.lastModified)}`, 722 PlacesUtils.toPRTime(updatedItem.lastModified), 723 updatedItem.type, 724 updatedItem._parentId, 725 updatedItem.guid, 726 updatedItem.parentGuid, "", 727 updatedItem.source ]); 728 } 729 if (info.hasOwnProperty("dateAdded") && 730 updateInfo.hasOwnProperty("dateAdded") && 731 item.dateAdded != updatedItem.dateAdded) { 732 notify(observers, "onItemChanged", [ updatedItem._id, "dateAdded", 733 false, `${PlacesUtils.toPRTime(updatedItem.dateAdded)}`, 734 PlacesUtils.toPRTime(updatedItem.lastModified), 735 updatedItem.type, 736 updatedItem._parentId, 737 updatedItem.guid, 738 updatedItem.parentGuid, 739 "", 740 updatedItem.source ]); 741 } 742 if (updateInfo.hasOwnProperty("title")) { 743 let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; 744 notify(observers, "onItemChanged", [ updatedItem._id, "title", 745 false, updatedItem.title, 746 PlacesUtils.toPRTime(updatedItem.lastModified), 747 updatedItem.type, 748 updatedItem._parentId, 749 updatedItem.guid, 750 updatedItem.parentGuid, "", 751 updatedItem.source ], 752 { isTagging }); 753 // If we're updating a tag, we must notify all the tagged bookmarks 754 // about the change. 755 if (isTagging) { 756 let URIs = PlacesUtils.tagging.getURIsForTag(updatedItem.title); 757 for (let uri of URIs) { 758 for (let entry of (await fetchBookmarksByURL({ url: new URL(uri.spec) }, true))) { 759 notify(observers, "onItemChanged", [ entry._id, "tags", false, "", 760 PlacesUtils.toPRTime(entry.lastModified), 761 entry.type, entry._parentId, 762 entry.guid, entry.parentGuid, 763 "", updatedItem.source ]); 764 } 765 } 766 } 767 } 768 if (updateInfo.hasOwnProperty("url")) { 769 await PlacesUtils.keywords.reassign(item.url, updatedItem.url, 770 updatedItem.source); 771 notify(observers, "onItemChanged", [ updatedItem._id, "uri", 772 false, updatedItem.url.href, 773 PlacesUtils.toPRTime(updatedItem.lastModified), 774 updatedItem.type, 775 updatedItem._parentId, 776 updatedItem.guid, 777 updatedItem.parentGuid, 778 item.url.href, 779 updatedItem.source ]); 780 } 781 // If the item was moved, notify onItemMoved. 782 if (item.parentGuid != updatedItem.parentGuid || 783 item.index != updatedItem.index) { 784 notify(observers, "onItemMoved", [ updatedItem._id, item._parentId, 785 item.index, updatedItem._parentId, 786 updatedItem.index, updatedItem.type, 787 updatedItem.guid, item.parentGuid, 788 updatedItem.parentGuid, 789 updatedItem.source ]); 790 } 791 792 // Remove non-enumerable properties. 793 delete updatedItem.source; 794 return Object.assign({}, updatedItem); 795 }); 796 })(); 797 }, 798 799 /** 800 * Removes one or more bookmark-items. 801 * 802 * @param guidOrInfo This may be: 803 * - The globally unique identifier of the item to remove 804 * - an object representing the item, as defined above 805 * - an array of objects representing the items to be removed 806 * @param {Object} [options={}] 807 * Additional options that can be passed to the function. 808 * Currently supports the following properties: 809 * - preventRemovalOfNonEmptyFolders: Causes an exception to be 810 * thrown when attempting to remove a folder that is not empty. 811 * - source: The change source, forwarded to all bookmark observers. 812 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 813 * 814 * @return {Promise} 815 * @resolves when the removal is complete 816 * @rejects if the provided guid doesn't match any existing bookmark. 817 * @throws if the arguments are invalid. 818 */ 819 remove(guidOrInfo, options = {}) { 820 let infos = guidOrInfo; 821 if (!infos) 822 throw new Error("Input should be a valid object"); 823 if (!Array.isArray(guidOrInfo)) { 824 if (typeof(guidOrInfo) != "object") { 825 infos = [{ guid: guidOrInfo }]; 826 } else { 827 infos = [guidOrInfo]; 828 } 829 } 830 831 if (!("source" in options)) { 832 options.source = Bookmarks.SOURCES.DEFAULT; 833 } 834 835 let removeInfos = []; 836 for (let info of infos) { 837 // Disallow removing the root folders. 838 if ([ 839 Bookmarks.rootGuid, Bookmarks.menuGuid, Bookmarks.toolbarGuid, 840 Bookmarks.unfiledGuid, Bookmarks.tagsGuid, Bookmarks.mobileGuid 841 ].includes(info.guid)) { 842 throw new Error("It's not possible to remove Places root folders."); 843 } 844 845 // Even if we ignore any other unneeded property, we still validate any 846 // known property to reduce likelihood of hidden bugs. 847 let removeInfo = validateBookmarkObject("Bookmarks.jsm: remove", info); 848 removeInfos.push(removeInfo); 849 } 850 851 return (async function() { 852 let removeItems = []; 853 for (let info of removeInfos) { 854 let item = await fetchBookmark(info); 855 if (!item) 856 throw new Error("No bookmarks found for the provided GUID."); 857 858 removeItems.push(item); 859 } 860 861 await removeBookmarks(removeItems, options); 862 863 // Notify onItemRemoved to listeners. 864 for (let item of removeItems) { 865 let observers = PlacesUtils.bookmarks.getObservers(); 866 let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; 867 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 868 notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index, 869 item.type, uri, item.guid, 870 item.parentGuid, 871 options.source ], 872 { isTagging: isUntagging }); 873 874 if (isUntagging) { 875 for (let entry of (await fetchBookmarksByURL(item, true))) { 876 notify(observers, "onItemChanged", [ entry._id, "tags", false, "", 877 PlacesUtils.toPRTime(entry.lastModified), 878 entry.type, entry._parentId, 879 entry.guid, entry.parentGuid, 880 "", options.source ]); 881 } 882 } 883 } 884 })(); 885 }, 886 887 /** 888 * Removes ALL bookmarks, resetting the bookmarks storage to an empty tree. 889 * 890 * Note that roots are preserved, only their children will be removed. 891 * 892 * @param {Object} [options={}] 893 * Additional options. Currently supports the following properties: 894 * - source: The change source, forwarded to all bookmark observers. 895 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 896 * 897 * @return {Promise} resolved when the removal is complete. 898 * @resolves once the removal is complete. 899 */ 900 eraseEverything(options = {}) { 901 if (!options.source) { 902 options.source = Bookmarks.SOURCES.DEFAULT; 903 } 904 905 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything", 906 async function(db) { 907 let urls; 908 909 await db.executeTransaction(async function() { 910 urls = await removeFoldersContents(db, Bookmarks.userContentRoots, options); 911 const time = PlacesUtils.toPRTime(new Date()); 912 const syncChangeDelta = 913 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source); 914 for (let folderGuid of Bookmarks.userContentRoots) { 915 await db.executeCached( 916 `UPDATE moz_bookmarks SET lastModified = :time, 917 syncChangeCounter = syncChangeCounter + :syncChangeDelta 918 WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid ) 919 `, { folderGuid, time, syncChangeDelta }); 920 } 921 }); 922 923 // We don't wait for the frecency calculation. 924 if (urls && urls.length) { 925 await PlacesUtils.keywords.eraseEverything(); 926 updateFrecency(db, urls, true).catch(Cu.reportError); 927 } 928 } 929 ); 930 }, 931 932 /** 933 * Returns a list of recently bookmarked items. 934 * Only includes actual bookmarks. Excludes folders, separators and queries. 935 * 936 * @param {integer} numberOfItems 937 * The maximum number of bookmark items to return. 938 * 939 * @return {Promise} resolved when the listing is complete. 940 * @resolves to an array of recent bookmark-items. 941 * @rejects if an error happens while querying. 942 */ 943 getRecent(numberOfItems) { 944 if (numberOfItems === undefined) { 945 throw new Error("numberOfItems argument is required"); 946 } 947 if (!typeof numberOfItems === "number" || (numberOfItems % 1) !== 0) { 948 throw new Error("numberOfItems argument must be an integer"); 949 } 950 if (numberOfItems <= 0) { 951 throw new Error("numberOfItems argument must be greater than zero"); 952 } 953 954 return fetchRecentBookmarks(numberOfItems); 955 }, 956 957 /** 958 * Fetches information about a bookmark-item. 959 * 960 * REMARK: any successful call to this method resolves to a single 961 * bookmark-item (or null), even when multiple bookmarks may exist 962 * (e.g. fetching by url). If you wish to retrieve all of the 963 * bookmarks for a given match, use the callback instead. 964 * 965 * Input can be either a guid or an object with one, and only one, of these 966 * filtering properties set: 967 * - guid 968 * retrieves the item with the specified guid. 969 * - parentGuid and index 970 * retrieves the item by its position. 971 * - url 972 * retrieves the most recent bookmark having the given URL. 973 * To retrieve ALL of the bookmarks for that URL, you must pass in an 974 * onResult callback, that will be invoked once for each found bookmark. 975 * - guidPrefix 976 * retrieves the most recent item with the specified guid prefix. 977 * To retrieve ALL of the bookmarks for that guid prefix, you must pass 978 * in an onResult callback, that will be invoked once for each bookmark. 979 * 980 * @param guidOrInfo 981 * The globally unique identifier of the item to fetch, or an 982 * object representing it, as defined above. 983 * @param onResult [optional] 984 * Callback invoked for each found bookmark. 985 * @param options [optional] 986 * an optional object whose properties describe options for the fetch: 987 * - concurrent: fetches concurrently to any writes, returning results 988 * faster. On the negative side, it may return stale 989 * information missing the currently ongoing write. 990 * 991 * @return {Promise} resolved when the fetch is complete. 992 * @resolves to an object representing the found item, as described above, or 993 * an array of such objects. if no item is found, the returned 994 * promise is resolved to null. 995 * @rejects if an error happens while fetching. 996 * @throws if the arguments are invalid. 997 * 998 * @note Any unknown property in the info object is ignored. Known properties 999 * may be overwritten. 1000 */ 1001 fetch(guidOrInfo, onResult = null, options = {}) { 1002 if (!("concurrent" in options)) { 1003 options.concurrent = false; 1004 } 1005 if (onResult && typeof onResult != "function") 1006 throw new Error("onResult callback must be a valid function"); 1007 let info = guidOrInfo; 1008 if (!info) 1009 throw new Error("Input should be a valid object"); 1010 if (typeof(info) != "object") { 1011 info = { guid: guidOrInfo }; 1012 } else if (Object.keys(info).length == 1) { 1013 // Just a faster code path. 1014 if (!["url", "guid", "parentGuid", "index", "guidPrefix"].includes(Object.keys(info)[0])) 1015 throw new Error(`Unexpected number of conditions provided: 0`); 1016 } else { 1017 // Only one condition at a time can be provided. 1018 let conditionsCount = [ 1019 v => v.hasOwnProperty("guid"), 1020 v => v.hasOwnProperty("parentGuid") && v.hasOwnProperty("index"), 1021 v => v.hasOwnProperty("url"), 1022 v => v.hasOwnProperty("guidPrefix") 1023 ].reduce((old, fn) => old + fn(info) | 0, 0); 1024 if (conditionsCount != 1) 1025 throw new Error(`Unexpected number of conditions provided: ${conditionsCount}`); 1026 } 1027 1028 let behavior = {}; 1029 if (info.hasOwnProperty("parentGuid") || info.hasOwnProperty("index")) { 1030 behavior = { 1031 parentGuid: { requiredIf: b => b.hasOwnProperty("index") }, 1032 index: { requiredIf: b => b.hasOwnProperty("parentGuid"), 1033 validIf: b => typeof(b.index) == "number" && 1034 b.index >= 0 || b.index == this.DEFAULT_INDEX } 1035 }; 1036 } 1037 1038 // Even if we ignore any other unneeded property, we still validate any 1039 // known property to reduce likelihood of hidden bugs. 1040 let fetchInfo = validateBookmarkObject("Bookmarks.jsm: fetch", info, 1041 behavior); 1042 1043 return (async function() { 1044 let results; 1045 if (fetchInfo.hasOwnProperty("url")) 1046 results = await fetchBookmarksByURL(fetchInfo, options && options.concurrent); 1047 else if (fetchInfo.hasOwnProperty("guid")) 1048 results = await fetchBookmark(fetchInfo, options && options.concurrent); 1049 else if (fetchInfo.hasOwnProperty("parentGuid") && fetchInfo.hasOwnProperty("index")) 1050 results = await fetchBookmarkByPosition(fetchInfo, options && options.concurrent); 1051 else if (fetchInfo.hasOwnProperty("guidPrefix")) 1052 results = await fetchBookmarksByGUIDPrefix(fetchInfo, options && options.concurrent); 1053 1054 if (!results) 1055 return null; 1056 1057 if (!Array.isArray(results)) 1058 results = [results]; 1059 // Remove non-enumerable properties. 1060 results = results.map(r => Object.assign({}, r)); 1061 1062 // Ideally this should handle an incremental behavior and thus be invoked 1063 // while we fetch. Though, the likelihood of 2 or more bookmarks for the 1064 // same match is very low, so it's not worth the added code complication. 1065 if (onResult) { 1066 for (let result of results) { 1067 try { 1068 onResult(result); 1069 } catch (ex) { 1070 Cu.reportError(ex); 1071 } 1072 } 1073 } 1074 1075 return results[0]; 1076 })(); 1077 }, 1078 1079 /** 1080 * Retrieves an object representation of a bookmark-item, along with all of 1081 * its descendants, if any. 1082 * 1083 * Each node in the tree is an object that extends the item representation 1084 * described above with some additional properties: 1085 * 1086 * - [deprecated] id (number) 1087 * the item's id. Defined only if aOptions.includeItemIds is set. 1088 * - annos (array) 1089 * the item's annotations. This is not set if there are no annotations 1090 * set for the item. 1091 * 1092 * The root object of the tree also has the following properties set: 1093 * - itemsCount (number, not enumerable) 1094 * the number of items, including the root item itself, which are 1095 * represented in the resolved object. 1096 * 1097 * Bookmarked URLs may also have the following properties: 1098 * - tags (string) 1099 * csv string of the bookmark's tags, if any. 1100 * - charset (string) 1101 * the last known charset of the bookmark, if any. 1102 * - iconurl (URL) 1103 * the bookmark's favicon URL, if any. 1104 * 1105 * Folders may also have the following properties: 1106 * - children (array) 1107 * the folder's children information, each of them having the same set of 1108 * properties as above. 1109 * 1110 * @param [optional] guid 1111 * the topmost item to be queried. If it's not passed, the Places 1112 * root folder is queried: that is, you get a representation of the 1113 * entire bookmarks hierarchy. 1114 * @param [optional] options 1115 * Options for customizing the query behavior, in the form of an 1116 * object with any of the following properties: 1117 * - excludeItemsCallback: a function for excluding items, along with 1118 * their descendants. Given an item object (that has everything set 1119 * apart its potential children data), it should return true if the 1120 * item should be excluded. Once an item is excluded, the function 1121 * isn't called for any of its descendants. This isn't called for 1122 * the root item. 1123 * WARNING: since the function may be called for each item, using 1124 * this option can slow down the process significantly if the 1125 * callback does anything that's not relatively trivial. It is 1126 * highly recommended to avoid any synchronous I/O or DB queries. 1127 * - includeItemIds: opt-in to include the deprecated id property. 1128 * Use it if you must. It'll be removed once the switch to guids is 1129 * complete. 1130 * 1131 * @return {Promise} resolved when the fetch is complete. 1132 * @resolves to an object that represents either a single item or a 1133 * bookmarks tree. if guid points to a non-existent item, the 1134 * returned promise is resolved to null. 1135 * @rejects if an error happens while fetching. 1136 * @throws if the arguments are invalid. 1137 */ 1138 // TODO must implement these methods yet: 1139 // PlacesUtils.promiseBookmarksTree() 1140 fetchTree(guid = "", options = {}) { 1141 throw new Error("Not yet implemented"); 1142 }, 1143 1144 /** 1145 * Reorders contents of a folder based on a provided array of GUIDs. 1146 * 1147 * @param parentGuid 1148 * The globally unique identifier of the folder whose contents should 1149 * be reordered. 1150 * @param orderedChildrenGuids 1151 * Ordered array of the children's GUIDs. If this list contains 1152 * non-existing entries they will be ignored. If the list is 1153 * incomplete, and the current child list is already in order with 1154 * respect to orderedChildrenGuids, no change is made. Otherwise, the 1155 * new items are appended but maintain their current order relative to 1156 * eachother. 1157 * @param {Object} [options={}] 1158 * Additional options. Currently supports the following properties: 1159 * - source: The change source, forwarded to all bookmark observers. 1160 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 1161 * 1162 * @return {Promise} resolved when reordering is complete. 1163 * @rejects if an error happens while reordering. 1164 * @throws if the arguments are invalid. 1165 */ 1166 reorder(parentGuid, orderedChildrenGuids, options = {}) { 1167 let info = { guid: parentGuid }; 1168 info = validateBookmarkObject("Bookmarks.jsm: reorder", info, 1169 { guid: { required: true } }); 1170 1171 if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length) 1172 throw new Error("Must provide a sorted array of children GUIDs."); 1173 try { 1174 orderedChildrenGuids.forEach(PlacesUtils.BOOKMARK_VALIDATORS.guid); 1175 } catch (ex) { 1176 throw new Error("Invalid GUID found in the sorted children array."); 1177 } 1178 1179 if (!("source" in options)) { 1180 options.source = Bookmarks.SOURCES.DEFAULT; 1181 } 1182 1183 return (async () => { 1184 let parent = await fetchBookmark(info); 1185 if (!parent || parent.type != this.TYPE_FOLDER) 1186 throw new Error("No folder found for the provided GUID."); 1187 1188 let sortedChildren = await reorderChildren(parent, orderedChildrenGuids, 1189 options); 1190 1191 let { source = Bookmarks.SOURCES.DEFAULT } = options; 1192 let observers = PlacesUtils.bookmarks.getObservers(); 1193 // Note that child.index is the old index. 1194 for (let i = 0; i < sortedChildren.length; ++i) { 1195 let child = sortedChildren[i]; 1196 notify(observers, "onItemMoved", [ child._id, child._parentId, 1197 child.index, child._parentId, 1198 i, child.type, 1199 child.guid, child.parentGuid, 1200 child.parentGuid, 1201 source ]); 1202 } 1203 })(); 1204 }, 1205 1206 /** 1207 * Searches a list of bookmark-items by a search term, url or title. 1208 * 1209 * IMPORTANT: 1210 * This is intended as an interim API for the web-extensions implementation. 1211 * It will be removed as soon as we have a new querying API. 1212 * 1213 * Note also that this used to exclude separators but no longer does so. 1214 * 1215 * If you just want to search bookmarks by URL, use .fetch() instead. 1216 * 1217 * @param query 1218 * Either a string to use as search term, or an object 1219 * containing any of these keys: query, title or url with the 1220 * corresponding string to match as value. 1221 * The url property can be either a string or an nsIURI. 1222 * 1223 * @return {Promise} resolved when the search is complete. 1224 * @resolves to an array of found bookmark-items. 1225 * @rejects if an error happens while searching. 1226 * @throws if the arguments are invalid. 1227 * 1228 * @note Any unknown property in the query object is ignored. 1229 * Known properties may be overwritten. 1230 */ 1231 search(query) { 1232 if (!query) { 1233 throw new Error("Query object is required"); 1234 } 1235 if (typeof query === "string") { 1236 query = { query }; 1237 } 1238 if (typeof query !== "object") { 1239 throw new Error("Query must be an object or a string"); 1240 } 1241 if (query.query && typeof query.query !== "string") { 1242 throw new Error("Query option must be a string"); 1243 } 1244 if (query.title && typeof query.title !== "string") { 1245 throw new Error("Title option must be a string"); 1246 } 1247 1248 if (query.url) { 1249 if (typeof query.url === "string" || (query.url instanceof URL)) { 1250 query.url = new URL(query.url).href; 1251 } else if (query.url instanceof Ci.nsIURI) { 1252 query.url = query.url.spec; 1253 } else { 1254 throw new Error("Url option must be a string or a URL object"); 1255 } 1256 } 1257 1258 return queryBookmarks(query); 1259 }, 1260}); 1261 1262// Globals. 1263 1264/** 1265 * Sends a bookmarks notification through the given observers. 1266 * 1267 * @param {Array} observers 1268 * array of nsINavBookmarkObserver objects. 1269 * @param {String} notification 1270 * the notification name. 1271 * @param {Array} [args] 1272 * array of arguments to pass to the notification. 1273 * @param {Object} [information] 1274 * Information about the notification, so we can filter based 1275 * based on the observer's preferences. 1276 */ 1277function notify(observers, notification, args = [], information = {}) { 1278 for (let observer of observers) { 1279 if (information.isTagging && observer.skipTags) { 1280 continue; 1281 } 1282 1283 if (information.isDescendantRemoval && observer.skipDescendantsOnItemRemoval && 1284 !(PlacesUtils.bookmarks.userContentRoots.includes(information.parentGuid))) { 1285 continue; 1286 } 1287 1288 try { 1289 observer[notification](...args); 1290 } catch (ex) {} 1291 } 1292} 1293 1294// Update implementation. 1295 1296function updateBookmark(info, item, newParent) { 1297 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark", 1298 async function(db) { 1299 1300 let tuples = new Map(); 1301 tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) }); 1302 if (info.hasOwnProperty("title")) 1303 tuples.set("title", { value: info.title, 1304 fragment: `title = NULLIF(:title, "")` }); 1305 if (info.hasOwnProperty("dateAdded")) 1306 tuples.set("dateAdded", { value: PlacesUtils.toPRTime(info.dateAdded) }); 1307 1308 await db.executeTransaction(async function() { 1309 let isTagging = item._grandParentId == PlacesUtils.tagsFolderId; 1310 let syncChangeDelta = 1311 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(info.source); 1312 1313 if (info.hasOwnProperty("url")) { 1314 // Ensure a page exists in moz_places for this URL. 1315 await maybeInsertPlace(db, info.url); 1316 // Update tuples for the update query. 1317 tuples.set("url", { value: info.url.href, 1318 fragment: "fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)" }); 1319 } 1320 1321 let newIndex = info.hasOwnProperty("index") ? info.index : item.index; 1322 if (newParent) { 1323 // For simplicity, update the index regardless. 1324 tuples.set("position", { value: newIndex }); 1325 1326 if (newParent.guid == item.parentGuid) { 1327 // Moving inside the original container. 1328 // When moving "up", add 1 to each index in the interval. 1329 // Otherwise when moving down, we subtract 1. 1330 // Only the parent needs a sync change, which is handled in 1331 // `setAncestorsLastModified`. 1332 let sign = newIndex < item.index ? +1 : -1; 1333 await db.executeCached( 1334 `UPDATE moz_bookmarks SET position = position + :sign 1335 WHERE parent = :newParentId 1336 AND position BETWEEN :lowIndex AND :highIndex 1337 `, { sign, newParentId: newParent._id, 1338 lowIndex: Math.min(item.index, newIndex), 1339 highIndex: Math.max(item.index, newIndex) }); 1340 } else { 1341 // Moving across different containers. In this case, both parents and 1342 // the child need sync changes. `setAncestorsLastModified` handles the 1343 // parents; the `needsSyncChange` check below handles the child. 1344 tuples.set("parent", { value: newParent._id} ); 1345 await db.executeCached( 1346 `UPDATE moz_bookmarks SET position = position + :sign 1347 WHERE parent = :oldParentId 1348 AND position >= :oldIndex 1349 `, { sign: -1, oldParentId: item._parentId, oldIndex: item.index }); 1350 await db.executeCached( 1351 `UPDATE moz_bookmarks SET position = position + :sign 1352 WHERE parent = :newParentId 1353 AND position >= :newIndex 1354 `, { sign: +1, newParentId: newParent._id, newIndex }); 1355 1356 await setAncestorsLastModified(db, item.parentGuid, info.lastModified, 1357 syncChangeDelta); 1358 } 1359 await setAncestorsLastModified(db, newParent.guid, info.lastModified, 1360 syncChangeDelta); 1361 } 1362 1363 if (syncChangeDelta) { 1364 // Sync stores child indices in the parent's record, so we only bump the 1365 // item's counter if we're updating at least one more property in 1366 // addition to the index, last modified time, and dateAdded. 1367 let sizeThreshold = 1; 1368 if (info.hasOwnProperty("index") && info.index != item.index) { 1369 ++sizeThreshold; 1370 } 1371 if (tuples.has("dateAdded")) { 1372 ++sizeThreshold; 1373 } 1374 let needsSyncChange = tuples.size > sizeThreshold; 1375 if (needsSyncChange) { 1376 tuples.set("syncChangeDelta", { value: syncChangeDelta, 1377 fragment: "syncChangeCounter = syncChangeCounter + :syncChangeDelta" }); 1378 } 1379 } 1380 1381 if (isTagging) { 1382 // If we're updating a tag entry, bump the sync change counter for 1383 // bookmarks with the tagged URL. 1384 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 1385 db, item.url, syncChangeDelta); 1386 if (info.hasOwnProperty("url")) { 1387 // Changing the URL of a tag entry is equivalent to untagging the 1388 // old URL and tagging the new one, so we bump the change counter 1389 // for the new URL here. 1390 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 1391 db, info.url, syncChangeDelta); 1392 } 1393 } 1394 1395 let isChangingTagFolder = item._parentId == PlacesUtils.tagsFolderId; 1396 if (isChangingTagFolder) { 1397 // If we're updating a tag folder (for example, changing a tag's title), 1398 // bump the change counter for all tagged bookmarks. 1399 await addSyncChangesForBookmarksInFolder(db, item, syncChangeDelta); 1400 } 1401 1402 await db.executeCached( 1403 `UPDATE moz_bookmarks 1404 SET ${Array.from(tuples.keys()).map(v => tuples.get(v).fragment || `${v} = :${v}`).join(", ")} 1405 WHERE guid = :guid 1406 `, Object.assign({ guid: info.guid }, 1407 [...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {}))); 1408 1409 if (newParent) { 1410 if (newParent.guid == item.parentGuid) { 1411 // Mark all affected separators as changed 1412 // Also bumps the change counter if the item itself is a separator 1413 const startIndex = Math.min(newIndex, item.index); 1414 await adjustSeparatorsSyncCounter(db, newParent._id, startIndex, syncChangeDelta); 1415 } else { 1416 // Mark all affected separators as changed 1417 await adjustSeparatorsSyncCounter(db, item._parentId, item.index, syncChangeDelta); 1418 await adjustSeparatorsSyncCounter(db, newParent._id, newIndex, syncChangeDelta); 1419 } 1420 // Remove the Sync orphan annotation from reparented items. We don't 1421 // notify annotation observers about this because this is a temporary, 1422 // internal anno that's only used by Sync. 1423 await db.executeCached( 1424 `DELETE FROM moz_items_annos 1425 WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes 1426 WHERE name = :orphanAnno) AND 1427 item_id = :id`, 1428 { orphanAnno: PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO, 1429 id: item._id }); 1430 } 1431 }); 1432 1433 // If the parent changed, update related non-enumerable properties. 1434 let additionalParentInfo = {}; 1435 if (newParent) { 1436 Object.defineProperty(additionalParentInfo, "_parentId", 1437 { value: newParent._id, enumerable: false }); 1438 Object.defineProperty(additionalParentInfo, "_grandParentId", 1439 { value: newParent._parentId, enumerable: false }); 1440 } 1441 1442 let updatedItem = mergeIntoNewObject(item, info, additionalParentInfo); 1443 1444 return updatedItem; 1445 }); 1446} 1447 1448// Insert implementation. 1449 1450function insertBookmark(item, parent) { 1451 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark", 1452 async function(db) { 1453 1454 // If a guid was not provided, generate one, so we won't need to fetch the 1455 // bookmark just after having created it. 1456 let hasExistingGuid = item.hasOwnProperty("guid"); 1457 if (!hasExistingGuid) 1458 item.guid = PlacesUtils.history.makeGuid(); 1459 1460 let isTagging = parent._parentId == PlacesUtils.tagsFolderId; 1461 1462 await db.executeTransaction(async function transaction() { 1463 if (item.type == Bookmarks.TYPE_BOOKMARK) { 1464 // Ensure a page exists in moz_places for this URL. 1465 // The IGNORE conflict can trigger on `guid`. 1466 await maybeInsertPlace(db, item.url); 1467 } 1468 1469 // Adjust indices. 1470 await db.executeCached( 1471 `UPDATE moz_bookmarks SET position = position + 1 1472 WHERE parent = :parent 1473 AND position >= :index 1474 `, { parent: parent._id, index: item.index }); 1475 1476 let syncChangeDelta = 1477 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(item.source); 1478 let syncStatus = 1479 PlacesSyncUtils.bookmarks.determineInitialSyncStatus(item.source); 1480 1481 // Insert the bookmark into the database. 1482 await db.executeCached( 1483 `INSERT INTO moz_bookmarks (fk, type, parent, position, title, 1484 dateAdded, lastModified, guid, 1485 syncChangeCounter, syncStatus) 1486 VALUES (CASE WHEN :url ISNULL THEN NULL ELSE (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) END, 1487 :type, :parent, :index, NULLIF(:title, ""), :date_added, 1488 :last_modified, :guid, :syncChangeCounter, :syncStatus) 1489 `, { url: item.hasOwnProperty("url") ? item.url.href : null, 1490 type: item.type, parent: parent._id, index: item.index, 1491 title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded), 1492 last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid, 1493 syncChangeCounter: syncChangeDelta, syncStatus }); 1494 1495 // Mark all affected separators as changed 1496 await adjustSeparatorsSyncCounter(db, parent._id, item.index + 1, syncChangeDelta); 1497 1498 if (hasExistingGuid) { 1499 // Remove stale tombstones if we're reinserting an item. 1500 await db.executeCached( 1501 `DELETE FROM moz_bookmarks_deleted WHERE guid = :guid`, 1502 { guid: item.guid }); 1503 } 1504 1505 if (isTagging) { 1506 // New tag entry; bump the change counter for bookmarks with the 1507 // tagged URL. 1508 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 1509 db, item.url, syncChangeDelta); 1510 } 1511 1512 await setAncestorsLastModified(db, item.parentGuid, item.dateAdded, 1513 syncChangeDelta); 1514 }); 1515 1516 // If not a tag recalculate frecency... 1517 if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) { 1518 // ...though we don't wait for the calculation. 1519 updateFrecency(db, [item.url]).catch(Cu.reportError); 1520 } 1521 1522 return item; 1523 }); 1524} 1525 1526/** 1527 * Determines if a bookmark is a Livemark depending on how it is annotated. 1528 * 1529 * @param {Object} node The bookmark node to check. 1530 * @returns {Boolean} True if the node is a Livemark, false otherwise. 1531 */ 1532function isLivemark(node) { 1533 return node.type == Bookmarks.TYPE_FOLDER && node.annos && 1534 node.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI); 1535} 1536 1537function insertBookmarkTree(items, source, parent, urls, lastAddedForParent) { 1538 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmarkTree", async function(db) { 1539 await db.executeTransaction(async function transaction() { 1540 await maybeInsertManyPlaces(db, urls); 1541 1542 let syncChangeDelta = 1543 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source); 1544 let syncStatus = 1545 PlacesSyncUtils.bookmarks.determineInitialSyncStatus(source); 1546 1547 let rootId = parent._id; 1548 1549 items = items.map(item => ({ 1550 url: item.url && item.url.href, 1551 type: item.type, parentGuid: item.parentGuid, index: item.index, 1552 title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded), 1553 last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid, 1554 syncChangeCounter: syncChangeDelta, syncStatus, rootId 1555 })); 1556 await db.executeCached( 1557 `INSERT INTO moz_bookmarks (fk, type, parent, position, title, 1558 dateAdded, lastModified, guid, 1559 syncChangeCounter, syncStatus) 1560 VALUES (CASE WHEN :url ISNULL THEN NULL ELSE (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) END, :type, 1561 (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), 1562 IFNULL(:index, (SELECT count(*) FROM moz_bookmarks WHERE parent = :rootId)), 1563 NULLIF(:title, ""), :date_added, :last_modified, :guid, 1564 :syncChangeCounter, :syncStatus)`, items); 1565 1566 // Remove stale tombstones for new items. 1567 for (let chunk of chunkArray(items, SQLITE_MAX_VARIABLE_NUMBER)) { 1568 await db.executeCached( 1569 `DELETE FROM moz_bookmarks_deleted WHERE guid IN (${ 1570 new Array(chunk.length).fill("?").join(",")})`, 1571 chunk.map(item => item.guid) 1572 ); 1573 } 1574 1575 await setAncestorsLastModified(db, parent.guid, lastAddedForParent, 1576 syncChangeDelta); 1577 }); 1578 1579 // We don't wait for the frecency calculation. 1580 updateFrecency(db, urls, true).catch(Cu.reportError); 1581 1582 return items; 1583 }); 1584} 1585 1586/** 1587 * Handles data for a Livemark insert. 1588 * 1589 * @param {Object} item Livemark item that need to be added. 1590 */ 1591async function insertLivemarkData(item) { 1592 // Delete the placeholder but note the index of it, so that we can insert the 1593 // livemark item at the right place. 1594 let placeholder = await Bookmarks.fetch(item.guid); 1595 let index = placeholder.index; 1596 await removeBookmarks([item], {source: item.source}); 1597 1598 let feedURI = null; 1599 let siteURI = null; 1600 item.annos = item.annos.filter(function(aAnno) { 1601 switch (aAnno.name) { 1602 case PlacesUtils.LMANNO_FEEDURI: 1603 feedURI = NetUtil.newURI(aAnno.value); 1604 return false; 1605 case PlacesUtils.LMANNO_SITEURI: 1606 siteURI = NetUtil.newURI(aAnno.value); 1607 return false; 1608 default: 1609 return true; 1610 } 1611 }); 1612 1613 if (feedURI) { 1614 item.feedURI = feedURI; 1615 item.siteURI = siteURI; 1616 item.index = index; 1617 1618 if (item.dateAdded) { 1619 item.dateAdded = PlacesUtils.toPRTime(item.dateAdded); 1620 } 1621 if (item.lastModified) { 1622 item.lastModified = PlacesUtils.toPRTime(item.lastModified); 1623 } 1624 1625 let livemark = await PlacesUtils.livemarks.addLivemark(item); 1626 1627 let id = livemark.id; 1628 if (item.annos && item.annos.length) { 1629 // Note: for annotations, we intentionally skip updating the last modified 1630 // value for the bookmark, to avoid a second update of the added bookmark. 1631 PlacesUtils.setAnnotationsForItem(id, item.annos, item.source, true); 1632 } 1633 } 1634} 1635 1636/** 1637 * Handles special data on a bookmark, e.g. annotations, keywords, tags, charsets, 1638 * inserting the data into the appropriate place. 1639 * 1640 * @param {Integer} itemId The ID of the item within the bookmarks database. 1641 * @param {Object} item The bookmark item with possible special data to be inserted. 1642 */ 1643async function handleBookmarkItemSpecialData(itemId, item) { 1644 if (item.annos && item.annos.length) { 1645 // Note: for annotations, we intentionally skip updating the last modified 1646 // value for the bookmark, to avoid a second update of the added bookmark. 1647 try { 1648 PlacesUtils.setAnnotationsForItem(itemId, item.annos, item.source, true); 1649 } catch (ex) { 1650 Cu.reportError(`Failed to insert annotations for item: ${ex}`); 1651 } 1652 } 1653 if ("keyword" in item && item.keyword) { 1654 // POST data could be set in 2 ways: 1655 // 1. new backups have a postData property 1656 // 2. old backups have an item annotation 1657 let postDataAnno = item.annos && 1658 item.annos.find(anno => anno.name == PlacesUtils.POST_DATA_ANNO); 1659 let postData = item.postData || (postDataAnno && postDataAnno.value); 1660 try { 1661 await PlacesUtils.keywords.insert({ 1662 keyword: item.keyword, 1663 url: item.url, 1664 postData, 1665 source: item.source 1666 }); 1667 } catch (ex) { 1668 Cu.reportError(`Failed to insert keyword "${item.keyword} for ${item.url}": ${ex}`); 1669 } 1670 } 1671 if ("tags" in item) { 1672 try { 1673 PlacesUtils.tagging.tagURI(NetUtil.newURI(item.url), item.tags, item.source); 1674 } catch (ex) { 1675 // Invalid tag child, skip it. 1676 Cu.reportError(`Unable to set tags "${item.tags.join(", ")}" for ${item.url}: ${ex}`); 1677 } 1678 } 1679 if ("charset" in item && item.charset) { 1680 try { 1681 await PlacesUtils.setCharsetForURI(NetUtil.newURI(item.url), item.charset); 1682 } catch (ex) { 1683 Cu.reportError(`Failed to set charset "${item.charset}" for ${item.url}: ${ex}`); 1684 } 1685 } 1686} 1687 1688// Query implementation. 1689 1690async function queryBookmarks(info) { 1691 let queryParams = { 1692 tags_folder: await promiseTagsFolderId(), 1693 }; 1694 // We're searching for bookmarks, so exclude tags. 1695 let queryString = "WHERE b.parent <> :tags_folder"; 1696 queryString += " AND p.parent <> :tags_folder"; 1697 1698 if (info.title) { 1699 queryString += " AND b.title = :title"; 1700 queryParams.title = info.title; 1701 } 1702 1703 if (info.url) { 1704 queryString += " AND h.url_hash = hash(:url) AND h.url = :url"; 1705 queryParams.url = info.url; 1706 } 1707 1708 if (info.query) { 1709 queryString += " AND AUTOCOMPLETE_MATCH(:query, h.url, b.title, NULL, NULL, 1, 1, NULL, :matchBehavior, :searchBehavior) "; 1710 queryParams.query = info.query; 1711 queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED; 1712 queryParams.searchBehavior = BEHAVIOR_BOOKMARK; 1713 } 1714 1715 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: queryBookmarks", 1716 async function(db) { 1717 // _id, _childCount, _grandParentId and _parentId fields 1718 // are required to be in the result by the converting function 1719 // hence setting them to NULL 1720 let rows = await db.executeCached( 1721 `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1722 b.dateAdded, b.lastModified, b.type, 1723 IFNULL(b.title, "") AS title, h.url AS url, b.parent, p.parent, 1724 NULL AS _id, 1725 NULL AS _childCount, 1726 NULL AS _grandParentId, 1727 NULL AS _parentId, 1728 NULL AS _syncStatus 1729 FROM moz_bookmarks b 1730 LEFT JOIN moz_bookmarks p ON p.id = b.parent 1731 LEFT JOIN moz_places h ON h.id = b.fk 1732 ${queryString} 1733 `, queryParams); 1734 1735 return rowsToItemsArray(rows); 1736 } 1737 ); 1738} 1739 1740 1741// Fetch implementation. 1742 1743async function fetchBookmark(info, concurrent) { 1744 let query = async function(db) { 1745 let rows = await db.executeCached( 1746 `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1747 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, "") AS title, 1748 h.url AS url, b.id AS _id, b.parent AS _parentId, 1749 (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount, 1750 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 1751 FROM moz_bookmarks b 1752 LEFT JOIN moz_bookmarks p ON p.id = b.parent 1753 LEFT JOIN moz_places h ON h.id = b.fk 1754 WHERE b.guid = :guid 1755 `, { guid: info.guid }); 1756 1757 return rows.length ? rowsToItemsArray(rows)[0] : null; 1758 }; 1759 if (concurrent) { 1760 let db = await PlacesUtils.promiseDBConnection(); 1761 return query(db); 1762 } 1763 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark", 1764 query); 1765} 1766 1767async function fetchBookmarkByPosition(info, concurrent) { 1768 let query = async function(db) { 1769 let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index; 1770 let rows = await db.executeCached( 1771 `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1772 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, "") AS title, 1773 h.url AS url, b.id AS _id, b.parent AS _parentId, 1774 (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount, 1775 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 1776 FROM moz_bookmarks b 1777 LEFT JOIN moz_bookmarks p ON p.id = b.parent 1778 LEFT JOIN moz_places h ON h.id = b.fk 1779 WHERE p.guid = :parentGuid 1780 AND b.position = IFNULL(:index, (SELECT count(*) - 1 1781 FROM moz_bookmarks 1782 WHERE parent = p.id)) 1783 `, { parentGuid: info.parentGuid, index }); 1784 1785 return rows.length ? rowsToItemsArray(rows)[0] : null; 1786 }; 1787 if (concurrent) { 1788 let db = await PlacesUtils.promiseDBConnection(); 1789 return query(db); 1790 } 1791 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarkByPosition", 1792 query); 1793} 1794 1795async function fetchBookmarksByGUIDPrefix(info, concurrent) { 1796 let query = async function(db) { 1797 let rows = await db.executeCached( 1798 `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1799 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, "") AS title, 1800 h.url AS url, b.id AS _id, b.parent AS _parentId, 1801 NULL AS _childCount, 1802 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 1803 FROM moz_bookmarks b 1804 LEFT JOIN moz_bookmarks p ON p.id = b.parent 1805 LEFT JOIN moz_places h ON h.id = b.fk 1806 WHERE b.guid LIKE :guidPrefix 1807 ORDER BY b.lastModified DESC 1808 `, { guidPrefix: info.guidPrefix + "%" }); 1809 1810 return rows.length ? rowsToItemsArray(rows) : null; 1811 }; 1812 1813 if (concurrent) { 1814 let db = await PlacesUtils.promiseDBConnection(); 1815 return query(db); 1816 } 1817 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByGUIDPrefix", 1818 query); 1819} 1820 1821async function fetchBookmarksByURL(info, concurrent) { 1822 let query = async function(db) { 1823 let tagsFolderId = await promiseTagsFolderId(); 1824 let rows = await db.executeCached( 1825 `/* do not warn (bug no): not worth to add an index */ 1826 SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1827 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, "") AS title, 1828 h.url AS url, b.id AS _id, b.parent AS _parentId, 1829 NULL AS _childCount, /* Unused for now */ 1830 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 1831 FROM moz_bookmarks b 1832 JOIN moz_bookmarks p ON p.id = b.parent 1833 JOIN moz_places h ON h.id = b.fk 1834 WHERE h.url_hash = hash(:url) AND h.url = :url 1835 AND _grandParentId <> :tagsFolderId 1836 ORDER BY b.lastModified DESC 1837 `, { url: info.url.href, tagsFolderId }); 1838 1839 return rows.length ? rowsToItemsArray(rows) : null; 1840 }; 1841 1842 if (concurrent) { 1843 let db = await PlacesUtils.promiseDBConnection(); 1844 return query(db); 1845 } 1846 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByURL", 1847 query); 1848} 1849 1850function fetchRecentBookmarks(numberOfItems) { 1851 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchRecentBookmarks", 1852 async function(db) { 1853 let tagsFolderId = await promiseTagsFolderId(); 1854 let rows = await db.executeCached( 1855 `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1856 b.dateAdded, b.lastModified, b.type, 1857 IFNULL(b.title, "") AS title, h.url AS url, NULL AS _id, 1858 NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId, 1859 NULL AS _syncStatus 1860 FROM moz_bookmarks b 1861 JOIN moz_bookmarks p ON p.id = b.parent 1862 JOIN moz_places h ON h.id = b.fk 1863 WHERE p.parent <> :tagsFolderId 1864 AND b.type = :type 1865 AND url_hash NOT BETWEEN hash("place", "prefix_lo") 1866 AND hash("place", "prefix_hi") 1867 ORDER BY b.dateAdded DESC, b.ROWID DESC 1868 LIMIT :numberOfItems 1869 `, { 1870 tagsFolderId, 1871 type: Bookmarks.TYPE_BOOKMARK, 1872 numberOfItems, 1873 }); 1874 1875 return rows.length ? rowsToItemsArray(rows) : []; 1876 } 1877 ); 1878} 1879 1880async function fetchBookmarksByParent(db, info) { 1881 let rows = await db.executeCached( 1882 `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index', 1883 b.dateAdded, b.lastModified, b.type, IFNULL(b.title, "") AS title, 1884 h.url AS url, b.id AS _id, b.parent AS _parentId, 1885 (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount, 1886 p.parent AS _grandParentId, b.syncStatus AS _syncStatus 1887 FROM moz_bookmarks b 1888 LEFT JOIN moz_bookmarks p ON p.id = b.parent 1889 LEFT JOIN moz_places h ON h.id = b.fk 1890 WHERE p.guid = :parentGuid 1891 ORDER BY b.position ASC 1892 `, { parentGuid: info.parentGuid }); 1893 1894 return rowsToItemsArray(rows); 1895} 1896 1897// Remove implementation. 1898 1899function removeBookmarks(items, options) { 1900 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: removeBookmarks", 1901 async function(db) { 1902 let urls = []; 1903 1904 await db.executeTransaction(async function transaction() { 1905 let parentGuids = new Set(); 1906 let syncChangeDelta = 1907 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source); 1908 1909 for (let item of items) { 1910 parentGuids.add(item.parentGuid); 1911 1912 // If it's a folder, remove its contents first. 1913 if (item.type == Bookmarks.TYPE_FOLDER) { 1914 if (options.preventRemovalOfNonEmptyFolders && item._childCount > 0) { 1915 throw new Error("Cannot remove a non-empty folder."); 1916 } 1917 urls = urls.concat(await removeFoldersContents(db, [item.guid], options)); 1918 } 1919 } 1920 1921 for (let chunk of chunkArray(items, SQLITE_MAX_VARIABLE_NUMBER)) { 1922 // We don't go through the annotations service for this cause otherwise 1923 // we'd get a pointless onItemChanged notification and it would also 1924 // set lastModified to an unexpected value. 1925 await removeAnnotationsForItems(db, chunk); 1926 1927 // Remove the bookmarks. 1928 await db.executeCached( 1929 `DELETE FROM moz_bookmarks WHERE guid IN (${ 1930 new Array(chunk.length).fill("?").join(",")})`, 1931 chunk.map(item => item.guid) 1932 ); 1933 } 1934 1935 for (let item of items) { 1936 // Fix indices in the parent. 1937 await db.executeCached( 1938 `UPDATE moz_bookmarks SET position = position - 1 WHERE 1939 parent = :parentId AND position > :index 1940 `, { parentId: item._parentId, index: item.index }); 1941 1942 if (item._grandParentId == PlacesUtils.tagsFolderId) { 1943 // If we're removing a tag entry, increment the change counter for all 1944 // bookmarks with the tagged URL. 1945 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 1946 db, item.url, syncChangeDelta); 1947 } 1948 1949 await adjustSeparatorsSyncCounter(db, item._parentId, item.index, syncChangeDelta); 1950 } 1951 1952 for (let guid of parentGuids) { 1953 // Mark all affected parents as changed. 1954 await setAncestorsLastModified(db, guid, new Date(), syncChangeDelta); 1955 } 1956 1957 // Write tombstones for the removed items. 1958 await insertTombstones(db, items, syncChangeDelta); 1959 }); 1960 1961 // Update the frecencies outside of the transaction, excluding tags, so that 1962 // the updates can progress in the background. 1963 urls = urls.concat(items.filter(item => { 1964 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 1965 return !isUntagging && "url" in item; 1966 }).map(item => item.url)); 1967 1968 if (urls.length) { 1969 await PlacesUtils.keywords.removeFromURLsIfNotBookmarked(urls); 1970 updateFrecency(db, urls, urls.length > 1).catch(Cu.reportError); 1971 } 1972 }); 1973} 1974 1975// Reorder implementation. 1976 1977function reorderChildren(parent, orderedChildrenGuids, options) { 1978 return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: reorderChildren", 1979 db => db.executeTransaction(async function() { 1980 // Select all of the direct children for the given parent. 1981 let children = await fetchBookmarksByParent(db, { parentGuid: parent.guid }); 1982 if (!children.length) { 1983 return []; 1984 } 1985 1986 // Maps of GUIDs to indices for fast lookups in the comparator function. 1987 let guidIndices = new Map(); 1988 let currentIndices = new Map(); 1989 for (let i = 0; i < orderedChildrenGuids.length; ++i) { 1990 let guid = orderedChildrenGuids[i]; 1991 guidIndices.set(guid, i); 1992 } 1993 1994 // If we got an incomplete list but everything we have is in the right 1995 // order, we do nothing. 1996 let needReorder = true; 1997 let requestedChildIndices = []; 1998 for (let i = 0; i < children.length; ++i) { 1999 // Take the opportunity to build currentIndices here, since we already 2000 // are iterating over the children array. 2001 currentIndices.set(children[i].guid, i); 2002 2003 if (guidIndices.has(children[i].guid)) { 2004 let index = guidIndices.get(children[i].guid); 2005 requestedChildIndices.push(index); 2006 } 2007 } 2008 2009 if (requestedChildIndices.length) { 2010 needReorder = false; 2011 for (let i = 1; i < requestedChildIndices.length; ++i) { 2012 if (requestedChildIndices[i - 1] > requestedChildIndices[i]) { 2013 needReorder = true; 2014 break; 2015 } 2016 } 2017 } 2018 2019 if (needReorder) { 2020 2021 2022 // Reorder the children array according to the specified order, provided 2023 // GUIDs come first, others are appended in somehow random order. 2024 children.sort((a, b) => { 2025 // This works provided fetchBookmarksByParent returns sorted children. 2026 if (!guidIndices.has(a.guid) && !guidIndices.has(b.guid)) { 2027 return currentIndices.get(a.guid) < currentIndices.get(b.guid) ? -1 : 1; 2028 } 2029 if (!guidIndices.has(a.guid)) { 2030 return 1; 2031 } 2032 if (!guidIndices.has(b.guid)) { 2033 return -1; 2034 } 2035 return guidIndices.get(a.guid) < guidIndices.get(b.guid) ? -1 : 1; 2036 }); 2037 2038 // Update the bookmarks position now. If any unknown guid have been 2039 // inserted meanwhile, its position will be set to -position, and we'll 2040 // handle it later. 2041 // To do the update in a single step, we build a VALUES (guid, position) 2042 // table. We then use count() in the sorting table to avoid skipping values 2043 // when no more existing GUIDs have been provided. 2044 let valuesTable = children.map((child, i) => `("${child.guid}", ${i})`) 2045 .join(); 2046 await db.execute( 2047 `WITH sorting(g, p) AS ( 2048 VALUES ${valuesTable} 2049 ) 2050 UPDATE moz_bookmarks SET position = ( 2051 SELECT CASE count(*) WHEN 0 THEN -position 2052 ELSE count(*) - 1 2053 END 2054 FROM sorting a 2055 JOIN sorting b ON b.p <= a.p 2056 WHERE a.g = guid 2057 ) 2058 WHERE parent = :parentId 2059 `, { parentId: parent._id}); 2060 2061 let syncChangeDelta = 2062 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source); 2063 if (syncChangeDelta) { 2064 // Flag the parent as having a change. 2065 await db.executeCached(` 2066 UPDATE moz_bookmarks SET 2067 syncChangeCounter = syncChangeCounter + :syncChangeDelta 2068 WHERE id = :parentId`, 2069 { parentId: parent._id, syncChangeDelta }); 2070 } 2071 2072 // Update position of items that could have been inserted in the meanwhile. 2073 // Since this can happen rarely and it's only done for schema coherence 2074 // resonds, we won't notify about these changes. 2075 await db.executeCached( 2076 `CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger 2077 AFTER UPDATE OF position ON moz_bookmarks 2078 WHEN NEW.position = -1 2079 BEGIN 2080 UPDATE moz_bookmarks 2081 SET position = (SELECT MAX(position) FROM moz_bookmarks 2082 WHERE parent = NEW.parent) + 2083 (SELECT count(*) FROM moz_bookmarks 2084 WHERE parent = NEW.parent 2085 AND position BETWEEN OLD.position AND -1) 2086 WHERE guid = NEW.guid; 2087 END 2088 `); 2089 2090 await db.executeCached( 2091 `UPDATE moz_bookmarks SET position = -1 WHERE position < 0`); 2092 2093 await db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`); 2094 } 2095 2096 // Remove the Sync orphan annotation from the reordered children, so that 2097 // Sync doesn't try to reparent them once it sees the original parents. We 2098 // only do this for explicitly ordered children, to avoid removing orphan 2099 // annos set by Sync. 2100 let possibleOrphanIds = []; 2101 for (let child of children) { 2102 if (guidIndices.has(child.guid)) { 2103 possibleOrphanIds.push(child._id); 2104 } 2105 } 2106 await db.executeCached( 2107 `DELETE FROM moz_items_annos 2108 WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes 2109 WHERE name = :orphanAnno) AND 2110 item_id IN (${possibleOrphanIds.join(", ")})`, 2111 { orphanAnno: PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO }); 2112 2113 return children; 2114 }) 2115 ); 2116} 2117 2118// Helpers. 2119 2120/** 2121 * Merges objects into a new object, included non-enumerable properties. 2122 * 2123 * @param sources 2124 * source objects to merge. 2125 * @return a new object including all properties from the source objects. 2126 */ 2127function mergeIntoNewObject(...sources) { 2128 let dest = {}; 2129 for (let src of sources) { 2130 for (let prop of Object.getOwnPropertyNames(src)) { 2131 Object.defineProperty(dest, prop, Object.getOwnPropertyDescriptor(src, prop)); 2132 } 2133 } 2134 return dest; 2135} 2136 2137/** 2138 * Remove properties that have the same value across two bookmark objects. 2139 * 2140 * @param dest 2141 * destination bookmark object. 2142 * @param src 2143 * source bookmark object. 2144 * @return a cleaned up bookmark object. 2145 * @note "guid" is never removed. 2146 */ 2147function removeSameValueProperties(dest, src) { 2148 for (let prop in dest) { 2149 let remove = false; 2150 switch (prop) { 2151 case "lastModified": 2152 case "dateAdded": 2153 remove = src.hasOwnProperty(prop) && dest[prop].getTime() == src[prop].getTime(); 2154 break; 2155 case "url": 2156 remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href; 2157 break; 2158 default: 2159 remove = dest[prop] == src[prop]; 2160 } 2161 if (remove && prop != "guid") 2162 delete dest[prop]; 2163 } 2164} 2165 2166/** 2167 * Convert an array of mozIStorageRow objects to an array of bookmark objects. 2168 * 2169 * @param rows 2170 * the array of mozIStorageRow objects. 2171 * @return an array of bookmark objects. 2172 */ 2173function rowsToItemsArray(rows) { 2174 return rows.map(row => { 2175 let item = {}; 2176 for (let prop of ["guid", "index", "type", "title"]) { 2177 item[prop] = row.getResultByName(prop); 2178 } 2179 for (let prop of ["dateAdded", "lastModified"]) { 2180 let value = row.getResultByName(prop); 2181 if (value) 2182 item[prop] = PlacesUtils.toDate(value); 2183 } 2184 let parentGuid = row.getResultByName("parentGuid"); 2185 if (parentGuid) { 2186 item.parentGuid = parentGuid; 2187 } 2188 let url = row.getResultByName("url"); 2189 if (url) { 2190 item.url = new URL(url); 2191 } 2192 2193 for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId", 2194 "_syncStatus"]) { 2195 let val = row.getResultByName(prop); 2196 if (val !== null) { 2197 // These properties should not be returned to the API consumer, thus 2198 // they are non-enumerable and removed through Object.assign just before 2199 // the object is returned. 2200 // Configurable is set to support mergeIntoNewObject overwrites. 2201 Object.defineProperty(item, prop, { value: val, enumerable: false, 2202 configurable: true }); 2203 } 2204 } 2205 2206 return item; 2207 }); 2208} 2209 2210function validateBookmarkObject(name, input, behavior) { 2211 return PlacesUtils.validateItemProperties(name, 2212 PlacesUtils.BOOKMARK_VALIDATORS, input, behavior); 2213} 2214 2215/** 2216 * Updates frecency for a list of URLs. 2217 * 2218 * @param db 2219 * the Sqlite.jsm connection handle. 2220 * @param urls 2221 * the array of URLs to update. 2222 * @param [optional] collapseNotifications 2223 * whether we can send just one onManyFrecenciesChanged 2224 * notification instead of sending one notification for every URL. 2225 */ 2226var updateFrecency = async function(db, urls, collapseNotifications = false) { 2227 let urlQuery = 'hash("' + urls.map(url => url.href).join('"), hash("') + '")'; 2228 2229 let frecencyClause = "CALCULATE_FRECENCY(id)"; 2230 if (!collapseNotifications) { 2231 frecencyClause = "NOTIFY_FRECENCY(" + frecencyClause + 2232 ", url, guid, hidden, last_visit_date)"; 2233 } 2234 // We just use the hashes, since updating a few additional urls won't hurt. 2235 await db.execute( 2236 `UPDATE moz_places 2237 SET hidden = (url_hash BETWEEN hash("place", "prefix_lo") AND hash("place", "prefix_hi")), 2238 frecency = ${frecencyClause} 2239 WHERE url_hash IN ( ${urlQuery} ) 2240 `); 2241 if (collapseNotifications) { 2242 let observers = PlacesUtils.history.getObservers(); 2243 notify(observers, "onManyFrecenciesChanged"); 2244 } 2245}; 2246 2247/** 2248 * Removes any orphan annotation entries. 2249 * 2250 * @param db 2251 * the Sqlite.jsm connection handle. 2252 */ 2253var removeOrphanAnnotations = async function(db) { 2254 await db.executeCached( 2255 `DELETE FROM moz_items_annos 2256 WHERE id IN (SELECT a.id from moz_items_annos a 2257 LEFT JOIN moz_bookmarks b ON a.item_id = b.id 2258 WHERE b.id ISNULL) 2259 `); 2260 await db.executeCached( 2261 `DELETE FROM moz_anno_attributes 2262 WHERE id IN (SELECT n.id from moz_anno_attributes n 2263 LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id 2264 LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id 2265 WHERE a1.id ISNULL AND a2.id ISNULL) 2266 `); 2267}; 2268 2269/** 2270 * Removes annotations for a given item. 2271 * 2272 * @param db 2273 * the Sqlite.jsm connection handle. 2274 * @param items 2275 * The items for which to remove annotations. 2276 */ 2277var removeAnnotationsForItems = async function(db, items) { 2278 // Remove the annotations. 2279 let ids = sqlList(items.map(item => item._id)); 2280 await db.executeCached( 2281 `DELETE FROM moz_items_annos WHERE item_id IN (${ids})`, 2282 ); 2283 await db.executeCached( 2284 `DELETE FROM moz_anno_attributes 2285 WHERE id IN (SELECT n.id from moz_anno_attributes n 2286 LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id 2287 LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id 2288 WHERE a1.id ISNULL AND a2.id ISNULL) 2289 `); 2290}; 2291 2292/** 2293 * Updates lastModified for all the ancestors of a given folder GUID. 2294 * 2295 * @param db 2296 * the Sqlite.jsm connection handle. 2297 * @param folderGuid 2298 * the GUID of the folder whose ancestors should be updated. 2299 * @param time 2300 * a Date object to use for the update. 2301 * 2302 * @note the folder itself is also updated. 2303 */ 2304var setAncestorsLastModified = async function(db, folderGuid, time, syncChangeDelta) { 2305 await db.executeCached( 2306 `WITH RECURSIVE 2307 ancestors(aid) AS ( 2308 SELECT id FROM moz_bookmarks WHERE guid = :guid 2309 UNION ALL 2310 SELECT parent FROM moz_bookmarks 2311 JOIN ancestors ON id = aid 2312 WHERE type = :type 2313 ) 2314 UPDATE moz_bookmarks SET lastModified = :time 2315 WHERE id IN ancestors 2316 `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER, 2317 time: PlacesUtils.toPRTime(time) }); 2318 2319 if (syncChangeDelta) { 2320 // Flag the folder as having a change. 2321 await db.executeCached(` 2322 UPDATE moz_bookmarks SET 2323 syncChangeCounter = syncChangeCounter + :syncChangeDelta 2324 WHERE guid = :guid`, 2325 { guid: folderGuid, syncChangeDelta }); 2326 } 2327}; 2328 2329/** 2330 * Remove all descendants of one or more bookmark folders. 2331 * 2332 * @param {Object} db 2333 * the Sqlite.jsm connection handle. 2334 * @param {Array} folderGuids 2335 * array of folder guids. 2336 * @return {Array} 2337 * An array of urls that will need to be updated for frecency. These 2338 * are returned rather than updated immediately so that the caller 2339 * can decide when they need to be updated - they do not need to 2340 * stop this function from completing. 2341 */ 2342var removeFoldersContents = 2343async function(db, folderGuids, options) { 2344 let syncChangeDelta = 2345 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(options.source); 2346 2347 let itemsRemoved = []; 2348 for (let folderGuid of folderGuids) { 2349 let rows = await db.executeCached( 2350 `WITH RECURSIVE 2351 descendants(did) AS ( 2352 SELECT b.id FROM moz_bookmarks b 2353 JOIN moz_bookmarks p ON b.parent = p.id 2354 WHERE p.guid = :folderGuid 2355 UNION ALL 2356 SELECT id FROM moz_bookmarks 2357 JOIN descendants ON parent = did 2358 ) 2359 SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index', 2360 b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded, 2361 b.lastModified, IFNULL(b.title, "") AS title, 2362 p.parent AS _grandParentId, NULL AS _childCount, 2363 b.syncStatus AS _syncStatus 2364 FROM descendants 2365 /* The usage of CROSS JOIN is not random, it tells the optimizer 2366 to retain the original rows order, so the hierarchy is respected */ 2367 CROSS JOIN moz_bookmarks b ON did = b.id 2368 JOIN moz_bookmarks p ON p.id = b.parent 2369 LEFT JOIN moz_places h ON b.fk = h.id`, { folderGuid }); 2370 2371 itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows)); 2372 2373 await db.executeCached( 2374 `WITH RECURSIVE 2375 descendants(did) AS ( 2376 SELECT b.id FROM moz_bookmarks b 2377 JOIN moz_bookmarks p ON b.parent = p.id 2378 WHERE p.guid = :folderGuid 2379 UNION ALL 2380 SELECT id FROM moz_bookmarks 2381 JOIN descendants ON parent = did 2382 ) 2383 DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid }); 2384 } 2385 2386 // Write tombstones for removed items. 2387 await insertTombstones(db, itemsRemoved, syncChangeDelta); 2388 2389 // Bump the change counter for all tagged bookmarks when removing tag 2390 // folders. 2391 await addSyncChangesForRemovedTagFolders(db, itemsRemoved, syncChangeDelta); 2392 2393 // Cleanup orphans. 2394 await removeOrphanAnnotations(db); 2395 2396 // TODO (Bug 1087576): this may leave orphan tags behind. 2397 2398 // Send onItemRemoved notifications to listeners. 2399 // TODO (Bug 1087580): for the case of eraseEverything, this should send a 2400 // single clear bookmarks notification rather than notifying for each 2401 // bookmark. 2402 2403 // Notify listeners in reverse order to serve children before parents. 2404 let { source = Bookmarks.SOURCES.DEFAULT } = options; 2405 let observers = PlacesUtils.bookmarks.getObservers(); 2406 for (let item of itemsRemoved.reverse()) { 2407 let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null; 2408 notify(observers, "onItemRemoved", [ item._id, item._parentId, 2409 item.index, item.type, uri, 2410 item.guid, item.parentGuid, 2411 source ], 2412 // Notify observers that this item is being 2413 // removed as a descendent. 2414 { 2415 isDescendantRemoval: true, 2416 parentGuid: item.parentGuid 2417 }); 2418 2419 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 2420 if (isUntagging) { 2421 for (let entry of (await fetchBookmarksByURL(item, true))) { 2422 notify(observers, "onItemChanged", [ entry._id, "tags", false, "", 2423 PlacesUtils.toPRTime(entry.lastModified), 2424 entry.type, entry._parentId, 2425 entry.guid, entry.parentGuid, 2426 "", source ]); 2427 } 2428 } 2429 } 2430 return itemsRemoved.filter(item => "url" in item).map(item => item.url); 2431}; 2432 2433/** 2434 * Tries to insert a new place if it doesn't exist yet. 2435 * @param url 2436 * A valid URL object. 2437 * @return {Promise} resolved when the operation is complete. 2438 */ 2439async function maybeInsertPlace(db, url) { 2440 // The IGNORE conflict can trigger on `guid`. 2441 await db.executeCached( 2442 `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) 2443 VALUES (:url, hash(:url), :rev_host, 0, :frecency, 2444 IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), 2445 GENERATE_GUID())) 2446 `, { url: url.href, 2447 rev_host: PlacesUtils.getReversedHost(url), 2448 frecency: url.protocol == "place:" ? 0 : -1 }); 2449 await db.executeCached("DELETE FROM moz_updatehostsinsert_temp"); 2450} 2451 2452/** 2453 * Tries to insert a new place if it doesn't exist yet. 2454 * @param db 2455 * The database to use 2456 * @param urls 2457 * An array with all the url objects to insert. 2458 * @return {Promise} resolved when the operation is complete. 2459 */ 2460async function maybeInsertManyPlaces(db, urls) { 2461 await db.executeCached( 2462 `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) VALUES 2463 (:url, hash(:url), :rev_host, 0, :frecency, 2464 IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :maybeguid))`, 2465 urls.map(url => ({ 2466 url: url.href, 2467 rev_host: PlacesUtils.getReversedHost(url), 2468 frecency: url.protocol == "place:" ? 0 : -1, 2469 maybeguid: PlacesUtils.history.makeGuid(), 2470 }))); 2471 await db.executeCached("DELETE FROM moz_updatehostsinsert_temp"); 2472} 2473 2474// Indicates whether we should write a tombstone for an item that has been 2475// uploaded to the server. We ignore "NEW" and "UNKNOWN" items: "NEW" items 2476// haven't been uploaded yet, and "UNKNOWN" items need a full reconciliation 2477// with the server. 2478function needsTombstone(item) { 2479 return item._syncStatus == Bookmarks.SYNC_STATUS.NORMAL; 2480} 2481 2482// Inserts tombstones for removed synced items. 2483function insertTombstones(db, itemsRemoved, syncChangeDelta) { 2484 if (!syncChangeDelta) { 2485 return Promise.resolve(); 2486 } 2487 let syncedItems = itemsRemoved.filter(needsTombstone); 2488 if (!syncedItems.length) { 2489 return Promise.resolve(); 2490 } 2491 let dateRemoved = PlacesUtils.toPRTime(Date.now()); 2492 let valuesTable = syncedItems.map(item => `( 2493 ${JSON.stringify(item.guid)}, 2494 ${dateRemoved} 2495 )`).join(","); 2496 return db.execute(` 2497 INSERT INTO moz_bookmarks_deleted (guid, dateRemoved) 2498 VALUES ${valuesTable}` 2499 ); 2500} 2501 2502// Bumps the change counter for all bookmarks with URLs referenced in removed 2503// tag folders. 2504var addSyncChangesForRemovedTagFolders = async function(db, itemsRemoved, syncChangeDelta) { 2505 if (!syncChangeDelta) { 2506 return; 2507 } 2508 for (let item of itemsRemoved) { 2509 let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId; 2510 if (isUntagging) { 2511 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 2512 db, item.url, syncChangeDelta); 2513 } 2514 } 2515}; 2516 2517// Bumps the change counter for all bookmarked URLs within `folders`. 2518// This is used to update tagged bookmarks when changing a tag folder. 2519function addSyncChangesForBookmarksInFolder(db, folder, syncChangeDelta) { 2520 if (!syncChangeDelta) { 2521 return Promise.resolve(); 2522 } 2523 return db.execute(` 2524 UPDATE moz_bookmarks SET 2525 syncChangeCounter = syncChangeCounter + :syncChangeDelta 2526 WHERE type = :type AND 2527 fk = (SELECT fk FROM moz_bookmarks WHERE parent = :parent) 2528 `, 2529 { syncChangeDelta, type: Bookmarks.TYPE_BOOKMARK, parent: folder._id }); 2530} 2531 2532function adjustSeparatorsSyncCounter(db, parentId, startIndex, syncChangeDelta) { 2533 if (!syncChangeDelta) { 2534 return Promise.resolve(); 2535 } 2536 2537 return db.executeCached(` 2538 UPDATE moz_bookmarks 2539 SET syncChangeCounter = syncChangeCounter + :delta 2540 WHERE parent = :parent AND position >= :start_index 2541 AND type = :item_type 2542 `, 2543 { 2544 delta: syncChangeDelta, 2545 parent: parentId, 2546 start_index: startIndex, 2547 item_type: Bookmarks.TYPE_SEPARATOR 2548 }); 2549} 2550 2551function* chunkArray(array, chunkLength) { 2552 if (array.length <= chunkLength) { 2553 yield array; 2554 return; 2555 } 2556 let startIndex = 0; 2557 while (startIndex < array.length) { 2558 yield array.slice(startIndex, startIndex += chunkLength); 2559 } 2560} 2561 2562/** 2563 * Convert a list of strings or numbers to its SQL 2564 * representation as a string. 2565 */ 2566function sqlList(list) { 2567 return list.map(JSON.stringify).join(); 2568} 2569