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