1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/addon-sdk/source/lib/sdk/places/bookmarks.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,394 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +module.metadata = { 1.11 + "stability": "unstable", 1.12 + "engines": { 1.13 + "Firefox": "*" 1.14 + } 1.15 +}; 1.16 + 1.17 +/* 1.18 + * Requiring hosts so they can subscribe to client messages 1.19 + */ 1.20 +require('./host/host-bookmarks'); 1.21 +require('./host/host-tags'); 1.22 +require('./host/host-query'); 1.23 + 1.24 +const { Cc, Ci } = require('chrome'); 1.25 +const { Class } = require('../core/heritage'); 1.26 +const { send } = require('../addon/events'); 1.27 +const { defer, reject, all, resolve, promised } = require('../core/promise'); 1.28 +const { EventTarget } = require('../event/target'); 1.29 +const { emit } = require('../event/core'); 1.30 +const { identity, defer:async } = require('../lang/functional'); 1.31 +const { extend, merge } = require('../util/object'); 1.32 +const { fromIterator } = require('../util/array'); 1.33 +const { 1.34 + constructTree, fetchItem, createQuery, 1.35 + isRootGroup, createQueryOptions 1.36 +} = require('./utils'); 1.37 +const { 1.38 + bookmarkContract, groupContract, separatorContract 1.39 +} = require('./contract'); 1.40 +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. 1.41 + getService(Ci.nsINavBookmarksService); 1.42 + 1.43 +/* 1.44 + * Mapping of uncreated bookmarks with their created 1.45 + * counterparts 1.46 + */ 1.47 +const itemMap = new WeakMap(); 1.48 + 1.49 +/* 1.50 + * Constant used by nsIHistoryQuery; 1 is a bookmark query 1.51 + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions 1.52 + */ 1.53 +const BOOKMARK_QUERY = 1; 1.54 + 1.55 +/* 1.56 + * Bookmark Item classes 1.57 + */ 1.58 + 1.59 +const Bookmark = Class({ 1.60 + extends: [ 1.61 + bookmarkContract.properties(identity) 1.62 + ], 1.63 + initialize: function initialize (options) { 1.64 + merge(this, bookmarkContract(extend(defaults, options))); 1.65 + }, 1.66 + type: 'bookmark', 1.67 + toString: function () '[object Bookmark]' 1.68 +}); 1.69 +exports.Bookmark = Bookmark; 1.70 + 1.71 +const Group = Class({ 1.72 + extends: [ 1.73 + groupContract.properties(identity) 1.74 + ], 1.75 + initialize: function initialize (options) { 1.76 + // Don't validate if root group 1.77 + if (isRootGroup(options)) 1.78 + merge(this, options); 1.79 + else 1.80 + merge(this, groupContract(extend(defaults, options))); 1.81 + }, 1.82 + type: 'group', 1.83 + toString: function () '[object Group]' 1.84 +}); 1.85 +exports.Group = Group; 1.86 + 1.87 +const Separator = Class({ 1.88 + extends: [ 1.89 + separatorContract.properties(identity) 1.90 + ], 1.91 + initialize: function initialize (options) { 1.92 + merge(this, separatorContract(extend(defaults, options))); 1.93 + }, 1.94 + type: 'separator', 1.95 + toString: function () '[object Separator]' 1.96 +}); 1.97 +exports.Separator = Separator; 1.98 + 1.99 +/* 1.100 + * Functions 1.101 + */ 1.102 + 1.103 +function save (items, options) { 1.104 + items = [].concat(items); 1.105 + options = options || {}; 1.106 + let emitter = EventTarget(); 1.107 + let results = []; 1.108 + let errors = []; 1.109 + let root = constructTree(items); 1.110 + let cache = new Map(); 1.111 + 1.112 + let isExplicitSave = item => !!~items.indexOf(item); 1.113 + // `walk` returns an aggregate promise indicating the completion 1.114 + // of the `commitItem` on each node, not whether or not that 1.115 + // commit was successful 1.116 + 1.117 + // Force this to be async, as if a ducktype fails validation, 1.118 + // the promise implementation will fire an error event, which will 1.119 + // not trigger the handler as it's not yet bound 1.120 + // 1.121 + // Can remove after `Promise.jsm` is implemented in Bug 881047, 1.122 + // which will guarantee next tick execution 1.123 + async(() => root.walk(preCommitItem).then(commitComplete))(); 1.124 + 1.125 + function preCommitItem ({value:item}) { 1.126 + // Do nothing if tree root, default group (unsavable), 1.127 + // or if it's a dependency and not explicitly saved (in the list 1.128 + // of items to be saved), and not needed to be saved 1.129 + if (item === null || // node is the tree root 1.130 + isRootGroup(item) || 1.131 + (getId(item) && !isExplicitSave(item))) 1.132 + return; 1.133 + 1.134 + return promised(validate)(item) 1.135 + .then(() => commitItem(item, options)) 1.136 + .then(data => construct(data, cache)) 1.137 + .then(savedItem => { 1.138 + // If item was just created, make a map between 1.139 + // the creation object and created object, 1.140 + // so we can reference the item that doesn't have an id 1.141 + if (!getId(item)) 1.142 + saveId(item, savedItem.id); 1.143 + 1.144 + // Emit both the processed item, and original item 1.145 + // so a mapping can be understood in handler 1.146 + emit(emitter, 'data', savedItem, item); 1.147 + 1.148 + // Push to results iff item was explicitly saved 1.149 + if (isExplicitSave(item)) 1.150 + results[items.indexOf(item)] = savedItem; 1.151 + }, reason => { 1.152 + // Force reason to be a string for consistency 1.153 + reason = reason + ''; 1.154 + // Emit both the reason, and original item 1.155 + // so a mapping can be understood in handler 1.156 + emit(emitter, 'error', reason + '', item); 1.157 + // Store unsaved item in results list 1.158 + results[items.indexOf(item)] = item; 1.159 + errors.push(reason); 1.160 + }); 1.161 + } 1.162 + 1.163 + // Called when traversal of the node tree is completed and all 1.164 + // items have been committed 1.165 + function commitComplete () { 1.166 + emit(emitter, 'end', results); 1.167 + } 1.168 + 1.169 + return emitter; 1.170 +} 1.171 +exports.save = save; 1.172 + 1.173 +function search (queries, options) { 1.174 + queries = [].concat(queries); 1.175 + let emitter = EventTarget(); 1.176 + let cache = new Map(); 1.177 + let queryObjs = queries.map(createQuery.bind(null, BOOKMARK_QUERY)); 1.178 + let optionsObj = createQueryOptions(BOOKMARK_QUERY, options); 1.179 + 1.180 + // Can remove after `Promise.jsm` is implemented in Bug 881047, 1.181 + // which will guarantee next tick execution 1.182 + async(() => { 1.183 + send('sdk-places-query', { queries: queryObjs, options: optionsObj }) 1.184 + .then(handleQueryResponse); 1.185 + })(); 1.186 + 1.187 + function handleQueryResponse (data) { 1.188 + let deferreds = data.map(item => { 1.189 + return construct(item, cache).then(bookmark => { 1.190 + emit(emitter, 'data', bookmark); 1.191 + return bookmark; 1.192 + }, reason => { 1.193 + emit(emitter, 'error', reason); 1.194 + errors.push(reason); 1.195 + }); 1.196 + }); 1.197 + 1.198 + all(deferreds).then(data => { 1.199 + emit(emitter, 'end', data); 1.200 + }, () => emit(emitter, 'end', [])); 1.201 + } 1.202 + 1.203 + return emitter; 1.204 +} 1.205 +exports.search = search; 1.206 + 1.207 +function remove (items) { 1.208 + return [].concat(items).map(item => { 1.209 + item.remove = true; 1.210 + return item; 1.211 + }); 1.212 +} 1.213 + 1.214 +exports.remove = remove; 1.215 + 1.216 +/* 1.217 + * Internal Utilities 1.218 + */ 1.219 + 1.220 +function commitItem (item, options) { 1.221 + // Get the item's ID, or getId it's saved version if it exists 1.222 + let id = getId(item); 1.223 + let data = normalize(item); 1.224 + let promise; 1.225 + 1.226 + data.id = id; 1.227 + 1.228 + if (!id) { 1.229 + promise = send('sdk-places-bookmarks-create', data); 1.230 + } else if (item.remove) { 1.231 + promise = send('sdk-places-bookmarks-remove', { id: id }); 1.232 + } else { 1.233 + promise = send('sdk-places-bookmarks-last-updated', { 1.234 + id: id 1.235 + }).then(function (updated) { 1.236 + // If attempting to save an item that is not the 1.237 + // latest snapshot of a bookmark item, execute 1.238 + // the resolution function 1.239 + if (updated !== item.updated && options.resolve) 1.240 + return fetchItem(id) 1.241 + .then(options.resolve.bind(null, data)); 1.242 + else 1.243 + return data; 1.244 + }).then(send.bind(null, 'sdk-places-bookmarks-save')); 1.245 + } 1.246 + 1.247 + return promise; 1.248 +} 1.249 + 1.250 +/* 1.251 + * Turns a bookmark item into a plain object, 1.252 + * converts `tags` from Set to Array, group instance to an id 1.253 + */ 1.254 +function normalize (item) { 1.255 + let data = merge({}, item); 1.256 + // Circumvent prototype property of `type` 1.257 + delete data.type; 1.258 + data.type = item.type; 1.259 + data.tags = []; 1.260 + if (item.tags) { 1.261 + data.tags = fromIterator(item.tags); 1.262 + } 1.263 + data.group = getId(data.group) || exports.UNSORTED.id; 1.264 + 1.265 + return data; 1.266 +} 1.267 + 1.268 +/* 1.269 + * Takes a data object and constructs a BookmarkItem instance 1.270 + * of it, recursively generating parent instances as well. 1.271 + * 1.272 + * Pass in a `cache` Map to reuse instances of 1.273 + * bookmark items to reduce overhead; 1.274 + * The cache object is a map of id to a deferred with a 1.275 + * promise that resolves to the bookmark item. 1.276 + */ 1.277 +function construct (object, cache, forced) { 1.278 + let item = instantiate(object); 1.279 + let deferred = defer(); 1.280 + 1.281 + // Item could not be instantiated 1.282 + if (!item) 1.283 + return resolve(null); 1.284 + 1.285 + // Return promise for item if found in the cache, 1.286 + // and not `forced`. `forced` indicates that this is the construct 1.287 + // call that should not read from cache, but should actually perform 1.288 + // the construction, as it was set before several async calls 1.289 + if (cache.has(item.id) && !forced) 1.290 + return cache.get(item.id).promise; 1.291 + else if (cache.has(item.id)) 1.292 + deferred = cache.get(item.id); 1.293 + else 1.294 + cache.set(item.id, deferred); 1.295 + 1.296 + // When parent group is found in cache, use 1.297 + // the same deferred value 1.298 + if (item.group && cache.has(item.group)) { 1.299 + cache.get(item.group).promise.then(group => { 1.300 + item.group = group; 1.301 + deferred.resolve(item); 1.302 + }); 1.303 + 1.304 + // If not in the cache, and a root group, return 1.305 + // the premade instance 1.306 + } else if (rootGroups.get(item.group)) { 1.307 + item.group = rootGroups.get(item.group); 1.308 + deferred.resolve(item); 1.309 + 1.310 + // If not in the cache or a root group, fetch the parent 1.311 + } else { 1.312 + cache.set(item.group, defer()); 1.313 + fetchItem(item.group).then(group => { 1.314 + return construct(group, cache, true); 1.315 + }).then(group => { 1.316 + item.group = group; 1.317 + deferred.resolve(item); 1.318 + }, deferred.reject); 1.319 + } 1.320 + 1.321 + return deferred.promise; 1.322 +} 1.323 + 1.324 +function instantiate (object) { 1.325 + if (object.type === 'bookmark') 1.326 + return Bookmark(object); 1.327 + if (object.type === 'group') 1.328 + return Group(object); 1.329 + if (object.type === 'separator') 1.330 + return Separator(object); 1.331 + return null; 1.332 +} 1.333 + 1.334 +/** 1.335 + * Validates a bookmark item; will throw an error if ininvalid, 1.336 + * to be used with `promised`. As bookmark items check on their class, 1.337 + * this only checks ducktypes 1.338 + */ 1.339 +function validate (object) { 1.340 + if (!isDuckType(object)) return true; 1.341 + let contract = object.type === 'bookmark' ? bookmarkContract : 1.342 + object.type === 'group' ? groupContract : 1.343 + object.type === 'separator' ? separatorContract : 1.344 + null; 1.345 + if (!contract) { 1.346 + throw Error('No type specified'); 1.347 + } 1.348 + 1.349 + // If object has a property set, and undefined, 1.350 + // manually override with default as it'll fail otherwise 1.351 + let withDefaults = Object.keys(defaults).reduce((obj, prop) => { 1.352 + if (obj[prop] == null) obj[prop] = defaults[prop]; 1.353 + return obj; 1.354 + }, extend(object)); 1.355 + 1.356 + contract(withDefaults); 1.357 +} 1.358 + 1.359 +function isDuckType (item) { 1.360 + return !(item instanceof Bookmark) && 1.361 + !(item instanceof Group) && 1.362 + !(item instanceof Separator); 1.363 +} 1.364 + 1.365 +function saveId (unsaved, id) { 1.366 + itemMap.set(unsaved, id); 1.367 +} 1.368 + 1.369 +// Fetches an item's ID from itself, or from the mapped items 1.370 +function getId (item) { 1.371 + return typeof item === 'number' ? item : 1.372 + item ? item.id || itemMap.get(item) : 1.373 + null; 1.374 +} 1.375 + 1.376 +/* 1.377 + * Set up the default, root groups 1.378 + */ 1.379 + 1.380 +let defaultGroupMap = { 1.381 + MENU: bmsrv.bookmarksMenuFolder, 1.382 + TOOLBAR: bmsrv.toolbarFolder, 1.383 + UNSORTED: bmsrv.unfiledBookmarksFolder 1.384 +}; 1.385 + 1.386 +let rootGroups = new Map(); 1.387 + 1.388 +for (let i in defaultGroupMap) { 1.389 + let group = Object.freeze(Group({ title: i, id: defaultGroupMap[i] })); 1.390 + rootGroups.set(defaultGroupMap[i], group); 1.391 + exports[i] = group; 1.392 +} 1.393 + 1.394 +let defaults = { 1.395 + group: exports.UNSORTED, 1.396 + index: -1 1.397 +};