/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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";
ChromeUtils.defineESModuleGetters(
this, {
PlacesUtils:
"resource://gre/modules/PlacesUtils.sys.mjs",
});
var { ExtensionError } = ExtensionUtils;
const { TYPE_BOOKMARK, TYPE_FOLDER, TYPE_SEPARATOR } = PlacesUtils.bookmarks;
const BOOKMARKS_TYPES_TO_API_TYPES_MAP =
new Map([
[TYPE_BOOKMARK,
"bookmark"],
[TYPE_FOLDER,
"folder"],
[TYPE_SEPARATOR,
"separator"],
]);
const BOOKMARK_SEPERATOR_URL =
"data:";
ChromeUtils.defineLazyGetter(
this,
"API_TYPES_TO_BOOKMARKS_TYPES_MAP", () => {
let theMap =
new Map();
for (let [code, name] of BOOKMARKS_TYPES_TO_API_TYPES_MAP) {
theMap.set(name, code);
}
return theMap;
});
let listenerCount = 0;
function getUrl(type, url) {
switch (type) {
case TYPE_BOOKMARK:
return url;
case TYPE_SEPARATOR:
return BOOKMARK_SEPERATOR_URL;
default:
return undefined;
}
}
const getTree = (rootGuid, onlyChildren) => {
function convert(node, parent) {
let treenode = {
id: node.guid,
title: PlacesUtils.bookmarks.getLocalizedTitle(node) ||
"",
index: node.index,
dateAdded: node.dateAdded / 1000,
type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(node.typeCode),
url: getUrl(node.typeCode, node.uri),
};
if (parent && node.guid != PlacesUtils.bookmarks.rootGuid) {
treenode.parentId = parent.guid;
}
if (node.typeCode == TYPE_FOLDER) {
treenode.dateGroupModified = node.lastModified / 1000;
if (!onlyChildren) {
treenode.children = node.children
? node.children.map(child => convert(child, node))
: [];
}
}
return treenode;
}
return PlacesUtils.promiseBookmarksTree(rootGuid)
.then(root => {
if (onlyChildren) {
let children = root.children || [];
return children.map(child => convert(child, root));
}
let treenode = convert(root,
null);
treenode.parentId = root.parentGuid;
// It seems like the array always just contains the root node.
return [treenode];
})
.
catch(e => Promise.reject({ message: e.message }));
};
const convertBookmarks = result => {
let node = {
id: result.guid,
title: PlacesUtils.bookmarks.getLocalizedTitle(result) ||
"",
index: result.index,
dateAdded: result.dateAdded.getTime(),
type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(result.type),
url: getUrl(result.type, result.url && result.url.href),
};
if (result.guid != PlacesUtils.bookmarks.rootGuid) {
node.parentId = result.parentGuid;
}
if (result.type == TYPE_FOLDER) {
node.dateGroupModified = result.lastModified.getTime();
}
return node;
};
const throwIfRootId = id => {
if (id == PlacesUtils.bookmarks.rootGuid) {
throw new ExtensionError(
"The bookmark root cannot be modified");
}
};
let observer =
new (
class extends EventEmitter {
constructor() {
super();
this.handlePlacesEvents =
this.handlePlacesEvents.bind(
this);
}
handlePlacesEvents(events) {
for (let event of events) {
switch (event.type) {
case "bookmark-added":
if (event.isTagging) {
continue;
}
let bookmark = {
id: event.guid,
parentId: event.parentGuid,
index: event.index,
title: event.title,
dateAdded: event.dateAdded,
type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(event.itemType),
url: getUrl(event.itemType, event.url),
};
if (event.itemType == TYPE_FOLDER) {
bookmark.dateGroupModified = bookmark.dateAdded;
}
this.emit(
"created", bookmark);
break;
case "bookmark-removed":
if (event.isTagging || event.isDescendantRemoval) {
continue;
}
let node = {
id: event.guid,
parentId: event.parentGuid,
index: event.index,
type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(event.itemType),
url: getUrl(event.itemType, event.url),
title: event.title,
};
this.emit(
"removed", {
guid: event.guid,
info: { parentId: event.parentGuid, index: event.index, node },
});
break;
case "bookmark-moved":
this.emit(
"moved", {
guid: event.guid,
info: {
parentId: event.parentGuid,
index: event.index,
oldParentId: event.oldParentGuid,
oldIndex: event.oldIndex,
},
});
break;
case "bookmark-title-changed":
if (event.isTagging) {
continue;
}
this.emit(
"changed", {
guid: event.guid,
info: { title: event.title },
});
break;
case "bookmark-url-changed":
if (event.isTagging) {
continue;
}
this.emit(
"changed", {
guid: event.guid,
info: { url: event.url },
});
break;
}
}
}
})();
const decrementListeners = () => {
listenerCount -= 1;
if (!listenerCount) {
PlacesUtils.observers.removeListener(
[
"bookmark-added",
"bookmark-removed",
"bookmark-moved",
"bookmark-title-changed",
"bookmark-url-changed",
],
observer.handlePlacesEvents
);
}
};
const incrementListeners = () => {
listenerCount++;
if (listenerCount == 1) {
PlacesUtils.observers.addListener(
[
"bookmark-added",
"bookmark-removed",
"bookmark-moved",
"bookmark-title-changed",
"bookmark-url-changed",
],
observer.handlePlacesEvents
);
}
};
this.bookmarks =
class extends ExtensionAPIPersistent {
PERSISTENT_EVENTS = {
onCreated({ fire }) {
let listener = (event, bookmark) => {
fire.sync(bookmark.id, bookmark);
};
observer.on(
"created", listener);
incrementListeners();
return {
unregister() {
observer.off(
"created", listener);
decrementListeners();
},
convert(_fire) {
fire = _fire;
},
};
},
onRemoved({ fire }) {
let listener = (event, data) => {
fire.sync(data.guid, data.info);
};
observer.on(
"removed", listener);
incrementListeners();
return {
unregister() {
observer.off(
"removed", listener);
decrementListeners();
},
convert(_fire) {
fire = _fire;
},
};
},
onChanged({ fire }) {
let listener = (event, data) => {
fire.sync(data.guid, data.info);
};
observer.on(
"changed", listener);
incrementListeners();
return {
unregister() {
observer.off(
"changed", listener);
decrementListeners();
},
convert(_fire) {
fire = _fire;
},
};
},
onMoved({ fire }) {
let listener = (event, data) => {
fire.sync(data.guid, data.info);
};
observer.on(
"moved", listener);
incrementListeners();
return {
unregister() {
observer.off(
"moved", listener);
decrementListeners();
},
convert(_fire) {
fire = _fire;
},
};
},
};
getAPI(context) {
return {
bookmarks: {
async get(idOrIdList) {
let list = Array.isArray(idOrIdList) ? idOrIdList : [idOrIdList];
try {
let bookmarks = [];
for (let id of list) {
let bookmark = await PlacesUtils.bookmarks.fetch({ guid: id });
if (!bookmark) {
throw new Error(
"Bookmark not found");
}
bookmarks.push(convertBookmarks(bookmark));
}
return bookmarks;
}
catch (error) {
return Promise.reject({ message: error.message });
}
},
getChildren:
function (id) {
// TODO: We should optimize this.
return getTree(id,
true);
},
getTree:
function () {
return getTree(PlacesUtils.bookmarks.rootGuid,
false);
},
getSubTree:
function (id) {
return getTree(id,
false);
},
search:
function (query) {
return PlacesUtils.bookmarks
.search(query)
.then(result => result.map(convertBookmarks));
},
getRecent:
function (numberOfItems) {
return PlacesUtils.bookmarks
.getRecent(numberOfItems)
.then(result => result.map(convertBookmarks));
},
create:
function (bookmark) {
let info = {
title: bookmark.title ||
"",
};
info.type = API_TYPES_TO_BOOKMARKS_TYPES_MAP.get(bookmark.type);
if (!info.type) {
// If url is NULL or missing, it will be a folder.
if (bookmark.url !==
null) {
info.type = TYPE_BOOKMARK;
}
else {
info.type = TYPE_FOLDER;
}
}
if (info.type === TYPE_BOOKMARK) {
info.url = bookmark.url ||
"";
}
if (bookmark.index !==
null) {
info.index = bookmark.index;
}
if (bookmark.parentId !==
null) {
throwIfRootId(bookmark.parentId);
info.parentGuid = bookmark.parentId;
}
else {
info.parentGuid = PlacesUtils.bookmarks.unfiledGuid;
}
try {
return PlacesUtils.bookmarks
.insert(info)
.then(convertBookmarks)
.
catch(error => Promise.reject({ message: error.message }));
}
catch (e) {
return Promise.reject({
message: `Invalid bookmark: ${JSON.stringify(info)}`,
});
}
},
move:
function (id, destination) {
throwIfRootId(id);
let info = {
guid: id,
};
if (destination.parentId !==
null) {
throwIfRootId(destination.parentId);
info.parentGuid = destination.parentId;
}
info.index =
destination.index ===
null
? PlacesUtils.bookmarks.DEFAULT_INDEX
: destination.index;
try {
return PlacesUtils.bookmarks
.update(info)
.then(convertBookmarks)
.
catch(error => Promise.reject({ message: error.message }));
}
catch (e) {
return Promise.reject({
message: `Invalid bookmark: ${JSON.stringify(info)}`,
});
}
},
update:
function (id, changes) {
throwIfRootId(id);
let info = {
guid: id,
};
if (changes.title !==
null) {
info.title = changes.title;
}
if (changes.url !==
null) {
info.url = changes.url;
}
try {
return PlacesUtils.bookmarks
.update(info)
.then(convertBookmarks)
.
catch(error => Promise.reject({ message: error.message }));
}
catch (e) {
return Promise.reject({
message: `Invalid bookmark: ${JSON.stringify(info)}`,
});
}
},
remove:
function (id) {
throwIfRootId(id);
let info = {
guid: id,
};
// The API doesn't give you the old bookmark at the moment
try {
return PlacesUtils.bookmarks
.remove(info, { preventRemovalOfNonEmptyFolders:
true })
.
catch(error => Promise.reject({ message: error.message }));
}
catch (e) {
return Promise.reject({
message: `Invalid bookmark: ${JSON.stringify(info)}`,
});
}
},
removeTree:
function (id) {
throwIfRootId(id);
let info = {
guid: id,
};
try {
return PlacesUtils.bookmarks
.remove(info)
.
catch(error => Promise.reject({ message: error.message }));
}
catch (e) {
return Promise.reject({
message: `Invalid bookmark: ${JSON.stringify(info)}`,
});
}
},
onCreated:
new EventManager({
context,
module:
"bookmarks",
event:
"onCreated",
extensionApi:
this,
}).api(),
onRemoved:
new EventManager({
context,
module:
"bookmarks",
event:
"onRemoved",
extensionApi:
this,
}).api(),
onChanged:
new EventManager({
context,
module:
"bookmarks",
event:
"onChanged",
extensionApi:
this,
}).api(),
onMoved:
new EventManager({
context,
module:
"bookmarks",
event:
"onMoved",
extensionApi:
this,
}).api(),
},
};
}
};