1/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2/* This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6var EXPORTED_SYMBOLS = ["PlacesUtils"]; 7 8const { XPCOMUtils } = ChromeUtils.import( 9 "resource://gre/modules/XPCOMUtils.jsm" 10); 11const { AppConstants } = ChromeUtils.import( 12 "resource://gre/modules/AppConstants.jsm" 13); 14 15XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); 16 17XPCOMUtils.defineLazyModuleGetters(this, { 18 Services: "resource://gre/modules/Services.jsm", 19 NetUtil: "resource://gre/modules/NetUtil.jsm", 20 Sqlite: "resource://gre/modules/Sqlite.jsm", 21 Bookmarks: "resource://gre/modules/Bookmarks.jsm", 22 History: "resource://gre/modules/History.jsm", 23 PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.jsm", 24 PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", 25}); 26 27XPCOMUtils.defineLazyGetter(this, "MOZ_ACTION_REGEX", () => { 28 return /^moz-action:([^,]+),(.*)$/; 29}); 30 31XPCOMUtils.defineLazyGetter(this, "gCryptoHash", () => { 32 return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); 33}); 34 35// On Mac OSX, the transferable system converts "\r\n" to "\n\n", where 36// we really just want "\n". On other platforms, the transferable system 37// converts "\r\n" to "\n". 38const NEWLINE = AppConstants.platform == "macosx" ? "\n" : "\r\n"; 39 40// Timers resolution is not always good, it can have a 16ms precision on Win. 41const TIMERS_RESOLUTION_SKEW_MS = 16; 42 43function QI_node(aNode, aIID) { 44 try { 45 return aNode.QueryInterface(aIID); 46 } catch (ex) {} 47 return null; 48} 49function asContainer(aNode) { 50 return QI_node(aNode, Ci.nsINavHistoryContainerResultNode); 51} 52function asQuery(aNode) { 53 return QI_node(aNode, Ci.nsINavHistoryQueryResultNode); 54} 55 56/** 57 * Sends a bookmarks notification through the given observers. 58 * 59 * @param observers 60 * array of nsINavBookmarkObserver objects. 61 * @param notification 62 * the notification name. 63 * @param args 64 * array of arguments to pass to the notification. 65 */ 66function notify(observers, notification, args) { 67 for (let observer of observers) { 68 try { 69 observer[notification](...args); 70 } catch (ex) {} 71 } 72} 73 74/** 75 * Sends a keyword change notification. 76 * 77 * @param url 78 * the url to notify about. 79 * @param keyword 80 * The keyword to notify, or empty string if a keyword was removed. 81 */ 82async function notifyKeywordChange(url, keyword, source) { 83 // Notify bookmarks about the removal. 84 let bookmarks = []; 85 await PlacesUtils.bookmarks.fetch({ url }, b => bookmarks.push(b)); 86 for (let bookmark of bookmarks) { 87 let ids = await PlacesUtils.promiseManyItemIds([ 88 bookmark.guid, 89 bookmark.parentGuid, 90 ]); 91 bookmark.id = ids.get(bookmark.guid); 92 bookmark.parentId = ids.get(bookmark.parentGuid); 93 } 94 let observers = PlacesUtils.bookmarks.getObservers(); 95 for (let bookmark of bookmarks) { 96 notify(observers, "onItemChanged", [ 97 bookmark.id, 98 "keyword", 99 false, 100 keyword, 101 bookmark.lastModified * 1000, 102 bookmark.type, 103 bookmark.parentId, 104 bookmark.guid, 105 bookmark.parentGuid, 106 "", 107 source, 108 ]); 109 } 110} 111 112/** 113 * Serializes the given node in JSON format. 114 * 115 * @param aNode 116 * An nsINavHistoryResultNode 117 */ 118function serializeNode(aNode) { 119 let data = {}; 120 121 data.title = aNode.title; 122 // The id is no longer used for copying within the same instance/session of 123 // Firefox as of at least 61. However, we keep the id for now to maintain 124 // backwards compat of drag and drop with older Firefox versions. 125 data.id = aNode.itemId; 126 data.itemGuid = aNode.bookmarkGuid; 127 // Add an instanceId so we can tell which instance of an FF session the data 128 // is coming from. 129 data.instanceId = PlacesUtils.instanceId; 130 131 let guid = aNode.bookmarkGuid; 132 133 // Some nodes, e.g. the unfiled/menu/toolbar ones can have a virtual guid, so 134 // we ignore any that are a folder shortcut. These will be handled below. 135 if ( 136 guid && 137 !PlacesUtils.bookmarks.isVirtualRootItem(guid) && 138 !PlacesUtils.isVirtualLeftPaneItem(guid) 139 ) { 140 if (aNode.parent) { 141 data.parent = aNode.parent.itemId; 142 data.parentGuid = aNode.parent.bookmarkGuid; 143 } 144 145 data.dateAdded = aNode.dateAdded; 146 data.lastModified = aNode.lastModified; 147 } 148 149 if (PlacesUtils.nodeIsURI(aNode)) { 150 // Check for url validity. 151 new URL(aNode.uri); 152 data.type = PlacesUtils.TYPE_X_MOZ_PLACE; 153 data.uri = aNode.uri; 154 if (aNode.tags) { 155 data.tags = aNode.tags; 156 } 157 } else if (PlacesUtils.nodeIsFolder(aNode)) { 158 if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { 159 data.type = PlacesUtils.TYPE_X_MOZ_PLACE; 160 data.uri = aNode.uri; 161 data.concreteId = PlacesUtils.getConcreteItemId(aNode); 162 data.concreteGuid = PlacesUtils.getConcreteItemGuid(aNode); 163 } else { 164 data.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER; 165 } 166 } else if (PlacesUtils.nodeIsQuery(aNode)) { 167 data.type = PlacesUtils.TYPE_X_MOZ_PLACE; 168 data.uri = aNode.uri; 169 } else if (PlacesUtils.nodeIsSeparator(aNode)) { 170 data.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR; 171 } 172 173 return JSON.stringify(data); 174} 175 176// Imposed to limit database size. 177const DB_URL_LENGTH_MAX = 65536; 178const DB_TITLE_LENGTH_MAX = 4096; 179const DB_DESCRIPTION_LENGTH_MAX = 256; 180const DB_SITENAME_LENGTH_MAX = 50; 181 182/** 183 * Executes a boolean validate function, throwing if it returns false. 184 * 185 * @param boolValidateFn 186 * A boolean validate function. 187 * @return the input value. 188 * @throws if input doesn't pass the validate function. 189 */ 190function simpleValidateFunc(boolValidateFn) { 191 return (v, input) => { 192 if (!boolValidateFn(v, input)) { 193 throw new Error("Invalid value"); 194 } 195 return v; 196 }; 197} 198 199/** 200 * List of bookmark object validators, one per each known property. 201 * Validators must throw if the property value is invalid and return a fixed up 202 * version of the value, if needed. 203 */ 204const BOOKMARK_VALIDATORS = Object.freeze({ 205 guid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)), 206 parentGuid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)), 207 guidPrefix: simpleValidateFunc(v => PlacesUtils.isValidGuidPrefix(v)), 208 index: simpleValidateFunc( 209 v => Number.isInteger(v) && v >= PlacesUtils.bookmarks.DEFAULT_INDEX 210 ), 211 dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"), 212 lastModified: simpleValidateFunc(v => v.constructor.name == "Date"), 213 type: simpleValidateFunc( 214 v => 215 Number.isInteger(v) && 216 [ 217 PlacesUtils.bookmarks.TYPE_BOOKMARK, 218 PlacesUtils.bookmarks.TYPE_FOLDER, 219 PlacesUtils.bookmarks.TYPE_SEPARATOR, 220 ].includes(v) 221 ), 222 title: v => { 223 if (v === null) { 224 return ""; 225 } 226 if (typeof v == "string") { 227 return v.slice(0, DB_TITLE_LENGTH_MAX); 228 } 229 throw new Error("Invalid title"); 230 }, 231 url: v => { 232 simpleValidateFunc( 233 val => 234 (typeof val == "string" && val.length <= DB_URL_LENGTH_MAX) || 235 (val instanceof Ci.nsIURI && val.spec.length <= DB_URL_LENGTH_MAX) || 236 (val instanceof URL && val.href.length <= DB_URL_LENGTH_MAX) 237 ).call(this, v); 238 if (typeof v === "string") { 239 return new URL(v); 240 } 241 if (v instanceof Ci.nsIURI) { 242 return new URL(v.spec); 243 } 244 return v; 245 }, 246 source: simpleValidateFunc( 247 v => 248 Number.isInteger(v) && 249 Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) 250 ), 251 keyword: simpleValidateFunc(v => typeof v == "string" && v.length), 252 charset: simpleValidateFunc(v => typeof v == "string" && v.length), 253 postData: simpleValidateFunc(v => typeof v == "string" && v.length), 254 tags: simpleValidateFunc( 255 v => 256 Array.isArray(v) && 257 v.length && 258 v.every(item => item && typeof item == "string") 259 ), 260}); 261 262// Sync bookmark records can contain additional properties. 263const SYNC_BOOKMARK_VALIDATORS = Object.freeze({ 264 // Sync uses Places GUIDs for all records except roots. 265 recordId: simpleValidateFunc( 266 v => 267 typeof v == "string" && 268 (PlacesSyncUtils.bookmarks.ROOTS.includes(v) || 269 PlacesUtils.isValidGuid(v)) 270 ), 271 parentRecordId: v => SYNC_BOOKMARK_VALIDATORS.recordId(v), 272 // Sync uses kinds instead of types. 273 kind: simpleValidateFunc( 274 v => 275 typeof v == "string" && 276 Object.values(PlacesSyncUtils.bookmarks.KINDS).includes(v) 277 ), 278 query: simpleValidateFunc(v => v === null || (typeof v == "string" && v)), 279 folder: simpleValidateFunc( 280 v => 281 typeof v == "string" && 282 v && 283 v.length <= PlacesUtils.bookmarks.MAX_TAG_LENGTH 284 ), 285 tags: v => { 286 if (v === null) { 287 return []; 288 } 289 if (!Array.isArray(v)) { 290 throw new Error("Invalid tag array"); 291 } 292 for (let tag of v) { 293 if ( 294 typeof tag != "string" || 295 !tag || 296 tag.length > PlacesUtils.bookmarks.MAX_TAG_LENGTH 297 ) { 298 throw new Error(`Invalid tag: ${tag}`); 299 } 300 } 301 return v; 302 }, 303 keyword: simpleValidateFunc(v => v === null || typeof v == "string"), 304 dateAdded: simpleValidateFunc( 305 v => 306 typeof v === "number" && 307 v > PlacesSyncUtils.bookmarks.EARLIEST_BOOKMARK_TIMESTAMP 308 ), 309 feed: v => (v === null ? v : BOOKMARK_VALIDATORS.url(v)), 310 site: v => (v === null ? v : BOOKMARK_VALIDATORS.url(v)), 311 title: BOOKMARK_VALIDATORS.title, 312 url: BOOKMARK_VALIDATORS.url, 313}); 314 315// Sync change records are passed between `PlacesSyncUtils` and the Sync 316// bookmarks engine, and are used to update an item's sync status and change 317// counter at the end of a sync. 318const SYNC_CHANGE_RECORD_VALIDATORS = Object.freeze({ 319 modified: simpleValidateFunc(v => typeof v == "number" && v >= 0), 320 counter: simpleValidateFunc(v => typeof v == "number" && v >= 0), 321 status: simpleValidateFunc( 322 v => 323 typeof v == "number" && 324 Object.values(PlacesUtils.bookmarks.SYNC_STATUS).includes(v) 325 ), 326 tombstone: simpleValidateFunc(v => v === true || v === false), 327 synced: simpleValidateFunc(v => v === true || v === false), 328}); 329/** 330 * List PageInfo bookmark object validators. 331 */ 332const PAGEINFO_VALIDATORS = Object.freeze({ 333 guid: BOOKMARK_VALIDATORS.guid, 334 url: BOOKMARK_VALIDATORS.url, 335 title: v => { 336 if (v == null || v == undefined) { 337 return undefined; 338 } else if (typeof v === "string") { 339 return v; 340 } 341 throw new TypeError( 342 `title property of PageInfo object: ${v} must be a string if provided` 343 ); 344 }, 345 previewImageURL: v => { 346 if (!v) { 347 return null; 348 } 349 return BOOKMARK_VALIDATORS.url(v); 350 }, 351 description: v => { 352 if (typeof v === "string" || v === null) { 353 return v ? v.slice(0, DB_DESCRIPTION_LENGTH_MAX) : null; 354 } 355 throw new TypeError( 356 `description property of pageInfo object: ${v} must be either a string or null if provided` 357 ); 358 }, 359 siteName: v => { 360 if (typeof v === "string" || v === null) { 361 return v ? v.slice(0, DB_SITENAME_LENGTH_MAX) : null; 362 } 363 throw new TypeError( 364 `siteName property of pageInfo object: ${v} must be either a string or null if provided` 365 ); 366 }, 367 annotations: v => { 368 if (typeof v != "object" || v.constructor.name != "Map") { 369 throw new TypeError("annotations must be a Map"); 370 } 371 372 if (v.size == 0) { 373 throw new TypeError("there must be at least one annotation"); 374 } 375 376 for (let [key, value] of v.entries()) { 377 if (typeof key != "string") { 378 throw new TypeError("all annotation keys must be strings"); 379 } 380 if ( 381 typeof value != "string" && 382 typeof value != "number" && 383 typeof value != "boolean" && 384 value !== null && 385 value !== undefined 386 ) { 387 throw new TypeError( 388 "all annotation values must be Boolean, Numbers or Strings" 389 ); 390 } 391 } 392 return v; 393 }, 394 visits: v => { 395 if (!Array.isArray(v) || !v.length) { 396 throw new TypeError("PageInfo object must have an array of visits"); 397 } 398 let visits = []; 399 for (let inVisit of v) { 400 let visit = { 401 date: new Date(), 402 transition: inVisit.transition || History.TRANSITIONS.LINK, 403 }; 404 405 if (!PlacesUtils.history.isValidTransition(visit.transition)) { 406 throw new TypeError( 407 `transition: ${visit.transition} is not a valid transition type` 408 ); 409 } 410 411 if (inVisit.date) { 412 PlacesUtils.history.ensureDate(inVisit.date); 413 if (inVisit.date > Date.now() + TIMERS_RESOLUTION_SKEW_MS) { 414 throw new TypeError(`date: ${inVisit.date} cannot be a future date`); 415 } 416 visit.date = inVisit.date; 417 } 418 419 if (inVisit.referrer) { 420 visit.referrer = PlacesUtils.normalizeToURLOrGUID(inVisit.referrer); 421 } 422 visits.push(visit); 423 } 424 return visits; 425 }, 426}); 427 428var PlacesUtils = { 429 // Place entries that are containers, e.g. bookmark folders or queries. 430 TYPE_X_MOZ_PLACE_CONTAINER: "text/x-moz-place-container", 431 // Place entries that are bookmark separators. 432 TYPE_X_MOZ_PLACE_SEPARATOR: "text/x-moz-place-separator", 433 // Place entries that are not containers or separators 434 TYPE_X_MOZ_PLACE: "text/x-moz-place", 435 // Place entries in shortcut url format (url\ntitle) 436 TYPE_X_MOZ_URL: "text/x-moz-url", 437 // Place entries formatted as HTML anchors 438 TYPE_HTML: "text/html", 439 // Place entries as raw URL text 440 TYPE_UNICODE: "text/unicode", 441 // Used to track the action that populated the clipboard. 442 TYPE_X_MOZ_PLACE_ACTION: "text/x-moz-place-action", 443 444 // Deprecated: Remaining only for supporting migration of old livemarks. 445 LMANNO_FEEDURI: "livemark/feedURI", 446 LMANNO_SITEURI: "livemark/siteURI", 447 CHARSET_ANNO: "URIProperties/characterSet", 448 // Deprecated: This is only used for supporting import from older datasets. 449 MOBILE_ROOT_ANNO: "mobile/bookmarksRoot", 450 451 TOPIC_SHUTDOWN: "places-shutdown", 452 TOPIC_INIT_COMPLETE: "places-init-complete", 453 TOPIC_DATABASE_LOCKED: "places-database-locked", 454 TOPIC_EXPIRATION_FINISHED: "places-expiration-finished", 455 TOPIC_FAVICONS_EXPIRED: "places-favicons-expired", 456 TOPIC_VACUUM_STARTING: "places-vacuum-starting", 457 TOPIC_BOOKMARKS_RESTORE_BEGIN: "bookmarks-restore-begin", 458 TOPIC_BOOKMARKS_RESTORE_SUCCESS: "bookmarks-restore-success", 459 TOPIC_BOOKMARKS_RESTORE_FAILED: "bookmarks-restore-failed", 460 461 observers: PlacesObservers, 462 463 /** 464 * GUIDs associated with virtual queries that are used for displaying the 465 * top-level folders in the left pane. 466 */ 467 virtualAllBookmarksGuid: "allbms_____v", 468 virtualHistoryGuid: "history____v", 469 virtualDownloadsGuid: "downloads__v", 470 virtualTagsGuid: "tags_______v", 471 472 /** 473 * Checks if a guid is a virtual left-pane root. 474 * 475 * @param {String} guid The guid of the item to look for. 476 * @returns {Boolean} true if guid is a virtual root, false otherwise. 477 */ 478 isVirtualLeftPaneItem(guid) { 479 return ( 480 guid == PlacesUtils.virtualAllBookmarksGuid || 481 guid == PlacesUtils.virtualHistoryGuid || 482 guid == PlacesUtils.virtualDownloadsGuid || 483 guid == PlacesUtils.virtualTagsGuid 484 ); 485 }, 486 487 asContainer: aNode => asContainer(aNode), 488 asQuery: aNode => asQuery(aNode), 489 490 endl: NEWLINE, 491 492 /** 493 * Is a string a valid GUID? 494 * 495 * @param guid: (String) 496 * @return (Boolean) 497 */ 498 isValidGuid(guid) { 499 return typeof guid == "string" && guid && /^[a-zA-Z0-9\-_]{12}$/.test(guid); 500 }, 501 502 /** 503 * Is a string a valid GUID prefix? 504 * 505 * @param guidPrefix: (String) 506 * @return (Boolean) 507 */ 508 isValidGuidPrefix(guidPrefix) { 509 return ( 510 typeof guidPrefix == "string" && 511 guidPrefix && 512 /^[a-zA-Z0-9\-_]{1,11}$/.test(guidPrefix) 513 ); 514 }, 515 516 /** 517 * Generates a random GUID and replace its beginning with the given 518 * prefix. We do this instead of just prepending the prefix to keep 519 * the correct character length. 520 * 521 * @param prefix: (String) 522 * @return (String) 523 */ 524 generateGuidWithPrefix(prefix) { 525 return prefix + this.history.makeGuid().substring(prefix.length); 526 }, 527 528 /** 529 * Converts a string or n URL object to an nsIURI. 530 * 531 * @param url (URL) or (String) 532 * the URL to convert. 533 * @return nsIURI for the given URL. 534 */ 535 toURI(url) { 536 url = url instanceof URL ? url.href : url; 537 538 return NetUtil.newURI(url); 539 }, 540 541 /** 542 * Convert a Date object to a PRTime (microseconds). 543 * 544 * @param date 545 * the Date object to convert. 546 * @return microseconds from the epoch. 547 */ 548 toPRTime(date) { 549 if (typeof date != "number" && date.constructor.name != "Date") { 550 throw new Error("Invalid value passed to toPRTime"); 551 } 552 return date * 1000; 553 }, 554 555 /** 556 * Convert a PRTime to a Date object. 557 * 558 * @param time 559 * microseconds from the epoch. 560 * @return a Date object. 561 */ 562 toDate(time) { 563 if (typeof time != "number") { 564 throw new Error("Invalid value passed to toDate"); 565 } 566 return new Date(parseInt(time / 1000)); 567 }, 568 569 /** 570 * Wraps a string in a nsISupportsString wrapper. 571 * @param aString 572 * The string to wrap. 573 * @returns A nsISupportsString object containing a string. 574 */ 575 toISupportsString: function PU_toISupportsString(aString) { 576 let s = Cc["@mozilla.org/supports-string;1"].createInstance( 577 Ci.nsISupportsString 578 ); 579 s.data = aString; 580 return s; 581 }, 582 583 getFormattedString: function PU_getFormattedString(key, params) { 584 return bundle.formatStringFromName(key, params); 585 }, 586 587 getString: function PU_getString(key) { 588 return bundle.GetStringFromName(key); 589 }, 590 591 /** 592 * Parses a moz-action URL and returns its parts. 593 * 594 * @param url A moz-action URI. 595 * @note URL is in the format moz-action:ACTION,JSON_ENCODED_PARAMS 596 */ 597 parseActionUrl(url) { 598 if (url instanceof Ci.nsIURI) { 599 url = url.spec; 600 } else if (url instanceof URL) { 601 url = url.href; 602 } 603 // Faster bailout. 604 if (!url.startsWith("moz-action:")) { 605 return null; 606 } 607 608 try { 609 let [, type, params] = url.match(MOZ_ACTION_REGEX); 610 let action = { 611 type, 612 params: JSON.parse(params), 613 }; 614 for (let key in action.params) { 615 action.params[key] = decodeURIComponent(action.params[key]); 616 } 617 return action; 618 } catch (ex) { 619 Cu.reportError(`Invalid action url "${url}"`); 620 return null; 621 } 622 }, 623 624 /** 625 * Determines if a folder is generated from a query. 626 * @param aNode a result true. 627 * @returns true if the node is a folder generated from a query. 628 */ 629 isQueryGeneratedFolder(node) { 630 if (!node.parent) { 631 return false; 632 } 633 return this.nodeIsFolder(node) && this.nodeIsQuery(node.parent); 634 }, 635 636 /** 637 * Determines whether or not a ResultNode is a Bookmark folder. 638 * @param aNode 639 * A result node 640 * @returns true if the node is a Bookmark folder, false otherwise 641 */ 642 nodeIsFolder: function PU_nodeIsFolder(aNode) { 643 return ( 644 aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER || 645 aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT 646 ); 647 }, 648 649 /** 650 * Determines whether or not a ResultNode represents a bookmarked URI. 651 * @param aNode 652 * A result node 653 * @returns true if the node represents a bookmarked URI, false otherwise 654 */ 655 nodeIsBookmark: function PU_nodeIsBookmark(aNode) { 656 return ( 657 aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI && 658 aNode.itemId != -1 659 ); 660 }, 661 662 /** 663 * Determines whether or not a ResultNode is a Bookmark separator. 664 * @param aNode 665 * A result node 666 * @returns true if the node is a Bookmark separator, false otherwise 667 */ 668 nodeIsSeparator: function PU_nodeIsSeparator(aNode) { 669 return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR; 670 }, 671 672 /** 673 * Determines whether or not a ResultNode is a URL item. 674 * @param aNode 675 * A result node 676 * @returns true if the node is a URL item, false otherwise 677 */ 678 nodeIsURI: function PU_nodeIsURI(aNode) { 679 return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; 680 }, 681 682 /** 683 * Determines whether or not a ResultNode is a Query item. 684 * @param aNode 685 * A result node 686 * @returns true if the node is a Query item, false otherwise 687 */ 688 nodeIsQuery: function PU_nodeIsQuery(aNode) { 689 return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY; 690 }, 691 692 /** 693 * Generator for a node's ancestors. 694 * @param aNode 695 * A result node 696 */ 697 nodeAncestors: function* PU_nodeAncestors(aNode) { 698 let node = aNode.parent; 699 while (node) { 700 yield node; 701 node = node.parent; 702 } 703 }, 704 705 /** 706 * Checks validity of an object, filling up default values for optional 707 * properties. 708 * 709 * @param {string} name 710 * The operation name. This is included in the error message if 711 * validation fails. 712 * @param validators (object) 713 * An object containing input validators. Keys should be field names; 714 * values should be validation functions. 715 * @param props (object) 716 * The object to validate. 717 * @param behavior (object) [optional] 718 * Object defining special behavior for some of the properties. 719 * The following behaviors may be optionally set: 720 * - required: this property is required. 721 * - replaceWith: this property will be overwritten with the value 722 * provided 723 * - requiredIf: if the provided condition is satisfied, then this 724 * property is required. 725 * - validIf: if the provided condition is not satisfied, then this 726 * property is invalid. 727 * - defaultValue: an undefined property should default to this value. 728 * - fixup: a function invoked when validation fails, takes the input 729 * object as argument and must fix the property. 730 * 731 * @return a validated and normalized item. 732 * @throws if the object contains invalid data. 733 * @note any unknown properties are pass-through. 734 */ 735 validateItemProperties(name, validators, props, behavior = {}) { 736 if (typeof props != "object" || !props) { 737 throw new Error(`${name}: Input should be a valid object`); 738 } 739 // Make a shallow copy of `props` to avoid mutating the original object 740 // when filling in defaults. 741 let input = Object.assign({}, props); 742 let normalizedInput = {}; 743 let required = new Set(); 744 for (let prop in behavior) { 745 if ( 746 behavior[prop].hasOwnProperty("required") && 747 behavior[prop].required 748 ) { 749 required.add(prop); 750 } 751 if ( 752 behavior[prop].hasOwnProperty("requiredIf") && 753 behavior[prop].requiredIf(input) 754 ) { 755 required.add(prop); 756 } 757 if ( 758 behavior[prop].hasOwnProperty("validIf") && 759 input[prop] !== undefined && 760 !behavior[prop].validIf(input) 761 ) { 762 if (behavior[prop].hasOwnProperty("fixup")) { 763 behavior[prop].fixup(input); 764 } else { 765 throw new Error( 766 `${name}: Invalid value for property '${prop}': ${JSON.stringify( 767 input[prop] 768 )}` 769 ); 770 } 771 } 772 if ( 773 behavior[prop].hasOwnProperty("defaultValue") && 774 input[prop] === undefined 775 ) { 776 input[prop] = behavior[prop].defaultValue; 777 } 778 if (behavior[prop].hasOwnProperty("replaceWith")) { 779 input[prop] = behavior[prop].replaceWith; 780 } 781 } 782 783 for (let prop in input) { 784 if (required.has(prop)) { 785 required.delete(prop); 786 } else if (input[prop] === undefined) { 787 // Skip undefined properties that are not required. 788 continue; 789 } 790 if (validators.hasOwnProperty(prop)) { 791 try { 792 normalizedInput[prop] = validators[prop](input[prop], input); 793 } catch (ex) { 794 if ( 795 behavior.hasOwnProperty(prop) && 796 behavior[prop].hasOwnProperty("fixup") 797 ) { 798 behavior[prop].fixup(input); 799 normalizedInput[prop] = input[prop]; 800 } else { 801 throw new Error( 802 `${name}: Invalid value for property '${prop}': ${JSON.stringify( 803 input[prop] 804 )}` 805 ); 806 } 807 } 808 } 809 } 810 if (required.size > 0) { 811 throw new Error( 812 `${name}: The following properties were expected: ${[...required].join( 813 ", " 814 )}` 815 ); 816 } 817 return normalizedInput; 818 }, 819 820 BOOKMARK_VALIDATORS, 821 PAGEINFO_VALIDATORS, 822 SYNC_BOOKMARK_VALIDATORS, 823 SYNC_CHANGE_RECORD_VALIDATORS, 824 825 QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), 826 827 _shutdownFunctions: [], 828 registerShutdownFunction: function PU_registerShutdownFunction(aFunc) { 829 // If this is the first registered function, add the shutdown observer. 830 if (!this._shutdownFunctions.length) { 831 Services.obs.addObserver(this, this.TOPIC_SHUTDOWN); 832 } 833 this._shutdownFunctions.push(aFunc); 834 }, 835 836 // nsIObserver 837 observe: function PU_observe(aSubject, aTopic, aData) { 838 switch (aTopic) { 839 case this.TOPIC_SHUTDOWN: 840 Services.obs.removeObserver(this, this.TOPIC_SHUTDOWN); 841 while (this._shutdownFunctions.length) { 842 this._shutdownFunctions.shift().apply(this); 843 } 844 break; 845 } 846 }, 847 848 /** 849 * Determines whether or not a ResultNode is a host container. 850 * @param aNode 851 * A result node 852 * @returns true if the node is a host container, false otherwise 853 */ 854 nodeIsHost: function PU_nodeIsHost(aNode) { 855 return ( 856 aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY && 857 aNode.parent && 858 asQuery(aNode.parent).queryOptions.resultType == 859 Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY 860 ); 861 }, 862 863 /** 864 * Determines whether or not a ResultNode is a day container. 865 * @param node 866 * A NavHistoryResultNode 867 * @returns true if the node is a day container, false otherwise 868 */ 869 nodeIsDay: function PU_nodeIsDay(aNode) { 870 var resultType; 871 return ( 872 aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY && 873 aNode.parent && 874 ((resultType = asQuery(aNode.parent).queryOptions.resultType) == 875 Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || 876 resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) 877 ); 878 }, 879 880 /** 881 * Determines whether or not a result-node is a tag container. 882 * @param aNode 883 * A result-node 884 * @returns true if the node is a tag container, false otherwise 885 */ 886 nodeIsTagQuery: function PU_nodeIsTagQuery(aNode) { 887 if (aNode.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { 888 return false; 889 } 890 // Direct child of RESULTS_AS_TAGS_ROOT. 891 let parent = aNode.parent; 892 if ( 893 parent && 894 PlacesUtils.asQuery(parent).queryOptions.resultType == 895 Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT 896 ) { 897 return true; 898 } 899 // We must also support the right pane of the Library, when the tag query 900 // is the root node. Unfortunately this is also valid for any tag query 901 // selected in the left pane that is not a direct child of RESULTS_AS_TAGS_ROOT. 902 if ( 903 !parent && 904 aNode == aNode.parentResult.root && 905 PlacesUtils.asQuery(aNode).query.tags.length == 1 906 ) { 907 return true; 908 } 909 return false; 910 }, 911 912 /** 913 * Determines whether or not a ResultNode is a container. 914 * @param aNode 915 * A result node 916 * @returns true if the node is a container item, false otherwise 917 */ 918 containerTypes: [ 919 Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 920 Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, 921 Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY, 922 ], 923 nodeIsContainer: function PU_nodeIsContainer(aNode) { 924 return this.containerTypes.includes(aNode.type); 925 }, 926 927 /** 928 * Determines whether or not a ResultNode is an history related container. 929 * @param node 930 * A result node 931 * @returns true if the node is an history related container, false otherwise 932 */ 933 nodeIsHistoryContainer: function PU_nodeIsHistoryContainer(aNode) { 934 var resultType; 935 return ( 936 this.nodeIsQuery(aNode) && 937 ((resultType = asQuery(aNode).queryOptions.resultType) == 938 Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY || 939 resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || 940 resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY || 941 this.nodeIsDay(aNode) || 942 this.nodeIsHost(aNode)) 943 ); 944 }, 945 946 /** 947 * Gets the concrete item-id for the given node. Generally, this is just 948 * node.itemId, but for folder-shortcuts that's node.folderItemId. 949 */ 950 getConcreteItemId: function PU_getConcreteItemId(aNode) { 951 return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT 952 ? asQuery(aNode).folderItemId 953 : aNode.itemId; 954 }, 955 956 /** 957 * Gets the concrete item-guid for the given node. For everything but folder 958 * shortcuts, this is just node.bookmarkGuid. For folder shortcuts, this is 959 * node.targetFolderGuid (see nsINavHistoryService.idl for the semantics). 960 * 961 * @param aNode 962 * a result node. 963 * @return the concrete item-guid for aNode. 964 */ 965 getConcreteItemGuid(aNode) { 966 if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { 967 return asQuery(aNode).targetFolderGuid; 968 } 969 return aNode.bookmarkGuid; 970 }, 971 972 /** 973 * Reverse a host based on the moz_places algorithm, that is reverse the host 974 * string and add a trailing period. For example "google.com" becomes 975 * "moc.elgoog.". 976 * 977 * @param url 978 * the URL to generate a rev host for. 979 * @return the reversed host string. 980 */ 981 getReversedHost(url) { 982 return ( 983 url.host 984 .split("") 985 .reverse() 986 .join("") + "." 987 ); 988 }, 989 990 /** 991 * String-wraps a result node according to the rules of the specified 992 * content type for copy or move operations. 993 * 994 * @param aNode 995 * The Result node to wrap (serialize) 996 * @param aType 997 * The content type to serialize as 998 * @return A string serialization of the node 999 */ 1000 wrapNode(aNode, aType) { 1001 // when wrapping a node, we want all the items, even if the original 1002 // query options are excluding them. 1003 // This can happen when copying from the left hand pane of the bookmarks 1004 // organizer. 1005 // @return [node, shouldClose] 1006 function gatherDataFromNode(node, gatherDataFunc) { 1007 if ( 1008 PlacesUtils.nodeIsFolder(node) && 1009 node.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT && 1010 asQuery(node).queryOptions.excludeItems 1011 ) { 1012 let folderRoot = PlacesUtils.getFolderContents( 1013 node.bookmarkGuid, 1014 false, 1015 true 1016 ).root; 1017 try { 1018 return gatherDataFunc(folderRoot); 1019 } finally { 1020 folderRoot.containerOpen = false; 1021 } 1022 } 1023 // If we didn't create our own query, do not alter the node's state. 1024 return gatherDataFunc(node); 1025 } 1026 1027 function gatherDataHtml(node) { 1028 let htmlEscape = s => 1029 s 1030 .replace(/&/g, "&") 1031 .replace(/>/g, ">") 1032 .replace(/</g, "<") 1033 .replace(/"/g, """) 1034 .replace(/'/g, "'"); 1035 1036 // escape out potential HTML in the title 1037 let escapedTitle = node.title ? htmlEscape(node.title) : ""; 1038 1039 if (PlacesUtils.nodeIsContainer(node)) { 1040 asContainer(node); 1041 let wasOpen = node.containerOpen; 1042 if (!wasOpen) { 1043 node.containerOpen = true; 1044 } 1045 1046 let childString = "<DL><DT>" + escapedTitle + "</DT>" + NEWLINE; 1047 let cc = node.childCount; 1048 for (let i = 0; i < cc; ++i) { 1049 childString += 1050 "<DD>" + 1051 NEWLINE + 1052 gatherDataHtml(node.getChild(i)) + 1053 "</DD>" + 1054 NEWLINE; 1055 } 1056 node.containerOpen = wasOpen; 1057 return childString + "</DL>" + NEWLINE; 1058 } 1059 if (PlacesUtils.nodeIsURI(node)) { 1060 return `<A HREF="${node.uri}">${escapedTitle}</A>${NEWLINE}`; 1061 } 1062 if (PlacesUtils.nodeIsSeparator(node)) { 1063 return "<HR>" + NEWLINE; 1064 } 1065 return ""; 1066 } 1067 1068 function gatherDataText(node) { 1069 if (PlacesUtils.nodeIsContainer(node)) { 1070 asContainer(node); 1071 let wasOpen = node.containerOpen; 1072 if (!wasOpen) { 1073 node.containerOpen = true; 1074 } 1075 1076 let childString = node.title + NEWLINE; 1077 let cc = node.childCount; 1078 for (let i = 0; i < cc; ++i) { 1079 let child = node.getChild(i); 1080 let suffix = i < cc - 1 ? NEWLINE : ""; 1081 childString += gatherDataText(child) + suffix; 1082 } 1083 node.containerOpen = wasOpen; 1084 return childString; 1085 } 1086 if (PlacesUtils.nodeIsURI(node)) { 1087 return node.uri; 1088 } 1089 if (PlacesUtils.nodeIsSeparator(node)) { 1090 return "--------------------"; 1091 } 1092 return ""; 1093 } 1094 1095 switch (aType) { 1096 case this.TYPE_X_MOZ_PLACE: 1097 case this.TYPE_X_MOZ_PLACE_SEPARATOR: 1098 case this.TYPE_X_MOZ_PLACE_CONTAINER: { 1099 // Serialize the node to JSON. 1100 return serializeNode(aNode); 1101 } 1102 case this.TYPE_X_MOZ_URL: { 1103 if (PlacesUtils.nodeIsURI(aNode)) { 1104 return aNode.uri + NEWLINE + aNode.title; 1105 } 1106 if (PlacesUtils.nodeIsContainer(aNode)) { 1107 return PlacesUtils.getURLsForContainerNode(aNode) 1108 .map(item => item.uri + "\n" + item.title) 1109 .join("\n"); 1110 } 1111 return ""; 1112 } 1113 case this.TYPE_HTML: { 1114 return gatherDataFromNode(aNode, gatherDataHtml); 1115 } 1116 } 1117 1118 // Otherwise, we wrap as TYPE_UNICODE. 1119 return gatherDataFromNode(aNode, gatherDataText); 1120 }, 1121 1122 /** 1123 * Unwraps data from the Clipboard or the current Drag Session. 1124 * @param blob 1125 * A blob (string) of data, in some format we potentially know how 1126 * to parse. 1127 * @param type 1128 * The content type of the blob. 1129 * @returns An array of objects representing each item contained by the source. 1130 * @throws if the blob contains invalid data. 1131 */ 1132 unwrapNodes: function PU_unwrapNodes(blob, type) { 1133 // We split on "\n" because the transferable system converts "\r\n" to "\n" 1134 var nodes = []; 1135 switch (type) { 1136 case this.TYPE_X_MOZ_PLACE: 1137 case this.TYPE_X_MOZ_PLACE_SEPARATOR: 1138 case this.TYPE_X_MOZ_PLACE_CONTAINER: 1139 nodes = JSON.parse("[" + blob + "]"); 1140 break; 1141 case this.TYPE_X_MOZ_URL: { 1142 let parts = blob.split("\n"); 1143 // data in this type has 2 parts per entry, so if there are fewer 1144 // than 2 parts left, the blob is malformed and we should stop 1145 // but drag and drop of files from the shell has parts.length = 1 1146 if (parts.length != 1 && parts.length % 2) { 1147 break; 1148 } 1149 for (let i = 0; i < parts.length; i = i + 2) { 1150 let uriString = parts[i]; 1151 let titleString = ""; 1152 if (parts.length > i + 1) { 1153 titleString = parts[i + 1]; 1154 } else { 1155 // for drag and drop of files, try to use the leafName as title 1156 try { 1157 titleString = Services.io 1158 .newURI(uriString) 1159 .QueryInterface(Ci.nsIURL).fileName; 1160 } catch (ex) {} 1161 } 1162 // note: Services.io.newURI() will throw if uriString is not a valid URI 1163 let uri = Services.io.newURI(uriString); 1164 if (Services.io.newURI(uriString) && uri.scheme != "place") { 1165 nodes.push({ 1166 uri: uriString, 1167 title: titleString ? titleString : uriString, 1168 type: this.TYPE_X_MOZ_URL, 1169 }); 1170 } 1171 } 1172 break; 1173 } 1174 case this.TYPE_UNICODE: { 1175 let parts = blob.split("\n"); 1176 for (let i = 0; i < parts.length; i++) { 1177 let uriString = parts[i]; 1178 // text/uri-list is converted to TYPE_UNICODE but it could contain 1179 // comments line prepended by #, we should skip them, as well as 1180 // empty uris. 1181 if (uriString.substr(0, 1) == "\x23" || uriString == "") { 1182 continue; 1183 } 1184 // note: Services.io.newURI) will throw if uriString is not a valid URI 1185 let uri = Services.io.newURI(uriString); 1186 if (uri.scheme != "place") { 1187 nodes.push({ 1188 uri: uriString, 1189 title: uriString, 1190 type: this.TYPE_X_MOZ_URL, 1191 }); 1192 } 1193 } 1194 break; 1195 } 1196 default: 1197 throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); 1198 } 1199 return nodes; 1200 }, 1201 1202 /** 1203 * Validate an input PageInfo object, returning a valid PageInfo object. 1204 * 1205 * @param pageInfo: (PageInfo) 1206 * @return (PageInfo) 1207 */ 1208 validatePageInfo(pageInfo, validateVisits = true) { 1209 return this.validateItemProperties( 1210 "PageInfo", 1211 PAGEINFO_VALIDATORS, 1212 pageInfo, 1213 { 1214 url: { requiredIf: b => !b.guid }, 1215 guid: { requiredIf: b => !b.url }, 1216 visits: { requiredIf: b => validateVisits }, 1217 } 1218 ); 1219 }, 1220 /** 1221 * Normalize a key to either a string (if it is a valid GUID) or an 1222 * instance of `URL` (if it is a `URL`, `nsIURI`, or a string 1223 * representing a valid url). 1224 * 1225 * @throws (TypeError) 1226 * If the key is neither a valid guid nor a valid url. 1227 */ 1228 normalizeToURLOrGUID(key) { 1229 if (typeof key === "string") { 1230 // A string may be a URL or a guid 1231 if (this.isValidGuid(key)) { 1232 return key; 1233 } 1234 return new URL(key); 1235 } 1236 if (key instanceof URL) { 1237 return key; 1238 } 1239 if (key instanceof Ci.nsIURI) { 1240 return new URL(key.spec); 1241 } 1242 throw new TypeError("Invalid url or guid: " + key); 1243 }, 1244 1245 /** 1246 * Generates a nsINavHistoryResult for the contents of a folder. 1247 * @param aFolderGuid 1248 * The folder to open 1249 * @param [optional] excludeItems 1250 * True to hide all items (individual bookmarks). This is used on 1251 * the left places pane so you just get a folder hierarchy. 1252 * @param [optional] expandQueries 1253 * True to make query items expand as new containers. For managing, 1254 * you want this to be false, for menus and such, you want this to 1255 * be true. 1256 * @returns A nsINavHistoryResult containing the contents of the 1257 * folder. The result.root is guaranteed to be open. 1258 */ 1259 getFolderContents(aFolderGuid, aExcludeItems, aExpandQueries) { 1260 if (!this.isValidGuid(aFolderGuid)) { 1261 throw new Error("aFolderGuid should be a valid GUID."); 1262 } 1263 var query = this.history.getNewQuery(); 1264 query.setParents([aFolderGuid]); 1265 var options = this.history.getNewQueryOptions(); 1266 options.excludeItems = aExcludeItems; 1267 options.expandQueries = aExpandQueries; 1268 1269 var result = this.history.executeQuery(query, options); 1270 result.root.containerOpen = true; 1271 return result; 1272 }, 1273 1274 // Identifier getters for special folders. 1275 // You should use these everywhere PlacesUtils is available to avoid XPCOM 1276 // traversal just to get roots' ids. 1277 get placesRootId() { 1278 delete this.placesRootId; 1279 return (this.placesRootId = this.bookmarks.placesRoot); 1280 }, 1281 1282 get bookmarksMenuFolderId() { 1283 delete this.bookmarksMenuFolderId; 1284 return (this.bookmarksMenuFolderId = this.bookmarks.bookmarksMenuFolder); 1285 }, 1286 1287 get toolbarFolderId() { 1288 delete this.toolbarFolderId; 1289 return (this.toolbarFolderId = this.bookmarks.toolbarFolder); 1290 }, 1291 1292 get tagsFolderId() { 1293 delete this.tagsFolderId; 1294 return (this.tagsFolderId = this.bookmarks.tagsFolder); 1295 }, 1296 1297 /** 1298 * Checks if item is a root. 1299 * 1300 * @param {String} guid The guid of the item to look for. 1301 * @returns {Boolean} true if guid is a root, false otherwise. 1302 */ 1303 isRootItem(guid) { 1304 return ( 1305 guid == PlacesUtils.bookmarks.menuGuid || 1306 guid == PlacesUtils.bookmarks.toolbarGuid || 1307 guid == PlacesUtils.bookmarks.unfiledGuid || 1308 guid == PlacesUtils.bookmarks.tagsGuid || 1309 guid == PlacesUtils.bookmarks.rootGuid || 1310 guid == PlacesUtils.bookmarks.mobileGuid 1311 ); 1312 }, 1313 1314 /** 1315 * Returns a nsNavHistoryContainerResultNode with forced excludeItems and 1316 * expandQueries. 1317 * @param aNode 1318 * The node to convert 1319 * @param [optional] excludeItems 1320 * True to hide all items (individual bookmarks). This is used on 1321 * the left places pane so you just get a folder hierarchy. 1322 * @param [optional] expandQueries 1323 * True to make query items expand as new containers. For managing, 1324 * you want this to be false, for menus and such, you want this to 1325 * be true. 1326 * @returns A nsINavHistoryContainerResultNode containing the unfiltered 1327 * contents of the container. 1328 * @note The returned container node could be open or closed, we don't 1329 * guarantee its status. 1330 */ 1331 getContainerNodeWithOptions: function PU_getContainerNodeWithOptions( 1332 aNode, 1333 aExcludeItems, 1334 aExpandQueries 1335 ) { 1336 if (!this.nodeIsContainer(aNode)) { 1337 throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); 1338 } 1339 1340 // excludeItems is inherited by child containers in an excludeItems view. 1341 var excludeItems = 1342 asQuery(aNode).queryOptions.excludeItems || 1343 asQuery(aNode.parentResult.root).queryOptions.excludeItems; 1344 // expandQueries is inherited by child containers in an expandQueries view. 1345 var expandQueries = 1346 asQuery(aNode).queryOptions.expandQueries && 1347 asQuery(aNode.parentResult.root).queryOptions.expandQueries; 1348 1349 // If our options are exactly what we expect, directly return the node. 1350 if (excludeItems == aExcludeItems && expandQueries == aExpandQueries) { 1351 return aNode; 1352 } 1353 1354 // Otherwise, get contents manually. 1355 var query = {}, 1356 options = {}; 1357 this.history.queryStringToQuery(aNode.uri, query, options); 1358 options.value.excludeItems = aExcludeItems; 1359 options.value.expandQueries = aExpandQueries; 1360 return this.history.executeQuery(query.value, options.value).root; 1361 }, 1362 1363 /** 1364 * Returns true if a container has uri nodes in its first level. 1365 * Has better performance than (getURLsForContainerNode(node).length > 0). 1366 * @param aNode 1367 * The container node to search through. 1368 * @returns true if the node contains uri nodes, false otherwise. 1369 */ 1370 hasChildURIs: function PU_hasChildURIs(aNode) { 1371 if (!this.nodeIsContainer(aNode)) { 1372 return false; 1373 } 1374 1375 let root = this.getContainerNodeWithOptions(aNode, false, true); 1376 let result = root.parentResult; 1377 let didSuppressNotifications = false; 1378 let wasOpen = root.containerOpen; 1379 if (!wasOpen) { 1380 didSuppressNotifications = result.suppressNotifications; 1381 if (!didSuppressNotifications) { 1382 result.suppressNotifications = true; 1383 } 1384 1385 root.containerOpen = true; 1386 } 1387 1388 let found = false; 1389 for (let i = 0; i < root.childCount && !found; i++) { 1390 let child = root.getChild(i); 1391 if (this.nodeIsURI(child)) { 1392 found = true; 1393 } 1394 } 1395 1396 if (!wasOpen) { 1397 root.containerOpen = false; 1398 if (!didSuppressNotifications) { 1399 result.suppressNotifications = false; 1400 } 1401 } 1402 return found; 1403 }, 1404 1405 getChildCountForFolder(guid) { 1406 let folder = PlacesUtils.getFolderContents(guid).root; 1407 let childCount = folder.childCount; 1408 folder.containerOpen = false; 1409 return childCount; 1410 }, 1411 1412 /** 1413 * Returns an array containing all the uris in the first level of the 1414 * passed in container. 1415 * If you only need to know if the node contains uris, use hasChildURIs. 1416 * @param aNode 1417 * The container node to search through 1418 * @returns array of uris in the first level of the container. 1419 */ 1420 getURLsForContainerNode: function PU_getURLsForContainerNode(aNode) { 1421 let urls = []; 1422 if (!this.nodeIsContainer(aNode)) { 1423 return urls; 1424 } 1425 1426 let root = this.getContainerNodeWithOptions(aNode, false, true); 1427 let result = root.parentResult; 1428 let wasOpen = root.containerOpen; 1429 let didSuppressNotifications = false; 1430 if (!wasOpen) { 1431 didSuppressNotifications = result.suppressNotifications; 1432 if (!didSuppressNotifications) { 1433 result.suppressNotifications = true; 1434 } 1435 1436 root.containerOpen = true; 1437 } 1438 1439 for (let i = 0; i < root.childCount; ++i) { 1440 let child = root.getChild(i); 1441 if (this.nodeIsURI(child)) { 1442 urls.push({ 1443 uri: child.uri, 1444 isBookmark: this.nodeIsBookmark(child), 1445 title: child.title, 1446 }); 1447 } 1448 } 1449 1450 if (!wasOpen) { 1451 root.containerOpen = false; 1452 if (!didSuppressNotifications) { 1453 result.suppressNotifications = false; 1454 } 1455 } 1456 return urls; 1457 }, 1458 1459 /** 1460 * Gets a shared Sqlite.jsm readonly connection to the Places database, 1461 * usable only for SELECT queries. 1462 * 1463 * This is intended to be used mostly internally, components outside of 1464 * Places should, when possible, use API calls and file bugs to get proper 1465 * APIs, where they are missing. 1466 * Keep in mind the Places DB schema is by no means frozen or even stable. 1467 * Your custom queries can - and will - break overtime. 1468 * 1469 * Example: 1470 * let db = await PlacesUtils.promiseDBConnection(); 1471 * let rows = await db.executeCached(sql, params); 1472 */ 1473 promiseDBConnection: () => gAsyncDBConnPromised, 1474 1475 /** 1476 * This is pretty much the same as promiseDBConnection, but with a larger 1477 * page cache, useful for consumers doing large table scans, like the urlbar. 1478 * @see promiseDBConnection 1479 */ 1480 promiseLargeCacheDBConnection: () => gAsyncDBLargeCacheConnPromised, 1481 get largeCacheDBConnDeferred() { 1482 return gAsyncDBLargeCacheConnDeferred; 1483 }, 1484 1485 /** 1486 * Returns a Sqlite.jsm wrapper for the main Places connection. Most callers 1487 * should prefer `withConnectionWrapper`, which ensures that all database 1488 * operations finish before the connection is closed. 1489 */ 1490 promiseUnsafeWritableDBConnection: () => gAsyncDBWrapperPromised, 1491 1492 /** 1493 * Performs a read/write operation on the Places database through a Sqlite.jsm 1494 * wrapped connection to the Places database. 1495 * 1496 * This is intended to be used only by Places itself, always use APIs if you 1497 * need to modify the Places database. Use promiseDBConnection if you need to 1498 * SELECT from the database and there's no covering API. 1499 * Keep in mind the Places DB schema is by no means frozen or even stable. 1500 * Your custom queries can - and will - break overtime. 1501 * 1502 * As all operations on the Places database are asynchronous, if shutdown 1503 * is initiated while an operation is pending, this could cause dataloss. 1504 * Using `withConnectionWrapper` ensures that shutdown waits until all 1505 * operations are complete before proceeding. 1506 * 1507 * Example: 1508 * await withConnectionWrapper("Bookmarks: Remove a bookmark", Task.async(function*(db) { 1509 * // Proceed with the db, asynchronously. 1510 * // Shutdown will not interrupt operations that take place here. 1511 * })); 1512 * 1513 * @param {string} name The name of the operation. Used for debugging, logging 1514 * and crash reporting. 1515 * @param {function(db)} task A function that takes as argument a Sqlite.jsm 1516 * connection and returns a Promise. Shutdown is guaranteed to not interrupt 1517 * execution of `task`. 1518 */ 1519 async withConnectionWrapper(name, task) { 1520 if (!name) { 1521 throw new TypeError("Expecting a user-readable name"); 1522 } 1523 let db = await gAsyncDBWrapperPromised; 1524 return db.executeBeforeShutdown(name, task); 1525 }, 1526 1527 /** 1528 * Gets favicon data for a given page url. 1529 * 1530 * @param {string | URL | nsIURI} aPageUrl 1531 * url of the page to look favicon for. 1532 * @param {number} preferredWidth 1533 * The preferred width of the favicon in pixels. The default value of 0 1534 * returns the largest icon available. 1535 * @resolves to an object representing a favicon entry, having the following 1536 * properties: { uri, dataLen, data, mimeType } 1537 * @rejects JavaScript exception if the given url has no associated favicon. 1538 */ 1539 promiseFaviconData(aPageUrl, preferredWidth = 0) { 1540 return new Promise((resolve, reject) => { 1541 if (!(aPageUrl instanceof Ci.nsIURI)) { 1542 aPageUrl = PlacesUtils.toURI(aPageUrl); 1543 } 1544 PlacesUtils.favicons.getFaviconDataForPage( 1545 aPageUrl, 1546 function(uri, dataLen, data, mimeType, size) { 1547 if (uri) { 1548 resolve({ uri, dataLen, data, mimeType, size }); 1549 } else { 1550 reject(); 1551 } 1552 }, 1553 preferredWidth 1554 ); 1555 }); 1556 }, 1557 1558 /** 1559 * Returns the passed URL with a #size ref for the specified size and 1560 * devicePixelRatio. 1561 * 1562 * @param window 1563 * The window where the icon will appear. 1564 * @param href 1565 * The string href we should add the ref to. 1566 * @param size 1567 * The target image size 1568 * @return The URL with the fragment at the end, in the same formar as input. 1569 */ 1570 urlWithSizeRef(window, href, size) { 1571 return ( 1572 href + 1573 (href.includes("#") ? "&" : "#") + 1574 "size=" + 1575 Math.round(size) * window.devicePixelRatio 1576 ); 1577 }, 1578 1579 /** 1580 * Get the unique id for an item (a bookmark, a folder or a separator) given 1581 * its item id. 1582 * 1583 * @param aItemId 1584 * an item id 1585 * @return {Promise} 1586 * @resolves to the GUID. 1587 * @rejects if aItemId is invalid. 1588 */ 1589 promiseItemGuid(aItemId) { 1590 return GuidHelper.getItemGuid(aItemId); 1591 }, 1592 1593 /** 1594 * Get the item id for an item (a bookmark, a folder or a separator) given 1595 * its unique id. 1596 * 1597 * @param aGuid 1598 * an item GUID 1599 * @return {Promise} 1600 * @resolves to the item id. 1601 * @rejects if there's no item for the given GUID. 1602 */ 1603 promiseItemId(aGuid) { 1604 return GuidHelper.getItemId(aGuid); 1605 }, 1606 1607 /** 1608 * Get the item ids for multiple items (a bookmark, a folder or a separator) 1609 * given the unique ids for each item. 1610 * 1611 * @param {Array} aGuids An array of item GUIDs. 1612 * @return {Promise} 1613 * @resolves to a Map of item ids. 1614 * @rejects if not all of the GUIDs could be found. 1615 */ 1616 promiseManyItemIds(aGuids) { 1617 return GuidHelper.getManyItemIds(aGuids); 1618 }, 1619 1620 /** 1621 * Invalidate the GUID cache for the given itemId. 1622 * 1623 * @param aItemId 1624 * an item id 1625 */ 1626 invalidateCachedGuidFor(aItemId) { 1627 GuidHelper.invalidateCacheForItemId(aItemId); 1628 }, 1629 1630 /** 1631 * Invalidates the entire GUID cache. 1632 */ 1633 invalidateCachedGuids() { 1634 GuidHelper.invalidateCache(); 1635 }, 1636 1637 /** 1638 * Asynchronously retrieve a JS-object representation of a places bookmarks 1639 * item (a bookmark, a folder, or a separator) along with all of its 1640 * descendants. 1641 * 1642 * @param [optional] aItemGuid 1643 * the (topmost) item to be queried. If it's not passed, the places 1644 * root is queried: that is, you get a representation of the entire 1645 * bookmarks hierarchy. 1646 * @param [optional] aOptions 1647 * Options for customizing the query behavior, in the form of a JS 1648 * object with any of the following properties: 1649 * - excludeItemsCallback: a function for excluding items, along with 1650 * their descendants. Given an item object (that has everything set 1651 * apart its potential children data), it should return true if the 1652 * item should be excluded. Once an item is excluded, the function 1653 * isn't called for any of its descendants. This isn't called for 1654 * the root item. 1655 * WARNING: since the function may be called for each item, using 1656 * this option can slow down the process significantly if the 1657 * callback does anything that's not relatively trivial. It is 1658 * highly recommended to avoid any synchronous I/O or DB queries. 1659 * - includeItemIds: opt-in to include the deprecated id property. 1660 * Use it if you must. It'll be removed once the switch to GUIDs is 1661 * complete. 1662 * 1663 * @return {Promise} 1664 * @resolves to a JS object that represents either a single item or a 1665 * bookmarks tree. Each node in the tree has the following properties set: 1666 * - guid (string): the item's GUID (same as aItemGuid for the top item). 1667 * - [deprecated] id (number): the item's id. This is only if 1668 * aOptions.includeItemIds is set. 1669 * - type (string): the item's type. @see PlacesUtils.TYPE_X_* 1670 * - typeCode (number): the item's type in numeric format. 1671 * @see PlacesUtils.bookmarks.TYPE_* 1672 * - title (string): the item's title. If it has no title, this property 1673 * isn't set. 1674 * - dateAdded (number, microseconds from the epoch): the date-added value of 1675 * the item. 1676 * - lastModified (number, microseconds from the epoch): the last-modified 1677 * value of the item. 1678 * - index: the item's index under it's parent. 1679 * 1680 * The root object (i.e. the one for aItemGuid) also has the following 1681 * properties set: 1682 * - parentGuid (string): the GUID of the root's parent. This isn't set if 1683 * the root item is the places root. 1684 * - itemsCount (number, not enumerable): the number of items, including the 1685 * root item itself, which are represented in the resolved object. 1686 * 1687 * Bookmark items also have the following properties: 1688 * - uri (string): the item's url. 1689 * - tags (string): csv string of the bookmark's tags. 1690 * - charset (string): the last known charset of the bookmark. 1691 * - keyword (string): the bookmark's keyword (unset if none). 1692 * - postData (string): the bookmark's keyword postData (unset if none). 1693 * - iconuri (string): the bookmark's favicon url. 1694 * The last four properties are not set at all if they're irrelevant (e.g. 1695 * |charset| is not set if no charset was previously set for the bookmark 1696 * url). 1697 * 1698 * Folders may also have the following properties: 1699 * - children (array): the folder's children information, each of them 1700 * having the same set of properties as above. 1701 * 1702 * @rejects if the query failed for any reason. 1703 * @note if aItemGuid points to a non-existent item, the returned promise is 1704 * resolved to null. 1705 */ 1706 async promiseBookmarksTree(aItemGuid = "", aOptions = {}) { 1707 let createItemInfoObject = async function(aRow, aIncludeParentGuid) { 1708 let item = {}; 1709 let copyProps = (...props) => { 1710 for (let prop of props) { 1711 let val = aRow.getResultByName(prop); 1712 if (val !== null) { 1713 item[prop] = val; 1714 } 1715 } 1716 }; 1717 copyProps("guid", "title", "index", "dateAdded", "lastModified"); 1718 if (aIncludeParentGuid) { 1719 copyProps("parentGuid"); 1720 } 1721 1722 let itemId = aRow.getResultByName("id"); 1723 if (aOptions.includeItemIds) { 1724 item.id = itemId; 1725 } 1726 1727 // Cache it for promiseItemId consumers regardless. 1728 GuidHelper.updateCache(itemId, item.guid); 1729 1730 let type = aRow.getResultByName("type"); 1731 item.typeCode = type; 1732 if (type == Ci.nsINavBookmarksService.TYPE_BOOKMARK) { 1733 copyProps("charset", "tags", "iconuri"); 1734 } 1735 1736 switch (type) { 1737 case PlacesUtils.bookmarks.TYPE_BOOKMARK: 1738 item.type = PlacesUtils.TYPE_X_MOZ_PLACE; 1739 // If this throws due to an invalid url, the item will be skipped. 1740 try { 1741 item.uri = NetUtil.newURI(aRow.getResultByName("url")).spec; 1742 } catch (ex) { 1743 let error = new Error("Invalid bookmark URL"); 1744 error.becauseInvalidURL = true; 1745 throw error; 1746 } 1747 // Keywords are cached, so this should be decently fast. 1748 let entry = await PlacesUtils.keywords.fetch({ url: item.uri }); 1749 if (entry) { 1750 item.keyword = entry.keyword; 1751 item.postData = entry.postData; 1752 } 1753 break; 1754 case PlacesUtils.bookmarks.TYPE_FOLDER: 1755 item.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER; 1756 // Mark root folders. 1757 if (item.guid == PlacesUtils.bookmarks.rootGuid) { 1758 item.root = "placesRoot"; 1759 } else if (item.guid == PlacesUtils.bookmarks.menuGuid) { 1760 item.root = "bookmarksMenuFolder"; 1761 } else if (item.guid == PlacesUtils.bookmarks.unfiledGuid) { 1762 item.root = "unfiledBookmarksFolder"; 1763 } else if (item.guid == PlacesUtils.bookmarks.toolbarGuid) { 1764 item.root = "toolbarFolder"; 1765 } else if (item.guid == PlacesUtils.bookmarks.mobileGuid) { 1766 item.root = "mobileFolder"; 1767 } 1768 break; 1769 case PlacesUtils.bookmarks.TYPE_SEPARATOR: 1770 item.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR; 1771 break; 1772 default: 1773 Cu.reportError(`Unexpected bookmark type ${type}`); 1774 break; 1775 } 1776 return item; 1777 }; 1778 1779 const QUERY_STR = `/* do not warn (bug no): cannot use an index */ 1780 WITH RECURSIVE 1781 descendants(fk, level, type, id, guid, parent, parentGuid, position, 1782 title, dateAdded, lastModified) AS ( 1783 SELECT b1.fk, 0, b1.type, b1.id, b1.guid, b1.parent, 1784 (SELECT guid FROM moz_bookmarks WHERE id = b1.parent), 1785 b1.position, b1.title, b1.dateAdded, b1.lastModified 1786 FROM moz_bookmarks b1 WHERE b1.guid=:item_guid 1787 UNION ALL 1788 SELECT b2.fk, level + 1, b2.type, b2.id, b2.guid, b2.parent, 1789 descendants.guid, b2.position, b2.title, b2.dateAdded, 1790 b2.lastModified 1791 FROM moz_bookmarks b2 1792 JOIN descendants ON b2.parent = descendants.id AND b2.id <> :tags_folder) 1793 SELECT d.level, d.id, d.guid, d.parent, d.parentGuid, d.type, 1794 d.position AS [index], IFNULL(d.title, '') AS title, d.dateAdded, 1795 d.lastModified, h.url, (SELECT icon_url FROM moz_icons i 1796 JOIN moz_icons_to_pages ON icon_id = i.id 1797 JOIN moz_pages_w_icons pi ON page_id = pi.id 1798 WHERE pi.page_url_hash = hash(h.url) AND pi.page_url = h.url 1799 ORDER BY width DESC LIMIT 1) AS iconuri, 1800 (SELECT GROUP_CONCAT(t.title, ',') 1801 FROM moz_bookmarks b2 1802 JOIN moz_bookmarks t ON t.id = +b2.parent AND t.parent = :tags_folder 1803 WHERE b2.fk = h.id 1804 ) AS tags, 1805 (SELECT a.content FROM moz_annos a 1806 JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id 1807 WHERE place_id = h.id AND n.name = :charset_anno 1808 ) AS charset 1809 FROM descendants d 1810 LEFT JOIN moz_bookmarks b3 ON b3.id = d.parent 1811 LEFT JOIN moz_places h ON h.id = d.fk 1812 ORDER BY d.level, d.parent, d.position`; 1813 1814 if (!aItemGuid) { 1815 aItemGuid = this.bookmarks.rootGuid; 1816 } 1817 1818 let hasExcludeItemsCallback = aOptions.hasOwnProperty( 1819 "excludeItemsCallback" 1820 ); 1821 let excludedParents = new Set(); 1822 let shouldExcludeItem = (aItem, aParentGuid) => { 1823 let exclude = 1824 excludedParents.has(aParentGuid) || 1825 aOptions.excludeItemsCallback(aItem); 1826 if (exclude) { 1827 if (aItem.type == this.TYPE_X_MOZ_PLACE_CONTAINER) { 1828 excludedParents.add(aItem.guid); 1829 } 1830 } 1831 return exclude; 1832 }; 1833 1834 let rootItem = null; 1835 let parentsMap = new Map(); 1836 let conn = await this.promiseDBConnection(); 1837 let rows = await conn.executeCached(QUERY_STR, { 1838 tags_folder: PlacesUtils.tagsFolderId, 1839 charset_anno: PlacesUtils.CHARSET_ANNO, 1840 item_guid: aItemGuid, 1841 }); 1842 let yieldCounter = 0; 1843 for (let row of rows) { 1844 let item; 1845 if (!rootItem) { 1846 try { 1847 // This is the first row. 1848 rootItem = item = await createItemInfoObject(row, true); 1849 Object.defineProperty(rootItem, "itemsCount", { 1850 value: 1, 1851 writable: true, 1852 enumerable: false, 1853 configurable: false, 1854 }); 1855 } catch (ex) { 1856 Cu.reportError("Failed to fetch the data for the root item"); 1857 throw ex; 1858 } 1859 } else { 1860 try { 1861 // Our query guarantees that we always visit parents ahead of their 1862 // children. 1863 item = await createItemInfoObject(row, false); 1864 let parentGuid = row.getResultByName("parentGuid"); 1865 if (hasExcludeItemsCallback && shouldExcludeItem(item, parentGuid)) { 1866 continue; 1867 } 1868 1869 let parentItem = parentsMap.get(parentGuid); 1870 if ("children" in parentItem) { 1871 parentItem.children.push(item); 1872 } else { 1873 parentItem.children = [item]; 1874 } 1875 1876 rootItem.itemsCount++; 1877 } catch (ex) { 1878 // This is a bogus child, report and skip it. 1879 Cu.reportError("Failed to fetch the data for an item " + ex); 1880 continue; 1881 } 1882 } 1883 1884 if (item.type == this.TYPE_X_MOZ_PLACE_CONTAINER) { 1885 parentsMap.set(item.guid, item); 1886 } 1887 1888 // With many bookmarks we end up stealing the CPU - even with yielding! 1889 // So we let everyone else have a go every few items (bug 1186714). 1890 if (++yieldCounter % 50 == 0) { 1891 await new Promise(resolve => { 1892 Services.tm.dispatchToMainThread(resolve); 1893 }); 1894 } 1895 } 1896 1897 return rootItem; 1898 }, 1899 1900 /** 1901 * Returns a generator that iterates over `array` and yields slices of no 1902 * more than `chunkLength` elements at a time. 1903 * 1904 * @param {Array} array An array containing zero or more elements. 1905 * @param {number} chunkLength The maximum number of elements in each chunk. 1906 * @yields {Array} A chunk of the array. 1907 * @throws if `chunkLength` is negative or not an integer. 1908 */ 1909 *chunkArray(array, chunkLength) { 1910 if (chunkLength <= 0 || !Number.isInteger(chunkLength)) { 1911 throw new TypeError("Chunk length must be a positive integer"); 1912 } 1913 if (!array.length) { 1914 return; 1915 } 1916 if (array.length <= chunkLength) { 1917 yield array; 1918 return; 1919 } 1920 let startIndex = 0; 1921 while (startIndex < array.length) { 1922 yield array.slice(startIndex, (startIndex += chunkLength)); 1923 } 1924 }, 1925 1926 /** 1927 * Run some text through md5 and return the hash. 1928 * @param {string} data The string to hash. 1929 * @param {string} [format] Which format of the hash to return: 1930 * - "ascii" for ascii format. 1931 * - "hex" for hex format. 1932 * @returns {string} md5 hash of the input string in the required format. 1933 */ 1934 md5(data, { format = "ascii" } = {}) { 1935 gCryptoHash.init(gCryptoHash.MD5); 1936 1937 // Convert the data to a byte array for hashing 1938 gCryptoHash.update( 1939 data.split("").map(c => c.charCodeAt(0)), 1940 data.length 1941 ); 1942 switch (format) { 1943 case "hex": 1944 let hash = gCryptoHash.finish(false); 1945 return Array.from(hash, (c, i) => 1946 hash 1947 .charCodeAt(i) 1948 .toString(16) 1949 .padStart(2, "0") 1950 ).join(""); 1951 case "ascii": 1952 default: 1953 return gCryptoHash.finish(true); 1954 } 1955 }, 1956}; 1957 1958XPCOMUtils.defineLazyGetter(PlacesUtils, "history", function() { 1959 let hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService( 1960 Ci.nsINavHistoryService 1961 ); 1962 return Object.freeze( 1963 new Proxy(hs, { 1964 get(target, name) { 1965 let property, object; 1966 if (name in target) { 1967 property = target[name]; 1968 object = target; 1969 } else { 1970 property = History[name]; 1971 object = History; 1972 } 1973 if (typeof property == "function") { 1974 return property.bind(object); 1975 } 1976 return property; 1977 }, 1978 }) 1979 ); 1980}); 1981 1982XPCOMUtils.defineLazyServiceGetter( 1983 PlacesUtils, 1984 "favicons", 1985 "@mozilla.org/browser/favicon-service;1", 1986 "nsIFaviconService" 1987); 1988 1989XPCOMUtils.defineLazyServiceGetter( 1990 this, 1991 "bmsvc", 1992 "@mozilla.org/browser/nav-bookmarks-service;1", 1993 "nsINavBookmarksService" 1994); 1995XPCOMUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => { 1996 return Object.freeze( 1997 new Proxy(Bookmarks, { 1998 get: (target, name) => 1999 Bookmarks.hasOwnProperty(name) ? Bookmarks[name] : bmsvc[name], 2000 }) 2001 ); 2002}); 2003 2004XPCOMUtils.defineLazyServiceGetter( 2005 PlacesUtils, 2006 "tagging", 2007 "@mozilla.org/browser/tagging-service;1", 2008 "nsITaggingService" 2009); 2010 2011XPCOMUtils.defineLazyGetter(this, "bundle", function() { 2012 const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties"; 2013 return Services.strings.createBundle(PLACES_STRING_BUNDLE_URI); 2014}); 2015 2016// This is just used as a reasonably-random value for copy & paste / drag operations. 2017XPCOMUtils.defineLazyGetter(PlacesUtils, "instanceId", () => { 2018 return PlacesUtils.history.makeGuid(); 2019}); 2020 2021/** 2022 * Setup internal databases for closing properly during shutdown. 2023 * 2024 * 1. Places initiates shutdown. 2025 * 2. Before places can move to the step where it closes the low-level connection, 2026 * we need to make sure that we have closed `conn`. 2027 * 3. Before we can close `conn`, we need to make sure that all external clients 2028 * have stopped using `conn`. 2029 * 4. Before we can close Sqlite, we need to close `conn`. 2030 */ 2031function setupDbForShutdown(conn, name) { 2032 try { 2033 let state = "0. Not started."; 2034 let promiseClosed = new Promise((resolve, reject) => { 2035 // The service initiates shutdown. 2036 // Before it can safely close its connection, we need to make sure 2037 // that we have closed the high-level connection. 2038 try { 2039 PlacesUtils.history.connectionShutdownClient.jsclient.addBlocker( 2040 `${name} closing as part of Places shutdown`, 2041 async function() { 2042 state = "1. Service has initiated shutdown"; 2043 2044 // At this stage, all external clients have finished using the 2045 // database. We just need to close the high-level connection. 2046 try { 2047 await conn.close(); 2048 state = "2. Closed Sqlite.jsm connection."; 2049 resolve(); 2050 } catch (ex) { 2051 state = "2. Failed to closed Sqlite.jsm connection: " + ex; 2052 reject(ex); 2053 } 2054 }, 2055 () => state 2056 ); 2057 } catch (ex) { 2058 // It's too late to block shutdown, just close the connection. 2059 conn.close(); 2060 reject(ex); 2061 } 2062 }).catch(Cu.reportError); 2063 2064 // Make sure that Sqlite.jsm doesn't close until we are done 2065 // with the high-level connection. 2066 Sqlite.shutdown.addBlocker( 2067 `${name} must be closed before Sqlite.jsm`, 2068 () => promiseClosed, 2069 () => state 2070 ); 2071 } catch (ex) { 2072 // It's too late to block shutdown, just close the connection. 2073 conn.close(); 2074 throw ex; 2075 } 2076} 2077 2078XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised", () => 2079 Sqlite.cloneStorageConnection({ 2080 connection: PlacesUtils.history.DBConnection, 2081 readOnly: true, 2082 }) 2083 .then(conn => { 2084 setupDbForShutdown(conn, "PlacesUtils read-only connection"); 2085 return conn; 2086 }) 2087 .catch(Cu.reportError) 2088); 2089 2090XPCOMUtils.defineLazyGetter(this, "gAsyncDBWrapperPromised", () => 2091 Sqlite.wrapStorageConnection({ 2092 connection: PlacesUtils.history.DBConnection, 2093 }) 2094 .then(conn => { 2095 setupDbForShutdown(conn, "PlacesUtils wrapped connection"); 2096 return conn; 2097 }) 2098 .catch(Cu.reportError) 2099); 2100 2101var gAsyncDBLargeCacheConnDeferred = PromiseUtils.defer(); 2102XPCOMUtils.defineLazyGetter(this, "gAsyncDBLargeCacheConnPromised", () => 2103 Sqlite.cloneStorageConnection({ 2104 connection: PlacesUtils.history.DBConnection, 2105 readOnly: true, 2106 }) 2107 .then(async conn => { 2108 setupDbForShutdown(conn, "PlacesUtils large cache read-only connection"); 2109 // Components like the urlbar often fallback to a table scan due to lack 2110 // of full text indices. A larger cache helps reducing IO and improves 2111 // performance. This value is expected to be larger than the default 2112 // mozStorage value defined as MAX_CACHE_SIZE_BYTES in 2113 // storage/mozStorageConnection.cpp. 2114 await conn.execute("PRAGMA cache_size = -6144"); // 6MiB 2115 // These should be kept in sync with nsPlacesTables.h. 2116 await conn.execute(` 2117 CREATE TEMP TABLE IF NOT EXISTS moz_openpages_temp ( 2118 url TEXT, 2119 userContextId INTEGER, 2120 open_count INTEGER, 2121 PRIMARY KEY (url, userContextId) 2122 )`); 2123 await conn.execute(` 2124 CREATE TEMP TRIGGER IF NOT EXISTS moz_openpages_temp_afterupdate_trigger 2125 AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW 2126 WHEN NEW.open_count = 0 2127 BEGIN 2128 DELETE FROM moz_openpages_temp 2129 WHERE url = NEW.url 2130 AND userContextId = NEW.userContextId; 2131 END`); 2132 gAsyncDBLargeCacheConnDeferred.resolve(conn); 2133 return conn; 2134 }) 2135 .catch(Cu.reportError) 2136); 2137 2138/** 2139 * The metadata API allows consumers to store simple key-value metadata in 2140 * Places. Keys are strings, values can be any type that SQLite supports: 2141 * numbers (integers and doubles), Booleans, strings, and blobs. Values are 2142 * cached in memory for faster lookups. 2143 * 2144 * Since some consumers set metadata as part of an existing operation or active 2145 * transaction, the API also exposes a `*withConnection` variant for each 2146 * method that takes an open database connection. 2147 */ 2148PlacesUtils.metadata = { 2149 cache: new Map(), 2150 jsonPrefix: "data:application/json;base64,", 2151 2152 /** 2153 * Returns the value associated with a metadata key. 2154 * 2155 * @param {String} key 2156 * The metadata key to look up. 2157 * @param {String|Object|Array} defaultValue 2158 * Optional. The default value to return if the value is not present, 2159 * or cannot be parsed. 2160 * @resolves {*} 2161 * The value associated with the key, or the defaultValue if there is one. 2162 * @rejects 2163 * Rejected if the value is not found or it cannot be parsed 2164 * and there is no defaultValue. 2165 */ 2166 get(key, defaultValue) { 2167 return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.get", db => 2168 this.getWithConnection(db, key, defaultValue) 2169 ); 2170 }, 2171 2172 /** 2173 * Sets the value for a metadata key. 2174 * 2175 * @param {String} key 2176 * The metadata key to update. 2177 * @param {*} 2178 * The value to associate with the key. 2179 */ 2180 set(key, value) { 2181 return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.set", db => 2182 this.setWithConnection(db, key, value) 2183 ); 2184 }, 2185 2186 /** 2187 * Removes the values for the given metadata keys. 2188 * 2189 * @param {String...} 2190 * One or more metadata keys to remove. 2191 */ 2192 delete(...keys) { 2193 return PlacesUtils.withConnectionWrapper( 2194 "PlacesUtils.metadata.delete", 2195 db => this.deleteWithConnection(db, ...keys) 2196 ); 2197 }, 2198 2199 async getWithConnection(db, key, defaultValue) { 2200 key = this.canonicalizeKey(key); 2201 if (this.cache.has(key)) { 2202 return this.cache.get(key); 2203 } 2204 let rows = await db.executeCached( 2205 ` 2206 SELECT value FROM moz_meta WHERE key = :key`, 2207 { key } 2208 ); 2209 let value = null; 2210 if (rows.length) { 2211 let row = rows[0]; 2212 let rawValue = row.getResultByName("value"); 2213 // Convert blobs back to `Uint8Array`s. 2214 if (row.getTypeOfIndex(0) == row.VALUE_TYPE_BLOB) { 2215 value = new Uint8Array(rawValue); 2216 } else if ( 2217 typeof rawValue == "string" && 2218 rawValue.startsWith(this.jsonPrefix) 2219 ) { 2220 try { 2221 value = JSON.parse( 2222 this._base64Decode(rawValue.substr(this.jsonPrefix.length)) 2223 ); 2224 } catch (ex) { 2225 if (defaultValue !== undefined) { 2226 // We must create a new array in the local scope to avoid a memory 2227 // leak due to the array global object. 2228 value = Cu.cloneInto(defaultValue, {}); 2229 } else { 2230 throw ex; 2231 } 2232 } 2233 } else { 2234 value = rawValue; 2235 } 2236 } else if (defaultValue !== undefined) { 2237 // We must create a new array in the local scope to avoid a memory leak due 2238 // to the array global object. 2239 value = Cu.cloneInto(defaultValue, {}); 2240 } else { 2241 throw new Error(`No data stored for key ${key}`); 2242 } 2243 this.cache.set(key, value); 2244 return value; 2245 }, 2246 2247 async setWithConnection(db, key, value) { 2248 if (value === null) { 2249 await this.deleteWithConnection(db, key); 2250 return; 2251 } 2252 2253 let cacheValue = value; 2254 if ( 2255 typeof value == "object" && 2256 ChromeUtils.getClassName(value) != "Uint8Array" 2257 ) { 2258 value = this.jsonPrefix + this._base64Encode(JSON.stringify(value)); 2259 } 2260 2261 key = this.canonicalizeKey(key); 2262 await db.executeCached( 2263 ` 2264 REPLACE INTO moz_meta (key, value) 2265 VALUES (:key, :value)`, 2266 { key, value } 2267 ); 2268 this.cache.set(key, cacheValue); 2269 }, 2270 2271 async deleteWithConnection(db, ...keys) { 2272 keys = keys.map(this.canonicalizeKey); 2273 if (!keys.length) { 2274 return; 2275 } 2276 await db.execute( 2277 ` 2278 DELETE FROM moz_meta 2279 WHERE key IN (${new Array(keys.length).fill("?").join(",")})`, 2280 keys 2281 ); 2282 for (let key of keys) { 2283 this.cache.delete(key); 2284 } 2285 }, 2286 2287 canonicalizeKey(key) { 2288 if (typeof key != "string" || !/^[a-zA-Z0-9\/]+$/.test(key)) { 2289 throw new TypeError("Invalid metadata key: " + key); 2290 } 2291 return key.toLowerCase(); 2292 }, 2293 2294 _base64Encode(str) { 2295 return ChromeUtils.base64URLEncode(new TextEncoder("utf-8").encode(str), { 2296 pad: true, 2297 }); 2298 }, 2299 2300 _base64Decode(str) { 2301 return new TextDecoder("utf-8").decode( 2302 ChromeUtils.base64URLDecode(str, { padding: "require" }) 2303 ); 2304 }, 2305}; 2306 2307/** 2308 * Keywords management API. 2309 * Sooner or later these keywords will merge with search aliases, this is an 2310 * interim API that should then be replaced by a unified one. 2311 * Keywords are associated with URLs and can have POST data. 2312 * The relations between URLs and keywords are the following: 2313 * - 1 keyword can only point to 1 URL 2314 * - 1 URL can have multiple keywords, iff they differ by POST data (included the empty one). 2315 */ 2316PlacesUtils.keywords = { 2317 /** 2318 * Fetches a keyword entry based on keyword or URL. 2319 * 2320 * @param keywordOrEntry 2321 * Either the keyword to fetch or an entry providing keyword 2322 * or url property to find keywords for. If both properties are set, 2323 * this returns their intersection. 2324 * @param onResult [optional] 2325 * Callback invoked for each found entry. 2326 * @return {Promise} 2327 * @resolves to an object in the form: { keyword, url, postData }, 2328 * or null if a keyword entry was not found. 2329 */ 2330 fetch(keywordOrEntry, onResult = null) { 2331 if (typeof keywordOrEntry == "string") { 2332 keywordOrEntry = { keyword: keywordOrEntry }; 2333 } 2334 2335 if ( 2336 keywordOrEntry === null || 2337 typeof keywordOrEntry != "object" || 2338 ("keyword" in keywordOrEntry && typeof keywordOrEntry.keyword != "string") 2339 ) { 2340 throw new Error("Invalid keyword"); 2341 } 2342 2343 let hasKeyword = "keyword" in keywordOrEntry; 2344 let hasUrl = "url" in keywordOrEntry; 2345 2346 if (!hasKeyword && !hasUrl) { 2347 throw new Error("At least keyword or url must be provided"); 2348 } 2349 if (onResult && typeof onResult != "function") { 2350 throw new Error("onResult callback must be a valid function"); 2351 } 2352 2353 if (hasUrl) { 2354 try { 2355 keywordOrEntry.url = BOOKMARK_VALIDATORS.url(keywordOrEntry.url); 2356 } catch (ex) { 2357 throw new Error(keywordOrEntry.url + " is not a valid URL"); 2358 } 2359 } 2360 if (hasKeyword) { 2361 keywordOrEntry.keyword = keywordOrEntry.keyword.trim().toLowerCase(); 2362 } 2363 2364 let safeOnResult = entry => { 2365 if (onResult) { 2366 try { 2367 onResult(entry); 2368 } catch (ex) { 2369 Cu.reportError(ex); 2370 } 2371 } 2372 }; 2373 2374 return promiseKeywordsCache().then(cache => { 2375 let entries = []; 2376 if (hasKeyword) { 2377 let entry = cache.get(keywordOrEntry.keyword); 2378 if (entry) { 2379 entries.push(entry); 2380 } 2381 } 2382 if (hasUrl) { 2383 for (let entry of cache.values()) { 2384 if (entry.url.href == keywordOrEntry.url.href) { 2385 entries.push(entry); 2386 } 2387 } 2388 } 2389 2390 entries = entries.filter(e => { 2391 return ( 2392 (!hasUrl || e.url.href == keywordOrEntry.url.href) && 2393 (!hasKeyword || e.keyword == keywordOrEntry.keyword) 2394 ); 2395 }); 2396 2397 entries.forEach(safeOnResult); 2398 return entries.length ? entries[0] : null; 2399 }); 2400 }, 2401 2402 /** 2403 * Adds a new keyword and postData for the given URL. 2404 * 2405 * @param keywordEntry 2406 * An object describing the keyword to insert, in the form: 2407 * { 2408 * keyword: non-empty string, 2409 * url: URL or href to associate to the keyword, 2410 * postData: optional POST data to associate to the keyword 2411 * source: The change source, forwarded to all bookmark observers. 2412 * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. 2413 * } 2414 * @note Do not define a postData property if there isn't any POST data. 2415 * Defining an empty string for POST data is equivalent to not having it. 2416 * @resolves when the addition is complete. 2417 */ 2418 insert(keywordEntry) { 2419 if (!keywordEntry || typeof keywordEntry != "object") { 2420 throw new Error("Input should be a valid object"); 2421 } 2422 2423 if ( 2424 !("keyword" in keywordEntry) || 2425 !keywordEntry.keyword || 2426 typeof keywordEntry.keyword != "string" 2427 ) { 2428 throw new Error("Invalid keyword"); 2429 } 2430 if ( 2431 "postData" in keywordEntry && 2432 keywordEntry.postData && 2433 typeof keywordEntry.postData != "string" 2434 ) { 2435 throw new Error("Invalid POST data"); 2436 } 2437 if (!("url" in keywordEntry)) { 2438 throw new Error("undefined is not a valid URL"); 2439 } 2440 2441 if (!("source" in keywordEntry)) { 2442 keywordEntry.source = PlacesUtils.bookmarks.SOURCES.DEFAULT; 2443 } 2444 let { keyword, url, source } = keywordEntry; 2445 keyword = keyword.trim().toLowerCase(); 2446 let postData = keywordEntry.postData || ""; 2447 // This also checks href for validity 2448 try { 2449 url = BOOKMARK_VALIDATORS.url(url); 2450 } catch (ex) { 2451 throw new Error(url + " is not a valid URL"); 2452 } 2453 2454 return PlacesUtils.withConnectionWrapper( 2455 "PlacesUtils.keywords.insert", 2456 async db => { 2457 let cache = await promiseKeywordsCache(); 2458 2459 // Trying to set the same keyword is a no-op. 2460 let oldEntry = cache.get(keyword); 2461 if ( 2462 oldEntry && 2463 oldEntry.url.href == url.href && 2464 (oldEntry.postData || "") == postData 2465 ) { 2466 return; 2467 } 2468 2469 // A keyword can only be associated to a single page. 2470 // If another page is using the new keyword, we must update the keyword 2471 // entry. 2472 // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete 2473 // trigger. 2474 if (oldEntry) { 2475 await db.executeCached( 2476 `UPDATE moz_keywords 2477 SET place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), 2478 post_data = :post_data 2479 WHERE keyword = :keyword 2480 `, 2481 { url: url.href, keyword, post_data: postData } 2482 ); 2483 await notifyKeywordChange(oldEntry.url.href, "", source); 2484 } else { 2485 // An entry for the given page could be missing, in such a case we need to 2486 // create it. The IGNORE conflict can trigger on `guid`. 2487 await db.executeTransaction(async () => { 2488 await db.executeCached( 2489 `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) 2490 VALUES (:url, hash(:url), :rev_host, 0, :frecency, 2491 IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), 2492 GENERATE_GUID())) 2493 `, 2494 { 2495 url: url.href, 2496 rev_host: PlacesUtils.getReversedHost(url), 2497 frecency: url.protocol == "place:" ? 0 : -1, 2498 } 2499 ); 2500 await db.executeCached("DELETE FROM moz_updateoriginsinsert_temp"); 2501 2502 // A new keyword could be assigned to an url that already has one, 2503 // then we must replace the old keyword with the new one. 2504 let oldKeywords = []; 2505 for (let entry of cache.values()) { 2506 if ( 2507 entry.url.href == url.href && 2508 (entry.postData || "") == postData 2509 ) { 2510 oldKeywords.push(entry.keyword); 2511 } 2512 } 2513 if (oldKeywords.length) { 2514 for (let oldKeyword of oldKeywords) { 2515 await db.executeCached( 2516 `DELETE FROM moz_keywords WHERE keyword = :oldKeyword`, 2517 { oldKeyword } 2518 ); 2519 cache.delete(oldKeyword); 2520 } 2521 } 2522 2523 await db.executeCached( 2524 `INSERT INTO moz_keywords (keyword, place_id, post_data) 2525 VALUES (:keyword, (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :post_data) 2526 `, 2527 { url: url.href, keyword, post_data: postData } 2528 ); 2529 2530 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 2531 db, 2532 url, 2533 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source) 2534 ); 2535 }); 2536 } 2537 2538 cache.set(keyword, { keyword, url, postData: postData || null }); 2539 2540 // In any case, notify about the new keyword. 2541 await notifyKeywordChange(url.href, keyword, source); 2542 } 2543 ); 2544 }, 2545 2546 /** 2547 * Removes a keyword. 2548 * 2549 * @param keyword 2550 * The keyword to remove. 2551 * @return {Promise} 2552 * @resolves when the removal is complete. 2553 */ 2554 remove(keywordOrEntry) { 2555 if (typeof keywordOrEntry == "string") { 2556 keywordOrEntry = { 2557 keyword: keywordOrEntry, 2558 source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, 2559 }; 2560 } 2561 2562 if ( 2563 keywordOrEntry === null || 2564 typeof keywordOrEntry != "object" || 2565 !keywordOrEntry.keyword || 2566 typeof keywordOrEntry.keyword != "string" 2567 ) { 2568 throw new Error("Invalid keyword"); 2569 } 2570 2571 let { 2572 keyword, 2573 source = Ci.nsINavBookmarksService.SOURCE_DEFAULT, 2574 } = keywordOrEntry; 2575 keyword = keywordOrEntry.keyword.trim().toLowerCase(); 2576 return PlacesUtils.withConnectionWrapper( 2577 "PlacesUtils.keywords.remove", 2578 async db => { 2579 let cache = await promiseKeywordsCache(); 2580 if (!cache.has(keyword)) { 2581 return; 2582 } 2583 let { url } = cache.get(keyword); 2584 cache.delete(keyword); 2585 2586 await db.executeTransaction(async function() { 2587 await db.execute( 2588 `DELETE FROM moz_keywords WHERE keyword = :keyword`, 2589 { keyword } 2590 ); 2591 2592 await PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( 2593 db, 2594 url, 2595 PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source) 2596 ); 2597 }); 2598 2599 // Notify bookmarks about the removal. 2600 await notifyKeywordChange(url.href, "", source); 2601 } 2602 ); 2603 }, 2604 2605 /** 2606 * Moves all (keyword, POST data) pairs from one URL to another, and fires 2607 * observer notifications for all affected bookmarks. If the destination URL 2608 * already has keywords, they will be removed and replaced with the source 2609 * URL's keywords. 2610 * 2611 * @param oldURL 2612 * The source URL. 2613 * @param newURL 2614 * The destination URL. 2615 * @param source 2616 * The change source, forwarded to all bookmark observers. 2617 * @return {Promise} 2618 * @resolves when all keywords have been moved to the destination URL. 2619 */ 2620 reassign(oldURL, newURL, source = PlacesUtils.bookmarks.SOURCES.DEFAULT) { 2621 try { 2622 oldURL = BOOKMARK_VALIDATORS.url(oldURL); 2623 } catch (ex) { 2624 throw new Error(oldURL + " is not a valid source URL"); 2625 } 2626 try { 2627 newURL = BOOKMARK_VALIDATORS.url(newURL); 2628 } catch (ex) { 2629 throw new Error(oldURL + " is not a valid destination URL"); 2630 } 2631 return PlacesUtils.withConnectionWrapper( 2632 "PlacesUtils.keywords.reassign", 2633 async function(db) { 2634 let keywordsToReassign = []; 2635 let keywordsToRemove = []; 2636 let cache = await promiseKeywordsCache(); 2637 for (let [keyword, entry] of cache) { 2638 if (entry.url.href == oldURL.href) { 2639 keywordsToReassign.push(keyword); 2640 } 2641 if (entry.url.href == newURL.href) { 2642 keywordsToRemove.push(keyword); 2643 } 2644 } 2645 if (!keywordsToReassign.length) { 2646 return; 2647 } 2648 2649 await db.executeTransaction(async function() { 2650 // Remove existing keywords from the new URL. 2651 await db.executeCached( 2652 `DELETE FROM moz_keywords WHERE keyword = :keyword`, 2653 keywordsToRemove.map(keyword => ({ keyword })) 2654 ); 2655 2656 // Move keywords from the old URL to the new URL. 2657 await db.executeCached( 2658 ` 2659 UPDATE moz_keywords SET 2660 place_id = (SELECT id FROM moz_places 2661 WHERE url_hash = hash(:newURL) AND 2662 url = :newURL) 2663 WHERE place_id = (SELECT id FROM moz_places 2664 WHERE url_hash = hash(:oldURL) AND 2665 url = :oldURL)`, 2666 { newURL: newURL.href, oldURL: oldURL.href } 2667 ); 2668 }); 2669 for (let keyword of keywordsToReassign) { 2670 let entry = cache.get(keyword); 2671 entry.url = newURL; 2672 } 2673 for (let keyword of keywordsToRemove) { 2674 cache.delete(keyword); 2675 } 2676 2677 if (keywordsToReassign.length) { 2678 // If we moved any keywords, notify that we removed all keywords from 2679 // the old and new URLs, then notify for each moved keyword. 2680 await notifyKeywordChange(oldURL, "", source); 2681 await notifyKeywordChange(newURL, "", source); 2682 for (let keyword of keywordsToReassign) { 2683 await notifyKeywordChange(newURL, keyword, source); 2684 } 2685 } else if (keywordsToRemove.length) { 2686 // If the old URL didn't have any keywords, but the new URL did, just 2687 // notify that we removed all keywords from the new URL. 2688 await notifyKeywordChange(oldURL, "", source); 2689 } 2690 } 2691 ); 2692 }, 2693 2694 /** 2695 * Removes all orphaned keywords from the given URLs. Orphaned keywords are 2696 * associated with URLs that are no longer bookmarked. If a given URL is still 2697 * bookmarked, its keywords will not be removed. 2698 * 2699 * @param urls 2700 * A list of URLs to check for orphaned keywords. 2701 * @return {Promise} 2702 * @resolves when all keywords have been removed from URLs that are no longer 2703 * bookmarked. 2704 */ 2705 removeFromURLsIfNotBookmarked(urls) { 2706 let hrefs = new Set(); 2707 for (let url of urls) { 2708 try { 2709 url = BOOKMARK_VALIDATORS.url(url); 2710 } catch (ex) { 2711 throw new Error(url + " is not a valid URL"); 2712 } 2713 hrefs.add(url.href); 2714 } 2715 return PlacesUtils.withConnectionWrapper( 2716 "PlacesUtils.keywords.removeFromURLsIfNotBookmarked", 2717 async function(db) { 2718 let keywordsByHref = new Map(); 2719 let cache = await promiseKeywordsCache(); 2720 for (let [keyword, entry] of cache) { 2721 let href = entry.url.href; 2722 if (!hrefs.has(href)) { 2723 continue; 2724 } 2725 if (!keywordsByHref.has(href)) { 2726 keywordsByHref.set(href, [keyword]); 2727 continue; 2728 } 2729 let existingKeywords = keywordsByHref.get(href); 2730 existingKeywords.push(keyword); 2731 } 2732 if (!keywordsByHref.size) { 2733 return; 2734 } 2735 2736 let placeInfosToRemove = []; 2737 let rows = await db.execute( 2738 ` 2739 SELECT h.id, h.url 2740 FROM moz_places h 2741 JOIN moz_keywords k ON k.place_id = h.id 2742 GROUP BY h.id 2743 HAVING h.foreign_count = count(*) + 2744 (SELECT count(*) 2745 FROM moz_bookmarks b 2746 JOIN moz_bookmarks p ON b.parent = p.id 2747 WHERE p.parent = :tags_root AND b.fk = h.id) 2748 `, 2749 { tags_root: PlacesUtils.tagsFolderId } 2750 ); 2751 for (let row of rows) { 2752 placeInfosToRemove.push({ 2753 placeId: row.getResultByName("id"), 2754 href: row.getResultByName("url"), 2755 }); 2756 } 2757 if (!placeInfosToRemove.length) { 2758 return; 2759 } 2760 2761 await db.execute( 2762 `DELETE FROM moz_keywords WHERE place_id IN (${Array.from( 2763 placeInfosToRemove.map(info => info.placeId) 2764 ).join()})` 2765 ); 2766 for (let { href } of placeInfosToRemove) { 2767 let keywords = keywordsByHref.get(href); 2768 for (let keyword of keywords) { 2769 cache.delete(keyword); 2770 } 2771 } 2772 } 2773 ); 2774 }, 2775 2776 /** 2777 * Removes all keywords from all URLs. 2778 * 2779 * @return {Promise} 2780 * @resolves when all keywords have been removed. 2781 */ 2782 eraseEverything() { 2783 return PlacesUtils.withConnectionWrapper( 2784 "PlacesUtils.keywords.eraseEverything", 2785 async function(db) { 2786 let cache = await promiseKeywordsCache(); 2787 if (!cache.size) { 2788 return; 2789 } 2790 await db.executeCached(`DELETE FROM moz_keywords`); 2791 cache.clear(); 2792 } 2793 ); 2794 }, 2795 2796 /** 2797 * Invalidates the keywords cache, leaving all existing keywords in place. 2798 * The cache will be repopulated on the next `PlacesUtils.keywords.*` call. 2799 * 2800 * @return {Promise} 2801 * @resolves when the cache has been cleared. 2802 */ 2803 invalidateCachedKeywords() { 2804 gKeywordsCachePromise = gKeywordsCachePromise.then(_ => null); 2805 return gKeywordsCachePromise; 2806 }, 2807}; 2808 2809var gKeywordsCachePromise = Promise.resolve(); 2810 2811function promiseKeywordsCache() { 2812 let promise = gKeywordsCachePromise.then(function(cache) { 2813 if (cache) { 2814 return cache; 2815 } 2816 return PlacesUtils.withConnectionWrapper( 2817 "PlacesUtils: promiseKeywordsCache", 2818 async db => { 2819 let cache = new Map(); 2820 let rows = await db.execute( 2821 `SELECT keyword, url, post_data 2822 FROM moz_keywords k 2823 JOIN moz_places h ON h.id = k.place_id 2824 ` 2825 ); 2826 let brokenKeywords = []; 2827 for (let row of rows) { 2828 let keyword = row.getResultByName("keyword"); 2829 try { 2830 let entry = { 2831 keyword, 2832 url: new URL(row.getResultByName("url")), 2833 postData: row.getResultByName("post_data") || null, 2834 }; 2835 cache.set(keyword, entry); 2836 } catch (ex) { 2837 // The url is invalid, don't load the keyword and remove it, or it 2838 // would break the whole keywords API. 2839 brokenKeywords.push(keyword); 2840 } 2841 } 2842 if (brokenKeywords.length) { 2843 await db.execute( 2844 `DELETE FROM moz_keywords 2845 WHERE keyword IN (${brokenKeywords.map(JSON.stringify).join(",")}) 2846 ` 2847 ); 2848 } 2849 return cache; 2850 } 2851 ); 2852 }); 2853 gKeywordsCachePromise = promise.catch(_ => {}); 2854 return promise; 2855} 2856 2857// Sometime soon, likely as part of the transition to mozIAsyncBookmarks, 2858// itemIds will be deprecated in favour of GUIDs, which play much better 2859// with multiple undo/redo operations. Because these GUIDs are already stored, 2860// and because we don't want to revise the transactions API once more when this 2861// happens, transactions are set to work with GUIDs exclusively, in the sense 2862// that they may never expose itemIds, nor do they accept them as input. 2863// More importantly, transactions which add or remove items guarantee to 2864// restore the GUIDs on undo/redo, so that the following transactions that may 2865// done or undo can assume the items they're interested in are stil accessible 2866// through the same GUID. 2867// The current bookmarks API, however, doesn't expose the necessary means for 2868// working with GUIDs. So, until it does, this helper object accesses the 2869// Places database directly in order to switch between GUIDs and itemIds, and 2870// "restore" GUIDs on items re-created items. 2871var GuidHelper = { 2872 // Cache for GUID<->itemId paris. 2873 guidsForIds: new Map(), 2874 idsForGuids: new Map(), 2875 2876 async getItemId(aGuid) { 2877 let cached = this.idsForGuids.get(aGuid); 2878 if (cached !== undefined) { 2879 return cached; 2880 } 2881 2882 let itemId = await PlacesUtils.withConnectionWrapper( 2883 "GuidHelper.getItemId", 2884 async function(db) { 2885 let rows = await db.executeCached( 2886 "SELECT b.id, b.guid from moz_bookmarks b WHERE b.guid = :guid LIMIT 1", 2887 { guid: aGuid } 2888 ); 2889 if (!rows.length) { 2890 throw new Error("no item found for the given GUID"); 2891 } 2892 2893 return rows[0].getResultByName("id"); 2894 } 2895 ); 2896 2897 this.updateCache(itemId, aGuid); 2898 return itemId; 2899 }, 2900 2901 async getManyItemIds(aGuids) { 2902 let uncachedGuids = aGuids.filter(guid => !this.idsForGuids.has(guid)); 2903 if (uncachedGuids.length) { 2904 await PlacesUtils.withConnectionWrapper( 2905 "GuidHelper.getItemId", 2906 async db => { 2907 while (uncachedGuids.length) { 2908 let chunk = uncachedGuids.splice(0, 100); 2909 let rows = await db.executeCached( 2910 `SELECT b.id, b.guid from moz_bookmarks b WHERE 2911 b.guid IN (${"?,".repeat(chunk.length - 1) + "?"}) 2912 LIMIT ${chunk.length}`, 2913 chunk 2914 ); 2915 if (rows.length < chunk.length) { 2916 throw new Error("Not all items were found!"); 2917 } 2918 for (let row of rows) { 2919 this.updateCache( 2920 row.getResultByIndex(0), 2921 row.getResultByIndex(1) 2922 ); 2923 } 2924 } 2925 } 2926 ); 2927 } 2928 return new Map(aGuids.map(guid => [guid, this.idsForGuids.get(guid)])); 2929 }, 2930 2931 async getItemGuid(aItemId) { 2932 let cached = this.guidsForIds.get(aItemId); 2933 if (cached !== undefined) { 2934 return cached; 2935 } 2936 2937 let guid = await PlacesUtils.withConnectionWrapper( 2938 "GuidHelper.getItemGuid", 2939 async function(db) { 2940 let rows = await db.executeCached( 2941 "SELECT b.id, b.guid from moz_bookmarks b WHERE b.id = :id LIMIT 1", 2942 { id: aItemId } 2943 ); 2944 if (!rows.length) { 2945 throw new Error("no item found for the given itemId"); 2946 } 2947 2948 return rows[0].getResultByName("guid"); 2949 } 2950 ); 2951 2952 this.updateCache(aItemId, guid); 2953 return guid; 2954 }, 2955 2956 /** 2957 * Updates the cache. 2958 * 2959 * @note This is the only place where the cache should be populated, 2960 * invalidation relies on both Maps being populated at the same time. 2961 */ 2962 updateCache(aItemId, aGuid) { 2963 if (typeof aItemId != "number" || aItemId <= 0) { 2964 throw new Error( 2965 "Trying to update the GUIDs cache with an invalid itemId" 2966 ); 2967 } 2968 if (!PlacesUtils.isValidGuid(aGuid)) { 2969 throw new Error("Trying to update the GUIDs cache with an invalid GUID"); 2970 } 2971 this.ensureObservingRemovedItems(); 2972 this.guidsForIds.set(aItemId, aGuid); 2973 this.idsForGuids.set(aGuid, aItemId); 2974 }, 2975 2976 invalidateCacheForItemId(aItemId) { 2977 let guid = this.guidsForIds.get(aItemId); 2978 this.guidsForIds.delete(aItemId); 2979 this.idsForGuids.delete(guid); 2980 }, 2981 2982 invalidateCache() { 2983 this.guidsForIds.clear(); 2984 this.idsForGuids.clear(); 2985 }, 2986 2987 ensureObservingRemovedItems() { 2988 if (this.addListeners) { 2989 return; 2990 } 2991 /** 2992 * This observers serves two purposes: 2993 * (1) Invalidate cached id<->GUID paris on when items are removed. 2994 * (2) Cache GUIDs given us free of charge by onItemAdded/onItemRemoved. 2995 * So, for exmaple, when the NewBookmark needs the new GUID, we already 2996 * have it cached. 2997 */ 2998 let listener = events => { 2999 for (let event of events) { 3000 switch (event.type) { 3001 case "bookmark-added": 3002 this.updateCache(event.id, event.guid); 3003 this.updateCache(event.parentId, event.parentGuid); 3004 break; 3005 case "bookmark-removed": 3006 this.guidsForIds.delete(event.id); 3007 this.idsForGuids.delete(event.guid); 3008 this.updateCache(event.parentId, event.parentGuid); 3009 break; 3010 } 3011 } 3012 }; 3013 3014 this.addListeners = true; 3015 PlacesUtils.observers.addListener( 3016 ["bookmark-added", "bookmark-removed"], 3017 listener 3018 ); 3019 PlacesUtils.registerShutdownFunction(() => { 3020 PlacesUtils.observers.removeListener( 3021 ["bookmark-added", "bookmark-removed"], 3022 listener 3023 ); 3024 }); 3025 }, 3026}; 3027