diff options
Diffstat (limited to 'toolkit/components/places/PlacesUtils.sys.mjs')
-rw-r--r-- | toolkit/components/places/PlacesUtils.sys.mjs | 2923 |
1 files changed, 2923 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesUtils.sys.mjs b/toolkit/components/places/PlacesUtils.sys.mjs new file mode 100644 index 0000000000..aeebedd31a --- /dev/null +++ b/toolkit/components/places/PlacesUtils.sys.mjs @@ -0,0 +1,2923 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Bookmarks: "resource://gre/modules/Bookmarks.sys.mjs", + History: "resource://gre/modules/History.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "MOZ_ACTION_REGEX", () => { + return /^moz-action:([^,]+),(.*)$/; +}); + +ChromeUtils.defineLazyGetter(lazy, "CryptoHash", () => { + return Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" + ); +}); + +// On Mac OSX, the transferable system converts "\r\n" to "\n\n", where +// we really just want "\n". On other platforms, the transferable system +// converts "\r\n" to "\n". +const NEWLINE = AppConstants.platform == "macosx" ? "\n" : "\r\n"; + +// Timers resolution is not always good, it can have a 16ms precision on Win. +const TIMERS_RESOLUTION_SKEW_MS = 16; + +function QI_node(aNode, aIID) { + try { + return aNode.QueryInterface(aIID); + } catch (ex) {} + return null; +} +function asContainer(aNode) { + return QI_node(aNode, Ci.nsINavHistoryContainerResultNode); +} +function asQuery(aNode) { + return QI_node(aNode, Ci.nsINavHistoryQueryResultNode); +} + +/** + * Sends a keyword change notification. + * + * @param url + * the url to notify about. + * @param keyword + * The keyword to notify, or empty string if a keyword was removed. + */ +async function notifyKeywordChange(url, keyword, source) { + // Notify bookmarks about the removal. + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url }, b => bookmarks.push(b), { + includeItemIds: true, + }); + + const notifications = bookmarks.map( + bookmark => + new PlacesBookmarkKeyword({ + id: bookmark.itemId, + itemType: bookmark.type, + url, + guid: bookmark.guid, + parentGuid: bookmark.parentGuid, + keyword, + lastModified: bookmark.lastModified, + source, + isTagging: false, + }) + ); + if (notifications.length) { + PlacesObservers.notifyListeners(notifications); + } +} + +/** + * Serializes the given node in JSON format. + * + * @param aNode + * An nsINavHistoryResultNode + */ +function serializeNode(aNode) { + let data = {}; + + data.title = aNode.title; + // The id is no longer used for copying within the same instance/session of + // Firefox as of at least 61. However, we keep the id for now to maintain + // backwards compat of drag and drop with older Firefox versions. + data.id = aNode.itemId; + data.itemGuid = aNode.bookmarkGuid; + // Add an instanceId so we can tell which instance of an FF session the data + // is coming from. + data.instanceId = PlacesUtils.instanceId; + + let guid = aNode.bookmarkGuid; + + // Some nodes, e.g. the unfiled/menu/toolbar ones can have a virtual guid, so + // we ignore any that are a folder shortcut. These will be handled below. + if ( + guid && + !PlacesUtils.bookmarks.isVirtualRootItem(guid) && + !PlacesUtils.isVirtualLeftPaneItem(guid) + ) { + if (aNode.parent) { + data.parent = aNode.parent.itemId; + data.parentGuid = aNode.parent.bookmarkGuid; + } + + data.dateAdded = aNode.dateAdded; + data.lastModified = aNode.lastModified; + } + + if (PlacesUtils.nodeIsURI(aNode)) { + // Check for url validity. + new URL(aNode.uri); + data.type = PlacesUtils.TYPE_X_MOZ_PLACE; + data.uri = aNode.uri; + if (aNode.tags) { + data.tags = aNode.tags; + } + } else if (PlacesUtils.nodeIsFolder(aNode)) { + if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + data.type = PlacesUtils.TYPE_X_MOZ_PLACE; + data.uri = aNode.uri; + data.concreteGuid = PlacesUtils.getConcreteItemGuid(aNode); + } else { + data.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER; + } + } else if (PlacesUtils.nodeIsQuery(aNode)) { + data.type = PlacesUtils.TYPE_X_MOZ_PLACE; + data.uri = aNode.uri; + } else if (PlacesUtils.nodeIsSeparator(aNode)) { + data.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR; + } + + return JSON.stringify(data); +} + +// Imposed to limit database size. +const DB_URL_LENGTH_MAX = 65536; +const DB_TITLE_LENGTH_MAX = 4096; +const DB_DESCRIPTION_LENGTH_MAX = 256; +const DB_SITENAME_LENGTH_MAX = 50; + +/** + * Executes a boolean validate function, throwing if it returns false. + * + * @param boolValidateFn + * A boolean validate function. + * @return the input value. + * @throws if input doesn't pass the validate function. + */ +function simpleValidateFunc(boolValidateFn) { + return (v, input) => { + if (!boolValidateFn(v, input)) { + throw new Error("Invalid value"); + } + return v; + }; +} + +/** + * List of bookmark object validators, one per each known property. + * Validators must throw if the property value is invalid and return a fixed up + * version of the value, if needed. + */ +const BOOKMARK_VALIDATORS = Object.freeze({ + guid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)), + parentGuid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)), + guidPrefix: simpleValidateFunc(v => PlacesUtils.isValidGuidPrefix(v)), + index: simpleValidateFunc( + v => Number.isInteger(v) && v >= PlacesUtils.bookmarks.DEFAULT_INDEX + ), + dateAdded: simpleValidateFunc(v => v.constructor.name == "Date" && !isNaN(v)), + lastModified: simpleValidateFunc( + v => v.constructor.name == "Date" && !isNaN(v) + ), + type: simpleValidateFunc( + v => + Number.isInteger(v) && + [ + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + ].includes(v) + ), + title: v => { + if (v === null) { + return ""; + } + if (typeof v == "string") { + return v.slice(0, DB_TITLE_LENGTH_MAX); + } + throw new Error("Invalid title"); + }, + url: v => { + simpleValidateFunc( + val => + (typeof val == "string" && val.length <= DB_URL_LENGTH_MAX) || + (val instanceof Ci.nsIURI && val.spec.length <= DB_URL_LENGTH_MAX) || + (URL.isInstance(val) && val.href.length <= DB_URL_LENGTH_MAX) + )(v); + if (typeof v === "string") { + return new URL(v); + } + if (v instanceof Ci.nsIURI) { + return URL.fromURI(v); + } + return v; + }, + source: simpleValidateFunc( + v => + Number.isInteger(v) && + Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) + ), + keyword: simpleValidateFunc(v => typeof v == "string" && v.length), + charset: simpleValidateFunc(v => typeof v == "string" && v.length), + postData: simpleValidateFunc(v => typeof v == "string" && v.length), + tags: simpleValidateFunc( + v => + Array.isArray(v) && + v.length && + v.every(item => item && typeof item == "string") + ), +}); + +// Sync bookmark records can contain additional properties. +const SYNC_BOOKMARK_VALIDATORS = Object.freeze({ + // Sync uses Places GUIDs for all records except roots. + recordId: simpleValidateFunc( + v => + typeof v == "string" && + (lazy.PlacesSyncUtils.bookmarks.ROOTS.includes(v) || + PlacesUtils.isValidGuid(v)) + ), + parentRecordId: v => SYNC_BOOKMARK_VALIDATORS.recordId(v), + // Sync uses kinds instead of types. + kind: simpleValidateFunc( + v => + typeof v == "string" && + Object.values(lazy.PlacesSyncUtils.bookmarks.KINDS).includes(v) + ), + query: simpleValidateFunc(v => v === null || (typeof v == "string" && v)), + folder: simpleValidateFunc( + v => + typeof v == "string" && + v && + v.length <= PlacesUtils.bookmarks.MAX_TAG_LENGTH + ), + tags: v => { + if (v === null) { + return []; + } + if (!Array.isArray(v)) { + throw new Error("Invalid tag array"); + } + for (let tag of v) { + if ( + typeof tag != "string" || + !tag || + tag.length > PlacesUtils.bookmarks.MAX_TAG_LENGTH + ) { + throw new Error(`Invalid tag: ${tag}`); + } + } + return v; + }, + keyword: simpleValidateFunc(v => v === null || typeof v == "string"), + dateAdded: simpleValidateFunc( + v => + typeof v === "number" && + v > lazy.PlacesSyncUtils.bookmarks.EARLIEST_BOOKMARK_TIMESTAMP + ), + feed: v => (v === null ? v : BOOKMARK_VALIDATORS.url(v)), + site: v => (v === null ? v : BOOKMARK_VALIDATORS.url(v)), + title: BOOKMARK_VALIDATORS.title, + url: BOOKMARK_VALIDATORS.url, +}); + +// Sync change records are passed between `PlacesSyncUtils` and the Sync +// bookmarks engine, and are used to update an item's sync status and change +// counter at the end of a sync. +const SYNC_CHANGE_RECORD_VALIDATORS = Object.freeze({ + modified: simpleValidateFunc(v => typeof v == "number" && v >= 0), + counter: simpleValidateFunc(v => typeof v == "number" && v >= 0), + status: simpleValidateFunc( + v => + typeof v == "number" && + Object.values(PlacesUtils.bookmarks.SYNC_STATUS).includes(v) + ), + tombstone: simpleValidateFunc(v => v === true || v === false), + synced: simpleValidateFunc(v => v === true || v === false), +}); +/** + * List PageInfo bookmark object validators. + */ +const PAGEINFO_VALIDATORS = Object.freeze({ + guid: BOOKMARK_VALIDATORS.guid, + url: BOOKMARK_VALIDATORS.url, + title: v => { + if (v == null || v == undefined) { + return undefined; + } else if (typeof v === "string") { + return v; + } + throw new TypeError( + `title property of PageInfo object: ${v} must be a string if provided` + ); + }, + previewImageURL: v => { + if (!v) { + return null; + } + return BOOKMARK_VALIDATORS.url(v); + }, + description: v => { + if (typeof v === "string" || v === null) { + return v ? v.slice(0, DB_DESCRIPTION_LENGTH_MAX) : null; + } + throw new TypeError( + `description property of pageInfo object: ${v} must be either a string or null if provided` + ); + }, + siteName: v => { + if (typeof v === "string" || v === null) { + return v ? v.slice(0, DB_SITENAME_LENGTH_MAX) : null; + } + throw new TypeError( + `siteName property of pageInfo object: ${v} must be either a string or null if provided` + ); + }, + annotations: v => { + if (typeof v != "object" || v.constructor.name != "Map") { + throw new TypeError("annotations must be a Map"); + } + + if (v.size == 0) { + throw new TypeError("there must be at least one annotation"); + } + + for (let [key, value] of v.entries()) { + if (typeof key != "string") { + throw new TypeError("all annotation keys must be strings"); + } + if ( + typeof value != "string" && + typeof value != "number" && + typeof value != "boolean" && + value !== null && + value !== undefined + ) { + throw new TypeError( + "all annotation values must be Boolean, Numbers or Strings" + ); + } + } + return v; + }, + visits: v => { + if (!Array.isArray(v) || !v.length) { + throw new TypeError("PageInfo object must have an array of visits"); + } + let visits = []; + for (let inVisit of v) { + let visit = { + date: new Date(), + transition: inVisit.transition || lazy.History.TRANSITIONS.LINK, + }; + + if (!PlacesUtils.history.isValidTransition(visit.transition)) { + throw new TypeError( + `transition: ${visit.transition} is not a valid transition type` + ); + } + + if (inVisit.date) { + PlacesUtils.history.ensureDate(inVisit.date); + if (inVisit.date > Date.now() + TIMERS_RESOLUTION_SKEW_MS) { + throw new TypeError(`date: ${inVisit.date} cannot be a future date`); + } + visit.date = inVisit.date; + } + + if (inVisit.referrer) { + visit.referrer = PlacesUtils.normalizeToURLOrGUID(inVisit.referrer); + } + visits.push(visit); + } + return visits; + }, +}); + +export var PlacesUtils = { + // Place entries that are containers, e.g. bookmark folders or queries. + TYPE_X_MOZ_PLACE_CONTAINER: "text/x-moz-place-container", + // Place entries that are bookmark separators. + TYPE_X_MOZ_PLACE_SEPARATOR: "text/x-moz-place-separator", + // Place entries that are not containers or separators + TYPE_X_MOZ_PLACE: "text/x-moz-place", + // Place entries in shortcut url format (url\ntitle) + TYPE_X_MOZ_URL: "text/x-moz-url", + // Place entries formatted as HTML anchors + TYPE_HTML: "text/html", + // Place entries as raw URL text + TYPE_PLAINTEXT: "text/plain", + // Used to track the action that populated the clipboard. + TYPE_X_MOZ_PLACE_ACTION: "text/x-moz-place-action", + + // Deprecated: Remaining only for supporting migration of old livemarks. + LMANNO_FEEDURI: "livemark/feedURI", + LMANNO_SITEURI: "livemark/siteURI", + CHARSET_ANNO: "URIProperties/characterSet", + // Deprecated: This is only used for supporting import from older datasets. + MOBILE_ROOT_ANNO: "mobile/bookmarksRoot", + + TOPIC_SHUTDOWN: "places-shutdown", + TOPIC_INIT_COMPLETE: "places-init-complete", + TOPIC_DATABASE_LOCKED: "places-database-locked", + TOPIC_EXPIRATION_FINISHED: "places-expiration-finished", + TOPIC_FAVICONS_EXPIRED: "places-favicons-expired", + TOPIC_VACUUM_STARTING: "places-vacuum-starting", + TOPIC_BOOKMARKS_RESTORE_BEGIN: "bookmarks-restore-begin", + TOPIC_BOOKMARKS_RESTORE_SUCCESS: "bookmarks-restore-success", + TOPIC_BOOKMARKS_RESTORE_FAILED: "bookmarks-restore-failed", + + observers: PlacesObservers, + + /** + * GUIDs associated with virtual queries that are used for displaying the + * top-level folders in the left pane. + */ + virtualAllBookmarksGuid: "allbms_____v", + virtualHistoryGuid: "history____v", + virtualDownloadsGuid: "downloads__v", + virtualTagsGuid: "tags_______v", + + /** + * Checks if a guid is a virtual left-pane root. + * + * @param {String} guid The guid of the item to look for. + * @returns {Boolean} true if guid is a virtual root, false otherwise. + */ + isVirtualLeftPaneItem(guid) { + return ( + guid == PlacesUtils.virtualAllBookmarksGuid || + guid == PlacesUtils.virtualHistoryGuid || + guid == PlacesUtils.virtualDownloadsGuid || + guid == PlacesUtils.virtualTagsGuid + ); + }, + + asContainer: aNode => asContainer(aNode), + asQuery: aNode => asQuery(aNode), + + endl: NEWLINE, + + /** + * Is a string a valid GUID? + * + * @param guid: (String) + * @return (Boolean) + */ + isValidGuid(guid) { + return typeof guid == "string" && guid && /^[a-zA-Z0-9\-_]{12}$/.test(guid); + }, + + /** + * Is a string a valid GUID prefix? + * + * @param guidPrefix: (String) + * @return (Boolean) + */ + isValidGuidPrefix(guidPrefix) { + return ( + typeof guidPrefix == "string" && + guidPrefix && + /^[a-zA-Z0-9\-_]{1,11}$/.test(guidPrefix) + ); + }, + + /** + * Generates a random GUID and replace its beginning with the given + * prefix. We do this instead of just prepending the prefix to keep + * the correct character length. + * + * @param prefix: (String) + * @return (String) + */ + generateGuidWithPrefix(prefix) { + return prefix + this.history.makeGuid().substring(prefix.length); + }, + + /** + * Converts a string or n URL object to an nsIURI. + * + * @param url (URL) or (String) + * the URL to convert. + * @return nsIURI for the given URL. + */ + toURI(url) { + if (url instanceof Ci.nsIURI) { + return url; + } + if (URL.isInstance(url)) { + return url.URI; + } + return Services.io.newURI(url); + }, + + /** + * Convert a Date object to a PRTime (microseconds). + * + * @param date + * the Date object to convert. + * @return microseconds from the epoch. + */ + toPRTime(date) { + if ( + (typeof date != "number" && date.constructor.name != "Date") || + isNaN(date) + ) { + throw new Error("Invalid value passed to toPRTime"); + } + return date * 1000; + }, + + /** + * Convert a PRTime to a Date object. + * + * @param time + * microseconds from the epoch. + * @return a Date object. + */ + toDate(time) { + if (typeof time != "number" || isNaN(time)) { + throw new Error("Invalid value passed to toDate"); + } + return new Date(parseInt(time / 1000)); + }, + + /** + * Wraps a string in a nsISupportsString wrapper. + * @param aString + * The string to wrap. + * @returns A nsISupportsString object containing a string. + */ + toISupportsString: function PU_toISupportsString(aString) { + let s = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + s.data = aString; + return s; + }, + + getFormattedString: function PU_getFormattedString(key, params) { + return lazy.bundle.formatStringFromName(key, params); + }, + + getString: function PU_getString(key) { + return lazy.bundle.GetStringFromName(key); + }, + + /** + * Parses a moz-action URL and returns its parts. + * + * @param url A moz-action URI. + * @note URL is in the format moz-action:ACTION,JSON_ENCODED_PARAMS + */ + parseActionUrl(url) { + if (url instanceof Ci.nsIURI) { + url = url.spec; + } else if (URL.isInstance(url)) { + url = url.href; + } + // Faster bailout. + if (!url.startsWith("moz-action:")) { + return null; + } + + try { + let [, type, params] = url.match(lazy.MOZ_ACTION_REGEX); + let action = { + type, + params: JSON.parse(params), + }; + for (let key in action.params) { + action.params[key] = decodeURIComponent(action.params[key]); + } + return action; + } catch (ex) { + console.error(`Invalid action url "${url}"`); + return null; + } + }, + + /** + * Determines if a folder is generated from a query. + * @param aNode a result true. + * @returns true if the node is a folder generated from a query. + */ + isQueryGeneratedFolder(node) { + if (!node.parent) { + return false; + } + return this.nodeIsFolder(node) && this.nodeIsQuery(node.parent); + }, + + /** + * Determines whether or not a ResultNode is a Bookmark folder. + * @param aNode + * A result node + * @returns true if the node is a Bookmark folder, false otherwise + */ + nodeIsFolder: function PU_nodeIsFolder(aNode) { + return ( + aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER || + aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT + ); + }, + + /** + * Determines whether or not a ResultNode represents a bookmarked URI. + * @param aNode + * A result node + * @returns true if the node represents a bookmarked URI, false otherwise + */ + nodeIsBookmark: function PU_nodeIsBookmark(aNode) { + return ( + aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI && + (aNode.itemId != -1 || aNode.bookmarkGuid) + ); + }, + + /** + * Determines whether or not a ResultNode is a Bookmark separator. + * @param aNode + * A result node + * @returns true if the node is a Bookmark separator, false otherwise + */ + nodeIsSeparator: function PU_nodeIsSeparator(aNode) { + return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR; + }, + + /** + * Determines whether or not a ResultNode is a URL item. + * @param aNode + * A result node + * @returns true if the node is a URL item, false otherwise + */ + nodeIsURI: function PU_nodeIsURI(aNode) { + return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; + }, + + /** + * Determines whether or not a ResultNode is a Query item. + * @param aNode + * A result node + * @returns true if the node is a Query item, false otherwise + */ + nodeIsQuery: function PU_nodeIsQuery(aNode) { + return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY; + }, + + /** + * Generator for a node's ancestors. + * @param aNode + * A result node + */ + nodeAncestors: function* PU_nodeAncestors(aNode) { + let node = aNode.parent; + while (node) { + yield node; + node = node.parent; + } + }, + + /** + * Checks validity of an object, filling up default values for optional + * properties. + * + * @param {string} name + * The operation name. This is included in the error message if + * validation fails. + * @param validators (object) + * An object containing input validators. Keys should be field names; + * values should be validation functions. + * @param props (object) + * The object to validate. + * @param behavior (object) [optional] + * Object defining special behavior for some of the properties. + * The following behaviors may be optionally set: + * - required: this property is required. + * - replaceWith: this property will be overwritten with the value + * provided + * - requiredIf: if the provided condition is satisfied, then this + * property is required. + * - validIf: if the provided condition is not satisfied, then this + * property is invalid. + * - defaultValue: an undefined property should default to this value. + * - fixup: a function invoked when validation fails, takes the input + * object as argument and must fix the property. + * + * @return a validated and normalized item. + * @throws if the object contains invalid data. + * @note any unknown properties are pass-through. + */ + validateItemProperties(name, validators, props, behavior = {}) { + if (typeof props != "object" || !props) { + throw new Error(`${name}: Input should be a valid object`); + } + // Make a shallow copy of `props` to avoid mutating the original object + // when filling in defaults. + let input = Object.assign({}, props); + let normalizedInput = {}; + let required = new Set(); + for (let prop in behavior) { + if ( + behavior[prop].hasOwnProperty("required") && + behavior[prop].required + ) { + required.add(prop); + } + if ( + behavior[prop].hasOwnProperty("requiredIf") && + behavior[prop].requiredIf(input) + ) { + required.add(prop); + } + if ( + behavior[prop].hasOwnProperty("validIf") && + input[prop] !== undefined && + !behavior[prop].validIf(input) + ) { + if (behavior[prop].hasOwnProperty("fixup")) { + behavior[prop].fixup(input); + } else { + throw new Error( + `${name}: Invalid value for property '${prop}': ${JSON.stringify( + input[prop] + )}` + ); + } + } + if ( + behavior[prop].hasOwnProperty("defaultValue") && + input[prop] === undefined + ) { + input[prop] = behavior[prop].defaultValue; + } + if (behavior[prop].hasOwnProperty("replaceWith")) { + input[prop] = behavior[prop].replaceWith; + } + } + + for (let prop in input) { + if (required.has(prop)) { + required.delete(prop); + } else if (input[prop] === undefined) { + // Skip undefined properties that are not required. + continue; + } + if (validators.hasOwnProperty(prop)) { + try { + normalizedInput[prop] = validators[prop](input[prop], input); + } catch (ex) { + if ( + behavior.hasOwnProperty(prop) && + behavior[prop].hasOwnProperty("fixup") + ) { + behavior[prop].fixup(input); + normalizedInput[prop] = input[prop]; + } else { + throw new Error( + `${name}: Invalid value for property '${prop}': ${JSON.stringify( + input[prop] + )}` + ); + } + } + } + } + if (required.size > 0) { + throw new Error( + `${name}: The following properties were expected: ${[...required].join( + ", " + )}` + ); + } + return normalizedInput; + }, + + BOOKMARK_VALIDATORS, + PAGEINFO_VALIDATORS, + SYNC_BOOKMARK_VALIDATORS, + SYNC_CHANGE_RECORD_VALIDATORS, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + _shutdownFunctions: [], + registerShutdownFunction: function PU_registerShutdownFunction(aFunc) { + // If this is the first registered function, add the shutdown observer. + if (!this._shutdownFunctions.length) { + Services.obs.addObserver(this, this.TOPIC_SHUTDOWN); + } + this._shutdownFunctions.push(aFunc); + }, + + // nsIObserver + observe: function PU_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case this.TOPIC_SHUTDOWN: + Services.obs.removeObserver(this, this.TOPIC_SHUTDOWN); + while (this._shutdownFunctions.length) { + this._shutdownFunctions.shift().apply(this); + } + break; + } + }, + + /** + * Determines whether or not a ResultNode is a host container. + * @param aNode + * A result node + * @returns true if the node is a host container, false otherwise + */ + nodeIsHost: function PU_nodeIsHost(aNode) { + return ( + aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY && + aNode.parent && + asQuery(aNode.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY + ); + }, + + /** + * Determines whether or not a ResultNode is a day container. + * @param node + * A NavHistoryResultNode + * @returns true if the node is a day container, false otherwise + */ + nodeIsDay: function PU_nodeIsDay(aNode) { + var resultType; + return ( + aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY && + aNode.parent && + ((resultType = asQuery(aNode.parent).queryOptions.resultType) == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || + resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) + ); + }, + + /** + * Determines whether or not a result-node is a tag container. + * @param aNode + * A result-node + * @returns true if the node is a tag container, false otherwise + */ + nodeIsTagQuery: function PU_nodeIsTagQuery(aNode) { + if (aNode.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + return false; + } + // Direct child of RESULTS_AS_TAGS_ROOT. + let parent = aNode.parent; + if ( + parent && + PlacesUtils.asQuery(parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT + ) { + return true; + } + // We must also support the right pane of the Library, when the tag query + // is the root node. Unfortunately this is also valid for any tag query + // selected in the left pane that is not a direct child of RESULTS_AS_TAGS_ROOT. + if ( + !parent && + aNode == aNode.parentResult.root && + PlacesUtils.asQuery(aNode).query.tags.length == 1 + ) { + return true; + } + return false; + }, + + /** + * Determines whether or not a ResultNode is a container. + * @param aNode + * A result node + * @returns true if the node is a container item, false otherwise + */ + containerTypes: [ + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY, + ], + nodeIsContainer: function PU_nodeIsContainer(aNode) { + return this.containerTypes.includes(aNode.type); + }, + + /** + * Determines whether or not a ResultNode is an history related container. + * @param node + * A result node + * @returns true if the node is an history related container, false otherwise + */ + nodeIsHistoryContainer: function PU_nodeIsHistoryContainer(aNode) { + var resultType; + return ( + this.nodeIsQuery(aNode) && + ((resultType = asQuery(aNode).queryOptions.resultType) == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY || + resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY || + resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY || + this.nodeIsDay(aNode) || + this.nodeIsHost(aNode)) + ); + }, + + /** + * Gets the concrete item-guid for the given node. For everything but folder + * shortcuts, this is just node.bookmarkGuid. For folder shortcuts, this is + * node.targetFolderGuid (see nsINavHistoryService.idl for the semantics). + * + * @param aNode + * a result node. + * @return the concrete item-guid for aNode. + */ + getConcreteItemGuid(aNode) { + if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + return asQuery(aNode).targetFolderGuid; + } + return aNode.bookmarkGuid; + }, + + /** + * Reverse a host based on the moz_places algorithm, that is reverse the host + * string and add a trailing period. For example "google.com" becomes + * "moc.elgoog.". + * + * @param url + * the URL to generate a rev host for. + * @return the reversed host string. + */ + getReversedHost(url) { + return url.host.split("").reverse().join("") + "."; + }, + + /** + * String-wraps a result node according to the rules of the specified + * content type for copy or move operations. + * + * @param aNode + * The Result node to wrap (serialize) + * @param aType + * The content type to serialize as + * @return A string serialization of the node + */ + wrapNode(aNode, aType) { + // when wrapping a node, we want all the items, even if the original + // query options are excluding them. + // This can happen when copying from the left hand pane of the bookmarks + // organizer. + // @return [node, shouldClose] + function gatherDataFromNode(node, gatherDataFunc) { + if ( + PlacesUtils.nodeIsFolder(node) && + node.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT && + asQuery(node).queryOptions.excludeItems + ) { + let folderRoot = PlacesUtils.getFolderContents( + node.bookmarkGuid, + false, + true + ).root; + try { + return gatherDataFunc(folderRoot); + } finally { + folderRoot.containerOpen = false; + } + } + // If we didn't create our own query, do not alter the node's state. + return gatherDataFunc(node); + } + + function gatherDataHtml(node) { + let htmlEscape = s => + s + .replace(/&/g, "&") + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/"/g, """) + .replace(/'/g, "'"); + + // escape out potential HTML in the title + let escapedTitle = node.title ? htmlEscape(node.title) : ""; + + if (PlacesUtils.nodeIsContainer(node)) { + asContainer(node); + let wasOpen = node.containerOpen; + if (!wasOpen) { + node.containerOpen = true; + } + + let childString = "<DL><DT>" + escapedTitle + "</DT>" + NEWLINE; + let cc = node.childCount; + for (let i = 0; i < cc; ++i) { + childString += + "<DD>" + + NEWLINE + + gatherDataHtml(node.getChild(i)) + + "</DD>" + + NEWLINE; + } + node.containerOpen = wasOpen; + return childString + "</DL>" + NEWLINE; + } + if (PlacesUtils.nodeIsURI(node)) { + return `<A HREF="${node.uri}">${escapedTitle}</A>${NEWLINE}`; + } + if (PlacesUtils.nodeIsSeparator(node)) { + return "<HR>" + NEWLINE; + } + return ""; + } + + function gatherDataText(node) { + if (PlacesUtils.nodeIsContainer(node)) { + asContainer(node); + let wasOpen = node.containerOpen; + if (!wasOpen) { + node.containerOpen = true; + } + + let childString = node.title + NEWLINE; + let cc = node.childCount; + for (let i = 0; i < cc; ++i) { + let child = node.getChild(i); + let suffix = i < cc - 1 ? NEWLINE : ""; + childString += gatherDataText(child) + suffix; + } + node.containerOpen = wasOpen; + return childString; + } + if (PlacesUtils.nodeIsURI(node)) { + return node.uri; + } + if (PlacesUtils.nodeIsSeparator(node)) { + return "--------------------"; + } + return ""; + } + + switch (aType) { + case this.TYPE_X_MOZ_PLACE: + case this.TYPE_X_MOZ_PLACE_SEPARATOR: + case this.TYPE_X_MOZ_PLACE_CONTAINER: { + // Serialize the node to JSON. + return serializeNode(aNode); + } + case this.TYPE_X_MOZ_URL: { + if (PlacesUtils.nodeIsURI(aNode)) { + return aNode.uri + NEWLINE + aNode.title; + } + if (PlacesUtils.nodeIsContainer(aNode)) { + return PlacesUtils.getURLsForContainerNode(aNode) + .map(item => item.uri + "\n" + item.title) + .join("\n"); + } + return ""; + } + case this.TYPE_HTML: { + return gatherDataFromNode(aNode, gatherDataHtml); + } + } + + // Otherwise, we wrap as TYPE_PLAINTEXT. + return gatherDataFromNode(aNode, gatherDataText); + }, + + /** + * Unwraps data from the Clipboard or the current Drag Session. + * @param blob + * A blob (string) of data, in some format we potentially know how + * to parse. + * @param type + * The content type of the blob. + * @returns An array of objects representing each item contained by the source. + * @throws if the blob contains invalid data. + */ + unwrapNodes: function PU_unwrapNodes(blob, type) { + // We split on "\n" because the transferable system converts "\r\n" to "\n" + var nodes = []; + switch (type) { + case this.TYPE_X_MOZ_PLACE: + case this.TYPE_X_MOZ_PLACE_SEPARATOR: + case this.TYPE_X_MOZ_PLACE_CONTAINER: + nodes = JSON.parse("[" + blob + "]"); + break; + case this.TYPE_X_MOZ_URL: { + let parts = blob.split("\n"); + // data in this type has 2 parts per entry, so if there are fewer + // than 2 parts left, the blob is malformed and we should stop + // but drag and drop of files from the shell has parts.length = 1 + if (parts.length != 1 && parts.length % 2) { + break; + } + for (let i = 0; i < parts.length; i = i + 2) { + let uriString = parts[i]; + let titleString = ""; + if (parts.length > i + 1) { + titleString = parts[i + 1]; + } else { + // for drag and drop of files, try to use the leafName as title + try { + titleString = Services.io + .newURI(uriString) + .QueryInterface(Ci.nsIURL).fileName; + } catch (ex) {} + } + // note: Services.io.newURI() will throw if uriString is not a valid URI + let uri = Services.io.newURI(uriString); + if (Services.io.newURI(uriString) && uri.scheme != "place") { + nodes.push({ + uri: uriString, + title: titleString ? titleString : uriString, + type: this.TYPE_X_MOZ_URL, + }); + } + } + break; + } + case this.TYPE_PLAINTEXT: { + let parts = blob.split("\n"); + for (let i = 0; i < parts.length; i++) { + let uriString = parts[i]; + // text/uri-list is converted to TYPE_PLAINTEXT but it could contain + // comments line prepended by #, we should skip them, as well as + // empty uris. + if (uriString.substr(0, 1) == "\x23" || uriString == "") { + continue; + } + // note: Services.io.newURI) will throw if uriString is not a valid URI + let uri = Services.io.newURI(uriString); + if (uri.scheme != "place") { + nodes.push({ + uri: uriString, + title: uriString, + type: this.TYPE_X_MOZ_URL, + }); + } + } + break; + } + default: + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + return nodes; + }, + + /** + * Validate an input PageInfo object, returning a valid PageInfo object. + * + * @param pageInfo: (PageInfo) + * @return (PageInfo) + */ + validatePageInfo(pageInfo, validateVisits = true) { + return this.validateItemProperties( + "PageInfo", + PAGEINFO_VALIDATORS, + pageInfo, + { + url: { requiredIf: b => !b.guid }, + guid: { requiredIf: b => !b.url }, + visits: { requiredIf: b => validateVisits }, + } + ); + }, + /** + * Normalize a key to either a string (if it is a valid GUID) or an + * instance of `URL` (if it is a `URL`, `nsIURI`, or a string + * representing a valid url). + * + * @throws (TypeError) + * If the key is neither a valid guid nor a valid url. + */ + normalizeToURLOrGUID(key) { + if (typeof key === "string") { + // A string may be a URL or a guid + if (this.isValidGuid(key)) { + return key; + } + return new URL(key); + } + if (URL.isInstance(key)) { + return key; + } + if (key instanceof Ci.nsIURI) { + return URL.fromURI(key); + } + throw new TypeError("Invalid url or guid: " + key); + }, + + /** + * Generates a nsINavHistoryResult for the contents of a folder. + * @param aFolderGuid + * The folder to open + * @param [optional] excludeItems + * True to hide all items (individual bookmarks). This is used on + * the left places pane so you just get a folder hierarchy. + * @param [optional] expandQueries + * True to make query items expand as new containers. For managing, + * you want this to be false, for menus and such, you want this to + * be true. + * @returns A nsINavHistoryResult containing the contents of the + * folder. The result.root is guaranteed to be open. + */ + getFolderContents(aFolderGuid, aExcludeItems, aExpandQueries) { + if (!this.isValidGuid(aFolderGuid)) { + throw new Error("aFolderGuid should be a valid GUID."); + } + var query = this.history.getNewQuery(); + query.setParents([aFolderGuid]); + var options = this.history.getNewQueryOptions(); + options.excludeItems = aExcludeItems; + options.expandQueries = aExpandQueries; + + var result = this.history.executeQuery(query, options); + result.root.containerOpen = true; + return result; + }, + + // Identifier getters for special folders. + // You should use these everywhere PlacesUtils is available to avoid XPCOM + // traversal just to get roots' ids. + get tagsFolderId() { + delete this.tagsFolderId; + return (this.tagsFolderId = this.bookmarks.tagsFolder); + }, + + /** + * Checks if item is a root. + * + * @param {String} guid The guid of the item to look for. + * @returns {Boolean} true if guid is a root, false otherwise. + */ + isRootItem(guid) { + return ( + guid == PlacesUtils.bookmarks.menuGuid || + guid == PlacesUtils.bookmarks.toolbarGuid || + guid == PlacesUtils.bookmarks.unfiledGuid || + guid == PlacesUtils.bookmarks.tagsGuid || + guid == PlacesUtils.bookmarks.rootGuid || + guid == PlacesUtils.bookmarks.mobileGuid + ); + }, + + /** + * Returns a nsNavHistoryContainerResultNode with forced excludeItems and + * expandQueries. + * @param aNode + * The node to convert + * @param [optional] excludeItems + * True to hide all items (individual bookmarks). This is used on + * the left places pane so you just get a folder hierarchy. + * @param [optional] expandQueries + * True to make query items expand as new containers. For managing, + * you want this to be false, for menus and such, you want this to + * be true. + * @returns A nsINavHistoryContainerResultNode containing the unfiltered + * contents of the container. + * @note The returned container node could be open or closed, we don't + * guarantee its status. + */ + getContainerNodeWithOptions: function PU_getContainerNodeWithOptions( + aNode, + aExcludeItems, + aExpandQueries + ) { + if (!this.nodeIsContainer(aNode)) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + // excludeItems is inherited by child containers in an excludeItems view. + var excludeItems = + asQuery(aNode).queryOptions.excludeItems || + asQuery(aNode.parentResult.root).queryOptions.excludeItems; + // expandQueries is inherited by child containers in an expandQueries view. + var expandQueries = + asQuery(aNode).queryOptions.expandQueries && + asQuery(aNode.parentResult.root).queryOptions.expandQueries; + + // If our options are exactly what we expect, directly return the node. + if (excludeItems == aExcludeItems && expandQueries == aExpandQueries) { + return aNode; + } + + // Otherwise, get contents manually. + var query = {}, + options = {}; + this.history.queryStringToQuery(aNode.uri, query, options); + options.value.excludeItems = aExcludeItems; + options.value.expandQueries = aExpandQueries; + return this.history.executeQuery(query.value, options.value).root; + }, + + /** + * Returns true if a container has uri nodes in its first level. + * Has better performance than (getURLsForContainerNode(node).length > 0). + * @param aNode + * The container node to search through. + * @returns true if the node contains uri nodes, false otherwise. + */ + hasChildURIs: function PU_hasChildURIs(aNode) { + if (!this.nodeIsContainer(aNode)) { + return false; + } + + let root = this.getContainerNodeWithOptions(aNode, false, true); + let result = root.parentResult; + let didSuppressNotifications = false; + let wasOpen = root.containerOpen; + if (!wasOpen) { + didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + + root.containerOpen = true; + } + + let found = false; + for (let i = 0; i < root.childCount && !found; i++) { + let child = root.getChild(i); + if (this.nodeIsURI(child)) { + found = true; + } + } + + if (!wasOpen) { + root.containerOpen = false; + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + return found; + }, + + /** + * Returns an array containing all the uris in the first level of the + * passed in container. + * If you only need to know if the node contains uris, use hasChildURIs. + * @param aNode + * The container node to search through + * @returns array of uris in the first level of the container. + */ + getURLsForContainerNode: function PU_getURLsForContainerNode(aNode) { + let urls = []; + if (!this.nodeIsContainer(aNode)) { + return urls; + } + + let root = this.getContainerNodeWithOptions(aNode, false, true); + let result = root.parentResult; + let wasOpen = root.containerOpen; + let didSuppressNotifications = false; + if (!wasOpen) { + didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + + root.containerOpen = true; + } + + for (let i = 0; i < root.childCount; ++i) { + let child = root.getChild(i); + if (this.nodeIsURI(child)) { + urls.push({ + uri: child.uri, + isBookmark: this.nodeIsBookmark(child), + title: child.title, + }); + } + } + + if (!wasOpen) { + root.containerOpen = false; + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + return urls; + }, + + /** + * Gets a shared Sqlite.sys.mjs readonly connection to the Places database, + * usable only for SELECT queries. + * + * This is intended to be used mostly internally, components outside of + * Places should, when possible, use API calls and file bugs to get proper + * APIs, where they are missing. + * Keep in mind the Places DB schema is by no means frozen or even stable. + * Your custom queries can - and will - break overtime. + * + * Example: + * let db = await PlacesUtils.promiseDBConnection(); + * let rows = await db.executeCached(sql, params); + */ + promiseDBConnection: () => lazy.gAsyncDBConnPromised, + + /** + * This is pretty much the same as promiseDBConnection, but with a larger + * page cache, useful for consumers doing large table scans, like the urlbar. + * @see promiseDBConnection + */ + promiseLargeCacheDBConnection: () => lazy.gAsyncDBLargeCacheConnPromised, + get largeCacheDBConnDeferred() { + return gAsyncDBLargeCacheConnDeferred; + }, + + /** + * Returns a Sqlite.sys.mjs wrapper for the main Places connection. Most callers + * should prefer `withConnectionWrapper`, which ensures that all database + * operations finish before the connection is closed. + */ + promiseUnsafeWritableDBConnection: () => lazy.gAsyncDBWrapperPromised, + + /** + * Performs a read/write operation on the Places database through a Sqlite.sys.mjs + * wrapped connection to the Places database. + * + * This is intended to be used only by Places itself, always use APIs if you + * need to modify the Places database. Use promiseDBConnection if you need to + * SELECT from the database and there's no covering API. + * Keep in mind the Places DB schema is by no means frozen or even stable. + * Your custom queries can - and will - break overtime. + * + * As all operations on the Places database are asynchronous, if shutdown + * is initiated while an operation is pending, this could cause dataloss. + * Using `withConnectionWrapper` ensures that shutdown waits until all + * operations are complete before proceeding. + * + * Example: + * await withConnectionWrapper("Bookmarks: Remove a bookmark", Task.async(function*(db) { + * // Proceed with the db, asynchronously. + * // Shutdown will not interrupt operations that take place here. + * })); + * + * @param {string} name The name of the operation. Used for debugging, logging + * and crash reporting. + * @param {function(db)} task A function that takes as argument a Sqlite.sys.mjs + * connection and returns a Promise. Shutdown is guaranteed to not interrupt + * execution of `task`. + */ + async withConnectionWrapper(name, task) { + if (!name) { + throw new TypeError("Expecting a user-readable name"); + } + let db = await lazy.gAsyncDBWrapperPromised; + return db.executeBeforeShutdown(name, task); + }, + + /** + * Gets favicon data for a given page url. + * + * @param {string | URL | nsIURI} aPageUrl + * url of the page to look favicon for. + * @param {number} preferredWidth + * The preferred width of the favicon in pixels. The default value of 0 + * returns the largest icon available. + * @resolves to an object representing a favicon entry, having the following + * properties: { uri, dataLen, data, mimeType } + * @rejects JavaScript exception if the given url has no associated favicon. + */ + promiseFaviconData(aPageUrl, preferredWidth = 0) { + return new Promise((resolve, reject) => { + if (!(aPageUrl instanceof Ci.nsIURI)) { + aPageUrl = PlacesUtils.toURI(aPageUrl); + } + PlacesUtils.favicons.getFaviconDataForPage( + aPageUrl, + function (uri, dataLen, data, mimeType, size) { + if (uri) { + resolve({ uri, dataLen, data, mimeType, size }); + } else { + reject(); + } + }, + preferredWidth + ); + }); + }, + + /** + * Returns the passed URL with a #size ref for the specified size and + * devicePixelRatio. + * + * @param window + * The window where the icon will appear. + * @param href + * The string href we should add the ref to. + * @param size + * The target image size + * @return The URL with the fragment at the end, in the same formar as input. + */ + urlWithSizeRef(window, href, size) { + return ( + href + + (href.includes("#") ? "&" : "#") + + "size=" + + Math.round(size) * window.devicePixelRatio + ); + }, + + /** + * Asynchronously retrieve a JS-object representation of a places bookmarks + * item (a bookmark, a folder, or a separator) along with all of its + * descendants. + * + * @param [optional] aItemGuid + * the (topmost) item to be queried. If it's not passed, the places + * root is queried: that is, you get a representation of the entire + * bookmarks hierarchy. + * @param [optional] aOptions + * Options for customizing the query behavior, in the form of a JS + * object with any of the following properties: + * - excludeItemsCallback: a function for excluding items, along with + * their descendants. Given an item object (that has everything set + * apart its potential children data), it should return true if the + * item should be excluded. Once an item is excluded, the function + * isn't called for any of its descendants. This isn't called for + * the root item. + * WARNING: since the function may be called for each item, using + * this option can slow down the process significantly if the + * callback does anything that's not relatively trivial. It is + * highly recommended to avoid any synchronous I/O or DB queries. + * - includeItemIds: opt-in to include the deprecated id property. + * Use it if you must. It'll be removed once the switch to GUIDs is + * complete. + * + * @return {Promise} + * @resolves to a JS object that represents either a single item or a + * bookmarks tree. Each node in the tree has the following properties set: + * - guid (string): the item's GUID (same as aItemGuid for the top item). + * - [deprecated] id (number): the item's id. This is only if + * aOptions.includeItemIds is set. + * - type (string): the item's type. @see PlacesUtils.TYPE_X_* + * - typeCode (number): the item's type in numeric format. + * @see PlacesUtils.bookmarks.TYPE_* + * - title (string): the item's title. If it has no title, this property + * isn't set. + * - dateAdded (number, microseconds from the epoch): the date-added value of + * the item. + * - lastModified (number, microseconds from the epoch): the last-modified + * value of the item. + * - index: the item's index under it's parent. + * + * The root object (i.e. the one for aItemGuid) also has the following + * properties set: + * - parentGuid (string): the GUID of the root's parent. This isn't set if + * the root item is the places root. + * - itemsCount (number, not enumerable): the number of items, including the + * root item itself, which are represented in the resolved object. + * + * Bookmark items also have the following properties: + * - uri (string): the item's url. + * - tags (string): csv string of the bookmark's tags. + * - charset (string): the last known charset of the bookmark. + * - keyword (string): the bookmark's keyword (unset if none). + * - postData (string): the bookmark's keyword postData (unset if none). + * - iconUri (string): the bookmark's favicon url. + * The last four properties are not set at all if they're irrelevant (e.g. + * |charset| is not set if no charset was previously set for the bookmark + * url). + * + * Folders may also have the following properties: + * - children (array): the folder's children information, each of them + * having the same set of properties as above. + * + * @rejects if the query failed for any reason. + * @note if aItemGuid points to a non-existent item, the returned promise is + * resolved to null. + */ + async promiseBookmarksTree(aItemGuid = "", aOptions = {}) { + let createItemInfoObject = async function (aRow, aIncludeParentGuid) { + let item = {}; + let copyProps = (...props) => { + for (let prop of props) { + let val = aRow.getResultByName(prop); + if (val !== null) { + item[prop] = val; + } + } + }; + copyProps("guid", "title", "index", "dateAdded", "lastModified"); + if (aIncludeParentGuid) { + copyProps("parentGuid"); + } + + let itemId = aRow.getResultByName("id"); + if (aOptions.includeItemIds) { + item.id = itemId; + } + + let type = aRow.getResultByName("type"); + item.typeCode = type; + if (type == Ci.nsINavBookmarksService.TYPE_BOOKMARK) { + copyProps("charset", "tags", "iconUri"); + } + + switch (type) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + item.type = PlacesUtils.TYPE_X_MOZ_PLACE; + // If this throws due to an invalid url, the item will be skipped. + try { + item.uri = new URL(aRow.getResultByName("url")).href; + } catch (ex) { + let error = new Error("Invalid bookmark URL"); + error.becauseInvalidURL = true; + throw error; + } + // Keywords are cached, so this should be decently fast. + let entry = await PlacesUtils.keywords.fetch({ url: item.uri }); + if (entry) { + item.keyword = entry.keyword; + item.postData = entry.postData; + } + break; + case PlacesUtils.bookmarks.TYPE_FOLDER: + item.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER; + // Mark root folders. + if (item.guid == PlacesUtils.bookmarks.rootGuid) { + item.root = "placesRoot"; + } else if (item.guid == PlacesUtils.bookmarks.menuGuid) { + item.root = "bookmarksMenuFolder"; + } else if (item.guid == PlacesUtils.bookmarks.unfiledGuid) { + item.root = "unfiledBookmarksFolder"; + } else if (item.guid == PlacesUtils.bookmarks.toolbarGuid) { + item.root = "toolbarFolder"; + } else if (item.guid == PlacesUtils.bookmarks.mobileGuid) { + item.root = "mobileFolder"; + } + break; + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + item.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR; + break; + default: + console.error(`Unexpected bookmark type ${type}`); + break; + } + return item; + }; + + const QUERY_STR = `/* do not warn (bug no): cannot use an index */ + WITH RECURSIVE + descendants(fk, level, type, id, guid, parent, parentGuid, position, + title, dateAdded, lastModified) AS ( + SELECT b1.fk, 0, b1.type, b1.id, b1.guid, b1.parent, + (SELECT guid FROM moz_bookmarks WHERE id = b1.parent), + b1.position, b1.title, b1.dateAdded, b1.lastModified + FROM moz_bookmarks b1 WHERE b1.guid=:item_guid + UNION ALL + SELECT b2.fk, level + 1, b2.type, b2.id, b2.guid, b2.parent, + descendants.guid, b2.position, b2.title, b2.dateAdded, + b2.lastModified + FROM moz_bookmarks b2 + JOIN descendants ON b2.parent = descendants.id AND b2.id <> :tags_folder), + tagged(place_id, tags) AS ( + SELECT b.fk, group_concat(p.title ORDER BY p.title) + FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + JOIN moz_bookmarks g ON g.id = p.parent + WHERE g.guid = '${PlacesUtils.bookmarks.tagsGuid}' + GROUP BY b.fk + ) + SELECT d.level, d.id, d.guid, d.parent, d.parentGuid, d.type, + d.position AS [index], IFNULL(d.title, '') AS title, d.dateAdded, + d.lastModified, h.url, (SELECT icon_url FROM moz_icons i + JOIN moz_icons_to_pages ON icon_id = i.id + JOIN moz_pages_w_icons pi ON page_id = pi.id + WHERE pi.page_url_hash = hash(h.url) AND pi.page_url = h.url + ORDER BY width DESC LIMIT 1) AS iconUri, + (SELECT tags FROM tagged WHERE place_id = h.id) AS tags, + (SELECT a.content FROM moz_annos a + JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id + WHERE place_id = h.id AND n.name = :charset_anno + ) AS charset + FROM descendants d + LEFT JOIN moz_bookmarks b3 ON b3.id = d.parent + LEFT JOIN moz_places h ON h.id = d.fk + ORDER BY d.level, d.parent, d.position`; + + if (!aItemGuid) { + aItemGuid = this.bookmarks.rootGuid; + } + + let hasExcludeItemsCallback = aOptions.hasOwnProperty( + "excludeItemsCallback" + ); + let excludedParents = new Set(); + let shouldExcludeItem = (aItem, aParentGuid) => { + let exclude = + excludedParents.has(aParentGuid) || + aOptions.excludeItemsCallback(aItem); + if (exclude) { + if (aItem.type == this.TYPE_X_MOZ_PLACE_CONTAINER) { + excludedParents.add(aItem.guid); + } + } + return exclude; + }; + + let rootItem = null; + let parentsMap = new Map(); + let conn = await this.promiseDBConnection(); + let rows = await conn.executeCached(QUERY_STR, { + tags_folder: PlacesUtils.tagsFolderId, + charset_anno: PlacesUtils.CHARSET_ANNO, + item_guid: aItemGuid, + }); + let yieldCounter = 0; + for (let row of rows) { + let item; + if (!rootItem) { + try { + // This is the first row. + rootItem = item = await createItemInfoObject(row, true); + Object.defineProperty(rootItem, "itemsCount", { + value: 1, + writable: true, + enumerable: false, + configurable: false, + }); + } catch (ex) { + console.error("Failed to fetch the data for the root item"); + throw ex; + } + } else { + try { + // Our query guarantees that we always visit parents ahead of their + // children. + item = await createItemInfoObject(row, false); + let parentGuid = row.getResultByName("parentGuid"); + if (hasExcludeItemsCallback && shouldExcludeItem(item, parentGuid)) { + continue; + } + + let parentItem = parentsMap.get(parentGuid); + if ("children" in parentItem) { + parentItem.children.push(item); + } else { + parentItem.children = [item]; + } + + rootItem.itemsCount++; + } catch (ex) { + // This is a bogus child, report and skip it. + console.error("Failed to fetch the data for an item ", ex); + continue; + } + } + + if (item.type == this.TYPE_X_MOZ_PLACE_CONTAINER) { + parentsMap.set(item.guid, item); + } + + // With many bookmarks we end up stealing the CPU - even with yielding! + // So we let everyone else have a go every few items (bug 1186714). + if (++yieldCounter % 50 == 0) { + await new Promise(resolve => { + Services.tm.dispatchToMainThread(resolve); + }); + } + } + + return rootItem; + }, + + /** + * Returns a generator that iterates over `array` and yields slices of no + * more than `chunkLength` elements at a time. + * + * @param {Array} array An array containing zero or more elements. + * @param {number} chunkLength The maximum number of elements in each chunk. + * @yields {Array} A chunk of the array. + * @throws if `chunkLength` is negative or not an integer. + */ + *chunkArray(array, chunkLength) { + if (chunkLength <= 0 || !Number.isInteger(chunkLength)) { + throw new TypeError("Chunk length must be a positive integer"); + } + if (!array.length) { + return; + } + if (array.length <= chunkLength) { + yield array; + return; + } + let startIndex = 0; + while (startIndex < array.length) { + yield array.slice(startIndex, (startIndex += chunkLength)); + } + }, + + /** + * Returns SQL placeholders to bind multiple values into an IN clause. + * @param {Array|number} info + * Array or number of entries to create. + * @param {string} [prefix] + * String prefix to add before the SQL param. + * @param {string} [suffix] + * String suffix to add after the SQL param. + */ + sqlBindPlaceholders(info, prefix = "", suffix = "") { + let length = Array.isArray(info) ? info.length : info; + return new Array(length).fill(prefix + "?" + suffix).join(","); + }, + + /** + * Run some text through md5 and return the hash. + * @param {string} data The string to hash. + * @param {string} [format] Which format of the hash to return: + * - "ascii" for ascii format. + * - "hex" for hex format. + * @returns {string} hash of the input string in the required format. + * @deprecated use sha256 instead. + */ + md5(data, { format = "ascii" } = {}) { + let hasher = new lazy.CryptoHash("md5"); + + // Convert the data to a byte array for hashing. + data = new TextEncoder().encode(data); + hasher.update(data, data.length); + switch (format) { + case "hex": + let hash = hasher.finish(false); + return Array.from(hash, (c, i) => + hash.charCodeAt(i).toString(16).padStart(2, "0") + ).join(""); + case "ascii": + default: + return hasher.finish(true); + } + }, + + /** + * Run some text through SHA256 and return the hash. + * @param {string} data The string to hash. + * @param {string} [format] Which format of the hash to return: + * - "ascii" for ascii format. + * - "hex" for hex format. + * @returns {string} hash of the input string in the required format. + */ + sha256(data, { format = "ascii" } = {}) { + let hasher = new lazy.CryptoHash("sha256"); + + // Convert the data to a byte array for hashing. + data = new TextEncoder().encode(data); + hasher.update(data, data.length); + switch (format) { + case "hex": + let hash = hasher.finish(false); + return Array.from(hash, (c, i) => + hash.charCodeAt(i).toString(16).padStart(2, "0") + ).join(""); + case "ascii": + default: + return hasher.finish(true); + } + }, + + /** + * Inserts a new place if one doesn't currently exist. + * + * This should only be used from an API that is connecting this new entry to + * some additional foreign table. Otherwise this will just create an orphan + * entry that could be expired at any time. + * + * @param db + * The database connection to use. + * @param url + * A valid URL object. + * @return {Promise} resolved when the operation is complete. + */ + async maybeInsertPlace(db, url) { + // The IGNORE conflict can trigger on `guid`. + await db.executeCached( + `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) + VALUES (:url, hash(:url), :rev_host, + (CASE WHEN :url BETWEEN 'place:' AND 'place:' || X'FFFF' THEN 1 ELSE 0 END), + :frecency, + IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), + GENERATE_GUID())) + `, + { + url: url.href, + rev_host: this.getReversedHost(url), + frecency: url.protocol == "place:" ? 0 : -1, + } + ); + }, + + /** + * Tries to insert a set of new places if they don't exist yet. + * + * This should only be used from an API that is connecting this new entry to + * some additional foreign table. Otherwise this will just create an orphan + * entry that could be expired at any time. + * + * @param db + * The database to use + * @param urls + * An array with all the url objects to insert. + * @return {Promise} resolved when the operation is complete. + */ + async maybeInsertManyPlaces(db, urls) { + await db.executeCached( + `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) VALUES + (:url, hash(:url), :rev_host, + (CASE WHEN :url BETWEEN 'place:' AND 'place:' || X'FFFF' THEN 1 ELSE 0 END), + :frecency, + IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :maybeguid))`, + urls.map(url => ({ + url: url.href, + rev_host: this.getReversedHost(url), + frecency: url.protocol == "place:" ? 0 : -1, + maybeguid: this.history.makeGuid(), + })) + ); + }, + + /** + * Can be used to detect being in automation to allow specific code paths + * that are normally disallowed. + */ + get isInAutomation() { + return ( + Cu.isInAutomation || Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") + ); + }, + + /** + * Creates a logger. + * Logging level can be controlled through places.loglevel. + * + * @param {string} [prefix] Prefix to use for the logged messages, "::" will + * be appended automatically to the prefix. + * @returns {object} The logger. + */ + getLogger({ prefix = "" } = {}) { + if (!this._logger) { + this._logger = lazy.Log.repository.getLogger("places"); + this._logger.manageLevelFromPref("places.loglevel"); + this._logger.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + } + if (prefix) { + // This is not an early return because it is necessary to invoke getLogger + // at least once before getLoggerWithMessagePrefix; it replaces a + // method of the original logger, rather than using an actual Proxy. + return lazy.Log.repository.getLoggerWithMessagePrefix( + "places", + prefix + " :: " + ); + } + return this._logger; + }, +}; + +ChromeUtils.defineLazyGetter(PlacesUtils, "history", function () { + let hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); + return Object.freeze( + new Proxy(hs, { + get(target, name) { + let property, object; + if (name in target) { + property = target[name]; + object = target; + } else { + property = lazy.History[name]; + object = lazy.History; + } + if (typeof property == "function") { + return property.bind(object); + } + return property; + }, + set(target, name, val) { + // Forward to the XPCOM object, otherwise don't allow to set properties. + if (name in target) { + target[name] = val; + return true; + } + // This will throw in strict mode. + return false; + }, + }) + ); +}); + +XPCOMUtils.defineLazyServiceGetter( + PlacesUtils, + "favicons", + "@mozilla.org/browser/favicon-service;1", + "nsIFaviconService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "bmsvc", + "@mozilla.org/browser/nav-bookmarks-service;1", + "nsINavBookmarksService" +); +ChromeUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => { + return Object.freeze( + new Proxy(lazy.Bookmarks, { + get: (target, name) => + lazy.Bookmarks.hasOwnProperty(name) + ? lazy.Bookmarks[name] + : lazy.bmsvc[name], + }) + ); +}); + +XPCOMUtils.defineLazyServiceGetter( + PlacesUtils, + "tagging", + "@mozilla.org/browser/tagging-service;1", + "nsITaggingService" +); + +ChromeUtils.defineLazyGetter(lazy, "bundle", function () { + const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties"; + return Services.strings.createBundle(PLACES_STRING_BUNDLE_URI); +}); + +// This is just used as a reasonably-random value for copy & paste / drag operations. +ChromeUtils.defineLazyGetter(PlacesUtils, "instanceId", () => { + return PlacesUtils.history.makeGuid(); +}); + +/** + * Setup internal databases for closing properly during shutdown. + * + * 1. Places initiates shutdown. + * 2. Before places can move to the step where it closes the low-level connection, + * we need to make sure that we have closed `conn`. + * 3. Before we can close `conn`, we need to make sure that all external clients + * have stopped using `conn`. + * 4. Before we can close Sqlite, we need to close `conn`. + */ +function setupDbForShutdown(conn, name) { + try { + let state = "0. Not started."; + let promiseClosed = new Promise((resolve, reject) => { + // The service initiates shutdown. + // Before it can safely close its connection, we need to make sure + // that we have closed the high-level connection. + try { + PlacesUtils.history.connectionShutdownClient.jsclient.addBlocker( + `${name} closing as part of Places shutdown`, + async function () { + state = "1. Service has initiated shutdown"; + + // At this stage, all external clients have finished using the + // database. We just need to close the high-level connection. + try { + await conn.close(); + state = "2. Closed Sqlite.sys.mjs connection."; + resolve(); + } catch (ex) { + state = "2. Failed to closed Sqlite.sys.mjs connection: " + ex; + reject(ex); + } + }, + () => state + ); + } catch (ex) { + // It's too late to block shutdown, just close the connection. + conn.close(); + reject(ex); + } + }).catch(console.error); + + // Make sure that Sqlite.sys.mjs doesn't close until we are done + // with the high-level connection. + lazy.Sqlite.shutdown.addBlocker( + `${name} must be closed before Sqlite.sys.mjs`, + () => promiseClosed, + () => state + ); + } catch (ex) { + // It's too late to block shutdown, just close the connection. + conn.close(); + throw ex; + } +} + +ChromeUtils.defineLazyGetter(lazy, "gAsyncDBConnPromised", () => + lazy.Sqlite.cloneStorageConnection({ + connection: PlacesUtils.history.DBConnection, + readOnly: true, + }) + .then(conn => { + setupDbForShutdown(conn, "PlacesUtils read-only connection"); + return conn; + }) + .catch(console.error) +); + +ChromeUtils.defineLazyGetter(lazy, "gAsyncDBWrapperPromised", () => + lazy.Sqlite.wrapStorageConnection({ + connection: PlacesUtils.history.DBConnection, + }) + .then(conn => { + setupDbForShutdown(conn, "PlacesUtils wrapped connection"); + return conn; + }) + .catch(console.error) +); + +var gAsyncDBLargeCacheConnDeferred = Promise.withResolvers(); +ChromeUtils.defineLazyGetter(lazy, "gAsyncDBLargeCacheConnPromised", () => + lazy.Sqlite.cloneStorageConnection({ + connection: PlacesUtils.history.DBConnection, + readOnly: true, + }) + .then(async conn => { + setupDbForShutdown(conn, "PlacesUtils large cache read-only connection"); + // Components like the urlbar often fallback to a table scan due to lack + // of full text indices. A larger cache helps reducing IO and improves + // performance. This value is expected to be larger than the default + // mozStorage value defined as MAX_CACHE_SIZE_BYTES in + // storage/mozStorageConnection.cpp. + await conn.execute("PRAGMA cache_size = -6144"); // 6MiB + // These should be kept in sync with nsPlacesTables.h. + await conn.execute(` + CREATE TEMP TABLE IF NOT EXISTS moz_openpages_temp ( + url TEXT, + userContextId INTEGER, + open_count INTEGER, + PRIMARY KEY (url, userContextId) + )`); + await conn.execute(` + CREATE TEMP TRIGGER IF NOT EXISTS moz_openpages_temp_afterupdate_trigger + AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW + WHEN NEW.open_count = 0 + BEGIN + DELETE FROM moz_openpages_temp + WHERE url = NEW.url + AND userContextId = NEW.userContextId; + END`); + gAsyncDBLargeCacheConnDeferred.resolve(conn); + return conn; + }) + .catch(console.error) +); + +/** + * The metadata API allows consumers to store simple key-value metadata in + * Places. Keys are strings, values can be any type that SQLite supports: + * numbers (integers and doubles), Booleans, strings, and blobs. Values are + * cached in memory for faster lookups. + * + * Since some consumers set metadata as part of an existing operation or active + * transaction, the API also exposes a `*withConnection` variant for each + * method that takes an open database connection. + */ +PlacesUtils.metadata = { + cache: new Map(), + jsonPrefix: "data:application/json;base64,", + + /** + * Returns the value associated with a metadata key. + * + * @param {String} key + * The metadata key to look up. + * @param {String|Object|Array} defaultValue + * Optional. The default value to return if the value is not present, + * or cannot be parsed. + * @resolves {*} + * The value associated with the key, or the defaultValue if there is one. + * @rejects + * Rejected if the value is not found or it cannot be parsed + * and there is no defaultValue. + */ + get(key, defaultValue) { + return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.get", db => + this.getWithConnection(db, key, defaultValue) + ); + }, + + /** + * Sets the value for a metadata key. + * + * @param {String} key + * The metadata key to update. + * @param {*} + * The value to associate with the key. + */ + set(key, value) { + return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.set", db => + this.setWithConnection(db, new Map([[key, value]])) + ); + }, + + /** + * Sets the value for multiple keys. + * + * @param {Map} pairs + * The metadata keys to update, with their value. + */ + setMany(pairs) { + return PlacesUtils.withConnectionWrapper("PlacesUtils.metadata.set", db => + this.setWithConnection(db, pairs) + ); + }, + + /** + * Removes the values for the given metadata keys. + * + * @param {String...} + * One or more metadata keys to remove. + */ + delete(...keys) { + return PlacesUtils.withConnectionWrapper( + "PlacesUtils.metadata.delete", + db => this.deleteWithConnection(db, ...keys) + ); + }, + + async getWithConnection(db, key, defaultValue) { + key = this.canonicalizeKey(key); + if (this.cache.has(key)) { + return this.cache.get(key); + } + let rows = await db.executeCached( + ` + SELECT value FROM moz_meta WHERE key = :key`, + { key } + ); + let value = null; + if (rows.length) { + let row = rows[0]; + let rawValue = row.getResultByName("value"); + // Convert blobs back to `Uint8Array`s. + if (row.getTypeOfIndex(0) == row.VALUE_TYPE_BLOB) { + value = new Uint8Array(rawValue); + } else if ( + typeof rawValue == "string" && + rawValue.startsWith(this.jsonPrefix) + ) { + try { + value = JSON.parse( + this._base64Decode(rawValue.substr(this.jsonPrefix.length)) + ); + } catch (ex) { + if (defaultValue !== undefined) { + // We must create a new array in the local scope to avoid a memory + // leak due to the array global object. + value = Cu.cloneInto(defaultValue, {}); + } else { + throw ex; + } + } + } else { + value = rawValue; + } + } else if (defaultValue !== undefined) { + // We must create a new array in the local scope to avoid a memory leak due + // to the array global object. + value = Cu.cloneInto(defaultValue, {}); + } else { + throw new Error(`No data stored for key ${key}`); + } + this.cache.set(key, value); + return value; + }, + + async setWithConnection(db, pairs) { + let entriesToSet = []; + let keysToDelete = Array.from(pairs.entries()) + .filter(([key, value]) => { + if (value !== null) { + entriesToSet.push({ key: this.canonicalizeKey(key), value }); + return false; + } + return true; + }) + .map(([key, value]) => key); + if (keysToDelete.length) { + await this.deleteWithConnection(db, ...keysToDelete); + if (keysToDelete.length == pairs.size) { + return; + } + } + + // Generate key{i}, value{i} pairs for the SQL bindings. + let params = entriesToSet.reduce((accumulator, { key, value }, i) => { + accumulator[`key${i}`] = key; + // Convert Objects to base64 JSON urls. + accumulator[`value${i}`] = + typeof value == "object" && + ChromeUtils.getClassName(value) != "Uint8Array" + ? this.jsonPrefix + this._base64Encode(JSON.stringify(value)) + : value; + return accumulator; + }, {}); + await db.executeCached( + "REPLACE INTO moz_meta (key, value) VALUES " + + entriesToSet.map((e, i) => `(:key${i}, :value${i})`).join(), + params + ); + + // Update the cache. + entriesToSet.forEach(({ key, value }) => { + this.cache.set(key, value); + }); + }, + + async deleteWithConnection(db, ...keys) { + keys = keys.map(this.canonicalizeKey); + if (!keys.length) { + return; + } + await db.execute( + ` + DELETE FROM moz_meta + WHERE key IN (${new Array(keys.length).fill("?").join(",")})`, + keys + ); + for (let key of keys) { + this.cache.delete(key); + } + }, + + canonicalizeKey(key) { + if (typeof key != "string" || !/^[a-zA-Z0-9\/_]+$/.test(key)) { + throw new TypeError("Invalid metadata key: " + key); + } + return key.toLowerCase(); + }, + + _base64Encode(str) { + return ChromeUtils.base64URLEncode(new TextEncoder().encode(str), { + pad: true, + }); + }, + + _base64Decode(str) { + return new TextDecoder("utf-8").decode( + ChromeUtils.base64URLDecode(str, { padding: "require" }) + ); + }, +}; + +/** + * Keywords management API. + * Sooner or later these keywords will merge with search aliases, this is an + * interim API that should then be replaced by a unified one. + * Keywords are associated with URLs and can have POST data. + * The relations between URLs and keywords are the following: + * - 1 keyword can only point to 1 URL + * - 1 URL can have multiple keywords, iff they differ by POST data (included the empty one). + */ +PlacesUtils.keywords = { + /** + * Fetches a keyword entry based on keyword or URL. + * + * @param keywordOrEntry + * Either the keyword to fetch or an entry providing keyword + * or url property to find keywords for. If both properties are set, + * this returns their intersection. + * @param onResult [optional] + * Callback invoked for each found entry. + * @return {Promise} + * @resolves to an object in the form: { keyword, url, postData }, + * or null if a keyword entry was not found. + */ + fetch(keywordOrEntry, onResult = null) { + if (typeof keywordOrEntry == "string") { + keywordOrEntry = { keyword: keywordOrEntry }; + } + + if ( + keywordOrEntry === null || + typeof keywordOrEntry != "object" || + ("keyword" in keywordOrEntry && typeof keywordOrEntry.keyword != "string") + ) { + throw new Error("Invalid keyword"); + } + + let hasKeyword = "keyword" in keywordOrEntry; + let hasUrl = "url" in keywordOrEntry; + + if (!hasKeyword && !hasUrl) { + throw new Error("At least keyword or url must be provided"); + } + if (onResult && typeof onResult != "function") { + throw new Error("onResult callback must be a valid function"); + } + + if (hasUrl) { + try { + keywordOrEntry.url = BOOKMARK_VALIDATORS.url(keywordOrEntry.url); + } catch (ex) { + throw new Error(keywordOrEntry.url + " is not a valid URL"); + } + } + if (hasKeyword) { + keywordOrEntry.keyword = keywordOrEntry.keyword.trim().toLowerCase(); + } + + let safeOnResult = entry => { + if (onResult) { + try { + onResult(entry); + } catch (ex) { + console.error(ex); + } + } + }; + + return promiseKeywordsCache().then(cache => { + let entries = []; + if (hasKeyword) { + let entry = cache.get(keywordOrEntry.keyword); + if (entry) { + entries.push(entry); + } + } + if (hasUrl) { + for (let entry of cache.values()) { + if (entry.url.href == keywordOrEntry.url.href) { + entries.push(entry); + } + } + } + + entries = entries.filter(e => { + return ( + (!hasUrl || e.url.href == keywordOrEntry.url.href) && + (!hasKeyword || e.keyword == keywordOrEntry.keyword) + ); + }); + + entries.forEach(safeOnResult); + return entries.length ? entries[0] : null; + }); + }, + + /** + * Adds a new keyword and postData for the given URL. + * + * @param keywordEntry + * An object describing the keyword to insert, in the form: + * { + * keyword: non-empty string, + * url: URL or href to associate to the keyword, + * postData: optional POST data to associate to the keyword + * source: The change source, forwarded to all bookmark observers. + * Defaults to nsINavBookmarksService::SOURCE_DEFAULT. + * } + * @note Do not define a postData property if there isn't any POST data. + * Defining an empty string for POST data is equivalent to not having it. + * @resolves when the addition is complete. + */ + insert(keywordEntry) { + if (!keywordEntry || typeof keywordEntry != "object") { + throw new Error("Input should be a valid object"); + } + + if ( + !("keyword" in keywordEntry) || + !keywordEntry.keyword || + typeof keywordEntry.keyword != "string" + ) { + throw new Error("Invalid keyword"); + } + if ( + "postData" in keywordEntry && + keywordEntry.postData && + typeof keywordEntry.postData != "string" + ) { + throw new Error("Invalid POST data"); + } + if (!("url" in keywordEntry)) { + throw new Error("undefined is not a valid URL"); + } + + if (!("source" in keywordEntry)) { + keywordEntry.source = PlacesUtils.bookmarks.SOURCES.DEFAULT; + } + let { keyword, url, source } = keywordEntry; + keyword = keyword.trim().toLowerCase(); + let postData = keywordEntry.postData || ""; + // This also checks href for validity + try { + url = BOOKMARK_VALIDATORS.url(url); + } catch (ex) { + throw new Error(url + " is not a valid URL"); + } + + return PlacesUtils.withConnectionWrapper( + "PlacesUtils.keywords.insert", + async db => { + let cache = await promiseKeywordsCache(); + + // Trying to set the same keyword is a no-op. + let oldEntry = cache.get(keyword); + if ( + oldEntry && + oldEntry.url.href == url.href && + (oldEntry.postData || "") == postData + ) { + return; + } + + // A keyword can only be associated to a single page. + // If another page is using the new keyword, we must update the keyword + // entry. + // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete + // trigger. + if (oldEntry) { + await db.executeCached( + `UPDATE moz_keywords + SET place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), + post_data = :post_data + WHERE keyword = :keyword + `, + { url: url.href, keyword, post_data: postData } + ); + await notifyKeywordChange(oldEntry.url.href, "", source); + } else { + // An entry for the given page could be missing, in such a case we need to + // create it. The IGNORE conflict can trigger on `guid`. + await db.executeTransaction(async () => { + await PlacesUtils.maybeInsertPlace(db, url); + + // A new keyword could be assigned to an url that already has one, + // then we must replace the old keyword with the new one. + let oldKeywords = []; + for (let entry of cache.values()) { + if ( + entry.url.href == url.href && + (entry.postData || "") == postData + ) { + oldKeywords.push(entry.keyword); + } + } + if (oldKeywords.length) { + for (let oldKeyword of oldKeywords) { + await db.executeCached( + `DELETE FROM moz_keywords WHERE keyword = :oldKeyword`, + { oldKeyword } + ); + cache.delete(oldKeyword); + } + } + + await db.executeCached( + `INSERT INTO moz_keywords (keyword, place_id, post_data) + VALUES (:keyword, (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :post_data) + `, + { url: url.href, keyword, post_data: postData } + ); + + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + url, + lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source) + ); + }); + } + + cache.set(keyword, { keyword, url, postData: postData || null }); + + // In any case, notify about the new keyword. + await notifyKeywordChange(url.href, keyword, source); + } + ); + }, + + /** + * Removes a keyword. + * + * @param keyword + * The keyword to remove. + * @return {Promise} + * @resolves when the removal is complete. + */ + remove(keywordOrEntry) { + if (typeof keywordOrEntry == "string") { + keywordOrEntry = { + keyword: keywordOrEntry, + source: Ci.nsINavBookmarksService.SOURCE_DEFAULT, + }; + } + + if ( + keywordOrEntry === null || + typeof keywordOrEntry != "object" || + !keywordOrEntry.keyword || + typeof keywordOrEntry.keyword != "string" + ) { + throw new Error("Invalid keyword"); + } + + let { keyword, source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = + keywordOrEntry; + keyword = keywordOrEntry.keyword.trim().toLowerCase(); + return PlacesUtils.withConnectionWrapper( + "PlacesUtils.keywords.remove", + async db => { + let cache = await promiseKeywordsCache(); + if (!cache.has(keyword)) { + return; + } + let { url } = cache.get(keyword); + cache.delete(keyword); + + await db.executeTransaction(async function () { + await db.execute( + `DELETE FROM moz_keywords WHERE keyword = :keyword`, + { keyword } + ); + + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + url, + lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta(source) + ); + }); + + // Notify bookmarks about the removal. + await notifyKeywordChange(url.href, "", source); + } + ); + }, + + /** + * Moves all (keyword, POST data) pairs from one URL to another, and fires + * observer notifications for all affected bookmarks. If the destination URL + * already has keywords, they will be removed and replaced with the source + * URL's keywords. + * + * @param oldURL + * The source URL. + * @param newURL + * The destination URL. + * @param source + * The change source, forwarded to all bookmark observers. + * @return {Promise} + * @resolves when all keywords have been moved to the destination URL. + */ + reassign(oldURL, newURL, source = PlacesUtils.bookmarks.SOURCES.DEFAULT) { + try { + oldURL = BOOKMARK_VALIDATORS.url(oldURL); + } catch (ex) { + throw new Error(oldURL + " is not a valid source URL"); + } + try { + newURL = BOOKMARK_VALIDATORS.url(newURL); + } catch (ex) { + throw new Error(oldURL + " is not a valid destination URL"); + } + return PlacesUtils.withConnectionWrapper( + "PlacesUtils.keywords.reassign", + async function (db) { + let keywordsToReassign = []; + let keywordsToRemove = []; + let cache = await promiseKeywordsCache(); + for (let [keyword, entry] of cache) { + if (entry.url.href == oldURL.href) { + keywordsToReassign.push(keyword); + } + if (entry.url.href == newURL.href) { + keywordsToRemove.push(keyword); + } + } + if (!keywordsToReassign.length) { + return; + } + + await db.executeTransaction(async function () { + // Remove existing keywords from the new URL. + await db.executeCached( + `DELETE FROM moz_keywords WHERE keyword = :keyword`, + keywordsToRemove.map(keyword => ({ keyword })) + ); + + // Move keywords from the old URL to the new URL. + await db.executeCached( + ` + UPDATE moz_keywords SET + place_id = (SELECT id FROM moz_places + WHERE url_hash = hash(:newURL) AND + url = :newURL) + WHERE place_id = (SELECT id FROM moz_places + WHERE url_hash = hash(:oldURL) AND + url = :oldURL)`, + { newURL: newURL.href, oldURL: oldURL.href } + ); + }); + for (let keyword of keywordsToReassign) { + let entry = cache.get(keyword); + entry.url = newURL; + } + for (let keyword of keywordsToRemove) { + cache.delete(keyword); + } + + if (keywordsToReassign.length) { + // If we moved any keywords, notify that we removed all keywords from + // the old and new URLs, then notify for each moved keyword. + await notifyKeywordChange(oldURL, "", source); + await notifyKeywordChange(newURL, "", source); + for (let keyword of keywordsToReassign) { + await notifyKeywordChange(newURL, keyword, source); + } + } else if (keywordsToRemove.length) { + // If the old URL didn't have any keywords, but the new URL did, just + // notify that we removed all keywords from the new URL. + await notifyKeywordChange(oldURL, "", source); + } + } + ); + }, + + /** + * Removes all orphaned keywords from the given URLs. Orphaned keywords are + * associated with URLs that are no longer bookmarked. If a given URL is still + * bookmarked, its keywords will not be removed. + * + * @param urls + * A list of URLs to check for orphaned keywords. + * @return {Promise} + * @resolves when all keywords have been removed from URLs that are no longer + * bookmarked. + */ + removeFromURLsIfNotBookmarked(urls) { + let hrefs = new Set(); + for (let url of urls) { + try { + url = BOOKMARK_VALIDATORS.url(url); + } catch (ex) { + throw new Error(url + " is not a valid URL"); + } + hrefs.add(url.href); + } + return PlacesUtils.withConnectionWrapper( + "PlacesUtils.keywords.removeFromURLsIfNotBookmarked", + async function (db) { + let keywordsByHref = new Map(); + let cache = await promiseKeywordsCache(); + for (let [keyword, entry] of cache) { + let href = entry.url.href; + if (!hrefs.has(href)) { + continue; + } + if (!keywordsByHref.has(href)) { + keywordsByHref.set(href, [keyword]); + continue; + } + let existingKeywords = keywordsByHref.get(href); + existingKeywords.push(keyword); + } + if (!keywordsByHref.size) { + return; + } + + let placeInfosToRemove = []; + let rows = await db.execute( + ` + SELECT h.id, h.url + FROM moz_places h + JOIN moz_keywords k ON k.place_id = h.id + GROUP BY h.id + HAVING h.foreign_count = count(*) + + (SELECT count(*) + FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + WHERE p.parent = :tags_root AND b.fk = h.id) + `, + { tags_root: PlacesUtils.tagsFolderId } + ); + for (let row of rows) { + placeInfosToRemove.push({ + placeId: row.getResultByName("id"), + href: row.getResultByName("url"), + }); + } + if (!placeInfosToRemove.length) { + return; + } + + await db.execute( + `DELETE FROM moz_keywords WHERE place_id IN (${Array.from( + placeInfosToRemove.map(info => info.placeId) + ).join()})` + ); + for (let { href } of placeInfosToRemove) { + let keywords = keywordsByHref.get(href); + for (let keyword of keywords) { + cache.delete(keyword); + } + } + } + ); + }, + + /** + * Removes all keywords from all URLs. + * + * @return {Promise} + * @resolves when all keywords have been removed. + */ + eraseEverything() { + return PlacesUtils.withConnectionWrapper( + "PlacesUtils.keywords.eraseEverything", + async function (db) { + let cache = await promiseKeywordsCache(); + if (!cache.size) { + return; + } + await db.executeCached(`DELETE FROM moz_keywords`); + cache.clear(); + } + ); + }, + + /** + * Invalidates the keywords cache, leaving all existing keywords in place. + * The cache will be repopulated on the next `PlacesUtils.keywords.*` call. + * + * @return {Promise} + * @resolves when the cache has been cleared. + */ + invalidateCachedKeywords() { + gKeywordsCachePromise = gKeywordsCachePromise.then(_ => null); + this.ensureCacheInitialized(); + return gKeywordsCachePromise; + }, + + /** + * Ensures the keywords cache is initialized. + */ + async ensureCacheInitialized() { + this._cache = await promiseKeywordsCache(); + }, + + /** + * Checks from the cache if a given word is a bookmark keyword. + * We must make sure the cache is populated, and await ensureCacheInitialized() + * before calling this function. + * + * @return {Boolean} Whether the given word is a keyword. + */ + isKeywordFromCache(keyword) { + return this._cache?.has(keyword); + }, +}; + +var gKeywordsCachePromise = Promise.resolve(); + +function promiseKeywordsCache() { + let promise = gKeywordsCachePromise.then(function (cache) { + if (cache) { + return cache; + } + return PlacesUtils.withConnectionWrapper( + "PlacesUtils: promiseKeywordsCache", + async db => { + let cache = new Map(); + let rows = await db.execute( + `SELECT keyword, url, post_data + FROM moz_keywords k + JOIN moz_places h ON h.id = k.place_id + ` + ); + let brokenKeywords = []; + for (let row of rows) { + let keyword = row.getResultByName("keyword"); + try { + let entry = { + keyword, + url: new URL(row.getResultByName("url")), + postData: row.getResultByName("post_data") || null, + }; + cache.set(keyword, entry); + } catch (ex) { + // The url is invalid, don't load the keyword and remove it, or it + // would break the whole keywords API. + brokenKeywords.push(keyword); + } + } + if (brokenKeywords.length) { + await db.execute( + `DELETE FROM moz_keywords + WHERE keyword IN (${brokenKeywords.map(JSON.stringify).join(",")}) + ` + ); + } + return cache; + } + ); + }); + gKeywordsCachePromise = promise.catch(_ => {}); + return promise; +} |