ext-bookmarks.js (13475B)
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set sts=2 sw=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 5 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 "use strict"; 8 9 ChromeUtils.defineESModuleGetters(this, { 10 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 11 }); 12 13 var { ExtensionError } = ExtensionUtils; 14 15 const { TYPE_BOOKMARK, TYPE_FOLDER, TYPE_SEPARATOR } = PlacesUtils.bookmarks; 16 17 const BOOKMARKS_TYPES_TO_API_TYPES_MAP = new Map([ 18 [TYPE_BOOKMARK, "bookmark"], 19 [TYPE_FOLDER, "folder"], 20 [TYPE_SEPARATOR, "separator"], 21 ]); 22 23 const BOOKMARK_SEPERATOR_URL = "data:"; 24 25 ChromeUtils.defineLazyGetter(this, "API_TYPES_TO_BOOKMARKS_TYPES_MAP", () => { 26 let theMap = new Map(); 27 28 for (let [code, name] of BOOKMARKS_TYPES_TO_API_TYPES_MAP) { 29 theMap.set(name, code); 30 } 31 return theMap; 32 }); 33 34 let listenerCount = 0; 35 36 function getUrl(type, url) { 37 switch (type) { 38 case TYPE_BOOKMARK: 39 return url; 40 case TYPE_SEPARATOR: 41 return BOOKMARK_SEPERATOR_URL; 42 default: 43 return undefined; 44 } 45 } 46 47 const getTree = (rootGuid, onlyChildren) => { 48 function convert(node, parent) { 49 let treenode = { 50 id: node.guid, 51 title: PlacesUtils.bookmarks.getLocalizedTitle(node) || "", 52 index: node.index, 53 dateAdded: node.dateAdded / 1000, 54 type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(node.typeCode), 55 url: getUrl(node.typeCode, node.uri), 56 }; 57 58 if (parent && node.guid != PlacesUtils.bookmarks.rootGuid) { 59 treenode.parentId = parent.guid; 60 } 61 62 if (node.typeCode == TYPE_FOLDER) { 63 treenode.dateGroupModified = node.lastModified / 1000; 64 65 if (!onlyChildren) { 66 treenode.children = node.children 67 ? node.children.map(child => convert(child, node)) 68 : []; 69 } 70 } 71 72 return treenode; 73 } 74 75 return PlacesUtils.promiseBookmarksTree(rootGuid) 76 .then(root => { 77 if (onlyChildren) { 78 let children = root.children || []; 79 return children.map(child => convert(child, root)); 80 } 81 let treenode = convert(root, null); 82 treenode.parentId = root.parentGuid; 83 // It seems like the array always just contains the root node. 84 return [treenode]; 85 }) 86 .catch(e => Promise.reject({ message: e.message })); 87 }; 88 89 const convertBookmarks = result => { 90 let node = { 91 id: result.guid, 92 title: PlacesUtils.bookmarks.getLocalizedTitle(result) || "", 93 index: result.index, 94 dateAdded: result.dateAdded.getTime(), 95 type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(result.type), 96 url: getUrl(result.type, result.url && result.url.href), 97 }; 98 99 if (result.guid != PlacesUtils.bookmarks.rootGuid) { 100 node.parentId = result.parentGuid; 101 } 102 103 if (result.type == TYPE_FOLDER) { 104 node.dateGroupModified = result.lastModified.getTime(); 105 } 106 107 return node; 108 }; 109 110 const throwIfRootId = id => { 111 if (id == PlacesUtils.bookmarks.rootGuid) { 112 throw new ExtensionError("The bookmark root cannot be modified"); 113 } 114 }; 115 116 let observer = new (class extends EventEmitter { 117 constructor() { 118 super(); 119 this.handlePlacesEvents = this.handlePlacesEvents.bind(this); 120 } 121 122 handlePlacesEvents(events) { 123 for (let event of events) { 124 switch (event.type) { 125 case "bookmark-added": { 126 if (event.isTagging) { 127 continue; 128 } 129 let bookmark = { 130 id: event.guid, 131 parentId: event.parentGuid, 132 index: event.index, 133 title: event.title, 134 dateAdded: event.dateAdded, 135 type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(event.itemType), 136 url: getUrl(event.itemType, event.url), 137 }; 138 139 if (event.itemType == TYPE_FOLDER) { 140 bookmark.dateGroupModified = bookmark.dateAdded; 141 } 142 143 this.emit("created", bookmark); 144 break; 145 } 146 case "bookmark-removed": { 147 if (event.isTagging || event.isDescendantRemoval) { 148 continue; 149 } 150 let node = { 151 id: event.guid, 152 parentId: event.parentGuid, 153 index: event.index, 154 type: BOOKMARKS_TYPES_TO_API_TYPES_MAP.get(event.itemType), 155 url: getUrl(event.itemType, event.url), 156 title: event.title, 157 }; 158 159 this.emit("removed", { 160 guid: event.guid, 161 info: { parentId: event.parentGuid, index: event.index, node }, 162 }); 163 break; 164 } 165 case "bookmark-moved": 166 this.emit("moved", { 167 guid: event.guid, 168 info: { 169 parentId: event.parentGuid, 170 index: event.index, 171 oldParentId: event.oldParentGuid, 172 oldIndex: event.oldIndex, 173 }, 174 }); 175 break; 176 case "bookmark-title-changed": 177 if (event.isTagging) { 178 continue; 179 } 180 181 this.emit("changed", { 182 guid: event.guid, 183 info: { title: event.title }, 184 }); 185 break; 186 case "bookmark-url-changed": 187 if (event.isTagging) { 188 continue; 189 } 190 191 this.emit("changed", { 192 guid: event.guid, 193 info: { url: event.url }, 194 }); 195 break; 196 } 197 } 198 } 199 })(); 200 201 const decrementListeners = () => { 202 listenerCount -= 1; 203 if (!listenerCount) { 204 PlacesUtils.observers.removeListener( 205 [ 206 "bookmark-added", 207 "bookmark-removed", 208 "bookmark-moved", 209 "bookmark-title-changed", 210 "bookmark-url-changed", 211 ], 212 observer.handlePlacesEvents 213 ); 214 } 215 }; 216 217 const incrementListeners = () => { 218 listenerCount++; 219 if (listenerCount == 1) { 220 PlacesUtils.observers.addListener( 221 [ 222 "bookmark-added", 223 "bookmark-removed", 224 "bookmark-moved", 225 "bookmark-title-changed", 226 "bookmark-url-changed", 227 ], 228 observer.handlePlacesEvents 229 ); 230 } 231 }; 232 233 this.bookmarks = class extends ExtensionAPIPersistent { 234 PERSISTENT_EVENTS = { 235 onCreated({ fire }) { 236 let listener = (event, bookmark) => { 237 fire.sync(bookmark.id, bookmark); 238 }; 239 240 observer.on("created", listener); 241 incrementListeners(); 242 return { 243 unregister() { 244 observer.off("created", listener); 245 decrementListeners(); 246 }, 247 convert(_fire) { 248 fire = _fire; 249 }, 250 }; 251 }, 252 253 onRemoved({ fire }) { 254 let listener = (event, data) => { 255 fire.sync(data.guid, data.info); 256 }; 257 258 observer.on("removed", listener); 259 incrementListeners(); 260 return { 261 unregister() { 262 observer.off("removed", listener); 263 decrementListeners(); 264 }, 265 convert(_fire) { 266 fire = _fire; 267 }, 268 }; 269 }, 270 271 onChanged({ fire }) { 272 let listener = (event, data) => { 273 fire.sync(data.guid, data.info); 274 }; 275 276 observer.on("changed", listener); 277 incrementListeners(); 278 return { 279 unregister() { 280 observer.off("changed", listener); 281 decrementListeners(); 282 }, 283 convert(_fire) { 284 fire = _fire; 285 }, 286 }; 287 }, 288 289 onMoved({ fire }) { 290 let listener = (event, data) => { 291 fire.sync(data.guid, data.info); 292 }; 293 294 observer.on("moved", listener); 295 incrementListeners(); 296 return { 297 unregister() { 298 observer.off("moved", listener); 299 decrementListeners(); 300 }, 301 convert(_fire) { 302 fire = _fire; 303 }, 304 }; 305 }, 306 }; 307 308 getAPI(context) { 309 return { 310 bookmarks: { 311 async get(idOrIdList) { 312 let list = Array.isArray(idOrIdList) ? idOrIdList : [idOrIdList]; 313 314 try { 315 let bookmarks = []; 316 for (let id of list) { 317 let bookmark = await PlacesUtils.bookmarks.fetch({ guid: id }); 318 if (!bookmark) { 319 throw new Error("Bookmark not found"); 320 } 321 bookmarks.push(convertBookmarks(bookmark)); 322 } 323 return bookmarks; 324 } catch (error) { 325 return Promise.reject({ message: error.message }); 326 } 327 }, 328 329 getChildren: function (id) { 330 // TODO: We should optimize this. 331 return getTree(id, true); 332 }, 333 334 getTree: function () { 335 return getTree(PlacesUtils.bookmarks.rootGuid, false); 336 }, 337 338 getSubTree: function (id) { 339 return getTree(id, false); 340 }, 341 342 search: function (query) { 343 return PlacesUtils.bookmarks 344 .search(query) 345 .then(result => result.map(convertBookmarks)); 346 }, 347 348 getRecent: function (numberOfItems) { 349 return PlacesUtils.bookmarks 350 .getRecent(numberOfItems) 351 .then(result => result.map(convertBookmarks)); 352 }, 353 354 create: function (bookmark) { 355 let info = { 356 title: bookmark.title || "", 357 }; 358 359 info.type = API_TYPES_TO_BOOKMARKS_TYPES_MAP.get(bookmark.type); 360 if (!info.type) { 361 // If url is NULL or missing, it will be a folder. 362 if (bookmark.url !== null) { 363 info.type = TYPE_BOOKMARK; 364 } else { 365 info.type = TYPE_FOLDER; 366 } 367 } 368 369 if (info.type === TYPE_BOOKMARK) { 370 info.url = bookmark.url || ""; 371 } 372 373 if (bookmark.index !== null) { 374 info.index = bookmark.index; 375 } 376 377 if (bookmark.parentId !== null) { 378 throwIfRootId(bookmark.parentId); 379 info.parentGuid = bookmark.parentId; 380 } else { 381 info.parentGuid = PlacesUtils.bookmarks.unfiledGuid; 382 } 383 384 try { 385 return PlacesUtils.bookmarks 386 .insert(info) 387 .then(convertBookmarks) 388 .catch(error => Promise.reject({ message: error.message })); 389 } catch (e) { 390 return Promise.reject({ 391 message: `Invalid bookmark: ${JSON.stringify(info)}`, 392 }); 393 } 394 }, 395 396 move: function (id, destination) { 397 throwIfRootId(id); 398 let info = { 399 guid: id, 400 }; 401 402 if (destination.parentId !== null) { 403 throwIfRootId(destination.parentId); 404 info.parentGuid = destination.parentId; 405 } 406 info.index = 407 destination.index === null 408 ? PlacesUtils.bookmarks.DEFAULT_INDEX 409 : destination.index; 410 411 try { 412 return PlacesUtils.bookmarks 413 .update(info) 414 .then(convertBookmarks) 415 .catch(error => Promise.reject({ message: error.message })); 416 } catch (e) { 417 return Promise.reject({ 418 message: `Invalid bookmark: ${JSON.stringify(info)}`, 419 }); 420 } 421 }, 422 423 update: function (id, changes) { 424 throwIfRootId(id); 425 let info = { 426 guid: id, 427 }; 428 429 if (changes.title !== null) { 430 info.title = changes.title; 431 } 432 if (changes.url !== null) { 433 info.url = changes.url; 434 } 435 436 try { 437 return PlacesUtils.bookmarks 438 .update(info) 439 .then(convertBookmarks) 440 .catch(error => Promise.reject({ message: error.message })); 441 } catch (e) { 442 return Promise.reject({ 443 message: `Invalid bookmark: ${JSON.stringify(info)}`, 444 }); 445 } 446 }, 447 448 remove: function (id) { 449 throwIfRootId(id); 450 let info = { 451 guid: id, 452 }; 453 454 // The API doesn't give you the old bookmark at the moment 455 try { 456 return PlacesUtils.bookmarks 457 .remove(info, { preventRemovalOfNonEmptyFolders: true }) 458 .catch(error => Promise.reject({ message: error.message })); 459 } catch (e) { 460 return Promise.reject({ 461 message: `Invalid bookmark: ${JSON.stringify(info)}`, 462 }); 463 } 464 }, 465 466 removeTree: function (id) { 467 throwIfRootId(id); 468 let info = { 469 guid: id, 470 }; 471 472 try { 473 return PlacesUtils.bookmarks 474 .remove(info) 475 .catch(error => Promise.reject({ message: error.message })); 476 } catch (e) { 477 return Promise.reject({ 478 message: `Invalid bookmark: ${JSON.stringify(info)}`, 479 }); 480 } 481 }, 482 483 onCreated: new EventManager({ 484 context, 485 module: "bookmarks", 486 event: "onCreated", 487 extensionApi: this, 488 }).api(), 489 490 onRemoved: new EventManager({ 491 context, 492 module: "bookmarks", 493 event: "onRemoved", 494 extensionApi: this, 495 }).api(), 496 497 onChanged: new EventManager({ 498 context, 499 module: "bookmarks", 500 event: "onChanged", 501 extensionApi: this, 502 }).api(), 503 504 onMoved: new EventManager({ 505 context, 506 module: "bookmarks", 507 event: "onMoved", 508 extensionApi: this, 509 }).api(), 510 }, 511 }; 512 } 513 };