mirror of https://github.com/roytam1/UXP.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
581 lines
22 KiB
581 lines
22 KiB
/* This Source Code Form is subject to the terms of the Mozilla Public |
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file, |
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
|
|
this.EXPORTED_SYMBOLS = [ "BookmarkJSONUtils" ]; |
|
|
|
const Ci = Components.interfaces; |
|
const Cc = Components.classes; |
|
const Cu = Components.utils; |
|
const Cr = Components.results; |
|
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
|
Cu.import("resource://gre/modules/Services.jsm"); |
|
Cu.import("resource://gre/modules/NetUtil.jsm"); |
|
Cu.import("resource://gre/modules/osfile.jsm"); |
|
Cu.import("resource://gre/modules/PlacesUtils.jsm"); |
|
Cu.import("resource://gre/modules/PromiseUtils.jsm"); |
|
Cu.import("resource://gre/modules/Task.jsm"); |
|
|
|
XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups", |
|
"resource://gre/modules/PlacesBackups.jsm"); |
|
XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", |
|
"resource://gre/modules/Deprecated.jsm"); |
|
|
|
XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => new TextDecoder()); |
|
XPCOMUtils.defineLazyGetter(this, "gTextEncoder", () => new TextEncoder()); |
|
|
|
/** |
|
* Generates an hash for the given string. |
|
* |
|
* @note The generated hash is returned in base64 form. Mind the fact base64 |
|
* is case-sensitive if you are going to reuse this code. |
|
*/ |
|
function generateHash(aString) { |
|
let cryptoHash = Cc["@mozilla.org/security/hash;1"] |
|
.createInstance(Ci.nsICryptoHash); |
|
cryptoHash.init(Ci.nsICryptoHash.MD5); |
|
let stringStream = Cc["@mozilla.org/io/string-input-stream;1"] |
|
.createInstance(Ci.nsIStringInputStream); |
|
stringStream.data = aString; |
|
cryptoHash.updateFromStream(stringStream, -1); |
|
// base64 allows the '/' char, but we can't use it for filenames. |
|
return cryptoHash.finish(true).replace(/\//g, "-"); |
|
} |
|
|
|
this.BookmarkJSONUtils = Object.freeze({ |
|
/** |
|
* Import bookmarks from a url. |
|
* |
|
* @param aSpec |
|
* url of the bookmark data. |
|
* @param aReplace |
|
* Boolean if true, replace existing bookmarks, else merge. |
|
* |
|
* @return {Promise} |
|
* @resolves When the new bookmarks have been created. |
|
* @rejects JavaScript exception. |
|
*/ |
|
importFromURL: function BJU_importFromURL(aSpec, aReplace) { |
|
return Task.spawn(function* () { |
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN); |
|
try { |
|
let importer = new BookmarkImporter(aReplace); |
|
yield importer.importFromURL(aSpec); |
|
|
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS); |
|
} catch (ex) { |
|
Cu.reportError("Failed to restore bookmarks from " + aSpec + ": " + ex); |
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED); |
|
} |
|
}); |
|
}, |
|
|
|
/** |
|
* Restores bookmarks and tags from a JSON file. |
|
* @note any item annotated with "places/excludeFromBackup" won't be removed |
|
* before executing the restore. |
|
* |
|
* @param aFilePath |
|
* OS.File path string of bookmarks in JSON or JSONlz4 format to be restored. |
|
* @param aReplace |
|
* Boolean if true, replace existing bookmarks, else merge. |
|
* |
|
* @return {Promise} |
|
* @resolves When the new bookmarks have been created. |
|
* @rejects JavaScript exception. |
|
* @deprecated passing an nsIFile is deprecated |
|
*/ |
|
importFromFile: function BJU_importFromFile(aFilePath, aReplace) { |
|
if (aFilePath instanceof Ci.nsIFile) { |
|
Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.importFromFile " + |
|
"is deprecated. Please use an OS.File path string instead.", |
|
"https://developer.mozilla.org/docs/JavaScript_OS.File"); |
|
aFilePath = aFilePath.path; |
|
} |
|
|
|
return Task.spawn(function* () { |
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN); |
|
try { |
|
if (!(yield OS.File.exists(aFilePath))) |
|
throw new Error("Cannot restore from nonexisting json file"); |
|
|
|
let importer = new BookmarkImporter(aReplace); |
|
if (aFilePath.endsWith("jsonlz4")) { |
|
yield importer.importFromCompressedFile(aFilePath); |
|
} else { |
|
yield importer.importFromURL(OS.Path.toFileURI(aFilePath)); |
|
} |
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS); |
|
} catch (ex) { |
|
Cu.reportError("Failed to restore bookmarks from " + aFilePath + ": " + ex); |
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED); |
|
throw ex; |
|
} |
|
}); |
|
}, |
|
|
|
/** |
|
* Serializes bookmarks using JSON, and writes to the supplied file path. |
|
* |
|
* @param aFilePath |
|
* OS.File path string for the bookmarks file to be created. |
|
* @param [optional] aOptions |
|
* Object containing options for the export: |
|
* - failIfHashIs: if the generated file would have the same hash |
|
* defined here, will reject with ex.becauseSameHash |
|
* - compress: if true, writes file using lz4 compression |
|
* @return {Promise} |
|
* @resolves once the file has been created, to an object with the |
|
* following properties: |
|
* - count: number of exported bookmarks |
|
* - hash: file hash for contents comparison |
|
* @rejects JavaScript exception. |
|
* @deprecated passing an nsIFile is deprecated |
|
*/ |
|
exportToFile: function BJU_exportToFile(aFilePath, aOptions={}) { |
|
if (aFilePath instanceof Ci.nsIFile) { |
|
Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.exportToFile " + |
|
"is deprecated. Please use an OS.File path string instead.", |
|
"https://developer.mozilla.org/docs/JavaScript_OS.File"); |
|
aFilePath = aFilePath.path; |
|
} |
|
return Task.spawn(function* () { |
|
let [bookmarks, count] = yield PlacesBackups.getBookmarksTree(); |
|
let startTime = Date.now(); |
|
let jsonString = JSON.stringify(bookmarks); |
|
|
|
let hash = generateHash(jsonString); |
|
|
|
if (hash === aOptions.failIfHashIs) { |
|
let e = new Error("Hash conflict"); |
|
e.becauseSameHash = true; |
|
throw e; |
|
} |
|
|
|
// Do not write to the tmp folder, otherwise if it has a different |
|
// filesystem writeAtomic will fail. Eventual dangling .tmp files should |
|
// be cleaned up by the caller. |
|
let writeOptions = { tmpPath: OS.Path.join(aFilePath + ".tmp") }; |
|
if (aOptions.compress) |
|
writeOptions.compression = "lz4"; |
|
|
|
yield OS.File.writeAtomic(aFilePath, jsonString, writeOptions); |
|
return { count: count, hash: hash }; |
|
}); |
|
} |
|
}); |
|
|
|
function BookmarkImporter(aReplace) { |
|
this._replace = aReplace; |
|
// The bookmark change source, used to determine the sync status and change |
|
// counter. |
|
this._source = aReplace ? PlacesUtils.bookmarks.SOURCE_IMPORT_REPLACE : |
|
PlacesUtils.bookmarks.SOURCE_IMPORT; |
|
} |
|
BookmarkImporter.prototype = { |
|
/** |
|
* Import bookmarks from a url. |
|
* |
|
* @param aSpec |
|
* url of the bookmark data. |
|
* |
|
* @return {Promise} |
|
* @resolves When the new bookmarks have been created. |
|
* @rejects JavaScript exception. |
|
*/ |
|
importFromURL(spec) { |
|
return new Promise((resolve, reject) => { |
|
let streamObserver = { |
|
onStreamComplete: (aLoader, aContext, aStatus, aLength, aResult) => { |
|
let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. |
|
createInstance(Ci.nsIScriptableUnicodeConverter); |
|
converter.charset = "UTF-8"; |
|
try { |
|
let jsonString = converter.convertFromByteArray(aResult, |
|
aResult.length); |
|
resolve(this.importFromJSON(jsonString)); |
|
} catch (ex) { |
|
Cu.reportError("Failed to import from URL: " + ex); |
|
reject(ex); |
|
} |
|
} |
|
}; |
|
|
|
let uri = NetUtil.newURI(spec); |
|
let channel = NetUtil.newChannel({ |
|
uri: uri, |
|
loadUsingSystemPrincipal: true |
|
}); |
|
let streamLoader = Cc["@mozilla.org/network/stream-loader;1"] |
|
.createInstance(Ci.nsIStreamLoader); |
|
streamLoader.init(streamObserver); |
|
channel.asyncOpen2(streamLoader); |
|
}); |
|
}, |
|
|
|
/** |
|
* Import bookmarks from a compressed file. |
|
* |
|
* @param aFilePath |
|
* OS.File path string of the bookmark data. |
|
* |
|
* @return {Promise} |
|
* @resolves When the new bookmarks have been created. |
|
* @rejects JavaScript exception. |
|
*/ |
|
importFromCompressedFile: function* BI_importFromCompressedFile(aFilePath) { |
|
let aResult = yield OS.File.read(aFilePath, { compression: "lz4" }); |
|
let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. |
|
createInstance(Ci.nsIScriptableUnicodeConverter); |
|
converter.charset = "UTF-8"; |
|
let jsonString = converter.convertFromByteArray(aResult, aResult.length); |
|
yield this.importFromJSON(jsonString); |
|
}, |
|
|
|
/** |
|
* Import bookmarks from a JSON string. |
|
* |
|
* @param aString |
|
* JSON string of serialized bookmark data. |
|
*/ |
|
importFromJSON: Task.async(function* (aString) { |
|
this._importPromises = []; |
|
let deferred = PromiseUtils.defer(); |
|
let nodes = |
|
PlacesUtils.unwrapNodes(aString, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER); |
|
|
|
if (nodes.length == 0 || !nodes[0].children || |
|
nodes[0].children.length == 0) { |
|
deferred.resolve(); // Nothing to restore |
|
} else { |
|
// Ensure tag folder gets processed last |
|
nodes[0].children.sort(function sortRoots(aNode, bNode) { |
|
if (aNode.root && aNode.root == "tagsFolder") |
|
return 1; |
|
if (bNode.root && bNode.root == "tagsFolder") |
|
return -1; |
|
return 0; |
|
}); |
|
|
|
let batch = { |
|
nodes: nodes[0].children, |
|
runBatched: function runBatched() { |
|
if (this._replace) { |
|
// Get roots excluded from the backup, we will not remove them |
|
// before restoring. |
|
let excludeItems = PlacesUtils.annotations.getItemsWithAnnotation( |
|
PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO); |
|
// Delete existing children of the root node, excepting: |
|
// 1. special folders: delete the child nodes |
|
// 2. tags folder: untag via the tagging api |
|
let root = PlacesUtils.getFolderContents(PlacesUtils.placesRootId, |
|
false, false).root; |
|
let childIds = []; |
|
for (let i = 0; i < root.childCount; i++) { |
|
let childId = root.getChild(i).itemId; |
|
if (!excludeItems.includes(childId) && |
|
childId != PlacesUtils.tagsFolderId) { |
|
childIds.push(childId); |
|
} |
|
} |
|
root.containerOpen = false; |
|
|
|
for (let i = 0; i < childIds.length; i++) { |
|
let rootItemId = childIds[i]; |
|
if (PlacesUtils.isRootItem(rootItemId)) { |
|
PlacesUtils.bookmarks.removeFolderChildren(rootItemId, |
|
this._source); |
|
} else { |
|
PlacesUtils.bookmarks.removeItem(rootItemId, this._source); |
|
} |
|
} |
|
} |
|
|
|
let searchIds = []; |
|
let folderIdMap = []; |
|
|
|
for (let node of batch.nodes) { |
|
if (!node.children || node.children.length == 0) |
|
continue; // Nothing to restore for this root |
|
|
|
if (node.root) { |
|
let container = PlacesUtils.placesRootId; // Default to places root |
|
switch (node.root) { |
|
case "bookmarksMenuFolder": |
|
container = PlacesUtils.bookmarksMenuFolderId; |
|
break; |
|
case "tagsFolder": |
|
container = PlacesUtils.tagsFolderId; |
|
break; |
|
case "unfiledBookmarksFolder": |
|
container = PlacesUtils.unfiledBookmarksFolderId; |
|
break; |
|
case "toolbarFolder": |
|
container = PlacesUtils.toolbarFolderId; |
|
break; |
|
case "mobileFolder": |
|
container = PlacesUtils.mobileFolderId; |
|
break; |
|
} |
|
|
|
// Insert the data into the db |
|
for (let child of node.children) { |
|
let index = child.index; |
|
let [folders, searches] = |
|
this.importJSONNode(child, container, index, 0); |
|
for (let i = 0; i < folders.length; i++) { |
|
if (folders[i]) |
|
folderIdMap[i] = folders[i]; |
|
} |
|
searchIds = searchIds.concat(searches); |
|
} |
|
} else { |
|
let [folders, searches] = this.importJSONNode( |
|
node, PlacesUtils.placesRootId, node.index, 0); |
|
for (let i = 0; i < folders.length; i++) { |
|
if (folders[i]) |
|
folderIdMap[i] = folders[i]; |
|
} |
|
searchIds = searchIds.concat(searches); |
|
} |
|
} |
|
|
|
// Fixup imported place: uris that contain folders |
|
for (let id of searchIds) { |
|
let oldURI = PlacesUtils.bookmarks.getBookmarkURI(id); |
|
let uri = fixupQuery(oldURI, folderIdMap); |
|
if (!uri.equals(oldURI)) { |
|
PlacesUtils.bookmarks.changeBookmarkURI(id, uri, this._source); |
|
} |
|
} |
|
|
|
deferred.resolve(); |
|
}.bind(this) |
|
}; |
|
|
|
PlacesUtils.bookmarks.runInBatchMode(batch, null); |
|
} |
|
yield deferred.promise; |
|
// TODO (bug 1095426) once converted to the new bookmarks API, methods will |
|
// yield, so this hack should not be needed anymore. |
|
try { |
|
yield Promise.all(this._importPromises); |
|
} finally { |
|
delete this._importPromises; |
|
} |
|
}), |
|
|
|
/** |
|
* Takes a JSON-serialized node and inserts it into the db. |
|
* |
|
* @param aData |
|
* The unwrapped data blob of dropped or pasted data. |
|
* @param aContainer |
|
* The container the data was dropped or pasted into |
|
* @param aIndex |
|
* The index within the container the item was dropped or pasted at |
|
* @return an array containing of maps of old folder ids to new folder ids, |
|
* and an array of saved search ids that need to be fixed up. |
|
* eg: [[[oldFolder1, newFolder1]], [search1]] |
|
*/ |
|
importJSONNode: function BI_importJSONNode(aData, aContainer, aIndex, |
|
aGrandParentId) { |
|
let folderIdMap = []; |
|
let searchIds = []; |
|
let id = -1; |
|
switch (aData.type) { |
|
case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: |
|
if (aContainer == PlacesUtils.tagsFolderId) { |
|
// Node is a tag |
|
if (aData.children) { |
|
for (let child of aData.children) { |
|
try { |
|
PlacesUtils.tagging.tagURI( |
|
NetUtil.newURI(child.uri), [aData.title], this._source); |
|
} catch (ex) { |
|
// Invalid tag child, skip it |
|
} |
|
} |
|
return [folderIdMap, searchIds]; |
|
} |
|
} else if (aData.annos && |
|
aData.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) { |
|
// Node is a livemark |
|
let feedURI = null; |
|
let siteURI = null; |
|
aData.annos = aData.annos.filter(function(aAnno) { |
|
switch (aAnno.name) { |
|
case PlacesUtils.LMANNO_FEEDURI: |
|
feedURI = NetUtil.newURI(aAnno.value); |
|
return false; |
|
case PlacesUtils.LMANNO_SITEURI: |
|
siteURI = NetUtil.newURI(aAnno.value); |
|
return false; |
|
default: |
|
return true; |
|
} |
|
}); |
|
|
|
if (feedURI) { |
|
let lmPromise = PlacesUtils.livemarks.addLivemark({ |
|
title: aData.title, |
|
feedURI: feedURI, |
|
parentId: aContainer, |
|
index: aIndex, |
|
lastModified: aData.lastModified, |
|
siteURI: siteURI, |
|
guid: aData.guid, |
|
source: this._source |
|
}).then(aLivemark => { |
|
let id = aLivemark.id; |
|
if (aData.dateAdded) |
|
PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded, |
|
this._source); |
|
if (aData.annos && aData.annos.length) |
|
PlacesUtils.setAnnotationsForItem(id, aData.annos, |
|
this._source); |
|
}); |
|
this._importPromises.push(lmPromise); |
|
} |
|
} else { |
|
let isMobileFolder = aData.annos && |
|
aData.annos.some(anno => anno.name == PlacesUtils.MOBILE_ROOT_ANNO); |
|
if (isMobileFolder) { |
|
// Mobile bookmark folders are special: we move their children to |
|
// the mobile root instead of importing them. We also rewrite |
|
// queries to use the special folder ID, and ignore generic |
|
// properties like timestamps and annotations set on the folder. |
|
id = PlacesUtils.mobileFolderId; |
|
} else { |
|
// For other folders, set `id` so that we can import timestamps |
|
// and annotations at the end of this function. |
|
id = PlacesUtils.bookmarks.createFolder( |
|
aContainer, aData.title, aIndex, aData.guid, this._source); |
|
} |
|
folderIdMap[aData.id] = id; |
|
// Process children |
|
if (aData.children) { |
|
for (let i = 0; i < aData.children.length; i++) { |
|
let child = aData.children[i]; |
|
let [folders, searches] = |
|
this.importJSONNode(child, id, i, aContainer); |
|
for (let j = 0; j < folders.length; j++) { |
|
if (folders[j]) |
|
folderIdMap[j] = folders[j]; |
|
} |
|
searchIds = searchIds.concat(searches); |
|
} |
|
} |
|
} |
|
break; |
|
case PlacesUtils.TYPE_X_MOZ_PLACE: |
|
id = PlacesUtils.bookmarks.insertBookmark( |
|
aContainer, NetUtil.newURI(aData.uri), aIndex, aData.title, aData.guid, this._source); |
|
if (aData.keyword) { |
|
// POST data could be set in 2 ways: |
|
// 1. new backups have a postData property |
|
// 2. old backups have an item annotation |
|
let postDataAnno = aData.annos && |
|
aData.annos.find(anno => anno.name == PlacesUtils.POST_DATA_ANNO); |
|
let postData = aData.postData || (postDataAnno && postDataAnno.value); |
|
let kwPromise = PlacesUtils.keywords.insert({ keyword: aData.keyword, |
|
url: aData.uri, |
|
postData, |
|
source: this._source }); |
|
this._importPromises.push(kwPromise); |
|
} |
|
if (aData.tags) { |
|
let tags = aData.tags.split(",").filter(aTag => |
|
aTag.length <= Ci.nsITaggingService.MAX_TAG_LENGTH); |
|
if (tags.length) { |
|
try { |
|
PlacesUtils.tagging.tagURI(NetUtil.newURI(aData.uri), tags, this._source); |
|
} catch (ex) { |
|
// Invalid tag child, skip it. |
|
Cu.reportError(`Unable to set tags "${tags.join(", ")}" for ${aData.uri}: ${ex}`); |
|
} |
|
} |
|
} |
|
if (aData.charset) { |
|
PlacesUtils.annotations.setPageAnnotation( |
|
NetUtil.newURI(aData.uri), PlacesUtils.CHARSET_ANNO, aData.charset, |
|
0, Ci.nsIAnnotationService.EXPIRE_NEVER); |
|
} |
|
if (aData.uri.substr(0, 6) == "place:") |
|
searchIds.push(id); |
|
if (aData.icon) { |
|
try { |
|
// Create a fake faviconURI to use (FIXME: bug 523932) |
|
let faviconURI = NetUtil.newURI("fake-favicon-uri:" + aData.uri); |
|
PlacesUtils.favicons.replaceFaviconDataFromDataURL( |
|
faviconURI, aData.icon, 0, |
|
Services.scriptSecurityManager.getSystemPrincipal()); |
|
PlacesUtils.favicons.setAndFetchFaviconForPage( |
|
NetUtil.newURI(aData.uri), faviconURI, false, |
|
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null, |
|
Services.scriptSecurityManager.getSystemPrincipal()); |
|
} catch (ex) { |
|
Components.utils.reportError("Failed to import favicon data:" + ex); |
|
} |
|
} |
|
if (aData.iconUri) { |
|
try { |
|
PlacesUtils.favicons.setAndFetchFaviconForPage( |
|
NetUtil.newURI(aData.uri), NetUtil.newURI(aData.iconUri), false, |
|
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null, |
|
Services.scriptSecurityManager.getSystemPrincipal()); |
|
} catch (ex) { |
|
Components.utils.reportError("Failed to import favicon URI:" + ex); |
|
} |
|
} |
|
break; |
|
case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: |
|
id = PlacesUtils.bookmarks.insertSeparator(aContainer, aIndex, aData.guid, this._source); |
|
break; |
|
default: |
|
// Unknown node type |
|
} |
|
|
|
// Set generic properties, valid for all nodes except tags and the mobile |
|
// root. |
|
if (id != -1 && id != PlacesUtils.mobileFolderId && |
|
aContainer != PlacesUtils.tagsFolderId && |
|
aGrandParentId != PlacesUtils.tagsFolderId) { |
|
if (aData.dateAdded) |
|
PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded, |
|
this._source); |
|
if (aData.lastModified) |
|
PlacesUtils.bookmarks.setItemLastModified(id, aData.lastModified, |
|
this._source); |
|
if (aData.annos && aData.annos.length) |
|
PlacesUtils.setAnnotationsForItem(id, aData.annos, this._source); |
|
} |
|
|
|
return [folderIdMap, searchIds]; |
|
} |
|
} |
|
|
|
function notifyObservers(topic) { |
|
Services.obs.notifyObservers(null, topic, "json"); |
|
} |
|
|
|
/** |
|
* Replaces imported folder ids with their local counterparts in a place: URI. |
|
* |
|
* @param aURI |
|
* A place: URI with folder ids. |
|
* @param aFolderIdMap |
|
* An array mapping old folder id to new folder ids. |
|
* @returns the fixed up URI if all matched. If some matched, it returns |
|
* the URI with only the matching folders included. If none matched |
|
* it returns the input URI unchanged. |
|
*/ |
|
function fixupQuery(aQueryURI, aFolderIdMap) { |
|
let convert = function(str, p1, offset, s) { |
|
return "folder=" + aFolderIdMap[p1]; |
|
} |
|
let stringURI = aQueryURI.spec.replace(/folder=([0-9]+)/g, convert); |
|
|
|
return NetUtil.newURI(stringURI); |
|
}
|
|
|