/* -*- 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/. */
var EXPORTED_SYMBOLS = ["PlacesUtils"];
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
XPCOMUtils.defineLazyModuleGetters(this, {
Services: "resource://gre/modules/Services.jsm",
NetUtil: "resource://gre/modules/NetUtil.jsm",
Sqlite: "resource://gre/modules/Sqlite.jsm",
Bookmarks: "resource://gre/modules/Bookmarks.jsm",
History: "resource://gre/modules/History.jsm",
PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.jsm",
});
XPCOMUtils.defineLazyGetter(this, "MOZ_ACTION_REGEX", () => {
return /^moz-action:([^,]+),(.*)$/;
});
// 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 bookmarks notification through the given observers.
*
* @param observers
* array of nsINavBookmarkObserver objects.
* @param notification
* the notification name.
* @param args
* array of arguments to pass to the notification.
*/
function notify(observers, notification, args) {
for (let observer of observers) {
try {
observer[notification](...args);
} catch (ex) {}
}
}
/**
* 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));
for (let bookmark of bookmarks) {
let ids = await PlacesUtils.promiseManyItemIds([bookmark.guid,
bookmark.parentGuid]);
bookmark.id = ids.get(bookmark.guid);
bookmark.parentId = ids.get(bookmark.parentGuid);
}
let observers = PlacesUtils.bookmarks.getObservers();
for (let bookmark of bookmarks) {
notify(observers, "onItemChanged", [ bookmark.id, "keyword", false,
keyword,
bookmark.lastModified * 1000,
bookmark.type,
bookmark.parentId,
bookmark.guid, bookmark.parentGuid,
"", source,
]);
}
}
/**
* Synchonously fetches all annotations for an item, including all properties of
* each annotation which would be required to recreate it.
* @note The async version (PlacesUtils.promiseAnnotationsForItem) should be
* used, unless there's absolutely no way to make the caller async.
* @param aItemId
* The identifier of the itme for which annotations are to be
* retrieved.
* @return Array of objects, each containing the following properties:
* name, flags, expires, mimeType, type, value
*/
function getAnnotationsForItem(aItemId) {
var annos = [];
var annoNames = PlacesUtils.annotations.getItemAnnotationNames(aItemId);
for (let name of annoNames) {
let value = {}, flags = {}, exp = {}, storageType = {};
PlacesUtils.annotations.getItemAnnotationInfo(aItemId, name, value,
flags, exp, storageType);
annos.push({
name,
flags: flags.value,
expires: exp.value,
value: value.value,
});
}
return annos;
}
/**
* 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;
let annos = getAnnotationsForItem(data.id);
if (annos.length > 0)
data.annos = annos;
}
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.concreteId = PlacesUtils.getConcreteItemId(aNode);
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;
/**
* 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"),
lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
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) ||
(val instanceof URL && val.href.length <= DB_URL_LENGTH_MAX)
).call(this, v);
if (typeof(v) === "string")
return new URL(v);
if (v instanceof Ci.nsIURI)
return new URL(v.spec);
return v;
},
source: simpleValidateFunc(v => Number.isInteger(v) &&
Object.values(PlacesUtils.bookmarks.SOURCES).includes(v)),
annos: simpleValidateFunc(v => Array.isArray(v) && v.length),
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" && (
(PlacesSyncUtils.bookmarks.ROOTS.includes(v) || PlacesUtils.isValidGuid(v)))),
parentRecordId: v => SYNC_BOOKMARK_VALIDATORS.recordId(v),
// Sync uses kinds instead of types, which distinguish between livemarks and
// queries.
kind: simpleValidateFunc(v => typeof v == "string" &&
Object.values(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 > 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`);
},
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 || 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;
},
});
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_UNICODE: "text/unicode",
// Used to track the action that populated the clipboard.
TYPE_X_MOZ_PLACE_ACTION: "text/x-moz-place-action",
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_FEEDBACK_UPDATED: "places-autocomplete-feedback-updated",
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",
ACTION_SCHEME: "moz-action:",
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));
},
/**
* 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) {
url = (url instanceof URL) ? url.href : url;
return NetUtil.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")
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")
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 bundle.formatStringFromName(key, params, params.length);
},
getString: function PU_getString(key) {
return bundle.GetStringFromName(key);
},
/**
* Makes a moz-action URI for the given action and set of parameters.
*
* @param type
* The action type.
* @param params
* A JS object of action params.
* @returns A moz-action URI as a string.
*/
mozActionURI(type, params) {
let encodedParams = {};
for (let key in params) {
// Strip null or undefined.
// Regardless, don't encode them or they would be converted to a string.
if (params[key] === null || params[key] === undefined) {
continue;
}
encodedParams[key] = encodeURIComponent(params[key]);
}
return this.ACTION_SCHEME + type + "," + JSON.stringify(encodedParams);
},
/**
* 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 instanceof URL)
url = url.href;
// Faster bailout.
if (!url.startsWith(this.ACTION_SCHEME))
return null;
try {
let [, type, params] = url.match(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) {
Cu.reportError(`Invalid action url "${url}"`);
return null;
}
},
/**
* Parses matchBuckets strings (for example, "suggestion:4,general:Infinity")
* like those used in the browser.urlbar.matchBuckets preference.
*
* @param str
* A matchBuckets string.
* @returns An array of the form: [
* [bucketName_0, bucketPriority_0],
* [bucketName_1, bucketPriority_1],
* ...
* [bucketName_n, bucketPriority_n]
* ]
*/
convertMatchBucketsStringToArray(str) {
return str.split(",")
.map(v => {
let bucket = v.split(":");
return [ bucket[0].trim().toLowerCase(), Number(bucket[1]) ];
});
},
/**
* 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;
},
/**
* 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,
SYNC_BOOKMARK_VALIDATORS,
SYNC_CHANGE_RECORD_VALIDATORS,
QueryInterface: ChromeUtils.generateQI([Ci.nsIObserver]),
_shutdownFunctions: [],
registerShutdownFunction: function PU_registerShutdownFunction(aFunc) {
// If this is the first registered function, add the shutdown observer.
if (this._shutdownFunctions.length == 0) {
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 > 0) {
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-id for the given node. Generally, this is just
* node.itemId, but for folder-shortcuts that's node.folderItemId.
*/
getConcreteItemId: function PU_getConcreteItemId(aNode) {
return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT ?
asQuery(aNode).folderItemId : aNode.itemId;
},
/**
* 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(/
" + escapedTitle + "" + NEWLINE;
let cc = node.childCount;
for (let i = 0; i < cc; ++i) {
childString += ""
+ NEWLINE
+ gatherDataHtml(node.getChild(i))
+ ""
+ NEWLINE;
}
node.containerOpen = wasOpen;
return childString + "" + NEWLINE;
}
if (PlacesUtils.nodeIsURI(node))
return `${escapedTitle}${NEWLINE}`;
if (PlacesUtils.nodeIsSeparator(node))
return "
" + 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_UNICODE.
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.
*/
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
if (Services.io.newURI(uriString)) {
nodes.push({ uri: uriString,
title: titleString ? titleString : uriString,
type: this.TYPE_X_MOZ_URL });
}
}
break;
}
case this.TYPE_UNICODE: {
let parts = blob.split("\n");
for (let i = 0; i < parts.length; i++) {
let uriString = parts[i];
// text/uri-list is converted to TYPE_UNICODE but it could contain
// comments line prepended by #, we should skip them
if (uriString.substr(0, 1) == "\x23")
continue;
// note: Services.io.newURI) will throw if uriString is not a valid URI
if (uriString != "" && Services.io.newURI(uriString))
nodes.push({ uri: uriString,
title: uriString,
type: this.TYPE_X_MOZ_URL });
}
break;
}
default:
throw 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 (key instanceof URL) {
return key;
}
if (key instanceof Ci.nsIURI) {
return new URL(key.spec);
}
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], 1);
var options = this.history.getNewQueryOptions();
options.excludeItems = aExcludeItems;
options.expandQueries = aExpandQueries;
var result = this.history.executeQuery(query, options);
result.root.containerOpen = true;
return result;
},
/**
* Fetch all annotations for an item, including all properties of each
* annotation which would be required to recreate it.
* @param itemId
* The identifier of the itme for which annotations are to be
* retrieved.
* @return Array of objects, each containing the following properties:
* name, flags, expires, mimeType, type, value
*/
async promiseAnnotationsForItem(itemId) {
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.executeCached(
`SELECT n.name, a.content, a.expiration, a.flags
FROM moz_items_annos a
JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id
WHERE a.item_id = :itemId
`, { itemId });
let result = [];
for (let row of rows) {
let anno = {
name: row.getResultByName("name"),
value: row.getResultByName("content"),
expires: row.getResultByName("expiration"),
flags: row.getResultByName("flags"),
};
result.push(anno);
}
return result;
},
/**
* Annotate an item with a batch of annotations.
* @param aItemId
* The identifier of the item for which annotations are to be set
* @param aAnnotations
* Array of objects, each containing the following properties:
* name, flags, expires.
* If the value for an annotation is not set it will be removed.
*/
setAnnotationsForItem: function PU_setAnnotationsForItem(aItemId, aAnnos, aSource, aDontUpdateLastModified) {
var annosvc = this.annotations;
aAnnos.forEach(function(anno) {
if (anno.value === undefined || anno.value === null) {
annosvc.removeItemAnnotation(aItemId, anno.name, aSource);
} else {
let flags = ("flags" in anno) ? anno.flags : 0;
let expires = ("expires" in anno) ?
anno.expires : Ci.nsIAnnotationService.EXPIRE_NEVER;
annosvc.setItemAnnotation(aItemId, anno.name, anno.value, flags,
expires, aSource, aDontUpdateLastModified);
}
});
},
// Identifier getters for special folders.
// You should use these everywhere PlacesUtils is available to avoid XPCOM
// traversal just to get roots' ids.
get placesRootId() {
delete this.placesRootId;
return this.placesRootId = this.bookmarks.placesRoot;
},
get bookmarksMenuFolderId() {
delete this.bookmarksMenuFolderId;
return this.bookmarksMenuFolderId = this.bookmarks.bookmarksMenuFolder;
},
get toolbarFolderId() {
delete this.toolbarFolderId;
return this.toolbarFolderId = this.bookmarks.toolbarFolder;
},
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 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.jsm 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: () => 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: () => gAsyncDBLargeCacheConnPromised,
/**
* Performs a read/write operation on the Places database through a Sqlite.jsm
* 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.jsm
* 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 gAsyncDBWrapperPromised;
return db.executeBeforeShutdown(name, task);
},
/**
* Gets favicon data for a given page url.
*
* @param aPageUrl url of the page to look favicon for.
* @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) {
return new Promise((resolve, reject) => {
PlacesUtils.favicons.getFaviconDataForPage(NetUtil.newURI(aPageUrl),
function(uri, dataLen, data, mimeType) {
if (uri) {
resolve({ uri, dataLen, data, mimeType });
} else {
reject();
}
});
});
},
/**
* 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);
},
/**
* Get the unique id for an item (a bookmark, a folder or a separator) given
* its item id.
*
* @param aItemId
* an item id
* @return {Promise}
* @resolves to the GUID.
* @rejects if aItemId is invalid.
*/
promiseItemGuid(aItemId) {
return GuidHelper.getItemGuid(aItemId);
},
/**
* Get the item id for an item (a bookmark, a folder or a separator) given
* its unique id.
*
* @param aGuid
* an item GUID
* @return {Promise}
* @resolves to the item id.
* @rejects if there's no item for the given GUID.
*/
promiseItemId(aGuid) {
return GuidHelper.getItemId(aGuid);
},
/**
* Get the item ids for multiple items (a bookmark, a folder or a separator)
* given the unique ids for each item.
*
* @param {Array} aGuids An array of item GUIDs.
* @return {Promise}
* @resolves to a Map of item ids.
* @rejects if not all of the GUIDs could be found.
*/
promiseManyItemIds(aGuids) {
return GuidHelper.getManyItemIds(aGuids);
},
/**
* Invalidate the GUID cache for the given itemId.
*
* @param aItemId
* an item id
*/
invalidateCachedGuidFor(aItemId) {
GuidHelper.invalidateCacheForItemId(aItemId);
},
/**
* 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.
* - annos (see getAnnotationsForItem): the item's annotations. This is not
* set if there are no annotations set for 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;
// Cache it for promiseItemId consumers regardless.
GuidHelper.updateCache(itemId, item.guid);
let type = aRow.getResultByName("type");
item.typeCode = type;
if (type == Ci.nsINavBookmarksService.TYPE_BOOKMARK)
copyProps("charset", "tags", "iconuri");
// Add annotations.
if (aRow.getResultByName("has_annos")) {
try {
item.annos = await PlacesUtils.promiseAnnotationsForItem(itemId);
} catch (ex) {
Cu.reportError("Unexpected error while reading annotations " + ex);
}
}
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.
item.uri = NetUtil.newURI(aRow.getResultByName("url")).spec;
// 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:
Cu.reportError(`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)
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 GROUP_CONCAT(t.title, ',')
FROM moz_bookmarks b2
JOIN moz_bookmarks t ON t.id = +b2.parent AND t.parent = :tags_folder
WHERE b2.fk = h.id
) AS tags,
EXISTS (SELECT 1 FROM moz_items_annos
WHERE item_id = d.id LIMIT 1) AS has_annos,
(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) {
throw new Error("Failed to fetch the data for the root item " + 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.
Cu.reportError("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;
},
};
XPCOMUtils.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 = History[name];
object = History;
}
if (typeof property == "function") {
return property.bind(object);
}
return property;
},
}));
});
XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "favicons",
"@mozilla.org/browser/favicon-service;1",
"nsIFaviconService");
XPCOMUtils.defineLazyServiceGetter(this, "bmsvc",
"@mozilla.org/browser/nav-bookmarks-service;1",
"nsINavBookmarksService");
XPCOMUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => {
return Object.freeze(new Proxy(Bookmarks, {
get: (target, name) => Bookmarks.hasOwnProperty(name) ? Bookmarks[name]
: bmsvc[name],
}));
});
XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "annotations",
"@mozilla.org/browser/annotation-service;1",
"nsIAnnotationService");
XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "tagging",
"@mozilla.org/browser/tagging-service;1",
"nsITaggingService");
XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "livemarks",
"@mozilla.org/browser/livemark-service;2",
"mozIAsyncLivemarks");
XPCOMUtils.defineLazyGetter(this, "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.
XPCOMUtils.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.
await conn.close();
state = "2. Closed Sqlite.jsm connection.";
resolve();
},
() => state
);
} catch (ex) {
// It's too late to block shutdown, just close the connection.
conn.close();
reject(ex);
}
});
// Make sure that Sqlite.jsm doesn't close until we are done
// with the high-level connection.
Sqlite.shutdown.addBlocker(`${name} must be closed before Sqlite.jsm`,
() => promiseClosed.catch(Cu.reportError),
() => state
);
} catch (ex) {
// It's too late to block shutdown, just close the connection.
conn.close();
throw ex;
}
}
XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised",
() => Sqlite.cloneStorageConnection({
connection: PlacesUtils.history.DBConnection,
readOnly: true,
}).then(conn => {
setupDbForShutdown(conn, "PlacesUtils read-only connection");
return conn;
}).catch(Cu.reportError)
);
XPCOMUtils.defineLazyGetter(this, "gAsyncDBWrapperPromised",
() => Sqlite.wrapStorageConnection({
connection: PlacesUtils.history.DBConnection,
}).then(conn => {
setupDbForShutdown(conn, "PlacesUtils wrapped connection");
return conn;
}).catch(Cu.reportError)
);
XPCOMUtils.defineLazyGetter(this, "gAsyncDBLargeCacheConnPromised",
() => 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
return conn;
}).catch(Cu.reportError)
);
/**
* 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, key, value));
},
/**
* 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, key, value) {
if (value === null) {
await this.deleteWithConnection(db, key);
return;
}
let cacheValue = value;
if (typeof value == "object" && ChromeUtils.getClassName(value) != "Uint8Array") {
value = this.jsonPrefix + this._base64Encode(JSON.stringify(value));
}
key = this.canonicalizeKey(key);
await db.executeCached(`
REPLACE INTO moz_meta (key, value)
VALUES (:key, :value)`,
{ key, value });
this.cache.set(key, cacheValue);
},
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("utf-8").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) {
Cu.reportError(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 db.executeCached(
`INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
VALUES (:url, hash(:url), :rev_host, 0, :frecency,
IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
GENERATE_GUID()))
`, { url: url.href, rev_host: PlacesUtils.getReversedHost(url),
frecency: url.protocol == "place:" ? 0 : -1 });
await db.executeCached("DELETE FROM moz_updateoriginsinsert_temp");
// 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 PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
db, url, 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 PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL(
db, url, 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);
return gKeywordsCachePromise;
},
};
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;
}
// Sometime soon, likely as part of the transition to mozIAsyncBookmarks,
// itemIds will be deprecated in favour of GUIDs, which play much better
// with multiple undo/redo operations. Because these GUIDs are already stored,
// and because we don't want to revise the transactions API once more when this
// happens, transactions are set to work with GUIDs exclusively, in the sense
// that they may never expose itemIds, nor do they accept them as input.
// More importantly, transactions which add or remove items guarantee to
// restore the GUIDs on undo/redo, so that the following transactions that may
// done or undo can assume the items they're interested in are stil accessible
// through the same GUID.
// The current bookmarks API, however, doesn't expose the necessary means for
// working with GUIDs. So, until it does, this helper object accesses the
// Places database directly in order to switch between GUIDs and itemIds, and
// "restore" GUIDs on items re-created items.
var GuidHelper = {
// Cache for GUID<->itemId paris.
guidsForIds: new Map(),
idsForGuids: new Map(),
async getItemId(aGuid) {
let cached = this.idsForGuids.get(aGuid);
if (cached !== undefined)
return cached;
let itemId = await PlacesUtils.withConnectionWrapper("GuidHelper.getItemId",
async function(db) {
let rows = await db.executeCached(
"SELECT b.id, b.guid from moz_bookmarks b WHERE b.guid = :guid LIMIT 1",
{ guid: aGuid });
if (rows.length == 0)
throw new Error("no item found for the given GUID");
return rows[0].getResultByName("id");
});
this.updateCache(itemId, aGuid);
return itemId;
},
async getManyItemIds(aGuids) {
let uncachedGuids = aGuids.filter(guid => !this.idsForGuids.has(guid));
if (uncachedGuids.length) {
await PlacesUtils.withConnectionWrapper("GuidHelper.getItemId",
async db => {
while (uncachedGuids.length) {
let chunk = uncachedGuids.splice(0, 100);
let rows = await db.executeCached(
`SELECT b.id, b.guid from moz_bookmarks b WHERE
b.guid IN (${"?,".repeat(chunk.length - 1) + "?"})
LIMIT ${chunk.length}`, chunk);
if (rows.length < chunk.length)
throw new Error("Not all items were found!");
for (let row of rows) {
this.updateCache(row.getResultByIndex(0), row.getResultByIndex(1));
}
}
});
}
return new Map(aGuids.map(guid => [guid, this.idsForGuids.get(guid)]));
},
async getItemGuid(aItemId) {
let cached = this.guidsForIds.get(aItemId);
if (cached !== undefined)
return cached;
let guid = await PlacesUtils.withConnectionWrapper("GuidHelper.getItemGuid",
async function(db) {
let rows = await db.executeCached(
"SELECT b.id, b.guid from moz_bookmarks b WHERE b.id = :id LIMIT 1",
{ id: aItemId });
if (rows.length == 0)
throw new Error("no item found for the given itemId");
return rows[0].getResultByName("guid");
});
this.updateCache(aItemId, guid);
return guid;
},
/**
* Updates the cache.
*
* @note This is the only place where the cache should be populated,
* invalidation relies on both Maps being populated at the same time.
*/
updateCache(aItemId, aGuid) {
if (typeof(aItemId) != "number" || aItemId <= 0)
throw new Error("Trying to update the GUIDs cache with an invalid itemId");
if (!PlacesUtils.isValidGuid(aGuid))
throw new Error("Trying to update the GUIDs cache with an invalid GUID");
this.ensureObservingRemovedItems();
this.guidsForIds.set(aItemId, aGuid);
this.idsForGuids.set(aGuid, aItemId);
},
invalidateCacheForItemId(aItemId) {
let guid = this.guidsForIds.get(aItemId);
this.guidsForIds.delete(aItemId);
this.idsForGuids.delete(guid);
},
ensureObservingRemovedItems() {
if (!("observer" in this)) {
/**
* This observers serves two purposes:
* (1) Invalidate cached id<->GUID paris on when items are removed.
* (2) Cache GUIDs given us free of charge by onItemAdded/onItemRemoved.
* So, for exmaple, when the NewBookmark needs the new GUID, we already
* have it cached.
*/
let listener = events => {
for (let event of events) {
this.updateCache(event.id, event.guid);
this.updateCache(event.parentId, event.parentGuid);
}
};
this.observer = {
onItemRemoved:
(aItemId, aParentId, aIndex, aItemTyep, aURI, aGuid, aParentGuid) => {
this.guidsForIds.delete(aItemId);
this.idsForGuids.delete(aGuid);
this.updateCache(aParentId, aParentGuid);
},
QueryInterface: ChromeUtils.generateQI([Ci.nsINavBookmarkObserver]),
onBeginUpdateBatch() {},
onEndUpdateBatch() {},
onItemChanged() {},
onItemVisited() {},
onItemMoved() {},
};
PlacesUtils.bookmarks.addObserver(this.observer);
PlacesUtils.observers.addListener(["bookmark-added"], listener);
PlacesUtils.registerShutdownFunction(() => {
PlacesUtils.bookmarks.removeObserver(this.observer);
PlacesUtils.observers.removeListener(["bookmark-added"], listener);
});
}
},
};