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