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/**
6 * This file works on the old-style "bookmarks.html" file.  It includes
7 * functions to import and export existing bookmarks to this file format.
8 *
9 * Format
10 * ------
11 *
12 * Primary heading := h1
13 *   Old version used this to set attributes on the bookmarks RDF root, such
14 *   as the last modified date. We only use H1 to check for the attribute
15 *   PLACES_ROOT, which tells us that this hierarchy root is the places root.
16 *   For backwards compatibility, if we don't find this, we assume that the
17 *   hierarchy is rooted at the bookmarks menu.
18 * Heading := any heading other than h1
19 *   Old version used this to set attributes on the current container. We only
20 *   care about the content of the heading container, which contains the title
21 *   of the bookmark container.
22 * Bookmark := a
23 *   HREF is the destination of the bookmark
24 *   FEEDURL is the URI of the RSS feed if this is a livemark.
25 *   LAST_CHARSET is stored as an annotation so that the next time we go to
26 *     that page we remember the user's preference.
27 *   WEB_PANEL is set to "true" if the bookmark should be loaded in the sidebar.
28 *   ICON will be stored in the favicon service
29 *   ICON_URI is new for places bookmarks.html, it refers to the original
30 *     URI of the favicon so we don't have to make up favicon URLs.
31 *   Text of the <a> container is the name of the bookmark
32 *   Ignored: LAST_VISIT, ID (writing out non-RDF IDs can confuse Firefox 2)
33 * Bookmark comment := dd
34 *   This affects the previosly added bookmark
35 * Separator := hr
36 *   Insert a separator into the current container
37 * The folder hierarchy is defined by <dl>/<ul>/<menu> (the old importing code
38 *     handles all these cases, when we write, use <dl>).
39 *
40 * Overall design
41 * --------------
42 *
43 * We need to emulate a recursive parser. A "Bookmark import frame" is created
44 * corresponding to each folder we encounter. These are arranged in a stack,
45 * and contain all the state we need to keep track of.
46 *
47 * A frame is created when we find a heading, which defines a new container.
48 * The frame also keeps track of the nesting of <DL>s, (in well-formed
49 * bookmarks files, these will have a 1-1 correspondence with frames, but we
50 * try to be a little more flexible here). When the nesting count decreases
51 * to 0, then we know a frame is complete and to pop back to the previous
52 * frame.
53 *
54 * Note that a lot of things happen when tags are CLOSED because we need to
55 * get the text from the content of the tag. For example, link and heading tags
56 * both require the content (= title) before actually creating it.
57 */
58
59var EXPORTED_SYMBOLS = [ "BookmarkHTMLUtils" ];
60
61ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
62ChromeUtils.import("resource://gre/modules/Services.jsm");
63ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
64ChromeUtils.import("resource://gre/modules/osfile.jsm");
65ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
66ChromeUtils.import("resource://gre/modules/PlacesUtils.jsm");
67
68Cu.importGlobalProperties(["XMLHttpRequest"]);
69
70ChromeUtils.defineModuleGetter(this, "PlacesBackups",
71  "resource://gre/modules/PlacesBackups.jsm");
72
73const Container_Normal = 0;
74const Container_Toolbar = 1;
75const Container_Menu = 2;
76const Container_Unfiled = 3;
77const Container_Places = 4;
78
79const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
80const DESCRIPTION_ANNO = "bookmarkProperties/description";
81
82const MICROSEC_PER_SEC = 1000000;
83
84const EXPORT_INDENT = "    "; // four spaces
85
86// Counter used to build fake favicon urls.
87var serialNumber = 0;
88
89function base64EncodeString(aString) {
90  let stream = Cc["@mozilla.org/io/string-input-stream;1"]
91                 .createInstance(Ci.nsIStringInputStream);
92  stream.setData(aString, aString.length);
93  let encoder = Cc["@mozilla.org/scriptablebase64encoder;1"]
94                  .createInstance(Ci.nsIScriptableBase64Encoder);
95  return encoder.encodeToString(stream, aString.length);
96}
97
98/**
99 * Provides HTML escaping for use in HTML attributes and body of the bookmarks
100 * file, compatible with the old bookmarks system.
101 */
102function escapeHtmlEntities(aText) {
103  return (aText || "").replace(/&/g, "&amp;")
104                      .replace(/</g, "&lt;")
105                      .replace(/>/g, "&gt;")
106                      .replace(/"/g, "&quot;")
107                      .replace(/'/g, "&#39;");
108}
109
110/**
111 * Provides URL escaping for use in HTML attributes of the bookmarks file,
112 * compatible with the old bookmarks system.
113 */
114function escapeUrl(aText) {
115  return (aText || "").replace(/"/g, "%22");
116}
117
118function notifyObservers(aTopic, aInitialImport) {
119  Services.obs.notifyObservers(null, aTopic, aInitialImport ? "html-initial"
120                                                            : "html");
121}
122
123var BookmarkHTMLUtils = Object.freeze({
124  /**
125   * Loads the current bookmarks hierarchy from a "bookmarks.html" file.
126   *
127   * @param aSpec
128   *        String containing the "file:" URI for the existing "bookmarks.html"
129   *        file to be loaded.
130   * @param aInitialImport
131   *        Whether this is the initial import executed on a new profile.
132   *
133   * @return {Promise}
134   * @resolves When the new bookmarks have been created.
135   * @rejects JavaScript exception.
136   */
137  async importFromURL(aSpec, aInitialImport) {
138    notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
139    try {
140      let importer = new BookmarkImporter(aInitialImport);
141      await importer.importFromURL(aSpec);
142
143      notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport);
144    } catch (ex) {
145      Cu.reportError("Failed to import bookmarks from " + aSpec + ": " + ex);
146      notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport);
147      throw ex;
148    }
149  },
150
151  /**
152   * Loads the current bookmarks hierarchy from a "bookmarks.html" file.
153   *
154   * @param aFilePath
155   *        OS.File path string of the "bookmarks.html" file to be loaded.
156   * @param aInitialImport
157   *        Whether this is the initial import executed on a new profile.
158   *
159   * @return {Promise}
160   * @resolves When the new bookmarks have been created.
161   * @rejects JavaScript exception.
162   */
163  async importFromFile(aFilePath, aInitialImport) {
164    notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
165    try {
166      if (!(await OS.File.exists(aFilePath))) {
167        throw new Error("Cannot import from nonexisting html file: " + aFilePath);
168      }
169      let importer = new BookmarkImporter(aInitialImport);
170      await importer.importFromURL(OS.Path.toFileURI(aFilePath));
171
172      notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport);
173    } catch (ex) {
174      Cu.reportError("Failed to import bookmarks from " + aFilePath + ": " + ex);
175      notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport);
176      throw ex;
177    }
178  },
179
180  /**
181   * Saves the current bookmarks hierarchy to a "bookmarks.html" file.
182   *
183   * @param aFilePath
184   *        OS.File path string for the "bookmarks.html" file to be created.
185   *
186   * @return {Promise}
187   * @resolves To the exported bookmarks count when the file has been created.
188   * @rejects JavaScript exception.
189   */
190  async exportToFile(aFilePath) {
191    let [bookmarks, count] = await PlacesBackups.getBookmarksTree();
192    let startTime = Date.now();
193
194    // Report the time taken to convert the tree to HTML.
195    let exporter = new BookmarkExporter(bookmarks);
196    await exporter.exportToFile(aFilePath);
197
198    try {
199      Services.telemetry
200              .getHistogramById("PLACES_EXPORT_TOHTML_MS")
201              .add(Date.now() - startTime);
202    } catch (ex) {
203      Cu.reportError("Unable to report telemetry.");
204    }
205
206    return count;
207  },
208
209  get defaultPath() {
210    try {
211      return Services.prefs.getCharPref("browser.bookmarks.file");
212    } catch (ex) {}
213    return OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.html");
214  }
215});
216
217function Frame(aFolder) {
218  this.folder = aFolder;
219
220  /**
221   * How many <dl>s have been nested. Each frame/container should start
222   * with a heading, and is then followed by a <dl>, <ul>, or <menu>. When
223   * that list is complete, then it is the end of this container and we need
224   * to pop back up one level for new items. If we never get an open tag for
225   * one of these things, we should assume that the container is empty and
226   * that things we find should be siblings of it. Normally, these <dl>s won't
227   * be nested so this will be 0 or 1.
228   */
229  this.containerNesting = 0;
230
231  /**
232   * when we find a heading tag, it actually affects the title of the NEXT
233   * container in the list. This stores that heading tag and whether it was
234   * special. 'consumeHeading' resets this._
235   */
236  this.lastContainerType = Container_Normal;
237
238  /**
239   * this contains the text from the last begin tag until now. It is reset
240   * at every begin tag. We can check it when we see a </a>, or </h3>
241   * to see what the text content of that node should be.
242   */
243  this.previousText = "";
244
245  /**
246   * true when we hit a <dd>, which contains the description for the preceding
247   * <a> tag. We can't just check for </dd> like we can for </a> or </h3>
248   * because if there is a sub-folder, it is actually a child of the <dd>
249   * because the tag is never explicitly closed. If this is true and we see a
250   * new open tag, that means to commit the description to the previous
251   * bookmark.
252   *
253   * Additional weirdness happens when the previous <dt> tag contains a <h3>:
254   * this means there is a new folder with the given description, and whose
255   * children are contained in the following <dl> list.
256   *
257   * This is handled in openContainer(), which commits previous text if
258   * necessary.
259   */
260  this.inDescription = false;
261
262  /**
263   * contains the URL of the previous bookmark created. This is used so that
264   * when we encounter a <dd>, we know what bookmark to associate the text with.
265   * This is cleared whenever we hit a <h3>, so that we know NOT to save this
266   * with a bookmark, but to keep it until
267   */
268  this.previousLink = null;
269
270  /**
271   * contains the URL of the previous livemark, so that when the link ends,
272   * and the livemark title is known, we can create it.
273   */
274  this.previousFeed = null;
275
276  /**
277   * Contains a reference to the last created bookmark or folder object.
278   */
279  this.previousItem = null;
280
281  /**
282   * Contains the date-added and last-modified-date of an imported item.
283   * Used to override the values set by insertBookmark, createFolder, etc.
284   */
285  this.previousDateAdded = null;
286  this.previousLastModifiedDate = null;
287}
288
289function BookmarkImporter(aInitialImport) {
290  this._isImportDefaults = aInitialImport;
291  // The bookmark change source, used to determine the sync status and change
292  // counter.
293  this._source = aInitialImport ? PlacesUtils.bookmarks.SOURCE_IMPORT_REPLACE :
294                                  PlacesUtils.bookmarks.SOURCE_IMPORT;
295
296  // This root is where we construct the bookmarks tree into, following the format
297  // of the imported file.
298  // If we're doing an initial import, the non-menu roots will be created as
299  // children of this root, so in _getBookmarkTrees we'll split them out.
300  // If we're not doing an initial import, everything gets imported under the
301  // bookmark menu folder, so there won't be any need for _getBookmarkTrees to
302  // do separation.
303  this._bookmarkTree = {
304    type: PlacesUtils.bookmarks.TYPE_FOLDER,
305    guid: PlacesUtils.bookmarks.menuGuid,
306    children: []
307  };
308
309  this._frames = [];
310  this._frames.push(new Frame(this._bookmarkTree));
311}
312
313BookmarkImporter.prototype = {
314  _safeTrim: function safeTrim(aStr) {
315    return aStr ? aStr.trim() : aStr;
316  },
317
318  get _curFrame() {
319    return this._frames[this._frames.length - 1];
320  },
321
322  get _previousFrame() {
323    return this._frames[this._frames.length - 2];
324  },
325
326  /**
327   * This is called when there is a new folder found. The folder takes the
328   * name from the previous frame's heading.
329   */
330  _newFrame: function newFrame() {
331    let frame = this._curFrame;
332    let containerTitle = frame.previousText;
333    frame.previousText = "";
334    let containerType = frame.lastContainerType;
335
336    let folder = {
337      children: [],
338      type: PlacesUtils.bookmarks.TYPE_FOLDER
339    };
340
341    switch (containerType) {
342      case Container_Normal:
343        // This can only be a sub-folder so no need to set a guid here.
344        folder.title = containerTitle;
345        break;
346      case Container_Places:
347        folder.guid = PlacesUtils.bookmarks.rootGuid;
348        break;
349      case Container_Menu:
350        folder.guid = PlacesUtils.bookmarks.menuGuid;
351        break;
352      case Container_Unfiled:
353        folder.guid = PlacesUtils.bookmarks.unfiledGuid;
354        break;
355      case Container_Toolbar:
356        folder.guid = PlacesUtils.bookmarks.toolbarGuid;
357        break;
358      default:
359        // NOT REACHED
360        throw new Error("Unknown bookmark container type!");
361    }
362
363    frame.folder.children.push(folder);
364
365    if (frame.previousDateAdded != null) {
366      folder.dateAdded = frame.previousDateAdded;
367      frame.previousDateAdded = null;
368    }
369
370    if (frame.previousLastModifiedDate != null) {
371      folder.lastModified = frame.previousLastModifiedDate;
372      frame.previousLastModifiedDate = null;
373    }
374
375    if (!folder.hasOwnProperty("dateAdded") &&
376         folder.hasOwnProperty("lastModified")) {
377      folder.dateAdded = folder.lastModified;
378    }
379
380    frame.previousItem = folder;
381
382    this._frames.push(new Frame(folder));
383  },
384
385  /**
386   * Handles <hr> as a separator.
387   *
388   * @note Separators may have a title in old html files, though Places dropped
389   *       support for them.
390   *       We also don't import ADD_DATE or LAST_MODIFIED for separators because
391   *       pre-Places bookmarks did not support them.
392   */
393  _handleSeparator: function handleSeparator(aElt) {
394    let frame = this._curFrame;
395
396    let separator = {
397      type: PlacesUtils.bookmarks.TYPE_SEPARATOR
398    };
399    frame.folder.children.push(separator);
400    frame.previousItem = separator;
401  },
402
403  /**
404   * Called for h2,h3,h4,h5,h6. This just stores the correct information in
405   * the current frame; the actual new frame corresponding to the container
406   * associated with the heading will be created when the tag has been closed
407   * and we know the title (we don't know to create a new folder or to merge
408   * with an existing one until we have the title).
409   */
410  _handleHeadBegin: function handleHeadBegin(aElt) {
411    let frame = this._curFrame;
412
413    // after a heading, a previous bookmark is not applicable (for example, for
414    // the descriptions contained in a <dd>). Neither is any previous head type
415    frame.previousLink = null;
416    frame.lastContainerType = Container_Normal;
417
418    // It is syntactically possible for a heading to appear after another heading
419    // but before the <dl> that encloses that folder's contents.  This should not
420    // happen in practice, as the file will contain "<dl></dl>" sequence for
421    // empty containers.
422    //
423    // Just to be on the safe side, if we encounter
424    //   <h3>FOO</h3>
425    //   <h3>BAR</h3>
426    //   <dl>...content 1...</dl>
427    //   <dl>...content 2...</dl>
428    // we'll pop the stack when we find the h3 for BAR, treating that as an
429    // implicit ending of the FOO container. The output will be FOO and BAR as
430    // siblings. If there's another <dl> following (as in "content 2"), those
431    // items will be treated as further siblings of FOO and BAR
432    // This special frame popping business, of course, only happens when our
433    // frame array has more than one element so we can avoid situations where
434    // we don't have a frame to parse into anymore.
435    if (frame.containerNesting == 0 && this._frames.length > 1) {
436      this._frames.pop();
437    }
438
439    // We have to check for some attributes to see if this is a "special"
440    // folder, which will have different creation rules when the end tag is
441    // processed.
442    if (aElt.hasAttribute("personal_toolbar_folder")) {
443      if (this._isImportDefaults) {
444        frame.lastContainerType = Container_Toolbar;
445      }
446    } else if (aElt.hasAttribute("bookmarks_menu")) {
447      if (this._isImportDefaults) {
448        frame.lastContainerType = Container_Menu;
449      }
450    } else if (aElt.hasAttribute("unfiled_bookmarks_folder")) {
451      if (this._isImportDefaults) {
452        frame.lastContainerType = Container_Unfiled;
453      }
454    } else if (aElt.hasAttribute("places_root")) {
455      if (this._isImportDefaults) {
456        frame.lastContainerType = Container_Places;
457      }
458    } else {
459      let addDate = aElt.getAttribute("add_date");
460      if (addDate) {
461        frame.previousDateAdded =
462          this._convertImportedDateToInternalDate(addDate);
463      }
464      let modDate = aElt.getAttribute("last_modified");
465      if (modDate) {
466        frame.previousLastModifiedDate =
467          this._convertImportedDateToInternalDate(modDate);
468      }
469    }
470    this._curFrame.previousText = "";
471  },
472
473  /*
474   * Handles "<a" tags by creating a new bookmark. The title of the bookmark
475   * will be the text content, which will be stuffed in previousText for us
476   * and which will be saved by handleLinkEnd
477   */
478  _handleLinkBegin: function handleLinkBegin(aElt) {
479    let frame = this._curFrame;
480
481    frame.previousFeed = null;
482    frame.previousItem = null;
483    frame.previousText = ""; // Will hold link text, clear it.
484
485    // Get the attributes we care about.
486    let href = this._safeTrim(aElt.getAttribute("href"));
487    let feedUrl = this._safeTrim(aElt.getAttribute("feedurl"));
488    let icon = this._safeTrim(aElt.getAttribute("icon"));
489    let iconUri = this._safeTrim(aElt.getAttribute("icon_uri"));
490    let lastCharset = this._safeTrim(aElt.getAttribute("last_charset"));
491    let keyword = this._safeTrim(aElt.getAttribute("shortcuturl"));
492    let postData = this._safeTrim(aElt.getAttribute("post_data"));
493    let webPanel = this._safeTrim(aElt.getAttribute("web_panel"));
494    let dateAdded = this._safeTrim(aElt.getAttribute("add_date"));
495    let lastModified = this._safeTrim(aElt.getAttribute("last_modified"));
496    let tags = this._safeTrim(aElt.getAttribute("tags"));
497
498    // For feeds, get the feed URL.  If it is invalid, mPreviousFeed will be
499    // NULL and we'll create it as a normal bookmark.
500    if (feedUrl) {
501      frame.previousFeed = feedUrl;
502    }
503
504    // Ignore <a> tags that have no href.
505    if (href) {
506      // Save the address if it's valid.  Note that we ignore errors if this is a
507      // feed since href is optional for them.
508      try {
509        frame.previousLink = Services.io.newURI(href).spec;
510      } catch (e) {
511        if (!frame.previousFeed) {
512          frame.previousLink = null;
513          return;
514        }
515      }
516    } else {
517      frame.previousLink = null;
518      // The exception is for feeds, where the href is an optional component
519      // indicating the source web site.
520      if (!frame.previousFeed) {
521        return;
522      }
523    }
524
525    let bookmark = {};
526
527    // Only set the url for bookmarks, not for livemarks.
528    if (frame.previousLink && !frame.previousFeed) {
529      bookmark.url = frame.previousLink;
530    }
531
532    if (dateAdded) {
533      bookmark.dateAdded = this._convertImportedDateToInternalDate(dateAdded);
534    }
535    // Save bookmark's last modified date.
536    if (lastModified) {
537      bookmark.lastModified = this._convertImportedDateToInternalDate(lastModified);
538    }
539
540    if (!dateAdded && lastModified) {
541      bookmark.dateAdded = bookmark.lastModified;
542    }
543
544    if (frame.previousFeed) {
545      // This is a livemark, we've done all we need to do here, so finish early.
546      frame.folder.children.push(bookmark);
547      frame.previousItem = bookmark;
548      return;
549    }
550
551    if (tags) {
552      bookmark.tags = tags.split(",").filter(aTag => aTag.length > 0 &&
553        aTag.length <= Ci.nsITaggingService.MAX_TAG_LENGTH);
554
555      // If we end up with none, then delete the property completely.
556      if (!bookmark.tags.length) {
557        delete bookmark.tags;
558      }
559    }
560
561    if (webPanel && webPanel.toLowerCase() == "true") {
562      if (!bookmark.hasOwnProperty("annos")) {
563        bookmark.annos = [];
564      }
565      bookmark.annos.push({ "name": LOAD_IN_SIDEBAR_ANNO,
566                            "flags": 0,
567                            "expires": 4,
568                            "value": 1
569                          });
570    }
571
572    if (lastCharset) {
573      bookmark.charset = lastCharset;
574    }
575
576    if (keyword) {
577      bookmark.keyword = keyword;
578    }
579
580    if (postData) {
581      bookmark.postData = postData;
582    }
583
584    if (icon) {
585      bookmark.icon = icon;
586    }
587
588    if (iconUri) {
589      bookmark.iconUri = iconUri;
590    }
591
592    // Add bookmark to the tree.
593    frame.folder.children.push(bookmark);
594    frame.previousItem = bookmark;
595  },
596
597  _handleContainerBegin: function handleContainerBegin() {
598    this._curFrame.containerNesting++;
599  },
600
601  /**
602   * Our "indent" count has decreased, and when we hit 0 that means that this
603   * container is complete and we need to pop back to the outer frame. Never
604   * pop the toplevel frame
605   */
606  _handleContainerEnd: function handleContainerEnd() {
607    let frame = this._curFrame;
608    if (frame.containerNesting > 0)
609      frame.containerNesting--;
610    if (this._frames.length > 1 && frame.containerNesting == 0) {
611      this._frames.pop();
612    }
613  },
614
615  /**
616   * Creates the new frame for this heading now that we know the name of the
617   * container (tokens since the heading open tag will have been placed in
618   * previousText).
619   */
620  _handleHeadEnd: function handleHeadEnd() {
621    this._newFrame();
622  },
623
624  /**
625   * Saves the title for the given bookmark.
626   */
627  _handleLinkEnd: function handleLinkEnd() {
628    let frame = this._curFrame;
629    frame.previousText = frame.previousText.trim();
630
631    if (frame.previousItem != null) {
632      if (frame.previousFeed) {
633        if (!frame.previousItem.hasOwnProperty("annos")) {
634          frame.previousItem.annos = [];
635        }
636        frame.previousItem.type = PlacesUtils.bookmarks.TYPE_FOLDER;
637        frame.previousItem.annos.push({
638          "name": PlacesUtils.LMANNO_FEEDURI,
639          "flags": 0,
640          "expires": 4,
641          "value": frame.previousFeed
642        });
643        if (frame.previousLink) {
644          frame.previousItem.annos.push({
645            "name": PlacesUtils.LMANNO_SITEURI,
646            "flags": 0,
647            "expires": 4,
648            "value": frame.previousLink
649          });
650        }
651      }
652      frame.previousItem.title = frame.previousText;
653    }
654
655    frame.previousText = "";
656  },
657
658  _openContainer: function openContainer(aElt) {
659    if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") {
660      return;
661    }
662    switch (aElt.localName) {
663      case "h2":
664      case "h3":
665      case "h4":
666      case "h5":
667      case "h6":
668        this._handleHeadBegin(aElt);
669        break;
670      case "a":
671        this._handleLinkBegin(aElt);
672        break;
673      case "dl":
674      case "ul":
675      case "menu":
676        this._handleContainerBegin();
677        break;
678      case "dd":
679        this._curFrame.inDescription = true;
680        break;
681      case "hr":
682        this._handleSeparator(aElt);
683        break;
684    }
685  },
686
687  _closeContainer: function closeContainer(aElt) {
688    let frame = this._curFrame;
689
690    // see the comment for the definition of inDescription. Basically, we commit
691    // any text in previousText to the description of the node/folder if there
692    // is any.
693    if (frame.inDescription) {
694      // NOTE ES5 trim trims more than the previous C++ trim.
695      frame.previousText = frame.previousText.trim(); // important
696      if (frame.previousText) {
697        let item = frame.previousLink ? frame.previousItem : frame.folder;
698        if (!item.hasOwnProperty("annos")) {
699          item.annos = [];
700        }
701        item.annos.push({
702          "name": DESCRIPTION_ANNO,
703          "flags": 0,
704          "expires": 4,
705          "value": frame.previousText
706        });
707        frame.previousText = "";
708      }
709      frame.inDescription = false;
710    }
711
712    if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") {
713      return;
714    }
715    switch (aElt.localName) {
716      case "dl":
717      case "ul":
718      case "menu":
719        this._handleContainerEnd();
720        break;
721      case "dt":
722        break;
723      case "h1":
724        // ignore
725        break;
726      case "h2":
727      case "h3":
728      case "h4":
729      case "h5":
730      case "h6":
731        this._handleHeadEnd();
732        break;
733      case "a":
734        this._handleLinkEnd();
735        break;
736      default:
737        break;
738    }
739  },
740
741  _appendText: function appendText(str) {
742    this._curFrame.previousText += str;
743  },
744
745  /**
746   * data is a string that is a data URI for the favicon. Our job is to
747   * decode it and store it in the favicon service.
748   *
749   * When aIconURI is non-null, we will use that as the URI of the favicon
750   * when storing in the favicon service.
751   *
752   * When aIconURI is null, we have to make up a URI for this favicon so that
753   * it can be stored in the service. The real one will be set the next time
754   * the user visits the page. Our made up one should get expired when the
755   * page no longer references it.
756   */
757  _setFaviconForURI: function setFaviconForURI(aPageURI, aIconURI, aData) {
758    // if the input favicon URI is a chrome: URI, then we just save it and don't
759    // worry about data
760    if (aIconURI) {
761      if (aIconURI.schemeIs("chrome")) {
762        PlacesUtils.favicons.setAndFetchFaviconForPage(aPageURI, aIconURI, false,
763                                                       PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
764                                                       Services.scriptSecurityManager.getSystemPrincipal());
765
766        return;
767      }
768    }
769
770    // some bookmarks have placeholder URIs that contain just "data:"
771    // ignore these
772    if (aData.length <= 5) {
773      return;
774    }
775
776    let faviconURI;
777    if (aIconURI) {
778      faviconURI = aIconURI;
779    } else {
780      // Make up a favicon URI for this page.  Later, we'll make sure that this
781      // favicon URI is always associated with local favicon data, so that we
782      // don't load this URI from the network.
783      let faviconSpec = "http://www.mozilla.org/2005/made-up-favicon/"
784                      + serialNumber
785                      + "-"
786                      + new Date().getTime();
787      faviconURI = NetUtil.newURI(faviconSpec);
788      serialNumber++;
789    }
790
791    // This could fail if the favicon is bigger than defined limit, in such a
792    // case neither the favicon URI nor the favicon data will be saved.  If the
793    // bookmark is visited again later, the URI and data will be fetched.
794    PlacesUtils.favicons.replaceFaviconDataFromDataURL(faviconURI, aData, 0,
795                                                       Services.scriptSecurityManager.getSystemPrincipal());
796    PlacesUtils.favicons.setAndFetchFaviconForPage(aPageURI, faviconURI, false,
797                                                   PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
798                                                   Services.scriptSecurityManager.getSystemPrincipal());
799  },
800
801  /**
802   * Converts a string date in seconds to a date object
803   */
804  _convertImportedDateToInternalDate: function convertImportedDateToInternalDate(aDate) {
805    try {
806      if (aDate && !isNaN(aDate)) {
807        return new Date(parseInt(aDate) * 1000); // in bookmarks.html this value is in seconds
808      }
809    } catch (ex) {
810      // Do nothing.
811    }
812    return new Date();
813  },
814
815  _walkTreeForImport(aDoc) {
816    if (!aDoc) {
817      return;
818    }
819
820    let current = aDoc;
821    let next;
822    for (;;) {
823      switch (current.nodeType) {
824        case Ci.nsIDOMNode.ELEMENT_NODE:
825          this._openContainer(current);
826          break;
827        case Ci.nsIDOMNode.TEXT_NODE:
828          this._appendText(current.data);
829          break;
830      }
831      if ((next = current.firstChild)) {
832        current = next;
833        continue;
834      }
835      for (;;) {
836        if (current.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) {
837          this._closeContainer(current);
838        }
839        if (current == aDoc) {
840          return;
841        }
842        if ((next = current.nextSibling)) {
843          current = next;
844          break;
845        }
846        current = current.parentNode;
847      }
848    }
849  },
850
851  /**
852   * Returns the bookmark tree(s) from the importer. These are suitable for
853   * passing to PlacesUtils.bookmarks.insertTree().
854   *
855   * @returns {Array} An array of bookmark trees.
856   */
857  _getBookmarkTrees() {
858    // If we're not importing defaults, then everything gets imported under the
859    // Bookmarks menu.
860    if (!this._isImportDefaults) {
861      return [this._bookmarkTree];
862    }
863
864    // If we are importing defaults, we need to separate out the top-level
865    // default folders into separate items, for the caller to pass into insertTree.
866    let bookmarkTrees = [this._bookmarkTree];
867
868    // The children of this "root" element will contain normal children of the
869    // bookmark menu as well as the places roots. Hence, we need to filter out
870    // the separate roots, but keep the children that are relevant to the
871    // bookmark menu.
872    this._bookmarkTree.children = this._bookmarkTree.children.filter(child => {
873      if (child.guid && PlacesUtils.bookmarks.userContentRoots.includes(child.guid)) {
874        bookmarkTrees.push(child);
875        return false;
876      }
877      return true;
878    });
879
880    return bookmarkTrees;
881  },
882
883  /**
884   * Imports the bookmarks from the importer into the places database.
885   *
886   * @param {BookmarkImporter} importer The importer from which to get the
887   *                                    bookmark information.
888   */
889  async _importBookmarks() {
890    if (this._isImportDefaults) {
891      await PlacesUtils.bookmarks.eraseEverything();
892    }
893
894    let bookmarksTrees = this._getBookmarkTrees();
895    for (let tree of bookmarksTrees) {
896      if (!tree.children.length) {
897        continue;
898      }
899
900      // Give the tree the source.
901      tree.source = this._source;
902      await PlacesUtils.bookmarks.insertTree(tree, { fixupOrSkipInvalidEntries: true });
903      insertFaviconsForTree(tree);
904    }
905  },
906
907  /**
908   * Imports data into the places database from the supplied url.
909   *
910   * @param {String} href The url to import data from.
911   */
912  async importFromURL(href) {
913    let data = await fetchData(href);
914    this._walkTreeForImport(data);
915    await this._importBookmarks();
916  },
917};
918
919function BookmarkExporter(aBookmarksTree) {
920  // Create a map of the roots.
921  let rootsMap = new Map();
922  for (let child of aBookmarksTree.children) {
923    if (child.root) {
924      rootsMap.set(child.root, child);
925      // Also take the opportunity to get the correctly localised title for the
926      // root.
927      child.title = PlacesUtils.bookmarks.getLocalizedTitle(child);
928    }
929  }
930
931  // For backwards compatibility reasons the bookmarks menu is the root, while
932  // the bookmarks toolbar and unfiled bookmarks will be child items.
933  this._root = rootsMap.get("bookmarksMenuFolder");
934
935  for (let key of [ "toolbarFolder", "unfiledBookmarksFolder" ]) {
936    let root = rootsMap.get(key);
937    if (root.children && root.children.length > 0) {
938      if (!this._root.children)
939        this._root.children = [];
940      this._root.children.push(root);
941    }
942  }
943}
944
945BookmarkExporter.prototype = {
946  exportToFile: function exportToFile(aFilePath) {
947    return (async () => {
948      // Create a file that can be accessed by the current user only.
949      let out = FileUtils.openAtomicFileOutputStream(new FileUtils.File(aFilePath));
950      try {
951        // We need a buffered output stream for performance.  See bug 202477.
952        let bufferedOut = Cc["@mozilla.org/network/buffered-output-stream;1"]
953                          .createInstance(Ci.nsIBufferedOutputStream);
954        bufferedOut.init(out, 4096);
955        try {
956          // Write bookmarks in UTF-8.
957          this._converterOut = Cc["@mozilla.org/intl/converter-output-stream;1"]
958                               .createInstance(Ci.nsIConverterOutputStream);
959          this._converterOut.init(bufferedOut, "utf-8");
960          try {
961            this._writeHeader();
962            await this._writeContainer(this._root);
963            // Retain the target file on success only.
964            bufferedOut.QueryInterface(Ci.nsISafeOutputStream).finish();
965          } finally {
966            this._converterOut.close();
967            this._converterOut = null;
968          }
969        } finally {
970          bufferedOut.close();
971        }
972      } finally {
973        out.close();
974      }
975    })();
976  },
977
978  _converterOut: null,
979
980  _write(aText) {
981    this._converterOut.writeString(aText || "");
982  },
983
984  _writeAttribute(aName, aValue) {
985    this._write(" " + aName + '="' + aValue + '"');
986  },
987
988  _writeLine(aText) {
989    this._write(aText + "\n");
990  },
991
992  _writeHeader() {
993    this._writeLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
994    this._writeLine("<!-- This is an automatically generated file.");
995    this._writeLine("     It will be read and overwritten.");
996    this._writeLine("     DO NOT EDIT! -->");
997    this._writeLine('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; ' +
998                    'charset=UTF-8">');
999    this._writeLine("<TITLE>Bookmarks</TITLE>");
1000  },
1001
1002  async _writeContainer(aItem, aIndent = "") {
1003    if (aItem == this._root) {
1004      this._writeLine("<H1>" + escapeHtmlEntities(this._root.title) + "</H1>");
1005      this._writeLine("");
1006    } else {
1007      this._write(aIndent + "<DT><H3");
1008      this._writeDateAttributes(aItem);
1009
1010      if (aItem.root === "toolbarFolder")
1011        this._writeAttribute("PERSONAL_TOOLBAR_FOLDER", "true");
1012      else if (aItem.root === "unfiledBookmarksFolder")
1013        this._writeAttribute("UNFILED_BOOKMARKS_FOLDER", "true");
1014      this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</H3>");
1015    }
1016
1017    this._writeDescription(aItem, aIndent);
1018
1019    this._writeLine(aIndent + "<DL><p>");
1020    if (aItem.children)
1021      await this._writeContainerContents(aItem, aIndent);
1022    if (aItem == this._root)
1023      this._writeLine(aIndent + "</DL>");
1024    else
1025      this._writeLine(aIndent + "</DL><p>");
1026  },
1027
1028  async _writeContainerContents(aItem, aIndent) {
1029    let localIndent = aIndent + EXPORT_INDENT;
1030
1031    for (let child of aItem.children) {
1032      if (child.annos && child.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) {
1033        this._writeLivemark(child, localIndent);
1034      } else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) {
1035        await this._writeContainer(child, localIndent);
1036      } else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) {
1037        this._writeSeparator(child, localIndent);
1038      } else {
1039        await this._writeItem(child, localIndent);
1040      }
1041    }
1042  },
1043
1044  _writeSeparator(aItem, aIndent) {
1045    this._write(aIndent + "<HR");
1046    // We keep exporting separator titles, but don't support them anymore.
1047    if (aItem.title)
1048      this._writeAttribute("NAME", escapeHtmlEntities(aItem.title));
1049    this._write(">");
1050  },
1051
1052  _writeLivemark(aItem, aIndent) {
1053    this._write(aIndent + "<DT><A");
1054    let feedSpec = aItem.annos.find(anno => anno.name == PlacesUtils.LMANNO_FEEDURI).value;
1055    this._writeAttribute("FEEDURL", escapeUrl(feedSpec));
1056    let siteSpecAnno = aItem.annos.find(anno => anno.name == PlacesUtils.LMANNO_SITEURI);
1057    if (siteSpecAnno)
1058      this._writeAttribute("HREF", escapeUrl(siteSpecAnno.value));
1059    this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>");
1060    this._writeDescription(aItem, aIndent);
1061  },
1062
1063  async _writeItem(aItem, aIndent) {
1064    try {
1065      NetUtil.newURI(aItem.uri);
1066    } catch (ex) {
1067      // If the item URI is invalid, skip the item instead of failing later.
1068      return;
1069    }
1070
1071    this._write(aIndent + "<DT><A");
1072    this._writeAttribute("HREF", escapeUrl(aItem.uri));
1073    this._writeDateAttributes(aItem);
1074    await this._writeFaviconAttribute(aItem);
1075
1076    if (aItem.keyword) {
1077      this._writeAttribute("SHORTCUTURL", escapeHtmlEntities(aItem.keyword));
1078      if (aItem.postData)
1079        this._writeAttribute("POST_DATA", escapeHtmlEntities(aItem.postData));
1080    }
1081
1082    if (aItem.annos && aItem.annos.some(anno => anno.name == LOAD_IN_SIDEBAR_ANNO))
1083      this._writeAttribute("WEB_PANEL", "true");
1084    if (aItem.charset)
1085      this._writeAttribute("LAST_CHARSET", escapeHtmlEntities(aItem.charset));
1086    if (aItem.tags)
1087      this._writeAttribute("TAGS", escapeHtmlEntities(aItem.tags));
1088    this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>");
1089    this._writeDescription(aItem, aIndent);
1090  },
1091
1092  _writeDateAttributes(aItem) {
1093    if (aItem.dateAdded)
1094      this._writeAttribute("ADD_DATE",
1095                           Math.floor(aItem.dateAdded / MICROSEC_PER_SEC));
1096    if (aItem.lastModified)
1097      this._writeAttribute("LAST_MODIFIED",
1098                           Math.floor(aItem.lastModified / MICROSEC_PER_SEC));
1099  },
1100
1101  async _writeFaviconAttribute(aItem) {
1102    if (!aItem.iconuri)
1103      return;
1104    let favicon;
1105    try {
1106      favicon  = await PlacesUtils.promiseFaviconData(aItem.uri);
1107    } catch (ex) {
1108      Cu.reportError("Unexpected Error trying to fetch icon data");
1109      return;
1110    }
1111
1112    this._writeAttribute("ICON_URI", escapeUrl(favicon.uri.spec));
1113
1114    if (!favicon.uri.schemeIs("chrome") && favicon.dataLen > 0) {
1115      let faviconContents = "data:image/png;base64," +
1116        base64EncodeString(String.fromCharCode.apply(String, favicon.data));
1117      this._writeAttribute("ICON", faviconContents);
1118    }
1119  },
1120
1121  _writeDescription(aItem, aIndent) {
1122    let descriptionAnno = aItem.annos &&
1123                          aItem.annos.find(anno => anno.name == DESCRIPTION_ANNO);
1124    if (descriptionAnno)
1125      this._writeLine(aIndent + "<DD>" + escapeHtmlEntities(descriptionAnno.value));
1126  }
1127};
1128
1129/**
1130 * Handles inserting favicons into the database for a bookmark node.
1131 * It is assumed the node has already been inserted into the bookmarks
1132 * database.
1133 *
1134 * @param {Object} node The bookmark node for icons to be inserted.
1135 */
1136function insertFaviconForNode(node) {
1137  if (node.icon) {
1138    try {
1139      // Create a fake faviconURI to use (FIXME: bug 523932)
1140      let faviconURI = Services.io.newURI("fake-favicon-uri:" + node.url);
1141      PlacesUtils.favicons.replaceFaviconDataFromDataURL(
1142        faviconURI, node.icon, 0,
1143        Services.scriptSecurityManager.getSystemPrincipal());
1144      PlacesUtils.favicons.setAndFetchFaviconForPage(
1145        Services.io.newURI(node.url), faviconURI, false,
1146        PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
1147        Services.scriptSecurityManager.getSystemPrincipal());
1148    } catch (ex) {
1149      Cu.reportError("Failed to import favicon data:" + ex);
1150    }
1151  }
1152
1153  if (!node.iconUri) {
1154    return;
1155  }
1156
1157  try {
1158    PlacesUtils.favicons.setAndFetchFaviconForPage(
1159      Services.io.newURI(node.url), Services.io.newURI(node.iconUri), false,
1160      PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
1161      Services.scriptSecurityManager.getSystemPrincipal());
1162  } catch (ex) {
1163    Cu.reportError("Failed to import favicon URI:" + ex);
1164  }
1165}
1166
1167/**
1168 * Handles inserting favicons into the database for a bookmark tree - a node
1169 * and its children.
1170 *
1171 * It is assumed the nodes have already been inserted into the bookmarks
1172 * database.
1173 *
1174 * @param {Object} nodeTree The bookmark node tree for icons to be inserted.
1175 */
1176function insertFaviconsForTree(nodeTree) {
1177  insertFaviconForNode(nodeTree);
1178
1179  if (nodeTree.children) {
1180    for (let child of nodeTree.children) {
1181      insertFaviconsForTree(child);
1182    }
1183  }
1184}
1185
1186/**
1187 * Handles fetching data from a URL.
1188 *
1189 * @param {String} href The url to fetch data from.
1190 * @return {Promise} Returns a promise that is resolved with the data once
1191 *                   the fetch is complete, or is rejected if it fails.
1192 */
1193function fetchData(href) {
1194  return new Promise((resolve, reject) => {
1195    let xhr = new XMLHttpRequest();
1196    xhr.onload = () => {
1197      resolve(xhr.responseXML);
1198    };
1199    xhr.onabort = xhr.onerror = xhr.ontimeout = () => {
1200      reject(new Error("xmlhttprequest failed"));
1201    };
1202    xhr.open("GET", href);
1203    xhr.responseType = "document";
1204    xhr.overrideMimeType("text/html");
1205    xhr.send();
1206  });
1207}
1208