michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file,
michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0:
michael@0: /**
michael@0: * This file works on the old-style "bookmarks.html" file. It includes
michael@0: * functions to import and export existing bookmarks to this file format.
michael@0: *
michael@0: * Format
michael@0: * ------
michael@0: *
michael@0: * Primary heading := h1
michael@0: * Old version used this to set attributes on the bookmarks RDF root, such
michael@0: * as the last modified date. We only use H1 to check for the attribute
michael@0: * PLACES_ROOT, which tells us that this hierarchy root is the places root.
michael@0: * For backwards compatibility, if we don't find this, we assume that the
michael@0: * hierarchy is rooted at the bookmarks menu.
michael@0: * Heading := any heading other than h1
michael@0: * Old version used this to set attributes on the current container. We only
michael@0: * care about the content of the heading container, which contains the title
michael@0: * of the bookmark container.
michael@0: * Bookmark := a
michael@0: * HREF is the destination of the bookmark
michael@0: * FEEDURL is the URI of the RSS feed if this is a livemark.
michael@0: * LAST_CHARSET is stored as an annotation so that the next time we go to
michael@0: * that page we remember the user's preference.
michael@0: * WEB_PANEL is set to "true" if the bookmark should be loaded in the sidebar.
michael@0: * ICON will be stored in the favicon service
michael@0: * ICON_URI is new for places bookmarks.html, it refers to the original
michael@0: * URI of the favicon so we don't have to make up favicon URLs.
michael@0: * Text of the container is the name of the bookmark
michael@0: * Ignored: LAST_VISIT, ID (writing out non-RDF IDs can confuse Firefox 2)
michael@0: * Bookmark comment := dd
michael@0: * This affects the previosly added bookmark
michael@0: * Separator := hr
michael@0: * Insert a separator into the current container
michael@0: * The folder hierarchy is defined by /
, or
michael@0: * to see what the text content of that node should be.
michael@0: */
michael@0: this.previousText = "";
michael@0:
michael@0: /**
michael@0: * true when we hit a /
).
michael@0: *
michael@0: * Overall design
michael@0: * --------------
michael@0: *
michael@0: * We need to emulate a recursive parser. A "Bookmark import frame" is created
michael@0: * corresponding to each folder we encounter. These are arranged in a stack,
michael@0: * and contain all the state we need to keep track of.
michael@0: *
michael@0: * A frame is created when we find a heading, which defines a new container.
michael@0: * The frame also keeps track of the nesting of
s, (in well-formed
michael@0: * bookmarks files, these will have a 1-1 correspondence with frames, but we
michael@0: * try to be a little more flexible here). When the nesting count decreases
michael@0: * to 0, then we know a frame is complete and to pop back to the previous
michael@0: * frame.
michael@0: *
michael@0: * Note that a lot of things happen when tags are CLOSED because we need to
michael@0: * get the text from the content of the tag. For example, link and heading tags
michael@0: * both require the content (= title) before actually creating it.
michael@0: */
michael@0:
michael@0: this.EXPORTED_SYMBOLS = [ "BookmarkHTMLUtils" ];
michael@0:
michael@0: const Ci = Components.interfaces;
michael@0: const Cc = Components.classes;
michael@0: const Cu = Components.utils;
michael@0:
michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm");
michael@0: Cu.import("resource://gre/modules/Services.jsm");
michael@0: Cu.import("resource://gre/modules/NetUtil.jsm");
michael@0: Cu.import("resource://gre/modules/osfile.jsm");
michael@0: Cu.import("resource://gre/modules/FileUtils.jsm");
michael@0: Cu.import("resource://gre/modules/PlacesUtils.jsm");
michael@0: Cu.import("resource://gre/modules/Promise.jsm");
michael@0: Cu.import("resource://gre/modules/Task.jsm");
michael@0:
michael@0: XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups",
michael@0: "resource://gre/modules/PlacesBackups.jsm");
michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
michael@0: "resource://gre/modules/Deprecated.jsm");
michael@0:
michael@0: const Container_Normal = 0;
michael@0: const Container_Toolbar = 1;
michael@0: const Container_Menu = 2;
michael@0: const Container_Unfiled = 3;
michael@0: const Container_Places = 4;
michael@0:
michael@0: const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
michael@0: const DESCRIPTION_ANNO = "bookmarkProperties/description";
michael@0:
michael@0: const MICROSEC_PER_SEC = 1000000;
michael@0:
michael@0: const EXPORT_INDENT = " "; // four spaces
michael@0:
michael@0: // Counter used to build fake favicon urls.
michael@0: let serialNumber = 0;
michael@0:
michael@0: function base64EncodeString(aString) {
michael@0: let stream = Cc["@mozilla.org/io/string-input-stream;1"]
michael@0: .createInstance(Ci.nsIStringInputStream);
michael@0: stream.setData(aString, aString.length);
michael@0: let encoder = Cc["@mozilla.org/scriptablebase64encoder;1"]
michael@0: .createInstance(Ci.nsIScriptableBase64Encoder);
michael@0: return encoder.encodeToString(stream, aString.length);
michael@0: }
michael@0:
michael@0: /**
michael@0: * Provides HTML escaping for use in HTML attributes and body of the bookmarks
michael@0: * file, compatible with the old bookmarks system.
michael@0: */
michael@0: function escapeHtmlEntities(aText) {
michael@0: return (aText || "").replace("&", "&", "g")
michael@0: .replace("<", "<", "g")
michael@0: .replace(">", ">", "g")
michael@0: .replace("\"", """, "g")
michael@0: .replace("'", "'", "g");
michael@0: }
michael@0:
michael@0: /**
michael@0: * Provides URL escaping for use in HTML attributes of the bookmarks file,
michael@0: * compatible with the old bookmarks system.
michael@0: */
michael@0: function escapeUrl(aText) {
michael@0: return (aText || "").replace("\"", "%22", "g");
michael@0: }
michael@0:
michael@0: function notifyObservers(aTopic, aInitialImport) {
michael@0: Services.obs.notifyObservers(null, aTopic, aInitialImport ? "html-initial"
michael@0: : "html");
michael@0: }
michael@0:
michael@0: function promiseSoon() {
michael@0: let deferred = Promise.defer();
michael@0: Services.tm.mainThread.dispatch(deferred.resolve,
michael@0: Ci.nsIThread.DISPATCH_NORMAL);
michael@0: return deferred.promise;
michael@0: }
michael@0:
michael@0: this.BookmarkHTMLUtils = Object.freeze({
michael@0: /**
michael@0: * Loads the current bookmarks hierarchy from a "bookmarks.html" file.
michael@0: *
michael@0: * @param aSpec
michael@0: * String containing the "file:" URI for the existing "bookmarks.html"
michael@0: * file to be loaded.
michael@0: * @param aInitialImport
michael@0: * Whether this is the initial import executed on a new profile.
michael@0: *
michael@0: * @return {Promise}
michael@0: * @resolves When the new bookmarks have been created.
michael@0: * @rejects JavaScript exception.
michael@0: */
michael@0: importFromURL: function BHU_importFromURL(aSpec, aInitialImport) {
michael@0: return Task.spawn(function* () {
michael@0: notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
michael@0: try {
michael@0: let importer = new BookmarkImporter(aInitialImport);
michael@0: yield importer.importFromURL(aSpec);
michael@0:
michael@0: notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport);
michael@0: } catch(ex) {
michael@0: Cu.reportError("Failed to import bookmarks from " + aSpec + ": " + ex);
michael@0: notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport);
michael@0: throw ex;
michael@0: }
michael@0: });
michael@0: },
michael@0:
michael@0: /**
michael@0: * Loads the current bookmarks hierarchy from a "bookmarks.html" file.
michael@0: *
michael@0: * @param aFilePath
michael@0: * OS.File path string of the "bookmarks.html" file to be loaded.
michael@0: * @param aInitialImport
michael@0: * Whether this is the initial import executed on a new profile.
michael@0: *
michael@0: * @return {Promise}
michael@0: * @resolves When the new bookmarks have been created.
michael@0: * @rejects JavaScript exception.
michael@0: * @deprecated passing an nsIFile is deprecated
michael@0: */
michael@0: importFromFile: function BHU_importFromFile(aFilePath, aInitialImport) {
michael@0: if (aFilePath instanceof Ci.nsIFile) {
michael@0: Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.importFromFile " +
michael@0: "is deprecated. Please use an OS.File path string instead.",
michael@0: "https://developer.mozilla.org/docs/JavaScript_OS.File");
michael@0: aFilePath = aFilePath.path;
michael@0: }
michael@0:
michael@0: return Task.spawn(function* () {
michael@0: notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
michael@0: try {
michael@0: if (!(yield OS.File.exists(aFilePath)))
michael@0: throw new Error("Cannot import from nonexisting html file");
michael@0:
michael@0: let importer = new BookmarkImporter(aInitialImport);
michael@0: yield importer.importFromURL(OS.Path.toFileURI(aFilePath));
michael@0:
michael@0: notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport);
michael@0: } catch(ex) {
michael@0: Cu.reportError("Failed to import bookmarks from " + aFilePath + ": " + ex);
michael@0: notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport);
michael@0: throw ex;
michael@0: }
michael@0: });
michael@0: },
michael@0:
michael@0: /**
michael@0: * Saves the current bookmarks hierarchy to a "bookmarks.html" file.
michael@0: *
michael@0: * @param aFilePath
michael@0: * OS.File path string for the "bookmarks.html" file to be created.
michael@0: *
michael@0: * @return {Promise}
michael@0: * @resolves To the exported bookmarks count when the file has been created.
michael@0: * @rejects JavaScript exception.
michael@0: * @deprecated passing an nsIFile is deprecated
michael@0: */
michael@0: exportToFile: function BHU_exportToFile(aFilePath) {
michael@0: if (aFilePath instanceof Ci.nsIFile) {
michael@0: Deprecated.warning("Passing an nsIFile to BookmarksHTMLUtils.exportToFile " +
michael@0: "is deprecated. Please use an OS.File path string instead.",
michael@0: "https://developer.mozilla.org/docs/JavaScript_OS.File");
michael@0: aFilePath = aFilePath.path;
michael@0: }
michael@0: return Task.spawn(function* () {
michael@0: let [bookmarks, count] = yield PlacesBackups.getBookmarksTree();
michael@0: let startTime = Date.now();
michael@0:
michael@0: // Report the time taken to convert the tree to HTML.
michael@0: let exporter = new BookmarkExporter(bookmarks);
michael@0: yield exporter.exportToFile(aFilePath);
michael@0:
michael@0: try {
michael@0: Services.telemetry
michael@0: .getHistogramById("PLACES_EXPORT_TOHTML_MS")
michael@0: .add(Date.now() - startTime);
michael@0: } catch (ex) {
michael@0: Components.utils.reportError("Unable to report telemetry.");
michael@0: }
michael@0:
michael@0: return count;
michael@0: });
michael@0: },
michael@0:
michael@0: get defaultPath() {
michael@0: try {
michael@0: return Services.prefs.getCharPref("browser.bookmarks.file");
michael@0: } catch (ex) {}
michael@0: return OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.html")
michael@0: }
michael@0: });
michael@0:
michael@0: function Frame(aFrameId) {
michael@0: this.containerId = aFrameId;
michael@0:
michael@0: /**
michael@0: * How many
s have been nested. Each frame/container should start
michael@0: * with a heading, and is then followed by a
,
, or
s won't
michael@0: * be nested so this will be 0 or 1.
michael@0: */
michael@0: this.containerNesting = 0;
michael@0:
michael@0: /**
michael@0: * when we find a heading tag, it actually affects the title of the NEXT
michael@0: * container in the list. This stores that heading tag and whether it was
michael@0: * special. 'consumeHeading' resets this._
michael@0: */
michael@0: this.lastContainerType = Container_Normal;
michael@0:
michael@0: /**
michael@0: * this contains the text from the last begin tag until now. It is reset
michael@0: * at every begin tag. We can check it when we see a
"); michael@0: if (aItem.children) michael@0: yield this._writeContainerContents(aItem, aIndent); michael@0: if (aItem == this._root) michael@0: this._writeLine(aIndent + "
"); michael@0: }, michael@0: michael@0: _writeContainerContents: function (aItem, aIndent) { michael@0: let localIndent = aIndent + EXPORT_INDENT; michael@0: michael@0: for (let child of aItem.children) { michael@0: if (child.annos && child.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) michael@0: this._writeLivemark(child, localIndent); michael@0: else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) michael@0: yield this._writeContainer(child, localIndent); michael@0: else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) michael@0: this._writeSeparator(child, localIndent); michael@0: else michael@0: yield this._writeItem(child, localIndent); michael@0: } michael@0: }, michael@0: michael@0: _writeSeparator: function (aItem, aIndent) { michael@0: this._write(aIndent + "