diff options
Diffstat (limited to 'components/places/src/BookmarkHTMLUtils.jsm')
-rw-r--r-- | components/places/src/BookmarkHTMLUtils.jsm | 1186 |
1 files changed, 1186 insertions, 0 deletions
diff --git a/components/places/src/BookmarkHTMLUtils.jsm b/components/places/src/BookmarkHTMLUtils.jsm new file mode 100644 index 000000000..6b4ea7934 --- /dev/null +++ b/components/places/src/BookmarkHTMLUtils.jsm @@ -0,0 +1,1186 @@ +/* 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 file works on the old-style "bookmarks.html" file. It includes + * functions to import and export existing bookmarks to this file format. + * + * Format + * ------ + * + * Primary heading := h1 + * Old version used this to set attributes on the bookmarks RDF root, such + * as the last modified date. We only use H1 to check for the attribute + * PLACES_ROOT, which tells us that this hierarchy root is the places root. + * For backwards compatibility, if we don't find this, we assume that the + * hierarchy is rooted at the bookmarks menu. + * Heading := any heading other than h1 + * Old version used this to set attributes on the current container. We only + * care about the content of the heading container, which contains the title + * of the bookmark container. + * Bookmark := a + * HREF is the destination of the bookmark + * FEEDURL is the URI of the RSS feed if this is a livemark. + * LAST_CHARSET is stored as an annotation so that the next time we go to + * that page we remember the user's preference. + * WEB_PANEL is set to "true" if the bookmark should be loaded in the sidebar. + * ICON will be stored in the favicon service + * ICON_URI is new for places bookmarks.html, it refers to the original + * URI of the favicon so we don't have to make up favicon URLs. + * Text of the <a> container is the name of the bookmark + * Ignored: LAST_VISIT, ID (writing out non-RDF IDs can confuse Firefox 2) + * Bookmark comment := dd + * This affects the previosly added bookmark + * Separator := hr + * Insert a separator into the current container + * The folder hierarchy is defined by <dl>/<ul>/<menu> (the old importing code + * handles all these cases, when we write, use <dl>). + * + * Overall design + * -------------- + * + * We need to emulate a recursive parser. A "Bookmark import frame" is created + * corresponding to each folder we encounter. These are arranged in a stack, + * and contain all the state we need to keep track of. + * + * A frame is created when we find a heading, which defines a new container. + * The frame also keeps track of the nesting of <DL>s, (in well-formed + * bookmarks files, these will have a 1-1 correspondence with frames, but we + * try to be a little more flexible here). When the nesting count decreases + * to 0, then we know a frame is complete and to pop back to the previous + * frame. + * + * Note that a lot of things happen when tags are CLOSED because we need to + * get the text from the content of the tag. For example, link and heading tags + * both require the content (= title) before actually creating it. + */ + +this.EXPORTED_SYMBOLS = [ "BookmarkHTMLUtils" ]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +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/FileUtils.jsm"); +Cu.import("resource://gre/modules/PlacesUtils.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"); + +const Container_Normal = 0; +const Container_Toolbar = 1; +const Container_Menu = 2; +const Container_Unfiled = 3; +const Container_Places = 4; + +const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar"; +const DESCRIPTION_ANNO = "bookmarkProperties/description"; + +const MICROSEC_PER_SEC = 1000000; + +const EXPORT_INDENT = " "; // four spaces + +// Counter used to build fake favicon urls. +var serialNumber = 0; + +function base64EncodeString(aString) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + stream.setData(aString, aString.length); + let encoder = Cc["@mozilla.org/scriptablebase64encoder;1"] + .createInstance(Ci.nsIScriptableBase64Encoder); + return encoder.encodeToString(stream, aString.length); +} + +/** + * Provides HTML escaping for use in HTML attributes and body of the bookmarks + * file, compatible with the old bookmarks system. + */ +function escapeHtmlEntities(aText) { + return (aText || "").replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Provides URL escaping for use in HTML attributes of the bookmarks file, + * compatible with the old bookmarks system. + */ +function escapeUrl(aText) { + return (aText || "").replace(/"/g, "%22"); +} + +function notifyObservers(aTopic, aInitialImport) { + Services.obs.notifyObservers(null, aTopic, aInitialImport ? "html-initial" + : "html"); +} + +this.BookmarkHTMLUtils = Object.freeze({ + /** + * Loads the current bookmarks hierarchy from a "bookmarks.html" file. + * + * @param aSpec + * String containing the "file:" URI for the existing "bookmarks.html" + * file to be loaded. + * @param aInitialImport + * Whether this is the initial import executed on a new profile. + * + * @return {Promise} + * @resolves When the new bookmarks have been created. + * @rejects JavaScript exception. + */ + importFromURL: function BHU_importFromURL(aSpec, aInitialImport) { + return Task.spawn(function* () { + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport); + try { + let importer = new BookmarkImporter(aInitialImport); + yield importer.importFromURL(aSpec); + + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport); + } catch (ex) { + Cu.reportError("Failed to import bookmarks from " + aSpec + ": " + ex); + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport); + throw ex; + } + }); + }, + + /** + * Loads the current bookmarks hierarchy from a "bookmarks.html" file. + * + * @param aFilePath + * OS.File path string of the "bookmarks.html" file to be loaded. + * @param aInitialImport + * Whether this is the initial import executed on a new profile. + * + * @return {Promise} + * @resolves When the new bookmarks have been created. + * @rejects JavaScript exception. + * @deprecated passing an nsIFile is deprecated + */ + importFromFile: function BHU_importFromFile(aFilePath, aInitialImport) { + 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, aInitialImport); + try { + if (!(yield OS.File.exists(aFilePath))) { + throw new Error("Cannot import from nonexisting html file: " + aFilePath); + } + let importer = new BookmarkImporter(aInitialImport); + yield importer.importFromURL(OS.Path.toFileURI(aFilePath)); + + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport); + } catch (ex) { + Cu.reportError("Failed to import bookmarks from " + aFilePath + ": " + ex); + notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport); + throw ex; + } + }); + }, + + /** + * Saves the current bookmarks hierarchy to a "bookmarks.html" file. + * + * @param aFilePath + * OS.File path string for the "bookmarks.html" file to be created. + * + * @return {Promise} + * @resolves To the exported bookmarks count when the file has been created. + * @rejects JavaScript exception. + * @deprecated passing an nsIFile is deprecated + */ + exportToFile: function BHU_exportToFile(aFilePath) { + if (aFilePath instanceof Ci.nsIFile) { + Deprecated.warning("Passing an nsIFile to BookmarksHTMLUtils.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(); + + // Report the time taken to convert the tree to HTML. + let exporter = new BookmarkExporter(bookmarks); + yield exporter.exportToFile(aFilePath); + + return count; + }); + }, + + get defaultPath() { + try { + return Services.prefs.getCharPref("browser.bookmarks.file"); + } catch (ex) {} + return OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.html") + } +}); + +function Frame(aFrameId) { + this.containerId = aFrameId; + + /** + * How many <dl>s have been nested. Each frame/container should start + * with a heading, and is then followed by a <dl>, <ul>, or <menu>. When + * that list is complete, then it is the end of this container and we need + * to pop back up one level for new items. If we never get an open tag for + * one of these things, we should assume that the container is empty and + * that things we find should be siblings of it. Normally, these <dl>s won't + * be nested so this will be 0 or 1. + */ + this.containerNesting = 0; + + /** + * when we find a heading tag, it actually affects the title of the NEXT + * container in the list. This stores that heading tag and whether it was + * special. 'consumeHeading' resets this._ + */ + this.lastContainerType = Container_Normal; + + /** + * this contains the text from the last begin tag until now. It is reset + * at every begin tag. We can check it when we see a </a>, or </h3> + * to see what the text content of that node should be. + */ + this.previousText = ""; + + /** + * true when we hit a <dd>, which contains the description for the preceding + * <a> tag. We can't just check for </dd> like we can for </a> or </h3> + * because if there is a sub-folder, it is actually a child of the <dd> + * because the tag is never explicitly closed. If this is true and we see a + * new open tag, that means to commit the description to the previous + * bookmark. + * + * Additional weirdness happens when the previous <dt> tag contains a <h3>: + * this means there is a new folder with the given description, and whose + * children are contained in the following <dl> list. + * + * This is handled in openContainer(), which commits previous text if + * necessary. + */ + this.inDescription = false; + + /** + * contains the URL of the previous bookmark created. This is used so that + * when we encounter a <dd>, we know what bookmark to associate the text with. + * This is cleared whenever we hit a <h3>, so that we know NOT to save this + * with a bookmark, but to keep it until + */ + this.previousLink = null; // nsIURI + + /** + * contains the URL of the previous livemark, so that when the link ends, + * and the livemark title is known, we can create it. + */ + this.previousFeed = null; // nsIURI + + /** + * Contains the id of an imported, or newly created bookmark. + */ + this.previousId = 0; + + /** + * Contains the date-added and last-modified-date of an imported item. + * Used to override the values set by insertBookmark, createFolder, etc. + */ + this.previousDateAdded = 0; + this.previousLastModifiedDate = 0; +} + +function BookmarkImporter(aInitialImport) { + this._isImportDefaults = aInitialImport; + // The bookmark change source, used to determine the sync status and change + // counter. + this._source = aInitialImport ? PlacesUtils.bookmarks.SOURCE_IMPORT_REPLACE : + PlacesUtils.bookmarks.SOURCE_IMPORT; + this._frames = new Array(); + this._frames.push(new Frame(PlacesUtils.bookmarksMenuFolderId)); +} + +BookmarkImporter.prototype = { + + _safeTrim: function safeTrim(aStr) { + return aStr ? aStr.trim() : aStr; + }, + + get _curFrame() { + return this._frames[this._frames.length - 1]; + }, + + get _previousFrame() { + return this._frames[this._frames.length - 2]; + }, + + /** + * This is called when there is a new folder found. The folder takes the + * name from the previous frame's heading. + */ + _newFrame: function newFrame() { + let containerId = -1; + let frame = this._curFrame; + let containerTitle = frame.previousText; + frame.previousText = ""; + let containerType = frame.lastContainerType; + + switch (containerType) { + case Container_Normal: + // append a new folder + containerId = + PlacesUtils.bookmarks.createFolder(frame.containerId, + containerTitle, + PlacesUtils.bookmarks.DEFAULT_INDEX, + /* aGuid */ null, this._source); + break; + case Container_Places: + containerId = PlacesUtils.placesRootId; + break; + case Container_Menu: + containerId = PlacesUtils.bookmarksMenuFolderId; + break; + case Container_Unfiled: + containerId = PlacesUtils.unfiledBookmarksFolderId; + break; + case Container_Toolbar: + containerId = PlacesUtils.toolbarFolderId; + break; + default: + // NOT REACHED + throw new Error("Unreached"); + } + + if (frame.previousDateAdded > 0) { + try { + PlacesUtils.bookmarks.setItemDateAdded(containerId, frame.previousDateAdded, this._source); + } catch (e) { + } + frame.previousDateAdded = 0; + } + if (frame.previousLastModifiedDate > 0) { + try { + PlacesUtils.bookmarks.setItemLastModified(containerId, frame.previousLastModifiedDate, this._source); + } catch (e) { + } + // don't clear last-modified, in case there's a description + } + + frame.previousId = containerId; + + this._frames.push(new Frame(containerId)); + }, + + /** + * Handles <hr> as a separator. + * + * @note Separators may have a title in old html files, though Places dropped + * support for them. + * We also don't import ADD_DATE or LAST_MODIFIED for separators because + * pre-Places bookmarks did not support them. + */ + _handleSeparator: function handleSeparator(aElt) { + let frame = this._curFrame; + try { + frame.previousId = + PlacesUtils.bookmarks.insertSeparator(frame.containerId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + /* aGuid */ null, + this._source); + } catch (e) {} + }, + + /** + * Handles <H1>. We check for the attribute PLACES_ROOT and reset the + * container id if it's found. Otherwise, the default bookmark menu + * root is assumed and imported things will go into the bookmarks menu. + */ + _handleHead1Begin: function handleHead1Begin(aElt) { + if (this._frames.length > 1) { + return; + } + if (aElt.hasAttribute("places_root")) { + this._curFrame.containerId = PlacesUtils.placesRootId; + } + }, + + /** + * Called for h2,h3,h4,h5,h6. This just stores the correct information in + * the current frame; the actual new frame corresponding to the container + * associated with the heading will be created when the tag has been closed + * and we know the title (we don't know to create a new folder or to merge + * with an existing one until we have the title). + */ + _handleHeadBegin: function handleHeadBegin(aElt) { + let frame = this._curFrame; + + // after a heading, a previous bookmark is not applicable (for example, for + // the descriptions contained in a <dd>). Neither is any previous head type + frame.previousLink = null; + frame.lastContainerType = Container_Normal; + + // It is syntactically possible for a heading to appear after another heading + // but before the <dl> that encloses that folder's contents. This should not + // happen in practice, as the file will contain "<dl></dl>" sequence for + // empty containers. + // + // Just to be on the safe side, if we encounter + // <h3>FOO</h3> + // <h3>BAR</h3> + // <dl>...content 1...</dl> + // <dl>...content 2...</dl> + // we'll pop the stack when we find the h3 for BAR, treating that as an + // implicit ending of the FOO container. The output will be FOO and BAR as + // siblings. If there's another <dl> following (as in "content 2"), those + // items will be treated as further siblings of FOO and BAR + // This special frame popping business, of course, only happens when our + // frame array has more than one element so we can avoid situations where + // we don't have a frame to parse into anymore. + if (frame.containerNesting == 0 && this._frames.length > 1) { + this._frames.pop(); + } + + // We have to check for some attributes to see if this is a "special" + // folder, which will have different creation rules when the end tag is + // processed. + if (aElt.hasAttribute("personal_toolbar_folder")) { + if (this._isImportDefaults) { + frame.lastContainerType = Container_Toolbar; + } + } else if (aElt.hasAttribute("bookmarks_menu")) { + if (this._isImportDefaults) { + frame.lastContainerType = Container_Menu; + } + } else if (aElt.hasAttribute("unfiled_bookmarks_folder")) { + if (this._isImportDefaults) { + frame.lastContainerType = Container_Unfiled; + } + } else if (aElt.hasAttribute("places_root")) { + if (this._isImportDefaults) { + frame.lastContainerType = Container_Places; + } + } else { + let addDate = aElt.getAttribute("add_date"); + if (addDate) { + frame.previousDateAdded = + this._convertImportedDateToInternalDate(addDate); + } + let modDate = aElt.getAttribute("last_modified"); + if (modDate) { + frame.previousLastModifiedDate = + this._convertImportedDateToInternalDate(modDate); + } + } + this._curFrame.previousText = ""; + }, + + /* + * Handles "<a" tags by creating a new bookmark. The title of the bookmark + * will be the text content, which will be stuffed in previousText for us + * and which will be saved by handleLinkEnd + */ + _handleLinkBegin: function handleLinkBegin(aElt) { + let frame = this._curFrame; + + // Make sure that the feed URIs from previous frames are emptied. + frame.previousFeed = null; + // Make sure that the bookmark id from previous frames are emptied. + frame.previousId = 0; + // mPreviousText will hold link text, clear it. + frame.previousText = ""; + + // Get the attributes we care about. + let href = this._safeTrim(aElt.getAttribute("href")); + let feedUrl = this._safeTrim(aElt.getAttribute("feedurl")); + let icon = this._safeTrim(aElt.getAttribute("icon")); + let iconUri = this._safeTrim(aElt.getAttribute("icon_uri")); + let lastCharset = this._safeTrim(aElt.getAttribute("last_charset")); + let keyword = this._safeTrim(aElt.getAttribute("shortcuturl")); + let postData = this._safeTrim(aElt.getAttribute("post_data")); + let webPanel = this._safeTrim(aElt.getAttribute("web_panel")); + let dateAdded = this._safeTrim(aElt.getAttribute("add_date")); + let lastModified = this._safeTrim(aElt.getAttribute("last_modified")); + let tags = this._safeTrim(aElt.getAttribute("tags")); + + // For feeds, get the feed URL. If it is invalid, mPreviousFeed will be + // NULL and we'll create it as a normal bookmark. + if (feedUrl) { + frame.previousFeed = NetUtil.newURI(feedUrl); + } + + // Ignore <a> tags that have no href. + if (href) { + // Save the address if it's valid. Note that we ignore errors if this is a + // feed since href is optional for them. + try { + frame.previousLink = NetUtil.newURI(href); + } catch (e) { + if (!frame.previousFeed) { + frame.previousLink = null; + return; + } + } + } else { + frame.previousLink = null; + // The exception is for feeds, where the href is an optional component + // indicating the source web site. + if (!frame.previousFeed) { + return; + } + } + + // Save bookmark's last modified date. + if (lastModified) { + frame.previousLastModifiedDate = + this._convertImportedDateToInternalDate(lastModified); + } + + // If this is a live bookmark, we will handle it in HandleLinkEnd(), so we + // can skip bookmark creation. + if (frame.previousFeed) { + return; + } + + // Create the bookmark. The title is unknown for now, we will set it later. + try { + frame.previousId = + PlacesUtils.bookmarks.insertBookmark(frame.containerId, + frame.previousLink, + PlacesUtils.bookmarks.DEFAULT_INDEX, + /* aTitle */ "", + /* aGuid */ null, + this._source); + } catch (e) { + return; + } + + // Set the date added value, if we have it. + if (dateAdded) { + try { + PlacesUtils.bookmarks.setItemDateAdded(frame.previousId, + this._convertImportedDateToInternalDate(dateAdded), this._source); + } catch (e) { + } + } + + // Adds tags to the URI, if there are any. + if (tags) { + try { + let tagsArray = tags.split(","); + PlacesUtils.tagging.tagURI(frame.previousLink, tagsArray, this._source); + } catch (e) { + } + } + + // Save the favicon. + if (icon || iconUri) { + let iconUriObject; + try { + iconUriObject = NetUtil.newURI(iconUri); + } catch (e) { + } + if (icon || iconUriObject) { + try { + this._setFaviconForURI(frame.previousLink, iconUriObject, icon); + } catch (e) { + } + } + } + + // Save the keyword. + if (keyword) { + let kwPromise = PlacesUtils.keywords.insert({ keyword, + url: frame.previousLink.spec, + postData, + source: this._source }); + this._importPromises.push(kwPromise); + } + + // Set load-in-sidebar annotation for the bookmark. + if (webPanel && webPanel.toLowerCase() == "true") { + try { + PlacesUtils.annotations.setItemAnnotation(frame.previousId, + LOAD_IN_SIDEBAR_ANNO, + 1, + 0, + PlacesUtils.annotations.EXPIRE_NEVER, + this._source); + } catch (e) { + } + } + + // Import last charset. + if (lastCharset) { + let chPromise = PlacesUtils.setCharsetForURI(frame.previousLink, lastCharset, this._source); + this._importPromises.push(chPromise); + } + }, + + _handleContainerBegin: function handleContainerBegin() { + this._curFrame.containerNesting++; + }, + + /** + * Our "indent" count has decreased, and when we hit 0 that means that this + * container is complete and we need to pop back to the outer frame. Never + * pop the toplevel frame + */ + _handleContainerEnd: function handleContainerEnd() { + let frame = this._curFrame; + if (frame.containerNesting > 0) + frame.containerNesting --; + if (this._frames.length > 1 && frame.containerNesting == 0) { + // we also need to re-set the imported last-modified date here. Otherwise + // the addition of items will override the imported field. + let prevFrame = this._previousFrame; + if (prevFrame.previousLastModifiedDate > 0) { + PlacesUtils.bookmarks.setItemLastModified(frame.containerId, + prevFrame.previousLastModifiedDate, + this._source); + } + this._frames.pop(); + } + }, + + /** + * Creates the new frame for this heading now that we know the name of the + * container (tokens since the heading open tag will have been placed in + * previousText). + */ + _handleHeadEnd: function handleHeadEnd() { + this._newFrame(); + }, + + /** + * Saves the title for the given bookmark. + */ + _handleLinkEnd: function handleLinkEnd() { + let frame = this._curFrame; + frame.previousText = frame.previousText.trim(); + + try { + if (frame.previousFeed) { + // The is a live bookmark. We create it here since in HandleLinkBegin we + // don't know the title. + let lmPromise = PlacesUtils.livemarks.addLivemark({ + "title": frame.previousText, + "parentId": frame.containerId, + "index": PlacesUtils.bookmarks.DEFAULT_INDEX, + "feedURI": frame.previousFeed, + "siteURI": frame.previousLink, + "source": this._source, + }); + this._importPromises.push(lmPromise); + } else if (frame.previousLink) { + // This is a common bookmark. + PlacesUtils.bookmarks.setItemTitle(frame.previousId, + frame.previousText, + this._source); + } + } catch (e) { + } + + + // Set last modified date as the last change. + if (frame.previousId > 0 && frame.previousLastModifiedDate > 0) { + try { + PlacesUtils.bookmarks.setItemLastModified(frame.previousId, + frame.previousLastModifiedDate, + this._source); + } catch (e) { + } + // Note: don't clear previousLastModifiedDate, because if this item has a + // description, we'll need to set it again. + } + + frame.previousText = ""; + + }, + + _openContainer: function openContainer(aElt) { + if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") { + return; + } + switch (aElt.localName) { + case "h1": + this._handleHead1Begin(aElt); + break; + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + this._handleHeadBegin(aElt); + break; + case "a": + this._handleLinkBegin(aElt); + break; + case "dl": + case "ul": + case "menu": + this._handleContainerBegin(); + break; + case "dd": + this._curFrame.inDescription = true; + break; + case "hr": + this._closeContainer(aElt); + this._handleSeparator(aElt); + break; + } + }, + + _closeContainer: function closeContainer(aElt) { + let frame = this._curFrame; + + // see the comment for the definition of inDescription. Basically, we commit + // any text in previousText to the description of the node/folder if there + // is any. + if (frame.inDescription) { + // NOTE ES5 trim trims more than the previous C++ trim. + frame.previousText = frame.previousText.trim(); // important + if (frame.previousText) { + + let itemId = !frame.previousLink ? frame.containerId + : frame.previousId; + + try { + if (!PlacesUtils.annotations.itemHasAnnotation(itemId, DESCRIPTION_ANNO)) { + PlacesUtils.annotations.setItemAnnotation(itemId, + DESCRIPTION_ANNO, + frame.previousText, + 0, + PlacesUtils.annotations.EXPIRE_NEVER, + this._source); + } + } catch (e) { + } + frame.previousText = ""; + + // Set last-modified a 2nd time for all items with descriptions + // we need to set last-modified as the *last* step in processing + // any item type in the bookmarks.html file, so that we do + // not overwrite the imported value. for items without descriptions, + // setting this value after setting the item title is that + // last point at which we can save this value before it gets reset. + // for items with descriptions, it must set after that point. + // however, at the point at which we set the title, there's no way + // to determine if there will be a description following, + // so we need to set the last-modified-date at both places. + + let lastModified; + if (!frame.previousLink) { + lastModified = this._previousFrame.previousLastModifiedDate; + } else { + lastModified = frame.previousLastModifiedDate; + } + + if (itemId > 0 && lastModified > 0) { + PlacesUtils.bookmarks.setItemLastModified(itemId, lastModified, + this._source); + } + } + frame.inDescription = false; + } + + if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") { + return; + } + switch (aElt.localName) { + case "dl": + case "ul": + case "menu": + this._handleContainerEnd(); + break; + case "dt": + break; + case "h1": + // ignore + break; + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + this._handleHeadEnd(); + break; + case "a": + this._handleLinkEnd(); + break; + default: + break; + } + }, + + _appendText: function appendText(str) { + this._curFrame.previousText += str; + }, + + /** + * data is a string that is a data URI for the favicon. Our job is to + * decode it and store it in the favicon service. + * + * When aIconURI is non-null, we will use that as the URI of the favicon + * when storing in the favicon service. + * + * When aIconURI is null, we have to make up a URI for this favicon so that + * it can be stored in the service. The real one will be set the next time + * the user visits the page. Our made up one should get expired when the + * page no longer references it. + */ + _setFaviconForURI: function setFaviconForURI(aPageURI, aIconURI, aData) { + // if the input favicon URI is a chrome: URI, then we just save it and don't + // worry about data + if (aIconURI) { + if (aIconURI.schemeIs("chrome")) { + PlacesUtils.favicons.setAndFetchFaviconForPage(aPageURI, aIconURI, false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null, + Services.scriptSecurityManager.getSystemPrincipal()); + + return; + } + } + + // some bookmarks have placeholder URIs that contain just "data:" + // ignore these + if (aData.length <= 5) { + return; + } + + let faviconURI; + if (aIconURI) { + faviconURI = aIconURI; + } else { + // Make up a favicon URI for this page. Later, we'll make sure that this + // favicon URI is always associated with local favicon data, so that we + // don't load this URI from the network. + let faviconSpec = "http://www.mozilla.org/2005/made-up-favicon/" + + serialNumber + + "-" + + new Date().getTime(); + faviconURI = NetUtil.newURI(faviconSpec); + serialNumber++; + } + + // This could fail if the favicon is bigger than defined limit, in such a + // case neither the favicon URI nor the favicon data will be saved. If the + // bookmark is visited again later, the URI and data will be fetched. + PlacesUtils.favicons.replaceFaviconDataFromDataURL(faviconURI, aData, 0, + Services.scriptSecurityManager.getSystemPrincipal()); + PlacesUtils.favicons.setAndFetchFaviconForPage(aPageURI, faviconURI, false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null, + Services.scriptSecurityManager.getSystemPrincipal()); + }, + + /** + * Converts a string date in seconds to an int date in microseconds + */ + _convertImportedDateToInternalDate: function convertImportedDateToInternalDate(aDate) { + if (aDate && !isNaN(aDate)) { + return parseInt(aDate) * 1000000; // in bookmarks.html this value is in seconds, not microseconds + } + return Date.now(); + }, + + runBatched: function runBatched(aDoc) { + if (!aDoc) { + return; + } + + if (this._isImportDefaults) { + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarksMenuFolderId, this._source); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.toolbarFolderId, this._source); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId, this._source); + } + + let current = aDoc; + let next; + for (;;) { + switch (current.nodeType) { + case Ci.nsIDOMNode.ELEMENT_NODE: + this._openContainer(current); + break; + case Ci.nsIDOMNode.TEXT_NODE: + this._appendText(current.data); + break; + } + if ((next = current.firstChild)) { + current = next; + continue; + } + for (;;) { + if (current.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) { + this._closeContainer(current); + } + if (current == aDoc) { + return; + } + if ((next = current.nextSibling)) { + current = next; + break; + } + current = current.parentNode; + } + } + }, + + _walkTreeForImport: function walkTreeForImport(aDoc) { + PlacesUtils.bookmarks.runInBatchMode(this, aDoc); + }, + + importFromURL: Task.async(function* (href) { + this._importPromises = []; + yield new Promise((resolve, reject) => { + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + xhr.onload = () => { + try { + this._walkTreeForImport(xhr.responseXML); + resolve(); + } catch (e) { + reject(e); + } + }; + xhr.onabort = xhr.onerror = xhr.ontimeout = () => { + reject(new Error("xmlhttprequest failed")); + }; + xhr.open("GET", href); + xhr.responseType = "document"; + xhr.overrideMimeType("text/html"); + xhr.send(); + }); + // TODO (bug 1095427) 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; + } + }), +}; + +function BookmarkExporter(aBookmarksTree) { + // Create a map of the roots. + let rootsMap = new Map(); + for (let child of aBookmarksTree.children) { + if (child.root) + rootsMap.set(child.root, child); + } + + // For backwards compatibility reasons the bookmarks menu is the root, while + // the bookmarks toolbar and unfiled bookmarks will be child items. + this._root = rootsMap.get("bookmarksMenuFolder"); + + for (let key of [ "toolbarFolder", "unfiledBookmarksFolder" ]) { + let root = rootsMap.get(key); + if (root.children && root.children.length > 0) { + if (!this._root.children) + this._root.children = []; + this._root.children.push(root); + } + } +} + +BookmarkExporter.prototype = { + exportToFile: function exportToFile(aFilePath) { + return Task.spawn(function* () { + // Create a file that can be accessed by the current user only. + let out = FileUtils.openAtomicFileOutputStream(new FileUtils.File(aFilePath)); + try { + // We need a buffered output stream for performance. See bug 202477. + let bufferedOut = Cc["@mozilla.org/network/buffered-output-stream;1"] + .createInstance(Ci.nsIBufferedOutputStream); + bufferedOut.init(out, 4096); + try { + // Write bookmarks in UTF-8. + this._converterOut = Cc["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Ci.nsIConverterOutputStream); + this._converterOut.init(bufferedOut, "utf-8", 0, 0); + try { + this._writeHeader(); + yield this._writeContainer(this._root); + // Retain the target file on success only. + bufferedOut.QueryInterface(Ci.nsISafeOutputStream).finish(); + } finally { + this._converterOut.close(); + this._converterOut = null; + } + } finally { + bufferedOut.close(); + } + } finally { + out.close(); + } + }.bind(this)); + }, + + _converterOut: null, + + _write: function (aText) { + this._converterOut.writeString(aText || ""); + }, + + _writeAttribute: function (aName, aValue) { + this._write(' ' + aName + '="' + aValue + '"'); + }, + + _writeLine: function (aText) { + if (Services.sysinfo.getProperty("name") == "Windows_NT") { + // Write CRLF line endings on Windows + this._write(aText + "\r\n"); + } else { + this._write(aText + "\n"); + } + }, + + _writeHeader: function () { + this._writeLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>"); + this._writeLine("<!-- This is an automatically generated file."); + this._writeLine(" It will be read and overwritten."); + this._writeLine(" DO NOT EDIT! -->"); + this._writeLine('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; ' + + 'charset=UTF-8">'); + this._writeLine("<TITLE>Bookmarks</TITLE>"); + }, + + *_writeContainer(aItem, aIndent = "") { + if (aItem == this._root) { + this._writeLine("<H1>" + escapeHtmlEntities(this._root.title) + "</H1>"); + this._writeLine(""); + } + else { + this._write(aIndent + "<DT><H3"); + this._writeDateAttributes(aItem); + + if (aItem.root === "toolbarFolder") + this._writeAttribute("PERSONAL_TOOLBAR_FOLDER", "true"); + else if (aItem.root === "unfiledBookmarksFolder") + this._writeAttribute("UNFILED_BOOKMARKS_FOLDER", "true"); + this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</H3>"); + } + + this._writeDescription(aItem, aIndent); + + this._writeLine(aIndent + "<DL><p>"); + if (aItem.children) + yield this._writeContainerContents(aItem, aIndent); + if (aItem == this._root) + this._writeLine(aIndent + "</DL>"); + else + this._writeLine(aIndent + "</DL><p>"); + }, + + *_writeContainerContents(aItem, aIndent) { + let localIndent = aIndent + EXPORT_INDENT; + + for (let child of aItem.children) { + if (child.annos && child.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) { + this._writeLivemark(child, localIndent); + } else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + yield this._writeContainer(child, localIndent); + } else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { + this._writeSeparator(child, localIndent); + } else { + yield this._writeItem(child, localIndent); + } + } + }, + + _writeSeparator: function (aItem, aIndent) { + this._write(aIndent + "<HR"); + // We keep exporting separator titles, but don't support them anymore. + if (aItem.title) + this._writeAttribute("NAME", escapeHtmlEntities(aItem.title)); + this._write(">"); + }, + + _writeLivemark: function (aItem, aIndent) { + this._write(aIndent + "<DT><A"); + let feedSpec = aItem.annos.find(anno => anno.name == PlacesUtils.LMANNO_FEEDURI).value; + this._writeAttribute("FEEDURL", escapeUrl(feedSpec)); + let siteSpecAnno = aItem.annos.find(anno => anno.name == PlacesUtils.LMANNO_SITEURI); + if (siteSpecAnno) + this._writeAttribute("HREF", escapeUrl(siteSpecAnno.value)); + this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>"); + this._writeDescription(aItem, aIndent); + }, + + *_writeItem(aItem, aIndent) { + try { + NetUtil.newURI(aItem.uri); + } catch (ex) { + // If the item URI is invalid, skip the item instead of failing later. + return; + } + + this._write(aIndent + "<DT><A"); + this._writeAttribute("HREF", escapeUrl(aItem.uri)); + this._writeDateAttributes(aItem); + yield this._writeFaviconAttribute(aItem); + + if (aItem.keyword) { + this._writeAttribute("SHORTCUTURL", escapeHtmlEntities(aItem.keyword)); + if (aItem.postData) + this._writeAttribute("POST_DATA", escapeHtmlEntities(aItem.postData)); + } + + if (aItem.annos && aItem.annos.some(anno => anno.name == LOAD_IN_SIDEBAR_ANNO)) + this._writeAttribute("WEB_PANEL", "true"); + if (aItem.charset) + this._writeAttribute("LAST_CHARSET", escapeHtmlEntities(aItem.charset)); + if (aItem.tags) + this._writeAttribute("TAGS", escapeHtmlEntities(aItem.tags)); + this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>"); + this._writeDescription(aItem, aIndent); + }, + + _writeDateAttributes: function (aItem) { + if (aItem.dateAdded) + this._writeAttribute("ADD_DATE", + Math.floor(aItem.dateAdded / MICROSEC_PER_SEC)); + if (aItem.lastModified) + this._writeAttribute("LAST_MODIFIED", + Math.floor(aItem.lastModified / MICROSEC_PER_SEC)); + }, + + *_writeFaviconAttribute(aItem) { + if (!aItem.iconuri) + return; + let favicon; + try { + favicon = yield PlacesUtils.promiseFaviconData(aItem.uri); + } catch (ex) { + Components.utils.reportError("Unexpected Error trying to fetch icon data"); + return; + } + + this._writeAttribute("ICON_URI", escapeUrl(favicon.uri.spec)); + + if (!favicon.uri.schemeIs("chrome") && favicon.dataLen > 0) { + let faviconContents = "data:image/png;base64," + + base64EncodeString(String.fromCharCode.apply(String, favicon.data)); + this._writeAttribute("ICON", faviconContents); + } + }, + + _writeDescription: function (aItem, aIndent) { + let descriptionAnno = aItem.annos && + aItem.annos.find(anno => anno.name == DESCRIPTION_ANNO); + if (descriptionAnno) + this._writeLine(aIndent + "<DD>" + escapeHtmlEntities(descriptionAnno.value)); + } +}; |