|
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 file, |
|
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 this.EXPORTED_SYMBOLS = [ "BookmarkJSONUtils" ]; |
|
6 |
|
7 const Ci = Components.interfaces; |
|
8 const Cc = Components.classes; |
|
9 const Cu = Components.utils; |
|
10 const Cr = Components.results; |
|
11 |
|
12 Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
|
13 Cu.import("resource://gre/modules/Services.jsm"); |
|
14 Cu.import("resource://gre/modules/NetUtil.jsm"); |
|
15 Cu.import("resource://gre/modules/osfile.jsm"); |
|
16 Cu.import("resource://gre/modules/PlacesUtils.jsm"); |
|
17 Cu.import("resource://gre/modules/Promise.jsm"); |
|
18 Cu.import("resource://gre/modules/Task.jsm"); |
|
19 |
|
20 XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups", |
|
21 "resource://gre/modules/PlacesBackups.jsm"); |
|
22 XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", |
|
23 "resource://gre/modules/Deprecated.jsm"); |
|
24 |
|
25 XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => new TextDecoder()); |
|
26 XPCOMUtils.defineLazyGetter(this, "gTextEncoder", () => new TextEncoder()); |
|
27 |
|
28 /** |
|
29 * Generates an hash for the given string. |
|
30 * |
|
31 * @note The generated hash is returned in base64 form. Mind the fact base64 |
|
32 * is case-sensitive if you are going to reuse this code. |
|
33 */ |
|
34 function generateHash(aString) { |
|
35 let cryptoHash = Cc["@mozilla.org/security/hash;1"] |
|
36 .createInstance(Ci.nsICryptoHash); |
|
37 cryptoHash.init(Ci.nsICryptoHash.MD5); |
|
38 let stringStream = Cc["@mozilla.org/io/string-input-stream;1"] |
|
39 .createInstance(Ci.nsIStringInputStream); |
|
40 stringStream.data = aString; |
|
41 cryptoHash.updateFromStream(stringStream, -1); |
|
42 // base64 allows the '/' char, but we can't use it for filenames. |
|
43 return cryptoHash.finish(true).replace("/", "-", "g"); |
|
44 } |
|
45 |
|
46 this.BookmarkJSONUtils = Object.freeze({ |
|
47 /** |
|
48 * Import bookmarks from a url. |
|
49 * |
|
50 * @param aSpec |
|
51 * url of the bookmark data. |
|
52 * @param aReplace |
|
53 * Boolean if true, replace existing bookmarks, else merge. |
|
54 * |
|
55 * @return {Promise} |
|
56 * @resolves When the new bookmarks have been created. |
|
57 * @rejects JavaScript exception. |
|
58 */ |
|
59 importFromURL: function BJU_importFromURL(aSpec, aReplace) { |
|
60 return Task.spawn(function* () { |
|
61 notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN); |
|
62 try { |
|
63 let importer = new BookmarkImporter(aReplace); |
|
64 yield importer.importFromURL(aSpec); |
|
65 |
|
66 notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS); |
|
67 } catch(ex) { |
|
68 Cu.reportError("Failed to restore bookmarks from " + aSpec + ": " + ex); |
|
69 notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED); |
|
70 } |
|
71 }); |
|
72 }, |
|
73 |
|
74 /** |
|
75 * Restores bookmarks and tags from a JSON file. |
|
76 * @note any item annotated with "places/excludeFromBackup" won't be removed |
|
77 * before executing the restore. |
|
78 * |
|
79 * @param aFilePath |
|
80 * OS.File path string of bookmarks in JSON format to be restored. |
|
81 * @param aReplace |
|
82 * Boolean if true, replace existing bookmarks, else merge. |
|
83 * |
|
84 * @return {Promise} |
|
85 * @resolves When the new bookmarks have been created. |
|
86 * @rejects JavaScript exception. |
|
87 * @deprecated passing an nsIFile is deprecated |
|
88 */ |
|
89 importFromFile: function BJU_importFromFile(aFilePath, aReplace) { |
|
90 if (aFilePath instanceof Ci.nsIFile) { |
|
91 Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.importFromFile " + |
|
92 "is deprecated. Please use an OS.File path string instead.", |
|
93 "https://developer.mozilla.org/docs/JavaScript_OS.File"); |
|
94 aFilePath = aFilePath.path; |
|
95 } |
|
96 |
|
97 return Task.spawn(function* () { |
|
98 notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN); |
|
99 try { |
|
100 if (!(yield OS.File.exists(aFilePath))) |
|
101 throw new Error("Cannot restore from nonexisting json file"); |
|
102 |
|
103 let importer = new BookmarkImporter(aReplace); |
|
104 yield importer.importFromURL(OS.Path.toFileURI(aFilePath)); |
|
105 |
|
106 notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS); |
|
107 } catch(ex) { |
|
108 Cu.reportError("Failed to restore bookmarks from " + aFilePath + ": " + ex); |
|
109 notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED); |
|
110 throw ex; |
|
111 } |
|
112 }); |
|
113 }, |
|
114 |
|
115 /** |
|
116 * Serializes bookmarks using JSON, and writes to the supplied file path. |
|
117 * |
|
118 * @param aFilePath |
|
119 * OS.File path string for the "bookmarks.json" file to be created. |
|
120 * @param [optional] aOptions |
|
121 * Object containing options for the export: |
|
122 * - failIfHashIs: if the generated file would have the same hash |
|
123 * defined here, will reject with ex.becauseSameHash |
|
124 * @return {Promise} |
|
125 * @resolves once the file has been created, to an object with the |
|
126 * following properties: |
|
127 * - count: number of exported bookmarks |
|
128 * - hash: file hash for contents comparison |
|
129 * @rejects JavaScript exception. |
|
130 * @deprecated passing an nsIFile is deprecated |
|
131 */ |
|
132 exportToFile: function BJU_exportToFile(aFilePath, aOptions={}) { |
|
133 if (aFilePath instanceof Ci.nsIFile) { |
|
134 Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.exportToFile " + |
|
135 "is deprecated. Please use an OS.File path string instead.", |
|
136 "https://developer.mozilla.org/docs/JavaScript_OS.File"); |
|
137 aFilePath = aFilePath.path; |
|
138 } |
|
139 return Task.spawn(function* () { |
|
140 let [bookmarks, count] = yield PlacesBackups.getBookmarksTree(); |
|
141 let startTime = Date.now(); |
|
142 let jsonString = JSON.stringify(bookmarks); |
|
143 // Report the time taken to convert the tree to JSON. |
|
144 try { |
|
145 Services.telemetry |
|
146 .getHistogramById("PLACES_BACKUPS_TOJSON_MS") |
|
147 .add(Date.now() - startTime); |
|
148 } catch (ex) { |
|
149 Components.utils.reportError("Unable to report telemetry."); |
|
150 } |
|
151 |
|
152 startTime = Date.now(); |
|
153 let hash = generateHash(jsonString); |
|
154 // Report the time taken to generate the hash. |
|
155 try { |
|
156 Services.telemetry |
|
157 .getHistogramById("PLACES_BACKUPS_HASHING_MS") |
|
158 .add(Date.now() - startTime); |
|
159 } catch (ex) { |
|
160 Components.utils.reportError("Unable to report telemetry."); |
|
161 } |
|
162 |
|
163 if (hash === aOptions.failIfHashIs) { |
|
164 let e = new Error("Hash conflict"); |
|
165 e.becauseSameHash = true; |
|
166 throw e; |
|
167 } |
|
168 |
|
169 // Do not write to the tmp folder, otherwise if it has a different |
|
170 // filesystem writeAtomic will fail. Eventual dangling .tmp files should |
|
171 // be cleaned up by the caller. |
|
172 yield OS.File.writeAtomic(aFilePath, jsonString, |
|
173 { tmpPath: OS.Path.join(aFilePath + ".tmp") }); |
|
174 return { count: count, hash: hash }; |
|
175 }); |
|
176 } |
|
177 }); |
|
178 |
|
179 function BookmarkImporter(aReplace) { |
|
180 this._replace = aReplace; |
|
181 } |
|
182 BookmarkImporter.prototype = { |
|
183 /** |
|
184 * Import bookmarks from a url. |
|
185 * |
|
186 * @param aSpec |
|
187 * url of the bookmark data. |
|
188 * |
|
189 * @return {Promise} |
|
190 * @resolves When the new bookmarks have been created. |
|
191 * @rejects JavaScript exception. |
|
192 */ |
|
193 importFromURL: function BI_importFromURL(aSpec) { |
|
194 let deferred = Promise.defer(); |
|
195 |
|
196 let streamObserver = { |
|
197 onStreamComplete: function (aLoader, aContext, aStatus, aLength, |
|
198 aResult) { |
|
199 let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. |
|
200 createInstance(Ci.nsIScriptableUnicodeConverter); |
|
201 converter.charset = "UTF-8"; |
|
202 |
|
203 try { |
|
204 let jsonString = converter.convertFromByteArray(aResult, |
|
205 aResult.length); |
|
206 deferred.resolve(this.importFromJSON(jsonString)); |
|
207 } catch (ex) { |
|
208 Cu.reportError("Failed to import from URL: " + ex); |
|
209 deferred.reject(ex); |
|
210 throw ex; |
|
211 } |
|
212 }.bind(this) |
|
213 }; |
|
214 |
|
215 try { |
|
216 let channel = Services.io.newChannelFromURI(NetUtil.newURI(aSpec)); |
|
217 let streamLoader = Cc["@mozilla.org/network/stream-loader;1"]. |
|
218 createInstance(Ci.nsIStreamLoader); |
|
219 |
|
220 streamLoader.init(streamObserver); |
|
221 channel.asyncOpen(streamLoader, channel); |
|
222 } catch (ex) { |
|
223 deferred.reject(ex); |
|
224 } |
|
225 |
|
226 return deferred.promise; |
|
227 }, |
|
228 |
|
229 /** |
|
230 * Import bookmarks from a JSON string. |
|
231 * |
|
232 * @param aString |
|
233 * JSON string of serialized bookmark data. |
|
234 */ |
|
235 importFromJSON: function BI_importFromJSON(aString) { |
|
236 let deferred = Promise.defer(); |
|
237 let nodes = |
|
238 PlacesUtils.unwrapNodes(aString, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER); |
|
239 |
|
240 if (nodes.length == 0 || !nodes[0].children || |
|
241 nodes[0].children.length == 0) { |
|
242 deferred.resolve(); // Nothing to restore |
|
243 } else { |
|
244 // Ensure tag folder gets processed last |
|
245 nodes[0].children.sort(function sortRoots(aNode, bNode) { |
|
246 return (aNode.root && aNode.root == "tagsFolder") ? 1 : |
|
247 (bNode.root && bNode.root == "tagsFolder") ? -1 : 0; |
|
248 }); |
|
249 |
|
250 let batch = { |
|
251 nodes: nodes[0].children, |
|
252 runBatched: function runBatched() { |
|
253 if (this._replace) { |
|
254 // Get roots excluded from the backup, we will not remove them |
|
255 // before restoring. |
|
256 let excludeItems = PlacesUtils.annotations.getItemsWithAnnotation( |
|
257 PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO); |
|
258 // Delete existing children of the root node, excepting: |
|
259 // 1. special folders: delete the child nodes |
|
260 // 2. tags folder: untag via the tagging api |
|
261 let root = PlacesUtils.getFolderContents(PlacesUtils.placesRootId, |
|
262 false, false).root; |
|
263 let childIds = []; |
|
264 for (let i = 0; i < root.childCount; i++) { |
|
265 let childId = root.getChild(i).itemId; |
|
266 if (excludeItems.indexOf(childId) == -1 && |
|
267 childId != PlacesUtils.tagsFolderId) { |
|
268 childIds.push(childId); |
|
269 } |
|
270 } |
|
271 root.containerOpen = false; |
|
272 |
|
273 for (let i = 0; i < childIds.length; i++) { |
|
274 let rootItemId = childIds[i]; |
|
275 if (PlacesUtils.isRootItem(rootItemId)) { |
|
276 PlacesUtils.bookmarks.removeFolderChildren(rootItemId); |
|
277 } else { |
|
278 PlacesUtils.bookmarks.removeItem(rootItemId); |
|
279 } |
|
280 } |
|
281 } |
|
282 |
|
283 let searchIds = []; |
|
284 let folderIdMap = []; |
|
285 |
|
286 for (let node of batch.nodes) { |
|
287 if (!node.children || node.children.length == 0) |
|
288 continue; // Nothing to restore for this root |
|
289 |
|
290 if (node.root) { |
|
291 let container = PlacesUtils.placesRootId; // Default to places root |
|
292 switch (node.root) { |
|
293 case "bookmarksMenuFolder": |
|
294 container = PlacesUtils.bookmarksMenuFolderId; |
|
295 break; |
|
296 case "tagsFolder": |
|
297 container = PlacesUtils.tagsFolderId; |
|
298 break; |
|
299 case "unfiledBookmarksFolder": |
|
300 container = PlacesUtils.unfiledBookmarksFolderId; |
|
301 break; |
|
302 case "toolbarFolder": |
|
303 container = PlacesUtils.toolbarFolderId; |
|
304 break; |
|
305 } |
|
306 |
|
307 // Insert the data into the db |
|
308 for (let child of node.children) { |
|
309 let index = child.index; |
|
310 let [folders, searches] = |
|
311 this.importJSONNode(child, container, index, 0); |
|
312 for (let i = 0; i < folders.length; i++) { |
|
313 if (folders[i]) |
|
314 folderIdMap[i] = folders[i]; |
|
315 } |
|
316 searchIds = searchIds.concat(searches); |
|
317 } |
|
318 } else { |
|
319 this.importJSONNode( |
|
320 node, PlacesUtils.placesRootId, node.index, 0); |
|
321 } |
|
322 } |
|
323 |
|
324 // Fixup imported place: uris that contain folders |
|
325 searchIds.forEach(function(aId) { |
|
326 let oldURI = PlacesUtils.bookmarks.getBookmarkURI(aId); |
|
327 let uri = fixupQuery(oldURI, folderIdMap); |
|
328 if (!uri.equals(oldURI)) { |
|
329 PlacesUtils.bookmarks.changeBookmarkURI(aId, uri); |
|
330 } |
|
331 }); |
|
332 |
|
333 deferred.resolve(); |
|
334 }.bind(this) |
|
335 }; |
|
336 |
|
337 PlacesUtils.bookmarks.runInBatchMode(batch, null); |
|
338 } |
|
339 return deferred.promise; |
|
340 }, |
|
341 |
|
342 /** |
|
343 * Takes a JSON-serialized node and inserts it into the db. |
|
344 * |
|
345 * @param aData |
|
346 * The unwrapped data blob of dropped or pasted data. |
|
347 * @param aContainer |
|
348 * The container the data was dropped or pasted into |
|
349 * @param aIndex |
|
350 * The index within the container the item was dropped or pasted at |
|
351 * @return an array containing of maps of old folder ids to new folder ids, |
|
352 * and an array of saved search ids that need to be fixed up. |
|
353 * eg: [[[oldFolder1, newFolder1]], [search1]] |
|
354 */ |
|
355 importJSONNode: function BI_importJSONNode(aData, aContainer, aIndex, |
|
356 aGrandParentId) { |
|
357 let folderIdMap = []; |
|
358 let searchIds = []; |
|
359 let id = -1; |
|
360 switch (aData.type) { |
|
361 case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: |
|
362 if (aContainer == PlacesUtils.tagsFolderId) { |
|
363 // Node is a tag |
|
364 if (aData.children) { |
|
365 aData.children.forEach(function(aChild) { |
|
366 try { |
|
367 PlacesUtils.tagging.tagURI( |
|
368 NetUtil.newURI(aChild.uri), [aData.title]); |
|
369 } catch (ex) { |
|
370 // Invalid tag child, skip it |
|
371 } |
|
372 }); |
|
373 return [folderIdMap, searchIds]; |
|
374 } |
|
375 } else if (aData.annos && |
|
376 aData.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) { |
|
377 // Node is a livemark |
|
378 let feedURI = null; |
|
379 let siteURI = null; |
|
380 aData.annos = aData.annos.filter(function(aAnno) { |
|
381 switch (aAnno.name) { |
|
382 case PlacesUtils.LMANNO_FEEDURI: |
|
383 feedURI = NetUtil.newURI(aAnno.value); |
|
384 return false; |
|
385 case PlacesUtils.LMANNO_SITEURI: |
|
386 siteURI = NetUtil.newURI(aAnno.value); |
|
387 return false; |
|
388 default: |
|
389 return true; |
|
390 } |
|
391 }); |
|
392 |
|
393 if (feedURI) { |
|
394 PlacesUtils.livemarks.addLivemark({ |
|
395 title: aData.title, |
|
396 feedURI: feedURI, |
|
397 parentId: aContainer, |
|
398 index: aIndex, |
|
399 lastModified: aData.lastModified, |
|
400 siteURI: siteURI |
|
401 }).then(function (aLivemark) { |
|
402 let id = aLivemark.id; |
|
403 if (aData.dateAdded) |
|
404 PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded); |
|
405 if (aData.annos && aData.annos.length) |
|
406 PlacesUtils.setAnnotationsForItem(id, aData.annos); |
|
407 }, Cu.reportError); |
|
408 } |
|
409 } else { |
|
410 id = PlacesUtils.bookmarks.createFolder( |
|
411 aContainer, aData.title, aIndex); |
|
412 folderIdMap[aData.id] = id; |
|
413 // Process children |
|
414 if (aData.children) { |
|
415 for (let i = 0; i < aData.children.length; i++) { |
|
416 let child = aData.children[i]; |
|
417 let [folders, searches] = |
|
418 this.importJSONNode(child, id, i, aContainer); |
|
419 for (let j = 0; j < folders.length; j++) { |
|
420 if (folders[j]) |
|
421 folderIdMap[j] = folders[j]; |
|
422 } |
|
423 searchIds = searchIds.concat(searches); |
|
424 } |
|
425 } |
|
426 } |
|
427 break; |
|
428 case PlacesUtils.TYPE_X_MOZ_PLACE: |
|
429 id = PlacesUtils.bookmarks.insertBookmark( |
|
430 aContainer, NetUtil.newURI(aData.uri), aIndex, aData.title); |
|
431 if (aData.keyword) |
|
432 PlacesUtils.bookmarks.setKeywordForBookmark(id, aData.keyword); |
|
433 if (aData.tags) { |
|
434 // TODO (bug 967196) the tagging service should trim by itself. |
|
435 let tags = aData.tags.split(",").map(tag => tag.trim()); |
|
436 if (tags.length) |
|
437 PlacesUtils.tagging.tagURI(NetUtil.newURI(aData.uri), tags); |
|
438 } |
|
439 if (aData.charset) { |
|
440 PlacesUtils.annotations.setPageAnnotation( |
|
441 NetUtil.newURI(aData.uri), PlacesUtils.CHARSET_ANNO, aData.charset, |
|
442 0, Ci.nsIAnnotationService.EXPIRE_NEVER); |
|
443 } |
|
444 if (aData.uri.substr(0, 6) == "place:") |
|
445 searchIds.push(id); |
|
446 if (aData.icon) { |
|
447 try { |
|
448 // Create a fake faviconURI to use (FIXME: bug 523932) |
|
449 let faviconURI = NetUtil.newURI("fake-favicon-uri:" + aData.uri); |
|
450 PlacesUtils.favicons.replaceFaviconDataFromDataURL( |
|
451 faviconURI, aData.icon, 0); |
|
452 PlacesUtils.favicons.setAndFetchFaviconForPage( |
|
453 NetUtil.newURI(aData.uri), faviconURI, false, |
|
454 PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE); |
|
455 } catch (ex) { |
|
456 Components.utils.reportError("Failed to import favicon data:" + ex); |
|
457 } |
|
458 } |
|
459 if (aData.iconUri) { |
|
460 try { |
|
461 PlacesUtils.favicons.setAndFetchFaviconForPage( |
|
462 NetUtil.newURI(aData.uri), NetUtil.newURI(aData.iconUri), false, |
|
463 PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE); |
|
464 } catch (ex) { |
|
465 Components.utils.reportError("Failed to import favicon URI:" + ex); |
|
466 } |
|
467 } |
|
468 break; |
|
469 case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: |
|
470 id = PlacesUtils.bookmarks.insertSeparator(aContainer, aIndex); |
|
471 break; |
|
472 default: |
|
473 // Unknown node type |
|
474 } |
|
475 |
|
476 // Set generic properties, valid for all nodes |
|
477 if (id != -1 && aContainer != PlacesUtils.tagsFolderId && |
|
478 aGrandParentId != PlacesUtils.tagsFolderId) { |
|
479 if (aData.dateAdded) |
|
480 PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded); |
|
481 if (aData.lastModified) |
|
482 PlacesUtils.bookmarks.setItemLastModified(id, aData.lastModified); |
|
483 if (aData.annos && aData.annos.length) |
|
484 PlacesUtils.setAnnotationsForItem(id, aData.annos); |
|
485 } |
|
486 |
|
487 return [folderIdMap, searchIds]; |
|
488 } |
|
489 } |
|
490 |
|
491 function notifyObservers(topic) { |
|
492 Services.obs.notifyObservers(null, topic, "json"); |
|
493 } |
|
494 |
|
495 /** |
|
496 * Replaces imported folder ids with their local counterparts in a place: URI. |
|
497 * |
|
498 * @param aURI |
|
499 * A place: URI with folder ids. |
|
500 * @param aFolderIdMap |
|
501 * An array mapping old folder id to new folder ids. |
|
502 * @returns the fixed up URI if all matched. If some matched, it returns |
|
503 * the URI with only the matching folders included. If none matched |
|
504 * it returns the input URI unchanged. |
|
505 */ |
|
506 function fixupQuery(aQueryURI, aFolderIdMap) { |
|
507 let convert = function(str, p1, offset, s) { |
|
508 return "folder=" + aFolderIdMap[p1]; |
|
509 } |
|
510 let stringURI = aQueryURI.spec.replace(/folder=([0-9]+)/g, convert); |
|
511 |
|
512 return NetUtil.newURI(stringURI); |
|
513 } |