mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-02 15:15:23 +00:00
38a8ed9399
--HG-- extra : rebase_source : 3a593651ac1fc995e01d00af037aaac8b81c7c32
1436 lines
55 KiB
JavaScript
1436 lines
55 KiB
JavaScript
/* 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/. */
|
|
|
|
"use strict";
|
|
|
|
/**
|
|
* This module provides an asynchronous API for managing bookmarks.
|
|
*
|
|
* Bookmarks are organized in a tree structure, and include URLs, folders and
|
|
* separators. Multiple bookmarks for the same URL are allowed.
|
|
*
|
|
* Note that if you are handling bookmarks operations in the UI, you should
|
|
* not use this API directly, but rather use PlacesTransactions.jsm, so that
|
|
* any operation is undo/redo-able.
|
|
*
|
|
* Each bookmark-item is represented by an object having the following
|
|
* properties:
|
|
*
|
|
* - guid (string)
|
|
* The globally unique identifier of the item.
|
|
* - parentGuid (string)
|
|
* The globally unique identifier of the folder containing the item.
|
|
* This will be an empty string for the Places root folder.
|
|
* - index (number)
|
|
* The 0-based position of the item in the parent folder.
|
|
* - dateAdded (Date)
|
|
* The time at which the item was added.
|
|
* - lastModified (Date)
|
|
* The time at which the item was last modified.
|
|
* - type (number)
|
|
* The item's type, either TYPE_BOOKMARK, TYPE_FOLDER or TYPE_SEPARATOR.
|
|
*
|
|
* The following properties are only valid for URLs or folders.
|
|
*
|
|
* - title (string)
|
|
* The item's title, if any. Empty titles and null titles are considered
|
|
* the same, and the property is unset on retrieval in such a case.
|
|
* Titles longer than DB_TITLE_LENGTH_MAX will be truncated.
|
|
*
|
|
* The following properties are only valid for URLs:
|
|
*
|
|
* - url (URL, href or nsIURI)
|
|
* The item's URL. Note that while input objects can contains either
|
|
* an URL object, an href string, or an nsIURI, output objects will always
|
|
* contain an URL object.
|
|
* An URL cannot be longer than DB_URL_LENGTH_MAX, methods will throw if a
|
|
* longer value is provided.
|
|
*
|
|
* Each successful operation notifies through the nsINavBookmarksObserver
|
|
* interface. To listen to such notifications you must register using
|
|
* nsINavBookmarksService addObserver and removeObserver methods.
|
|
* Note that bookmark addition or order changes won't notify onItemMoved for
|
|
* items that have their indexes changed.
|
|
* Similarly, lastModified changes not done explicitly (like changing another
|
|
* property) won't fire an onItemChanged notification for the lastModified
|
|
* property.
|
|
* @see nsINavBookmarkObserver
|
|
*/
|
|
|
|
this.EXPORTED_SYMBOLS = [ "Bookmarks" ];
|
|
|
|
const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
|
|
|
|
Cu.importGlobalProperties(["URL"]);
|
|
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Services",
|
|
"resource://gre/modules/Services.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
|
|
"resource://gre/modules/NetUtil.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
|
|
"resource://gre/modules/Promise.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Task",
|
|
"resource://gre/modules/Task.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
|
|
"resource://gre/modules/Sqlite.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
|
|
"resource://gre/modules/PlacesUtils.jsm");
|
|
|
|
// Imposed to limit database size.
|
|
const DB_URL_LENGTH_MAX = 65536;
|
|
const DB_TITLE_LENGTH_MAX = 4096;
|
|
|
|
let Bookmarks = Object.freeze({
|
|
/**
|
|
* Item's type constants.
|
|
* These should stay consistent with nsINavBookmarksService.idl
|
|
*/
|
|
TYPE_BOOKMARK: 1,
|
|
TYPE_FOLDER: 2,
|
|
TYPE_SEPARATOR: 3,
|
|
|
|
/**
|
|
* Default index used to append a bookmark-item at the end of a folder.
|
|
* This should stay consistent with nsINavBookmarksService.idl
|
|
*/
|
|
DEFAULT_INDEX: -1,
|
|
|
|
/**
|
|
* Special GUIDs associated with bookmark roots.
|
|
* It's guaranteed that the roots will always have these guids.
|
|
*/
|
|
|
|
rootGuid: "root________",
|
|
menuGuid: "menu________",
|
|
toolbarGuid: "toolbar_____",
|
|
unfiledGuid: "unfiled_____",
|
|
|
|
// With bug 424160, tags will stop being bookmarks, thus this root will
|
|
// be removed. Do not rely on this, rather use the tagging service API.
|
|
tagsGuid: "tags________",
|
|
|
|
/**
|
|
* Inserts a bookmark-item into the bookmarks tree.
|
|
*
|
|
* For creating a bookmark, the following set of properties is required:
|
|
* - type
|
|
* - parentGuid
|
|
* - url, only for bookmarked URLs
|
|
*
|
|
* If an index is not specified, it defaults to appending.
|
|
* It's also possible to pass a non-existent GUID to force creation of an
|
|
* item with the given GUID, but unless you have a very sound reason, such as
|
|
* an undo manager implementation or synchronization, don't do that.
|
|
*
|
|
* Note that any known properties that don't apply to the specific item type
|
|
* cause an exception.
|
|
*
|
|
* @param info
|
|
* object representing a bookmark-item.
|
|
*
|
|
* @return {Promise} resolved when the creation is complete.
|
|
* @resolves to an object representing the created bookmark.
|
|
* @rejects if it's not possible to create the requested bookmark.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
insert(info) {
|
|
// Ensure to use the same date for dateAdded and lastModified, even if
|
|
// dateAdded may be imposed by the caller.
|
|
let time = (info && info.dateAdded) || new Date();
|
|
let insertInfo = validateBookmarkObject(info,
|
|
{ type: { defaultValue: this.TYPE_BOOKMARK }
|
|
, index: { defaultValue: this.DEFAULT_INDEX }
|
|
, url: { requiredIf: b => b.type == this.TYPE_BOOKMARK
|
|
, validIf: b => b.type == this.TYPE_BOOKMARK }
|
|
, parentGuid: { required: true }
|
|
, title: { validIf: b => [ this.TYPE_BOOKMARK
|
|
, this.TYPE_FOLDER ].indexOf(b.type) != -1 }
|
|
, dateAdded: { defaultValue: time
|
|
, validIf: b => !b.lastModified ||
|
|
b.dateAdded <= b.lastModified }
|
|
, lastModified: { defaultValue: time,
|
|
validIf: b => (!b.dateAdded && b.lastModified >= time) ||
|
|
(b.dateAdded && b.lastModified >= b.dateAdded) }
|
|
});
|
|
|
|
return Task.spawn(function* () {
|
|
// Ensure the parent exists.
|
|
let parent = yield fetchBookmark({ guid: insertInfo.parentGuid });
|
|
if (!parent)
|
|
throw new Error("parentGuid must be valid");
|
|
|
|
// Set index in the appending case.
|
|
if (insertInfo.index == this.DEFAULT_INDEX ||
|
|
insertInfo.index > parent._childCount) {
|
|
insertInfo.index = parent._childCount;
|
|
}
|
|
|
|
let item = yield insertBookmark(insertInfo, parent);
|
|
|
|
// Notify onItemAdded to listeners.
|
|
let observers = PlacesUtils.bookmarks.getObservers();
|
|
// We need the itemId to notify, though once the switch to guids is
|
|
// complete we may stop using it.
|
|
let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
|
|
let itemId = yield PlacesUtils.promiseItemId(item.guid);
|
|
notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
|
|
item.type, uri, item.title || null,
|
|
toPRTime(item.dateAdded), item.guid,
|
|
item.parentGuid ]);
|
|
|
|
// If it's a tag, notify OnItemChanged to all bookmarks for this URL.
|
|
let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
|
|
if (isTagging) {
|
|
for (let entry of (yield fetchBookmarksByURL(item))) {
|
|
notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
|
|
toPRTime(entry.lastModified),
|
|
entry.type, entry._parentId,
|
|
entry.guid, entry.parentGuid ]);
|
|
}
|
|
}
|
|
|
|
// Remove non-enumerable properties.
|
|
return Object.assign({}, item);
|
|
}.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Updates a bookmark-item.
|
|
*
|
|
* Only set the properties which should be changed (undefined properties
|
|
* won't be taken into account).
|
|
* Moreover, the item's type or dateAdded cannot be changed, since they are
|
|
* immutable after creation. Trying to change them will reject.
|
|
*
|
|
* Note that any known properties that don't apply to the specific item type
|
|
* cause an exception.
|
|
*
|
|
* @param info
|
|
* object representing a bookmark-item, as defined above.
|
|
*
|
|
* @return {Promise} resolved when the update is complete.
|
|
* @resolves to an object representing the updated bookmark.
|
|
* @rejects if it's not possible to update the given bookmark.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
update(info) {
|
|
// The info object is first validated here to ensure it's consistent, then
|
|
// it's compared to the existing item to remove any properties that don't
|
|
// need to be updated.
|
|
let updateInfo = validateBookmarkObject(info,
|
|
{ guid: { required: true }
|
|
, index: { requiredIf: b => b.hasOwnProperty("parentGuid")
|
|
, validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX }
|
|
, parentGuid: { requiredIf: b => b.hasOwnProperty("index") }
|
|
});
|
|
|
|
// There should be at last one more property in addition to guid.
|
|
if (Object.keys(updateInfo).length < 2)
|
|
throw new Error("Not enough properties to update");
|
|
|
|
return Task.spawn(function* () {
|
|
// Ensure the item exists.
|
|
let item = yield fetchBookmark(updateInfo);
|
|
if (!item)
|
|
throw new Error("No bookmarks found for the provided GUID");
|
|
if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type)
|
|
throw new Error("The bookmark type cannot be changed");
|
|
if (updateInfo.hasOwnProperty("dateAdded") &&
|
|
updateInfo.dateAdded.getTime() != item.dateAdded.getTime())
|
|
throw new Error("The bookmark dateAdded cannot be changed");
|
|
|
|
// Remove any property that will stay the same.
|
|
removeSameValueProperties(updateInfo, item);
|
|
// Check if anything should still be updated.
|
|
if (Object.keys(updateInfo).length < 2) {
|
|
// Remove non-enumerable properties.
|
|
return Object.assign({}, item);
|
|
}
|
|
|
|
let time = (updateInfo && updateInfo.dateAdded) || new Date();
|
|
updateInfo = validateBookmarkObject(updateInfo,
|
|
{ url: { validIf: () => item.type == this.TYPE_BOOKMARK }
|
|
, title: { validIf: () => [ this.TYPE_BOOKMARK
|
|
, this.TYPE_FOLDER ].indexOf(item.type) != -1 }
|
|
, lastModified: { defaultValue: new Date()
|
|
, validIf: b => b.lastModified >= item.dateAdded }
|
|
});
|
|
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: update",
|
|
Task.async(function*(db) {
|
|
let parent;
|
|
if (updateInfo.hasOwnProperty("parentGuid")) {
|
|
if (item.type == this.TYPE_FOLDER) {
|
|
// Make sure we are not moving a folder into itself or one of its
|
|
// descendants.
|
|
let rows = yield db.executeCached(
|
|
`WITH RECURSIVE
|
|
descendants(did) AS (
|
|
VALUES(:id)
|
|
UNION ALL
|
|
SELECT id FROM moz_bookmarks
|
|
JOIN descendants ON parent = did
|
|
WHERE type = :type
|
|
)
|
|
SELECT guid FROM moz_bookmarks
|
|
WHERE id IN descendants
|
|
`, { id: item._id, type: this.TYPE_FOLDER });
|
|
if ([r.getResultByName("guid") for (r of rows)].indexOf(updateInfo.parentGuid) != -1)
|
|
throw new Error("Cannot insert a folder into itself or one of its descendants");
|
|
}
|
|
|
|
parent = yield fetchBookmark({ guid: updateInfo.parentGuid });
|
|
if (!parent)
|
|
throw new Error("No bookmarks found for the provided parentGuid");
|
|
}
|
|
|
|
if (updateInfo.hasOwnProperty("index")) {
|
|
// If at this point we don't have a parent yet, we are moving into
|
|
// the same container. Thus we know it exists.
|
|
if (!parent)
|
|
parent = yield fetchBookmark({ guid: item.parentGuid });
|
|
|
|
if (updateInfo.index >= parent._childCount ||
|
|
updateInfo.index == this.DEFAULT_INDEX) {
|
|
updateInfo.index = parent._childCount;
|
|
|
|
// Fix the index when moving within the same container.
|
|
if (parent.guid == item.parentGuid)
|
|
updateInfo.index--;
|
|
}
|
|
}
|
|
|
|
let updatedItem = yield updateBookmark(updateInfo, item, parent);
|
|
|
|
if (item.type == this.TYPE_BOOKMARK &&
|
|
item.url.href != updatedItem.url.href) {
|
|
// ...though we don't wait for the calculation.
|
|
updateFrecency(db, [item.url]).then(null, Cu.reportError);
|
|
updateFrecency(db, [updatedItem.url]).then(null, Cu.reportError);
|
|
}
|
|
|
|
// Notify onItemChanged to listeners.
|
|
let observers = PlacesUtils.bookmarks.getObservers();
|
|
// For lastModified, we only care about the original input, since we
|
|
// should not notify implciit lastModified changes.
|
|
if (info.hasOwnProperty("lastModified") &&
|
|
updateInfo.hasOwnProperty("lastModified") &&
|
|
item.lastModified != updatedItem.lastModified) {
|
|
notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
|
|
false,
|
|
`${toPRTime(updatedItem.lastModified)}`,
|
|
toPRTime(updatedItem.lastModified),
|
|
updatedItem.type,
|
|
updatedItem._parentId,
|
|
updatedItem.guid,
|
|
updatedItem.parentGuid ]);
|
|
}
|
|
if (updateInfo.hasOwnProperty("title")) {
|
|
notify(observers, "onItemChanged", [ updatedItem._id, "title",
|
|
false, updatedItem.title,
|
|
toPRTime(updatedItem.lastModified),
|
|
updatedItem.type,
|
|
updatedItem._parentId,
|
|
updatedItem.guid,
|
|
updatedItem.parentGuid ]);
|
|
}
|
|
if (updateInfo.hasOwnProperty("url")) {
|
|
notify(observers, "onItemChanged", [ updatedItem._id, "uri",
|
|
false, updatedItem.url.href,
|
|
toPRTime(updatedItem.lastModified),
|
|
updatedItem.type,
|
|
updatedItem._parentId,
|
|
updatedItem.guid,
|
|
updatedItem.parentGuid ]);
|
|
}
|
|
// If the item was moved, notify onItemMoved.
|
|
if (item.parentGuid != updatedItem.parentGuid ||
|
|
item.index != updatedItem.index) {
|
|
notify(observers, "onItemMoved", [ updatedItem._id, item._parentId,
|
|
item.index, updatedItem._parentId,
|
|
updatedItem.index, updatedItem.type,
|
|
updatedItem.guid, item.parentGuid,
|
|
updatedItem.parentGuid ]);
|
|
}
|
|
|
|
// Remove non-enumerable properties.
|
|
return Object.assign({}, updatedItem);
|
|
}.bind(this)));
|
|
}.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Removes a bookmark-item.
|
|
*
|
|
* @param guidOrInfo
|
|
* The globally unique identifier of the item to remove, or an
|
|
* object representing it, as defined above.
|
|
*
|
|
* @return {Promise} resolved when the removal is complete.
|
|
* @resolves to an object representing the removed bookmark.
|
|
* @rejects if the provided guid doesn't match any existing bookmark.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
remove(guidOrInfo) {
|
|
let info = guidOrInfo;
|
|
if (!info)
|
|
throw new Error("Input should be a valid object");
|
|
if (typeof(guidOrInfo) != "object")
|
|
info = { guid: guidOrInfo };
|
|
|
|
// Disallow removing the root folders.
|
|
if ([this.rootGuid, this.menuGuid, this.toolbarGuid, this.unfiledGuid,
|
|
this.tagsGuid].indexOf(info.guid) != -1) {
|
|
throw new Error("It's not possible to remove Places root folders.");
|
|
}
|
|
|
|
// Even if we ignore any other unneeded property, we still validate any
|
|
// known property to reduce likelihood of hidden bugs.
|
|
let removeInfo = validateBookmarkObject(info);
|
|
|
|
return Task.spawn(function* () {
|
|
let item = yield fetchBookmark(removeInfo);
|
|
if (!item)
|
|
throw new Error("No bookmarks found for the provided GUID.");
|
|
|
|
item = yield removeBookmark(item);
|
|
|
|
// Notify onItemRemoved to listeners.
|
|
let observers = PlacesUtils.bookmarks.getObservers();
|
|
let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
|
|
notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
|
|
item.type, uri, item.guid,
|
|
item.parentGuid ]);
|
|
|
|
let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
|
|
if (isUntagging) {
|
|
for (let entry of (yield fetchBookmarksByURL(item))) {
|
|
notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
|
|
toPRTime(entry.lastModified),
|
|
entry.type, entry._parentId,
|
|
entry.guid, entry.parentGuid ]);
|
|
}
|
|
}
|
|
|
|
// Remove non-enumerable properties.
|
|
return Object.assign({}, item);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Removes ALL bookmarks, resetting the bookmarks storage to an empty tree.
|
|
*
|
|
* Note that roots are preserved, only their children will be removed.
|
|
*
|
|
* @return {Promise} resolved when the removal is complete.
|
|
* @resolves once the removal is complete.
|
|
*/
|
|
eraseEverything: function() {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
|
|
db => db.executeTransaction(function* () {
|
|
const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid];
|
|
yield removeFoldersContents(db, folderGuids);
|
|
const time = toPRTime(new Date());
|
|
for (let folderGuid of folderGuids) {
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET lastModified = :time
|
|
WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
|
|
`, { folderGuid, time });
|
|
}
|
|
}.bind(this))
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Fetches information about a bookmark-item.
|
|
*
|
|
* REMARK: any successful call to this method resolves to a single
|
|
* bookmark-item (or null), even when multiple bookmarks may exist
|
|
* (e.g. fetching by url). If you wish to retrieve all of the
|
|
* bookmarks for a given match, use the callback instead.
|
|
*
|
|
* Input can be either a guid or an object with one, and only one, of these
|
|
* filtering properties set:
|
|
* - guid
|
|
* retrieves the item with the specified guid.
|
|
* - parentGuid and index
|
|
* retrieves the item by its position.
|
|
* - url
|
|
* retrieves the most recent bookmark having the given URL.
|
|
* To retrieve ALL of the bookmarks for that URL, you must pass in an
|
|
* onResult callback, that will be invoked once for each found bookmark.
|
|
*
|
|
* @param guidOrInfo
|
|
* The globally unique identifier of the item to fetch, or an
|
|
* object representing it, as defined above.
|
|
* @param onResult [optional]
|
|
* Callback invoked for each found bookmark.
|
|
*
|
|
* @return {Promise} resolved when the fetch is complete.
|
|
* @resolves to an object representing the found item, as described above, or
|
|
* an array of such objects. if no item is found, the returned
|
|
* promise is resolved to null.
|
|
* @rejects if an error happens while fetching.
|
|
* @throws if the arguments are invalid.
|
|
*
|
|
* @note Any unknown property in the info object is ignored. Known properties
|
|
* may be overwritten.
|
|
*/
|
|
fetch(guidOrInfo, onResult=null) {
|
|
if (onResult && typeof onResult != "function")
|
|
throw new Error("onResult callback must be a valid function");
|
|
let info = guidOrInfo;
|
|
if (!info)
|
|
throw new Error("Input should be a valid object");
|
|
if (typeof(info) != "object")
|
|
info = { guid: guidOrInfo };
|
|
|
|
// Only one condition at a time can be provided.
|
|
let conditionsCount = [
|
|
v => v.hasOwnProperty("guid"),
|
|
v => v.hasOwnProperty("parentGuid") && v.hasOwnProperty("index"),
|
|
v => v.hasOwnProperty("url")
|
|
].reduce((old, fn) => old + fn(info)|0, 0);
|
|
if (conditionsCount != 1)
|
|
throw new Error(`Unexpected number of conditions provided: ${conditionsCount}`);
|
|
|
|
// Even if we ignore any other unneeded property, we still validate any
|
|
// known property to reduce likelihood of hidden bugs.
|
|
let fetchInfo = validateBookmarkObject(info,
|
|
{ parentGuid: { requiredIf: b => b.hasOwnProperty("index") }
|
|
, index: { requiredIf: b => b.hasOwnProperty("parentGuid")
|
|
, validIf: b => typeof(b.index) == "number" &&
|
|
b.index >= 0 || b.index == this.DEFAULT_INDEX }
|
|
});
|
|
|
|
return Task.spawn(function* () {
|
|
let results;
|
|
if (fetchInfo.hasOwnProperty("guid"))
|
|
results = yield fetchBookmark(fetchInfo);
|
|
else if (fetchInfo.hasOwnProperty("parentGuid") && fetchInfo.hasOwnProperty("index"))
|
|
results = yield fetchBookmarkByPosition(fetchInfo);
|
|
else if (fetchInfo.hasOwnProperty("url"))
|
|
results = yield fetchBookmarksByURL(fetchInfo);
|
|
|
|
if (!results)
|
|
return null;
|
|
|
|
if (!Array.isArray(results))
|
|
results = [results];
|
|
// Remove non-enumerable properties.
|
|
results = results.map(r => Object.assign({}, r));
|
|
|
|
// Ideally this should handle an incremental behavior and thus be invoked
|
|
// while we fetch. Though, the likelihood of 2 or more bookmarks for the
|
|
// same match is very low, so it's not worth the added code complication.
|
|
if (onResult) {
|
|
for (let result of results) {
|
|
try {
|
|
onResult(result);
|
|
} catch (ex) {
|
|
Cu.reportError(ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
return results[0];
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Retrieves an object representation of a bookmark-item, along with all of
|
|
* its descendants, if any.
|
|
*
|
|
* Each node in the tree is an object that extends the item representation
|
|
* described above with some additional properties:
|
|
*
|
|
* - [deprecated] id (number)
|
|
* the item's id. Defined only if aOptions.includeItemIds is set.
|
|
* - annos (array)
|
|
* the item's annotations. This is not set if there are no annotations
|
|
* set for the item.
|
|
*
|
|
* The root object of the tree also has the following properties set:
|
|
* - itemsCount (number, not enumerable)
|
|
* the number of items, including the root item itself, which are
|
|
* represented in the resolved object.
|
|
*
|
|
* Bookmarked URLs may also have the following properties:
|
|
* - tags (string)
|
|
* csv string of the bookmark's tags, if any.
|
|
* - charset (string)
|
|
* the last known charset of the bookmark, if any.
|
|
* - iconurl (URL)
|
|
* the bookmark's favicon URL, if any.
|
|
*
|
|
* 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.
|
|
*
|
|
* @param [optional] guid
|
|
* the topmost item to be queried. If it's not passed, the Places
|
|
* root folder is queried: that is, you get a representation of the
|
|
* entire bookmarks hierarchy.
|
|
* @param [optional] options
|
|
* Options for customizing the query behavior, in the form of an
|
|
* 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} resolved when the fetch is complete.
|
|
* @resolves to an object that represents either a single item or a
|
|
* bookmarks tree. if guid points to a non-existent item, the
|
|
* returned promise is resolved to null.
|
|
* @rejects if an error happens while fetching.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
// TODO must implement these methods yet:
|
|
// PlacesUtils.promiseBookmarksTree()
|
|
fetchTree(guid = "", options = {}) {
|
|
throw new Error("Not yet implemented");
|
|
},
|
|
|
|
/**
|
|
* Reorders contents of a folder based on a provided array of GUIDs.
|
|
*
|
|
* @param parentGuid
|
|
* The globally unique identifier of the folder whose contents should
|
|
* be reordered.
|
|
* @param orderedChildrenGuids
|
|
* Ordered array of the children's GUIDs. If this list contains
|
|
* non-existing entries they will be ignored. If the list is
|
|
* incomplete, missing entries will be appended.
|
|
*
|
|
* @return {Promise} resolved when reordering is complete.
|
|
* @rejects if an error happens while reordering.
|
|
* @throws if the arguments are invalid.
|
|
*/
|
|
reorder(parentGuid, orderedChildrenGuids) {
|
|
let info = { guid: parentGuid };
|
|
info = validateBookmarkObject(info, { guid: { required: true } });
|
|
|
|
if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length)
|
|
throw new Error("Must provide a sorted array of children GUIDs.");
|
|
try {
|
|
orderedChildrenGuids.forEach(VALIDATORS.guid);
|
|
} catch (ex) {
|
|
throw new Error("Invalid GUID found in the sorted children array.");
|
|
}
|
|
|
|
return Task.spawn(function* () {
|
|
let parent = yield fetchBookmark(info);
|
|
if (!parent || parent.type != this.TYPE_FOLDER)
|
|
throw new Error("No folder found for the provided GUID.");
|
|
|
|
let sortedChildren = yield reorderChildren(parent, orderedChildrenGuids);
|
|
|
|
let observers = PlacesUtils.bookmarks.getObservers();
|
|
// Note that child.index is the old index.
|
|
for (let i = 0; i < sortedChildren.length; ++i) {
|
|
let child = sortedChildren[i];
|
|
notify(observers, "onItemMoved", [ child._id, child._parentId,
|
|
child.index, child._parentId,
|
|
i, child.type,
|
|
child.guid, child.parentGuid,
|
|
child.parentGuid ]);
|
|
}
|
|
}.bind(this));
|
|
}
|
|
});
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Globals.
|
|
|
|
/**
|
|
* 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) {}
|
|
}
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update implementation.
|
|
|
|
function updateBookmark(info, item, newParent) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
|
|
Task.async(function*(db) {
|
|
|
|
let tuples = new Map();
|
|
if (info.hasOwnProperty("lastModified"))
|
|
tuples.set("lastModified", { value: toPRTime(info.lastModified) });
|
|
if (info.hasOwnProperty("title"))
|
|
tuples.set("title", { value: info.title });
|
|
|
|
yield db.executeTransaction(function* () {
|
|
if (info.hasOwnProperty("url")) {
|
|
// Ensure a page exists in moz_places for this URL.
|
|
yield db.executeCached(
|
|
`INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
|
|
VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
|
|
`, { url: info.url ? info.url.href : null,
|
|
rev_host: PlacesUtils.getReversedHost(info.url),
|
|
frecency: info.url.protocol == "place:" ? 0 : -1 });
|
|
tuples.set("url", { value: info.url.href
|
|
, fragment: "fk = (SELECT id FROM moz_places WHERE url = :url)" });
|
|
}
|
|
|
|
if (newParent) {
|
|
// For simplicity, update the index regardless.
|
|
let newIndex = info.hasOwnProperty("index") ? info.index : item.index;
|
|
tuples.set("position", { value: newIndex });
|
|
|
|
if (newParent.guid == item.parentGuid) {
|
|
// Moving inside the original container.
|
|
// When moving "up", add 1 to each index in the interval.
|
|
// Otherwise when moving down, we subtract 1.
|
|
let sign = newIndex < item.index ? +1 : -1;
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET position = position + :sign
|
|
WHERE parent = :newParentId
|
|
AND position BETWEEN :lowIndex AND :highIndex
|
|
`, { sign: sign, newParentId: newParent._id,
|
|
lowIndex: Math.min(item.index, newIndex),
|
|
highIndex: Math.max(item.index, newIndex) });
|
|
} else {
|
|
// Moving across different containers.
|
|
tuples.set("parent", { value: newParent._id} );
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET position = position + :sign
|
|
WHERE parent = :oldParentId
|
|
AND position >= :oldIndex
|
|
`, { sign: -1, oldParentId: item._parentId, oldIndex: item.index });
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET position = position + :sign
|
|
WHERE parent = :newParentId
|
|
AND position >= :newIndex
|
|
`, { sign: +1, newParentId: newParent._id, newIndex: newIndex });
|
|
|
|
yield setAncestorsLastModified(db, item.parentGuid, info.lastModified);
|
|
}
|
|
yield setAncestorsLastModified(db, newParent.guid, info.lastModified);
|
|
}
|
|
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks
|
|
SET ${[tuples.get(v).fragment || `${v} = :${v}` for (v of tuples.keys())].join(", ")}
|
|
WHERE guid = :guid
|
|
`, Object.assign({ guid: info.guid },
|
|
[...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {})));
|
|
});
|
|
|
|
// If the parent changed, update related non-enumerable properties.
|
|
let additionalParentInfo = {};
|
|
if (newParent) {
|
|
Object.defineProperty(additionalParentInfo, "_parentId",
|
|
{ value: newParent._id, enumerable: false });
|
|
Object.defineProperty(additionalParentInfo, "_grandParentId",
|
|
{ value: newParent._parentId, enumerable: false });
|
|
}
|
|
|
|
let updatedItem = mergeIntoNewObject(item, info, additionalParentInfo);
|
|
|
|
// Don't return an empty title to the caller.
|
|
if (updatedItem.hasOwnProperty("title") && updatedItem.title === null)
|
|
delete updatedItem.title;
|
|
|
|
return updatedItem;
|
|
}));
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Insert implementation.
|
|
|
|
function insertBookmark(item, parent) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark",
|
|
Task.async(function*(db) {
|
|
|
|
// If a guid was not provided, generate one, so we won't need to fetch the
|
|
// bookmark just after having created it.
|
|
if (!item.hasOwnProperty("guid"))
|
|
item.guid = (yield db.executeCached("SELECT GENERATE_GUID() AS guid"))[0].getResultByName("guid");
|
|
|
|
yield db.executeTransaction(function* transaction() {
|
|
if (item.type == Bookmarks.TYPE_BOOKMARK) {
|
|
// Ensure a page exists in moz_places for this URL.
|
|
yield db.executeCached(
|
|
`INSERT OR IGNORE INTO moz_places (url, rev_host, hidden, frecency, guid)
|
|
VALUES (:url, :rev_host, 0, :frecency, GENERATE_GUID())
|
|
`, { url: item.url.href, rev_host: PlacesUtils.getReversedHost(item.url),
|
|
frecency: item.url.protocol == "place:" ? 0 : -1 });
|
|
}
|
|
|
|
// Adjust indices.
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET position = position + 1
|
|
WHERE parent = :parent
|
|
AND position >= :index
|
|
`, { parent: parent._id, index: item.index });
|
|
|
|
// Insert the bookmark into the database.
|
|
yield db.executeCached(
|
|
`INSERT INTO moz_bookmarks (fk, type, parent, position, title,
|
|
dateAdded, lastModified, guid)
|
|
VALUES ((SELECT id FROM moz_places WHERE url = :url), :type, :parent,
|
|
:index, :title, :date_added, :last_modified, :guid)
|
|
`, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
|
|
type: item.type, parent: parent._id, index: item.index,
|
|
title: item.title, date_added: toPRTime(item.dateAdded),
|
|
last_modified: toPRTime(item.lastModified), guid: item.guid });
|
|
|
|
yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
|
|
});
|
|
|
|
// If not a tag recalculate frecency...
|
|
let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
|
|
if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
|
|
// ...though we don't wait for the calculation.
|
|
updateFrecency(db, [item.url]).then(null, Cu.reportError);
|
|
}
|
|
|
|
// Don't return an empty title to the caller.
|
|
if (item.hasOwnProperty("title") && item.title === null)
|
|
delete item.title;
|
|
|
|
return item;
|
|
}));
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Fetch implementation.
|
|
|
|
function fetchBookmark(info) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark",
|
|
Task.async(function*(db) {
|
|
|
|
let rows = yield db.executeCached(
|
|
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
|
|
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
|
|
b.id AS _id, b.parent AS _parentId,
|
|
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
|
|
p.parent AS _grandParentId
|
|
FROM moz_bookmarks b
|
|
LEFT JOIN moz_bookmarks p ON p.id = b.parent
|
|
LEFT JOIN moz_places h ON h.id = b.fk
|
|
WHERE b.guid = :guid
|
|
`, { guid: info.guid });
|
|
|
|
return rows.length ? rowsToItemsArray(rows)[0] : null;
|
|
}));
|
|
}
|
|
|
|
function fetchBookmarkByPosition(info) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarkByPosition",
|
|
Task.async(function*(db) {
|
|
let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
|
|
|
|
let rows = yield db.executeCached(
|
|
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
|
|
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
|
|
b.id AS _id, b.parent AS _parentId,
|
|
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
|
|
p.parent AS _grandParentId
|
|
FROM moz_bookmarks b
|
|
LEFT JOIN moz_bookmarks p ON p.id = b.parent
|
|
LEFT JOIN moz_places h ON h.id = b.fk
|
|
WHERE p.guid = :parentGuid
|
|
AND b.position = IFNULL(:index, (SELECT count(*) - 1
|
|
FROM moz_bookmarks
|
|
WHERE parent = p.id))
|
|
`, { parentGuid: info.parentGuid, index });
|
|
|
|
return rows.length ? rowsToItemsArray(rows)[0] : null;
|
|
}));
|
|
}
|
|
|
|
function fetchBookmarksByURL(info) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByURL",
|
|
Task.async(function*(db) {
|
|
|
|
let rows = yield db.executeCached(
|
|
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
|
|
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
|
|
b.id AS _id, b.parent AS _parentId,
|
|
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
|
|
p.parent AS _grandParentId
|
|
FROM moz_bookmarks b
|
|
LEFT JOIN moz_bookmarks p ON p.id = b.parent
|
|
LEFT JOIN moz_places h ON h.id = b.fk
|
|
WHERE h.url = :url
|
|
AND _grandParentId <> :tags_folder
|
|
ORDER BY b.lastModified DESC
|
|
`, { url: info.url.href,
|
|
tags_folder: PlacesUtils.tagsFolderId });
|
|
|
|
return rows.length ? rowsToItemsArray(rows) : null;
|
|
}));
|
|
}
|
|
|
|
function fetchBookmarksByParent(info) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByParent",
|
|
Task.async(function*(db) {
|
|
|
|
let rows = yield db.executeCached(
|
|
`SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
|
|
b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
|
|
b.id AS _id, b.parent AS _parentId,
|
|
(SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
|
|
p.parent AS _grandParentId
|
|
FROM moz_bookmarks b
|
|
LEFT JOIN moz_bookmarks p ON p.id = b.parent
|
|
LEFT JOIN moz_places h ON h.id = b.fk
|
|
WHERE p.guid = :parentGuid
|
|
ORDER BY b.position ASC
|
|
`, { parentGuid: info.parentGuid });
|
|
|
|
return rowsToItemsArray(rows);
|
|
}));
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Remove implementation.
|
|
|
|
function removeBookmark(item) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
|
|
Task.async(function*(db) {
|
|
|
|
let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
|
|
|
|
yield db.executeTransaction(function* transaction() {
|
|
// If it's a folder, remove its contents first.
|
|
if (item.type == Bookmarks.TYPE_FOLDER)
|
|
yield removeFoldersContents(db, [item.guid]);
|
|
|
|
// Remove annotations first. If it's a tag, we can avoid paying that cost.
|
|
if (!isUntagging) {
|
|
// We don't go through the annotations service for this cause otherwise
|
|
// we'd get a pointless onItemChanged notification and it would also
|
|
// set lastModified to an unexpected value.
|
|
yield removeAnnotationsForItem(db, item._id);
|
|
}
|
|
|
|
// Remove the bookmark from the database.
|
|
yield db.executeCached(
|
|
`DELETE FROM moz_bookmarks WHERE guid = :guid`, { guid: item.guid });
|
|
|
|
// Fix indices in the parent.
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET position = position - 1 WHERE
|
|
parent = :parentId AND position > :index
|
|
`, { parentId: item._parentId, index: item.index });
|
|
|
|
yield setAncestorsLastModified(db, item.parentGuid, new Date());
|
|
});
|
|
|
|
// If not a tag recalculate frecency...
|
|
if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
|
|
// ...though we don't wait for the calculation.
|
|
updateFrecency(db, [item.url]).then(null, Cu.reportError);
|
|
}
|
|
|
|
return item;
|
|
}));
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Reorder implementation.
|
|
|
|
function reorderChildren(parent, orderedChildrenGuids) {
|
|
return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
|
|
db => db.executeTransaction(function* () {
|
|
// Select all of the direct children for the given parent.
|
|
let children = yield fetchBookmarksByParent({ parentGuid: parent.guid });
|
|
if (!children.length)
|
|
return;
|
|
|
|
// Reorder the children array according to the specified order, provided
|
|
// GUIDs come first, others are appended in somehow random order.
|
|
children.sort((a, b) => {
|
|
let i = orderedChildrenGuids.indexOf(a.guid);
|
|
let j = orderedChildrenGuids.indexOf(b.guid);
|
|
// This works provided fetchBookmarksByParent returns sorted children.
|
|
return (i == -1 && j == -1) ? 0 :
|
|
(i != -1 && j != -1 && i < j) || (i != -1 && j == -1) ? -1 : 1;
|
|
});
|
|
|
|
// Update the bookmarks position now. If any unknown guid have been
|
|
// inserted meanwhile, its position will be set to -position, and we'll
|
|
// handle it later.
|
|
// To do the update in a single step, we build a VALUES (guid, position)
|
|
// table. We then use count() in the sorting table to avoid skipping values
|
|
// when no more existing GUIDs have been provided.
|
|
let valuesTable = children.map((child, i) => `("${child.guid}", ${i})`)
|
|
.join();
|
|
yield db.execute(
|
|
`WITH sorting(g, p) AS (
|
|
VALUES ${valuesTable}
|
|
)
|
|
UPDATE moz_bookmarks SET position = (
|
|
SELECT CASE count(a.g) WHEN 0 THEN -position
|
|
ELSE count(a.g) - 1
|
|
END
|
|
FROM sorting a
|
|
JOIN sorting b ON b.p <= a.p
|
|
WHERE a.g = guid
|
|
AND parent = :parentId
|
|
)`, { parentId: parent._id});
|
|
|
|
// Update position of items that could have been inserted in the meanwhile.
|
|
// Since this can happen rarely and it's only done for schema coherence
|
|
// resonds, we won't notify about these changes.
|
|
yield db.executeCached(
|
|
`CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger
|
|
AFTER UPDATE OF position ON moz_bookmarks
|
|
WHEN NEW.position = -1
|
|
BEGIN
|
|
UPDATE moz_bookmarks
|
|
SET position = (SELECT MAX(position) FROM moz_bookmarks
|
|
WHERE parent = NEW.parent) +
|
|
(SELECT count(*) FROM moz_bookmarks
|
|
WHERE parent = NEW.parent
|
|
AND position BETWEEN OLD.position AND -1)
|
|
WHERE guid = NEW.guid;
|
|
END
|
|
`);
|
|
|
|
yield db.executeCached(
|
|
`UPDATE moz_bookmarks SET position = -1 WHERE position < 0`);
|
|
|
|
yield db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`);
|
|
|
|
return children;
|
|
}.bind(this))
|
|
);
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Helpers.
|
|
|
|
/**
|
|
* Merges objects into a new object, included non-enumerable properties.
|
|
*
|
|
* @param sources
|
|
* source objects to merge.
|
|
* @return a new object including all properties from the source objects.
|
|
*/
|
|
function mergeIntoNewObject(...sources) {
|
|
let dest = {};
|
|
for (let src of sources) {
|
|
for (let prop of Object.getOwnPropertyNames(src)) {
|
|
Object.defineProperty(dest, prop, Object.getOwnPropertyDescriptor(src, prop));
|
|
}
|
|
}
|
|
return dest;
|
|
}
|
|
|
|
/**
|
|
* Remove properties that have the same value across two bookmark objects.
|
|
*
|
|
* @param dest
|
|
* destination bookmark object.
|
|
* @param src
|
|
* source bookmark object.
|
|
* @return a cleaned up bookmark object.
|
|
* @note "guid" is never removed.
|
|
*/
|
|
function removeSameValueProperties(dest, src) {
|
|
for (let prop in dest) {
|
|
let remove = false;
|
|
switch (prop) {
|
|
case "lastModified":
|
|
case "dateAdded":
|
|
remove = src.hasOwnProperty(prop) && dest[prop].getTime() == src[prop].getTime();
|
|
break;
|
|
case "url":
|
|
remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href;
|
|
break;
|
|
default:
|
|
remove = dest[prop] == src[prop];
|
|
}
|
|
if (remove && prop != "guid")
|
|
delete dest[prop];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts an URL object to an nsIURI.
|
|
*
|
|
* @param url
|
|
* the URL object to convert.
|
|
* @return nsIURI for the given URL.
|
|
*/
|
|
function toURI(url) {
|
|
return NetUtil.newURI(url.href);
|
|
}
|
|
|
|
/**
|
|
* Convert a Date object to a PRTime (microseconds).
|
|
*
|
|
* @param date
|
|
* the Date object to convert.
|
|
* @return microseconds from the epoch.
|
|
*/
|
|
function toPRTime(date) {
|
|
return date * 1000;
|
|
}
|
|
|
|
/**
|
|
* Convert a PRTime to a Date object.
|
|
*
|
|
* @param time
|
|
* microseconds from the epoch.
|
|
* @return a Date object.
|
|
*/
|
|
function toDate(time) {
|
|
return new Date(parseInt(time / 1000));
|
|
}
|
|
|
|
/**
|
|
* Convert an array of mozIStorageRow objects to an array of bookmark objects.
|
|
*
|
|
* @param rows
|
|
* the array of mozIStorageRow objects.
|
|
* @return an array of bookmark objects.
|
|
*/
|
|
function rowsToItemsArray(rows) {
|
|
return rows.map(row => {
|
|
let item = {};
|
|
for (let prop of ["guid", "index", "type"]) {
|
|
item[prop] = row.getResultByName(prop);
|
|
}
|
|
for (let prop of ["dateAdded", "lastModified"]) {
|
|
item[prop] = toDate(row.getResultByName(prop));
|
|
}
|
|
for (let prop of ["title", "parentGuid", "url" ]) {
|
|
let val = row.getResultByName(prop);
|
|
if (val)
|
|
item[prop] = prop === "url" ? new URL(val) : val;
|
|
}
|
|
for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
|
|
let val = row.getResultByName(prop);
|
|
if (val !== null) {
|
|
// These properties should not be returned to the API consumer, thus
|
|
// they are non-enumerable and removed through Object.assign just before
|
|
// the object is returned.
|
|
// Configurable is set to support mergeIntoNewObject overwrites.
|
|
Object.defineProperty(item, prop, { value: val, enumerable: false,
|
|
configurable: true });
|
|
}
|
|
}
|
|
|
|
return item;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 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 VALIDATORS = Object.freeze({
|
|
guid: simpleValidateFunc(v => typeof(v) == "string" &&
|
|
/^[a-zA-Z0-9\-_]{12}$/.test(v)),
|
|
parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
|
|
/^[a-zA-Z0-9\-_]{12}$/.test(v)),
|
|
index: simpleValidateFunc(v => Number.isInteger(v) &&
|
|
v >= Bookmarks.DEFAULT_INDEX),
|
|
dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
|
|
lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
|
|
type: simpleValidateFunc(v => Number.isInteger(v) &&
|
|
[ Bookmarks.TYPE_BOOKMARK
|
|
, Bookmarks.TYPE_FOLDER
|
|
, Bookmarks.TYPE_SEPARATOR ].indexOf(v) != -1),
|
|
title: v => {
|
|
simpleValidateFunc(val => val === null || typeof(val) == "string").call(this, v);
|
|
if (!v)
|
|
return null;
|
|
return v.slice(0, DB_TITLE_LENGTH_MAX);
|
|
},
|
|
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;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Checks validity of a bookmark object, filling up default values for optional
|
|
* properties.
|
|
*
|
|
* @param input (object)
|
|
* The bookmark object to validate.
|
|
* @param behavior (object) [optional]
|
|
* Object defining special behavior for some of the properties.
|
|
* The following behaviors may be optionally set:
|
|
* - 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.
|
|
*
|
|
* @return a validated and normalized bookmark-item.
|
|
* @throws if the object contains invalid data.
|
|
* @note any unknown properties are pass-through.
|
|
*/
|
|
function validateBookmarkObject(input, behavior={}) {
|
|
if (!input)
|
|
throw new Error("Input should be a valid object");
|
|
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)) {
|
|
throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
|
|
}
|
|
if (behavior[prop].hasOwnProperty("defaultValue") && input[prop] === undefined) {
|
|
input[prop] = behavior[prop].defaultValue;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
|
|
}
|
|
}
|
|
}
|
|
if (required.size > 0)
|
|
throw new Error(`The following properties were expected: ${[...required].join(", ")}`);
|
|
return normalizedInput;
|
|
}
|
|
|
|
/**
|
|
* Updates frecency for a list of URLs.
|
|
*
|
|
* @param db
|
|
* the Sqlite.jsm connection handle.
|
|
* @param urls
|
|
* the array of URLs to update.
|
|
*/
|
|
let updateFrecency = Task.async(function* (db, urls) {
|
|
yield db.execute(
|
|
`UPDATE moz_places
|
|
SET frecency = NOTIFY_FRECENCY(
|
|
CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
|
|
) WHERE url IN ( ${urls.map(url => JSON.stringify(url.href)).join(", ")} )
|
|
`);
|
|
|
|
yield db.execute(
|
|
`UPDATE moz_places
|
|
SET hidden = 0
|
|
WHERE url IN ( ${urls.map(url => JSON.stringify(url.href)).join(", ")} )
|
|
AND frecency <> 0
|
|
`);
|
|
});
|
|
|
|
/**
|
|
* Removes any orphan annotation entries.
|
|
*
|
|
* @param db
|
|
* the Sqlite.jsm connection handle.
|
|
*/
|
|
let removeOrphanAnnotations = Task.async(function* (db) {
|
|
yield db.executeCached(
|
|
`DELETE FROM moz_items_annos
|
|
WHERE id IN (SELECT a.id from moz_items_annos a
|
|
LEFT JOIN moz_bookmarks b ON a.item_id = b.id
|
|
WHERE b.id ISNULL)
|
|
`);
|
|
yield db.executeCached(
|
|
`DELETE FROM moz_anno_attributes
|
|
WHERE id IN (SELECT n.id from moz_anno_attributes n
|
|
LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
|
|
LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
|
|
WHERE a1.id ISNULL AND a2.id ISNULL)
|
|
`);
|
|
});
|
|
|
|
/**
|
|
* Removes annotations for a given item.
|
|
*
|
|
* @param db
|
|
* the Sqlite.jsm connection handle.
|
|
* @param itemId
|
|
* internal id of the item for which to remove annotations.
|
|
*/
|
|
let removeAnnotationsForItem = Task.async(function* (db, itemId) {
|
|
yield db.executeCached(
|
|
`DELETE FROM moz_items_annos
|
|
WHERE item_id = :id
|
|
`, { id: itemId });
|
|
yield db.executeCached(
|
|
`DELETE FROM moz_anno_attributes
|
|
WHERE id IN (SELECT n.id from moz_anno_attributes n
|
|
LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
|
|
LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
|
|
WHERE a1.id ISNULL AND a2.id ISNULL)
|
|
`);
|
|
});
|
|
|
|
/**
|
|
* Updates lastModified for all the ancestors of a given folder GUID.
|
|
*
|
|
* @param db
|
|
* the Sqlite.jsm connection handle.
|
|
* @param folderGuid
|
|
* the GUID of the folder whose ancestors should be updated.
|
|
* @param time
|
|
* a Date object to use for the update.
|
|
*
|
|
* @note the folder itself is also updated.
|
|
*/
|
|
let setAncestorsLastModified = Task.async(function* (db, folderGuid, time) {
|
|
yield db.executeCached(
|
|
`WITH RECURSIVE
|
|
ancestors(aid) AS (
|
|
SELECT id FROM moz_bookmarks WHERE guid = :guid
|
|
UNION ALL
|
|
SELECT parent FROM moz_bookmarks
|
|
JOIN ancestors ON id = aid
|
|
WHERE type = :type
|
|
)
|
|
UPDATE moz_bookmarks SET lastModified = :time
|
|
WHERE id IN ancestors
|
|
`, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
|
|
time: toPRTime(time) });
|
|
});
|
|
|
|
/**
|
|
* Remove all descendants of one or more bookmark folders.
|
|
*
|
|
* @param db
|
|
* the Sqlite.jsm connection handle.
|
|
* @param folderGuids
|
|
* array of folder guids.
|
|
*/
|
|
let removeFoldersContents =
|
|
Task.async(function* (db, folderGuids) {
|
|
let itemsRemoved = [];
|
|
for (let folderGuid of folderGuids) {
|
|
let rows = yield db.executeCached(
|
|
`WITH RECURSIVE
|
|
descendants(did) AS (
|
|
SELECT b.id FROM moz_bookmarks b
|
|
JOIN moz_bookmarks p ON b.parent = p.id
|
|
WHERE p.guid = :folderGuid
|
|
UNION ALL
|
|
SELECT id FROM moz_bookmarks
|
|
JOIN descendants ON parent = did
|
|
)
|
|
SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index',
|
|
b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded,
|
|
b.lastModified, b.title, p.parent AS _grandParentId,
|
|
NULL AS _childCount
|
|
FROM moz_bookmarks b
|
|
JOIN moz_bookmarks p ON p.id = b.parent
|
|
LEFT JOIN moz_places h ON b.fk = h.id
|
|
WHERE b.id IN descendants`, { folderGuid });
|
|
|
|
itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows));
|
|
|
|
yield db.executeCached(
|
|
`WITH RECURSIVE
|
|
descendants(did) AS (
|
|
SELECT b.id FROM moz_bookmarks b
|
|
JOIN moz_bookmarks p ON b.parent = p.id
|
|
WHERE p.guid = :folderGuid
|
|
UNION ALL
|
|
SELECT id FROM moz_bookmarks
|
|
JOIN descendants ON parent = did
|
|
)
|
|
DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid });
|
|
}
|
|
|
|
// Cleanup orphans.
|
|
yield removeOrphanAnnotations(db);
|
|
|
|
// TODO (Bug 1087576): this may leave orphan tags behind.
|
|
|
|
let urls = [for (item of itemsRemoved) if (item.url) item.url];
|
|
updateFrecency(db, urls).then(null, Cu.reportError);
|
|
|
|
// Send onItemRemoved notifications to listeners.
|
|
// TODO (Bug 1087580): for the case of eraseEverything, this should send a
|
|
// single clear bookmarks notification rather than notifying for each
|
|
// bookmark.
|
|
|
|
// Notify listeners in reverse order to serve children before parents.
|
|
let observers = PlacesUtils.bookmarks.getObservers();
|
|
for (let item of itemsRemoved.reverse()) {
|
|
let uri = item.hasOwnProperty("url") ? toURI(item.url) : null;
|
|
notify(observers, "onItemRemoved", [ item._id, item._parentId,
|
|
item.index, item.type, uri,
|
|
item.guid, item.parentGuid ]);
|
|
|
|
let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
|
|
if (isUntagging) {
|
|
for (let entry of (yield fetchBookmarksByURL(item))) {
|
|
notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
|
|
toPRTime(entry.lastModified),
|
|
entry.type, entry._parentId,
|
|
entry.guid, entry.parentGuid ]);
|
|
}
|
|
}
|
|
}
|
|
});
|