/* -*- 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"; var { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); 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:"; XPCOMUtils.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(), }, }; } };