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