1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/toolkit/components/places/BookmarkJSONUtils.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,513 @@ 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 file, 1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +this.EXPORTED_SYMBOLS = [ "BookmarkJSONUtils" ]; 1.9 + 1.10 +const Ci = Components.interfaces; 1.11 +const Cc = Components.classes; 1.12 +const Cu = Components.utils; 1.13 +const Cr = Components.results; 1.14 + 1.15 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.16 +Cu.import("resource://gre/modules/Services.jsm"); 1.17 +Cu.import("resource://gre/modules/NetUtil.jsm"); 1.18 +Cu.import("resource://gre/modules/osfile.jsm"); 1.19 +Cu.import("resource://gre/modules/PlacesUtils.jsm"); 1.20 +Cu.import("resource://gre/modules/Promise.jsm"); 1.21 +Cu.import("resource://gre/modules/Task.jsm"); 1.22 + 1.23 +XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups", 1.24 + "resource://gre/modules/PlacesBackups.jsm"); 1.25 +XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", 1.26 + "resource://gre/modules/Deprecated.jsm"); 1.27 + 1.28 +XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => new TextDecoder()); 1.29 +XPCOMUtils.defineLazyGetter(this, "gTextEncoder", () => new TextEncoder()); 1.30 + 1.31 +/** 1.32 + * Generates an hash for the given string. 1.33 + * 1.34 + * @note The generated hash is returned in base64 form. Mind the fact base64 1.35 + * is case-sensitive if you are going to reuse this code. 1.36 + */ 1.37 +function generateHash(aString) { 1.38 + let cryptoHash = Cc["@mozilla.org/security/hash;1"] 1.39 + .createInstance(Ci.nsICryptoHash); 1.40 + cryptoHash.init(Ci.nsICryptoHash.MD5); 1.41 + let stringStream = Cc["@mozilla.org/io/string-input-stream;1"] 1.42 + .createInstance(Ci.nsIStringInputStream); 1.43 + stringStream.data = aString; 1.44 + cryptoHash.updateFromStream(stringStream, -1); 1.45 + // base64 allows the '/' char, but we can't use it for filenames. 1.46 + return cryptoHash.finish(true).replace("/", "-", "g"); 1.47 +} 1.48 + 1.49 +this.BookmarkJSONUtils = Object.freeze({ 1.50 + /** 1.51 + * Import bookmarks from a url. 1.52 + * 1.53 + * @param aSpec 1.54 + * url of the bookmark data. 1.55 + * @param aReplace 1.56 + * Boolean if true, replace existing bookmarks, else merge. 1.57 + * 1.58 + * @return {Promise} 1.59 + * @resolves When the new bookmarks have been created. 1.60 + * @rejects JavaScript exception. 1.61 + */ 1.62 + importFromURL: function BJU_importFromURL(aSpec, aReplace) { 1.63 + return Task.spawn(function* () { 1.64 + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN); 1.65 + try { 1.66 + let importer = new BookmarkImporter(aReplace); 1.67 + yield importer.importFromURL(aSpec); 1.68 + 1.69 + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS); 1.70 + } catch(ex) { 1.71 + Cu.reportError("Failed to restore bookmarks from " + aSpec + ": " + ex); 1.72 + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED); 1.73 + } 1.74 + }); 1.75 + }, 1.76 + 1.77 + /** 1.78 + * Restores bookmarks and tags from a JSON file. 1.79 + * @note any item annotated with "places/excludeFromBackup" won't be removed 1.80 + * before executing the restore. 1.81 + * 1.82 + * @param aFilePath 1.83 + * OS.File path string of bookmarks in JSON format to be restored. 1.84 + * @param aReplace 1.85 + * Boolean if true, replace existing bookmarks, else merge. 1.86 + * 1.87 + * @return {Promise} 1.88 + * @resolves When the new bookmarks have been created. 1.89 + * @rejects JavaScript exception. 1.90 + * @deprecated passing an nsIFile is deprecated 1.91 + */ 1.92 + importFromFile: function BJU_importFromFile(aFilePath, aReplace) { 1.93 + if (aFilePath instanceof Ci.nsIFile) { 1.94 + Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.importFromFile " + 1.95 + "is deprecated. Please use an OS.File path string instead.", 1.96 + "https://developer.mozilla.org/docs/JavaScript_OS.File"); 1.97 + aFilePath = aFilePath.path; 1.98 + } 1.99 + 1.100 + return Task.spawn(function* () { 1.101 + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN); 1.102 + try { 1.103 + if (!(yield OS.File.exists(aFilePath))) 1.104 + throw new Error("Cannot restore from nonexisting json file"); 1.105 + 1.106 + let importer = new BookmarkImporter(aReplace); 1.107 + yield importer.importFromURL(OS.Path.toFileURI(aFilePath)); 1.108 + 1.109 + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS); 1.110 + } catch(ex) { 1.111 + Cu.reportError("Failed to restore bookmarks from " + aFilePath + ": " + ex); 1.112 + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED); 1.113 + throw ex; 1.114 + } 1.115 + }); 1.116 + }, 1.117 + 1.118 + /** 1.119 + * Serializes bookmarks using JSON, and writes to the supplied file path. 1.120 + * 1.121 + * @param aFilePath 1.122 + * OS.File path string for the "bookmarks.json" file to be created. 1.123 + * @param [optional] aOptions 1.124 + * Object containing options for the export: 1.125 + * - failIfHashIs: if the generated file would have the same hash 1.126 + * defined here, will reject with ex.becauseSameHash 1.127 + * @return {Promise} 1.128 + * @resolves once the file has been created, to an object with the 1.129 + * following properties: 1.130 + * - count: number of exported bookmarks 1.131 + * - hash: file hash for contents comparison 1.132 + * @rejects JavaScript exception. 1.133 + * @deprecated passing an nsIFile is deprecated 1.134 + */ 1.135 + exportToFile: function BJU_exportToFile(aFilePath, aOptions={}) { 1.136 + if (aFilePath instanceof Ci.nsIFile) { 1.137 + Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.exportToFile " + 1.138 + "is deprecated. Please use an OS.File path string instead.", 1.139 + "https://developer.mozilla.org/docs/JavaScript_OS.File"); 1.140 + aFilePath = aFilePath.path; 1.141 + } 1.142 + return Task.spawn(function* () { 1.143 + let [bookmarks, count] = yield PlacesBackups.getBookmarksTree(); 1.144 + let startTime = Date.now(); 1.145 + let jsonString = JSON.stringify(bookmarks); 1.146 + // Report the time taken to convert the tree to JSON. 1.147 + try { 1.148 + Services.telemetry 1.149 + .getHistogramById("PLACES_BACKUPS_TOJSON_MS") 1.150 + .add(Date.now() - startTime); 1.151 + } catch (ex) { 1.152 + Components.utils.reportError("Unable to report telemetry."); 1.153 + } 1.154 + 1.155 + startTime = Date.now(); 1.156 + let hash = generateHash(jsonString); 1.157 + // Report the time taken to generate the hash. 1.158 + try { 1.159 + Services.telemetry 1.160 + .getHistogramById("PLACES_BACKUPS_HASHING_MS") 1.161 + .add(Date.now() - startTime); 1.162 + } catch (ex) { 1.163 + Components.utils.reportError("Unable to report telemetry."); 1.164 + } 1.165 + 1.166 + if (hash === aOptions.failIfHashIs) { 1.167 + let e = new Error("Hash conflict"); 1.168 + e.becauseSameHash = true; 1.169 + throw e; 1.170 + } 1.171 + 1.172 + // Do not write to the tmp folder, otherwise if it has a different 1.173 + // filesystem writeAtomic will fail. Eventual dangling .tmp files should 1.174 + // be cleaned up by the caller. 1.175 + yield OS.File.writeAtomic(aFilePath, jsonString, 1.176 + { tmpPath: OS.Path.join(aFilePath + ".tmp") }); 1.177 + return { count: count, hash: hash }; 1.178 + }); 1.179 + } 1.180 +}); 1.181 + 1.182 +function BookmarkImporter(aReplace) { 1.183 + this._replace = aReplace; 1.184 +} 1.185 +BookmarkImporter.prototype = { 1.186 + /** 1.187 + * Import bookmarks from a url. 1.188 + * 1.189 + * @param aSpec 1.190 + * url of the bookmark data. 1.191 + * 1.192 + * @return {Promise} 1.193 + * @resolves When the new bookmarks have been created. 1.194 + * @rejects JavaScript exception. 1.195 + */ 1.196 + importFromURL: function BI_importFromURL(aSpec) { 1.197 + let deferred = Promise.defer(); 1.198 + 1.199 + let streamObserver = { 1.200 + onStreamComplete: function (aLoader, aContext, aStatus, aLength, 1.201 + aResult) { 1.202 + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. 1.203 + createInstance(Ci.nsIScriptableUnicodeConverter); 1.204 + converter.charset = "UTF-8"; 1.205 + 1.206 + try { 1.207 + let jsonString = converter.convertFromByteArray(aResult, 1.208 + aResult.length); 1.209 + deferred.resolve(this.importFromJSON(jsonString)); 1.210 + } catch (ex) { 1.211 + Cu.reportError("Failed to import from URL: " + ex); 1.212 + deferred.reject(ex); 1.213 + throw ex; 1.214 + } 1.215 + }.bind(this) 1.216 + }; 1.217 + 1.218 + try { 1.219 + let channel = Services.io.newChannelFromURI(NetUtil.newURI(aSpec)); 1.220 + let streamLoader = Cc["@mozilla.org/network/stream-loader;1"]. 1.221 + createInstance(Ci.nsIStreamLoader); 1.222 + 1.223 + streamLoader.init(streamObserver); 1.224 + channel.asyncOpen(streamLoader, channel); 1.225 + } catch (ex) { 1.226 + deferred.reject(ex); 1.227 + } 1.228 + 1.229 + return deferred.promise; 1.230 + }, 1.231 + 1.232 + /** 1.233 + * Import bookmarks from a JSON string. 1.234 + * 1.235 + * @param aString 1.236 + * JSON string of serialized bookmark data. 1.237 + */ 1.238 + importFromJSON: function BI_importFromJSON(aString) { 1.239 + let deferred = Promise.defer(); 1.240 + let nodes = 1.241 + PlacesUtils.unwrapNodes(aString, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER); 1.242 + 1.243 + if (nodes.length == 0 || !nodes[0].children || 1.244 + nodes[0].children.length == 0) { 1.245 + deferred.resolve(); // Nothing to restore 1.246 + } else { 1.247 + // Ensure tag folder gets processed last 1.248 + nodes[0].children.sort(function sortRoots(aNode, bNode) { 1.249 + return (aNode.root && aNode.root == "tagsFolder") ? 1 : 1.250 + (bNode.root && bNode.root == "tagsFolder") ? -1 : 0; 1.251 + }); 1.252 + 1.253 + let batch = { 1.254 + nodes: nodes[0].children, 1.255 + runBatched: function runBatched() { 1.256 + if (this._replace) { 1.257 + // Get roots excluded from the backup, we will not remove them 1.258 + // before restoring. 1.259 + let excludeItems = PlacesUtils.annotations.getItemsWithAnnotation( 1.260 + PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO); 1.261 + // Delete existing children of the root node, excepting: 1.262 + // 1. special folders: delete the child nodes 1.263 + // 2. tags folder: untag via the tagging api 1.264 + let root = PlacesUtils.getFolderContents(PlacesUtils.placesRootId, 1.265 + false, false).root; 1.266 + let childIds = []; 1.267 + for (let i = 0; i < root.childCount; i++) { 1.268 + let childId = root.getChild(i).itemId; 1.269 + if (excludeItems.indexOf(childId) == -1 && 1.270 + childId != PlacesUtils.tagsFolderId) { 1.271 + childIds.push(childId); 1.272 + } 1.273 + } 1.274 + root.containerOpen = false; 1.275 + 1.276 + for (let i = 0; i < childIds.length; i++) { 1.277 + let rootItemId = childIds[i]; 1.278 + if (PlacesUtils.isRootItem(rootItemId)) { 1.279 + PlacesUtils.bookmarks.removeFolderChildren(rootItemId); 1.280 + } else { 1.281 + PlacesUtils.bookmarks.removeItem(rootItemId); 1.282 + } 1.283 + } 1.284 + } 1.285 + 1.286 + let searchIds = []; 1.287 + let folderIdMap = []; 1.288 + 1.289 + for (let node of batch.nodes) { 1.290 + if (!node.children || node.children.length == 0) 1.291 + continue; // Nothing to restore for this root 1.292 + 1.293 + if (node.root) { 1.294 + let container = PlacesUtils.placesRootId; // Default to places root 1.295 + switch (node.root) { 1.296 + case "bookmarksMenuFolder": 1.297 + container = PlacesUtils.bookmarksMenuFolderId; 1.298 + break; 1.299 + case "tagsFolder": 1.300 + container = PlacesUtils.tagsFolderId; 1.301 + break; 1.302 + case "unfiledBookmarksFolder": 1.303 + container = PlacesUtils.unfiledBookmarksFolderId; 1.304 + break; 1.305 + case "toolbarFolder": 1.306 + container = PlacesUtils.toolbarFolderId; 1.307 + break; 1.308 + } 1.309 + 1.310 + // Insert the data into the db 1.311 + for (let child of node.children) { 1.312 + let index = child.index; 1.313 + let [folders, searches] = 1.314 + this.importJSONNode(child, container, index, 0); 1.315 + for (let i = 0; i < folders.length; i++) { 1.316 + if (folders[i]) 1.317 + folderIdMap[i] = folders[i]; 1.318 + } 1.319 + searchIds = searchIds.concat(searches); 1.320 + } 1.321 + } else { 1.322 + this.importJSONNode( 1.323 + node, PlacesUtils.placesRootId, node.index, 0); 1.324 + } 1.325 + } 1.326 + 1.327 + // Fixup imported place: uris that contain folders 1.328 + searchIds.forEach(function(aId) { 1.329 + let oldURI = PlacesUtils.bookmarks.getBookmarkURI(aId); 1.330 + let uri = fixupQuery(oldURI, folderIdMap); 1.331 + if (!uri.equals(oldURI)) { 1.332 + PlacesUtils.bookmarks.changeBookmarkURI(aId, uri); 1.333 + } 1.334 + }); 1.335 + 1.336 + deferred.resolve(); 1.337 + }.bind(this) 1.338 + }; 1.339 + 1.340 + PlacesUtils.bookmarks.runInBatchMode(batch, null); 1.341 + } 1.342 + return deferred.promise; 1.343 + }, 1.344 + 1.345 + /** 1.346 + * Takes a JSON-serialized node and inserts it into the db. 1.347 + * 1.348 + * @param aData 1.349 + * The unwrapped data blob of dropped or pasted data. 1.350 + * @param aContainer 1.351 + * The container the data was dropped or pasted into 1.352 + * @param aIndex 1.353 + * The index within the container the item was dropped or pasted at 1.354 + * @return an array containing of maps of old folder ids to new folder ids, 1.355 + * and an array of saved search ids that need to be fixed up. 1.356 + * eg: [[[oldFolder1, newFolder1]], [search1]] 1.357 + */ 1.358 + importJSONNode: function BI_importJSONNode(aData, aContainer, aIndex, 1.359 + aGrandParentId) { 1.360 + let folderIdMap = []; 1.361 + let searchIds = []; 1.362 + let id = -1; 1.363 + switch (aData.type) { 1.364 + case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: 1.365 + if (aContainer == PlacesUtils.tagsFolderId) { 1.366 + // Node is a tag 1.367 + if (aData.children) { 1.368 + aData.children.forEach(function(aChild) { 1.369 + try { 1.370 + PlacesUtils.tagging.tagURI( 1.371 + NetUtil.newURI(aChild.uri), [aData.title]); 1.372 + } catch (ex) { 1.373 + // Invalid tag child, skip it 1.374 + } 1.375 + }); 1.376 + return [folderIdMap, searchIds]; 1.377 + } 1.378 + } else if (aData.annos && 1.379 + aData.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) { 1.380 + // Node is a livemark 1.381 + let feedURI = null; 1.382 + let siteURI = null; 1.383 + aData.annos = aData.annos.filter(function(aAnno) { 1.384 + switch (aAnno.name) { 1.385 + case PlacesUtils.LMANNO_FEEDURI: 1.386 + feedURI = NetUtil.newURI(aAnno.value); 1.387 + return false; 1.388 + case PlacesUtils.LMANNO_SITEURI: 1.389 + siteURI = NetUtil.newURI(aAnno.value); 1.390 + return false; 1.391 + default: 1.392 + return true; 1.393 + } 1.394 + }); 1.395 + 1.396 + if (feedURI) { 1.397 + PlacesUtils.livemarks.addLivemark({ 1.398 + title: aData.title, 1.399 + feedURI: feedURI, 1.400 + parentId: aContainer, 1.401 + index: aIndex, 1.402 + lastModified: aData.lastModified, 1.403 + siteURI: siteURI 1.404 + }).then(function (aLivemark) { 1.405 + let id = aLivemark.id; 1.406 + if (aData.dateAdded) 1.407 + PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded); 1.408 + if (aData.annos && aData.annos.length) 1.409 + PlacesUtils.setAnnotationsForItem(id, aData.annos); 1.410 + }, Cu.reportError); 1.411 + } 1.412 + } else { 1.413 + id = PlacesUtils.bookmarks.createFolder( 1.414 + aContainer, aData.title, aIndex); 1.415 + folderIdMap[aData.id] = id; 1.416 + // Process children 1.417 + if (aData.children) { 1.418 + for (let i = 0; i < aData.children.length; i++) { 1.419 + let child = aData.children[i]; 1.420 + let [folders, searches] = 1.421 + this.importJSONNode(child, id, i, aContainer); 1.422 + for (let j = 0; j < folders.length; j++) { 1.423 + if (folders[j]) 1.424 + folderIdMap[j] = folders[j]; 1.425 + } 1.426 + searchIds = searchIds.concat(searches); 1.427 + } 1.428 + } 1.429 + } 1.430 + break; 1.431 + case PlacesUtils.TYPE_X_MOZ_PLACE: 1.432 + id = PlacesUtils.bookmarks.insertBookmark( 1.433 + aContainer, NetUtil.newURI(aData.uri), aIndex, aData.title); 1.434 + if (aData.keyword) 1.435 + PlacesUtils.bookmarks.setKeywordForBookmark(id, aData.keyword); 1.436 + if (aData.tags) { 1.437 + // TODO (bug 967196) the tagging service should trim by itself. 1.438 + let tags = aData.tags.split(",").map(tag => tag.trim()); 1.439 + if (tags.length) 1.440 + PlacesUtils.tagging.tagURI(NetUtil.newURI(aData.uri), tags); 1.441 + } 1.442 + if (aData.charset) { 1.443 + PlacesUtils.annotations.setPageAnnotation( 1.444 + NetUtil.newURI(aData.uri), PlacesUtils.CHARSET_ANNO, aData.charset, 1.445 + 0, Ci.nsIAnnotationService.EXPIRE_NEVER); 1.446 + } 1.447 + if (aData.uri.substr(0, 6) == "place:") 1.448 + searchIds.push(id); 1.449 + if (aData.icon) { 1.450 + try { 1.451 + // Create a fake faviconURI to use (FIXME: bug 523932) 1.452 + let faviconURI = NetUtil.newURI("fake-favicon-uri:" + aData.uri); 1.453 + PlacesUtils.favicons.replaceFaviconDataFromDataURL( 1.454 + faviconURI, aData.icon, 0); 1.455 + PlacesUtils.favicons.setAndFetchFaviconForPage( 1.456 + NetUtil.newURI(aData.uri), faviconURI, false, 1.457 + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE); 1.458 + } catch (ex) { 1.459 + Components.utils.reportError("Failed to import favicon data:" + ex); 1.460 + } 1.461 + } 1.462 + if (aData.iconUri) { 1.463 + try { 1.464 + PlacesUtils.favicons.setAndFetchFaviconForPage( 1.465 + NetUtil.newURI(aData.uri), NetUtil.newURI(aData.iconUri), false, 1.466 + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE); 1.467 + } catch (ex) { 1.468 + Components.utils.reportError("Failed to import favicon URI:" + ex); 1.469 + } 1.470 + } 1.471 + break; 1.472 + case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: 1.473 + id = PlacesUtils.bookmarks.insertSeparator(aContainer, aIndex); 1.474 + break; 1.475 + default: 1.476 + // Unknown node type 1.477 + } 1.478 + 1.479 + // Set generic properties, valid for all nodes 1.480 + if (id != -1 && aContainer != PlacesUtils.tagsFolderId && 1.481 + aGrandParentId != PlacesUtils.tagsFolderId) { 1.482 + if (aData.dateAdded) 1.483 + PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded); 1.484 + if (aData.lastModified) 1.485 + PlacesUtils.bookmarks.setItemLastModified(id, aData.lastModified); 1.486 + if (aData.annos && aData.annos.length) 1.487 + PlacesUtils.setAnnotationsForItem(id, aData.annos); 1.488 + } 1.489 + 1.490 + return [folderIdMap, searchIds]; 1.491 + } 1.492 +} 1.493 + 1.494 +function notifyObservers(topic) { 1.495 + Services.obs.notifyObservers(null, topic, "json"); 1.496 +} 1.497 + 1.498 +/** 1.499 + * Replaces imported folder ids with their local counterparts in a place: URI. 1.500 + * 1.501 + * @param aURI 1.502 + * A place: URI with folder ids. 1.503 + * @param aFolderIdMap 1.504 + * An array mapping old folder id to new folder ids. 1.505 + * @returns the fixed up URI if all matched. If some matched, it returns 1.506 + * the URI with only the matching folders included. If none matched 1.507 + * it returns the input URI unchanged. 1.508 + */ 1.509 +function fixupQuery(aQueryURI, aFolderIdMap) { 1.510 + let convert = function(str, p1, offset, s) { 1.511 + return "folder=" + aFolderIdMap[p1]; 1.512 + } 1.513 + let stringURI = aQueryURI.spec.replace(/folder=([0-9]+)/g, convert); 1.514 + 1.515 + return NetUtil.newURI(stringURI); 1.516 +}