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