|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 "use strict"; |
|
6 |
|
7 module.metadata = { |
|
8 "stability": "unstable", |
|
9 "engines": { |
|
10 "Firefox": "*" |
|
11 } |
|
12 }; |
|
13 |
|
14 /* |
|
15 * Requiring hosts so they can subscribe to client messages |
|
16 */ |
|
17 require('./host/host-bookmarks'); |
|
18 require('./host/host-tags'); |
|
19 require('./host/host-query'); |
|
20 |
|
21 const { Cc, Ci } = require('chrome'); |
|
22 const { Class } = require('../core/heritage'); |
|
23 const { send } = require('../addon/events'); |
|
24 const { defer, reject, all, resolve, promised } = require('../core/promise'); |
|
25 const { EventTarget } = require('../event/target'); |
|
26 const { emit } = require('../event/core'); |
|
27 const { identity, defer:async } = require('../lang/functional'); |
|
28 const { extend, merge } = require('../util/object'); |
|
29 const { fromIterator } = require('../util/array'); |
|
30 const { |
|
31 constructTree, fetchItem, createQuery, |
|
32 isRootGroup, createQueryOptions |
|
33 } = require('./utils'); |
|
34 const { |
|
35 bookmarkContract, groupContract, separatorContract |
|
36 } = require('./contract'); |
|
37 const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. |
|
38 getService(Ci.nsINavBookmarksService); |
|
39 |
|
40 /* |
|
41 * Mapping of uncreated bookmarks with their created |
|
42 * counterparts |
|
43 */ |
|
44 const itemMap = new WeakMap(); |
|
45 |
|
46 /* |
|
47 * Constant used by nsIHistoryQuery; 1 is a bookmark query |
|
48 * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions |
|
49 */ |
|
50 const BOOKMARK_QUERY = 1; |
|
51 |
|
52 /* |
|
53 * Bookmark Item classes |
|
54 */ |
|
55 |
|
56 const Bookmark = Class({ |
|
57 extends: [ |
|
58 bookmarkContract.properties(identity) |
|
59 ], |
|
60 initialize: function initialize (options) { |
|
61 merge(this, bookmarkContract(extend(defaults, options))); |
|
62 }, |
|
63 type: 'bookmark', |
|
64 toString: function () '[object Bookmark]' |
|
65 }); |
|
66 exports.Bookmark = Bookmark; |
|
67 |
|
68 const Group = Class({ |
|
69 extends: [ |
|
70 groupContract.properties(identity) |
|
71 ], |
|
72 initialize: function initialize (options) { |
|
73 // Don't validate if root group |
|
74 if (isRootGroup(options)) |
|
75 merge(this, options); |
|
76 else |
|
77 merge(this, groupContract(extend(defaults, options))); |
|
78 }, |
|
79 type: 'group', |
|
80 toString: function () '[object Group]' |
|
81 }); |
|
82 exports.Group = Group; |
|
83 |
|
84 const Separator = Class({ |
|
85 extends: [ |
|
86 separatorContract.properties(identity) |
|
87 ], |
|
88 initialize: function initialize (options) { |
|
89 merge(this, separatorContract(extend(defaults, options))); |
|
90 }, |
|
91 type: 'separator', |
|
92 toString: function () '[object Separator]' |
|
93 }); |
|
94 exports.Separator = Separator; |
|
95 |
|
96 /* |
|
97 * Functions |
|
98 */ |
|
99 |
|
100 function save (items, options) { |
|
101 items = [].concat(items); |
|
102 options = options || {}; |
|
103 let emitter = EventTarget(); |
|
104 let results = []; |
|
105 let errors = []; |
|
106 let root = constructTree(items); |
|
107 let cache = new Map(); |
|
108 |
|
109 let isExplicitSave = item => !!~items.indexOf(item); |
|
110 // `walk` returns an aggregate promise indicating the completion |
|
111 // of the `commitItem` on each node, not whether or not that |
|
112 // commit was successful |
|
113 |
|
114 // Force this to be async, as if a ducktype fails validation, |
|
115 // the promise implementation will fire an error event, which will |
|
116 // not trigger the handler as it's not yet bound |
|
117 // |
|
118 // Can remove after `Promise.jsm` is implemented in Bug 881047, |
|
119 // which will guarantee next tick execution |
|
120 async(() => root.walk(preCommitItem).then(commitComplete))(); |
|
121 |
|
122 function preCommitItem ({value:item}) { |
|
123 // Do nothing if tree root, default group (unsavable), |
|
124 // or if it's a dependency and not explicitly saved (in the list |
|
125 // of items to be saved), and not needed to be saved |
|
126 if (item === null || // node is the tree root |
|
127 isRootGroup(item) || |
|
128 (getId(item) && !isExplicitSave(item))) |
|
129 return; |
|
130 |
|
131 return promised(validate)(item) |
|
132 .then(() => commitItem(item, options)) |
|
133 .then(data => construct(data, cache)) |
|
134 .then(savedItem => { |
|
135 // If item was just created, make a map between |
|
136 // the creation object and created object, |
|
137 // so we can reference the item that doesn't have an id |
|
138 if (!getId(item)) |
|
139 saveId(item, savedItem.id); |
|
140 |
|
141 // Emit both the processed item, and original item |
|
142 // so a mapping can be understood in handler |
|
143 emit(emitter, 'data', savedItem, item); |
|
144 |
|
145 // Push to results iff item was explicitly saved |
|
146 if (isExplicitSave(item)) |
|
147 results[items.indexOf(item)] = savedItem; |
|
148 }, reason => { |
|
149 // Force reason to be a string for consistency |
|
150 reason = reason + ''; |
|
151 // Emit both the reason, and original item |
|
152 // so a mapping can be understood in handler |
|
153 emit(emitter, 'error', reason + '', item); |
|
154 // Store unsaved item in results list |
|
155 results[items.indexOf(item)] = item; |
|
156 errors.push(reason); |
|
157 }); |
|
158 } |
|
159 |
|
160 // Called when traversal of the node tree is completed and all |
|
161 // items have been committed |
|
162 function commitComplete () { |
|
163 emit(emitter, 'end', results); |
|
164 } |
|
165 |
|
166 return emitter; |
|
167 } |
|
168 exports.save = save; |
|
169 |
|
170 function search (queries, options) { |
|
171 queries = [].concat(queries); |
|
172 let emitter = EventTarget(); |
|
173 let cache = new Map(); |
|
174 let queryObjs = queries.map(createQuery.bind(null, BOOKMARK_QUERY)); |
|
175 let optionsObj = createQueryOptions(BOOKMARK_QUERY, options); |
|
176 |
|
177 // Can remove after `Promise.jsm` is implemented in Bug 881047, |
|
178 // which will guarantee next tick execution |
|
179 async(() => { |
|
180 send('sdk-places-query', { queries: queryObjs, options: optionsObj }) |
|
181 .then(handleQueryResponse); |
|
182 })(); |
|
183 |
|
184 function handleQueryResponse (data) { |
|
185 let deferreds = data.map(item => { |
|
186 return construct(item, cache).then(bookmark => { |
|
187 emit(emitter, 'data', bookmark); |
|
188 return bookmark; |
|
189 }, reason => { |
|
190 emit(emitter, 'error', reason); |
|
191 errors.push(reason); |
|
192 }); |
|
193 }); |
|
194 |
|
195 all(deferreds).then(data => { |
|
196 emit(emitter, 'end', data); |
|
197 }, () => emit(emitter, 'end', [])); |
|
198 } |
|
199 |
|
200 return emitter; |
|
201 } |
|
202 exports.search = search; |
|
203 |
|
204 function remove (items) { |
|
205 return [].concat(items).map(item => { |
|
206 item.remove = true; |
|
207 return item; |
|
208 }); |
|
209 } |
|
210 |
|
211 exports.remove = remove; |
|
212 |
|
213 /* |
|
214 * Internal Utilities |
|
215 */ |
|
216 |
|
217 function commitItem (item, options) { |
|
218 // Get the item's ID, or getId it's saved version if it exists |
|
219 let id = getId(item); |
|
220 let data = normalize(item); |
|
221 let promise; |
|
222 |
|
223 data.id = id; |
|
224 |
|
225 if (!id) { |
|
226 promise = send('sdk-places-bookmarks-create', data); |
|
227 } else if (item.remove) { |
|
228 promise = send('sdk-places-bookmarks-remove', { id: id }); |
|
229 } else { |
|
230 promise = send('sdk-places-bookmarks-last-updated', { |
|
231 id: id |
|
232 }).then(function (updated) { |
|
233 // If attempting to save an item that is not the |
|
234 // latest snapshot of a bookmark item, execute |
|
235 // the resolution function |
|
236 if (updated !== item.updated && options.resolve) |
|
237 return fetchItem(id) |
|
238 .then(options.resolve.bind(null, data)); |
|
239 else |
|
240 return data; |
|
241 }).then(send.bind(null, 'sdk-places-bookmarks-save')); |
|
242 } |
|
243 |
|
244 return promise; |
|
245 } |
|
246 |
|
247 /* |
|
248 * Turns a bookmark item into a plain object, |
|
249 * converts `tags` from Set to Array, group instance to an id |
|
250 */ |
|
251 function normalize (item) { |
|
252 let data = merge({}, item); |
|
253 // Circumvent prototype property of `type` |
|
254 delete data.type; |
|
255 data.type = item.type; |
|
256 data.tags = []; |
|
257 if (item.tags) { |
|
258 data.tags = fromIterator(item.tags); |
|
259 } |
|
260 data.group = getId(data.group) || exports.UNSORTED.id; |
|
261 |
|
262 return data; |
|
263 } |
|
264 |
|
265 /* |
|
266 * Takes a data object and constructs a BookmarkItem instance |
|
267 * of it, recursively generating parent instances as well. |
|
268 * |
|
269 * Pass in a `cache` Map to reuse instances of |
|
270 * bookmark items to reduce overhead; |
|
271 * The cache object is a map of id to a deferred with a |
|
272 * promise that resolves to the bookmark item. |
|
273 */ |
|
274 function construct (object, cache, forced) { |
|
275 let item = instantiate(object); |
|
276 let deferred = defer(); |
|
277 |
|
278 // Item could not be instantiated |
|
279 if (!item) |
|
280 return resolve(null); |
|
281 |
|
282 // Return promise for item if found in the cache, |
|
283 // and not `forced`. `forced` indicates that this is the construct |
|
284 // call that should not read from cache, but should actually perform |
|
285 // the construction, as it was set before several async calls |
|
286 if (cache.has(item.id) && !forced) |
|
287 return cache.get(item.id).promise; |
|
288 else if (cache.has(item.id)) |
|
289 deferred = cache.get(item.id); |
|
290 else |
|
291 cache.set(item.id, deferred); |
|
292 |
|
293 // When parent group is found in cache, use |
|
294 // the same deferred value |
|
295 if (item.group && cache.has(item.group)) { |
|
296 cache.get(item.group).promise.then(group => { |
|
297 item.group = group; |
|
298 deferred.resolve(item); |
|
299 }); |
|
300 |
|
301 // If not in the cache, and a root group, return |
|
302 // the premade instance |
|
303 } else if (rootGroups.get(item.group)) { |
|
304 item.group = rootGroups.get(item.group); |
|
305 deferred.resolve(item); |
|
306 |
|
307 // If not in the cache or a root group, fetch the parent |
|
308 } else { |
|
309 cache.set(item.group, defer()); |
|
310 fetchItem(item.group).then(group => { |
|
311 return construct(group, cache, true); |
|
312 }).then(group => { |
|
313 item.group = group; |
|
314 deferred.resolve(item); |
|
315 }, deferred.reject); |
|
316 } |
|
317 |
|
318 return deferred.promise; |
|
319 } |
|
320 |
|
321 function instantiate (object) { |
|
322 if (object.type === 'bookmark') |
|
323 return Bookmark(object); |
|
324 if (object.type === 'group') |
|
325 return Group(object); |
|
326 if (object.type === 'separator') |
|
327 return Separator(object); |
|
328 return null; |
|
329 } |
|
330 |
|
331 /** |
|
332 * Validates a bookmark item; will throw an error if ininvalid, |
|
333 * to be used with `promised`. As bookmark items check on their class, |
|
334 * this only checks ducktypes |
|
335 */ |
|
336 function validate (object) { |
|
337 if (!isDuckType(object)) return true; |
|
338 let contract = object.type === 'bookmark' ? bookmarkContract : |
|
339 object.type === 'group' ? groupContract : |
|
340 object.type === 'separator' ? separatorContract : |
|
341 null; |
|
342 if (!contract) { |
|
343 throw Error('No type specified'); |
|
344 } |
|
345 |
|
346 // If object has a property set, and undefined, |
|
347 // manually override with default as it'll fail otherwise |
|
348 let withDefaults = Object.keys(defaults).reduce((obj, prop) => { |
|
349 if (obj[prop] == null) obj[prop] = defaults[prop]; |
|
350 return obj; |
|
351 }, extend(object)); |
|
352 |
|
353 contract(withDefaults); |
|
354 } |
|
355 |
|
356 function isDuckType (item) { |
|
357 return !(item instanceof Bookmark) && |
|
358 !(item instanceof Group) && |
|
359 !(item instanceof Separator); |
|
360 } |
|
361 |
|
362 function saveId (unsaved, id) { |
|
363 itemMap.set(unsaved, id); |
|
364 } |
|
365 |
|
366 // Fetches an item's ID from itself, or from the mapped items |
|
367 function getId (item) { |
|
368 return typeof item === 'number' ? item : |
|
369 item ? item.id || itemMap.get(item) : |
|
370 null; |
|
371 } |
|
372 |
|
373 /* |
|
374 * Set up the default, root groups |
|
375 */ |
|
376 |
|
377 let defaultGroupMap = { |
|
378 MENU: bmsrv.bookmarksMenuFolder, |
|
379 TOOLBAR: bmsrv.toolbarFolder, |
|
380 UNSORTED: bmsrv.unfiledBookmarksFolder |
|
381 }; |
|
382 |
|
383 let rootGroups = new Map(); |
|
384 |
|
385 for (let i in defaultGroupMap) { |
|
386 let group = Object.freeze(Group({ title: i, id: defaultGroupMap[i] })); |
|
387 rootGroups.set(defaultGroupMap[i], group); |
|
388 exports[i] = group; |
|
389 } |
|
390 |
|
391 let defaults = { |
|
392 group: exports.UNSORTED, |
|
393 index: -1 |
|
394 }; |