Source code
Revision control
Copy as Markdown
Other Tools
/* -*- 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
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",
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.nodeIsFolderOrShortcut(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 bookmark folder or folder shortcut is generated by a query.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether `node` is a bookmark folder or folder shortcut
* generated as result of a query.
*/
nodeIsQueryGeneratedFolder(node) {
return (
node.parent &&
this.nodeIsFolderOrShortcut(node) &&
this.nodeIsQuery(node.parent)
);
},
/**
* Determines whether or not a ResultNode is a bookmark folder or folder
* shortcut.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether `node` is a bookmark folder or folder shortcut.
*/
nodeIsFolderOrShortcut(node) {
return [
Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT,
].includes(node.type);
},
/**
* Determines whether or not a ResultNode is a bookmark folder.
* For most UI purposes it's better to use nodeIsFolderOrShortcut,
* as folder shortcuts behave like the target folder.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether the node is a bookmark folder.
*/
nodeIsConcreteFolder(node) {
return node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER;
},
/**
* Determines whether or not a ResultNode represents a bookmarked URI.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether the node is a bookmarked URI.
*/
nodeIsBookmark(node) {
return (
node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI &&
(node.itemId != -1 || node.bookmarkGuid)
);
},
/**
* Determines whether or not a ResultNode is a bookmark separator.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether the node is a bookmark separator.
*/
nodeIsSeparator(node) {
return node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR;
},
/**
* Determines whether or not a ResultNode represents a URL.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether the node represents a URL.
*/
nodeIsURI(node) {
return node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
},
/**
* Determines whether or not a ResultNode is a Places query.
*
* @param {nsINavHistoryResultNode} node A result node.
* @returns {boolean} whether the node is a Places Query.
*/
nodeIsQuery(node) {
return node.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) {
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 pane of the Library.
// Since this method is only used for text/plain and text/html, we can
// use the concrete GUID without the risk of dragging root folders.
function gatherTextDataFromNode(node, gatherDataFunc) {
if (
PlacesUtils.nodeIsFolderOrShortcut(node) &&
asQuery(node).queryOptions.excludeItems
) {
let folderRoot = PlacesUtils.getFolderContents(
PlacesUtils.getConcreteItemGuid(node),
false,
true
).root;
try {
return gatherDataFunc(folderRoot);
} finally {
folderRoot.containerOpen = false;
}
}
return gatherDataFunc(node);
}
function gatherDataHtml(node, recursiveOpen = true) {
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 childString = "<DL><DT>" + escapedTitle + "</DT>" + NEWLINE;
let wasOpen = node.containerOpen;
if (!wasOpen && recursiveOpen) {
node.containerOpen = true;
}
if (node.containerOpen) {
for (let i = 0, count = node.childCount; i < count; ++i) {
let child = node.getChild(i);
// We only allow recursively opening concrete folders, not queries
// nor shortcuts, to avoid the possibility of walking infinite loops.
childString +=
"<DD>" +
NEWLINE +
gatherDataHtml(child, PlacesUtils.nodeIsConcreteFolder(child)) +
"</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, recursiveOpen = true) {
if (PlacesUtils.nodeIsContainer(node)) {
asContainer(node);
let childString = node.title + NEWLINE;
let wasOpen = node.containerOpen;
if (!wasOpen && recursiveOpen) {
node.containerOpen = true;
}
if (node.containerOpen) {
for (let i = 0, count = node.childCount; i < count; ++i) {
let child = node.getChild(i);
let suffix = i < count - 1 ? NEWLINE : "";
childString +=
gatherDataText(child, PlacesUtils.nodeIsConcreteFolder(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 gatherTextDataFromNode(aNode, gatherDataHtml);
}
}
// Otherwise, we wrap as TYPE_PLAINTEXT.
return gatherTextDataFromNode(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"
let validNodes = [];
let invalidNodes = [];
switch (type) {
case this.TYPE_X_MOZ_PLACE:
case this.TYPE_X_MOZ_PLACE_SEPARATOR:
case this.TYPE_X_MOZ_PLACE_CONTAINER:
validNodes = 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].trimStart();
if (!uriString) {
continue;
}
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) {}
}
try {
let uri = Services.io.newURI(uriString);
if (uri.scheme != "place") {
validNodes.push({
uri: uriString,
title: titleString ? titleString : uriString,
type: this.TYPE_X_MOZ_URL,
});
}
} catch (e) {
console.error(e);
invalidNodes.push({
uri: uriString,
});
}
}
break;
}
case this.TYPE_PLAINTEXT: {
let parts = blob.split("\n");
for (let i = 0; i < parts.length; i++) {
let uriString = parts[i].trimStart();
// 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;
}
try {
let uri = Services.io.newURI(uriString);
if (uri.scheme != "place") {
validNodes.push({
uri: uriString,
title: uriString,
type: this.TYPE_X_MOZ_URL,
});
}
} catch (e) {
console.error(e);
invalidNodes.push({
uri: uriString,
});
}
}
break;
}
default:
throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
}
return { validNodes, invalidNodes };
},
/**
* 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: () => 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, count = root.childCount; i < count && !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, count = root.childCount; i < count; ++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: