mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-28 23:31:56 +00:00
98a644b4c8
This modernizes an old part of the build system to not require build-time localization at all. That's generally preferable. The most significant changes to the in-product functionality is to make import localize HTML so that we can use Fluent's `data-l10n-id`. The locale used is the user's current locale. This is different than the existing approach, which always uses the build-time (repack) locale. I believe this is a strictly superior user experience and it may lead to future improvements where-in the default bookmarks become truly dynamic and vary with the user's chosen locale rather than being point-in-time decisions. I tried to restrict these changes to only applen when we import the default bookmarks, but I think the various layers of flags no longer achieve this restriction in practice and the formatting and localization will apply to all imported `bookmarks.html` files. Since we don't anticipate (nor ourselves write) these new things in (respectively, to) `bookmarks.html`, and the file is already user-controlled, I don't think this exposes any meaningful change in functionality (or in security surface). Some notes: 1) There's no migration of `.inc` -> `.ftl` because this is the lone `.inc` file. 2) I elected to prefix all strings with `default-bookmarks-`, since the existing names were very short and likely to collide (now or in the future). 3) I elected to change the HTML file name for easier searching. 4) Since the `default-bookmarks.html` file is product-specific and the existing tests are in `toolkit/`, I elected to not test the file directly in automation. 5) We removed the explicit locale (or equivalent `%LOCALE%`) since Mozilla properties will redirect to the appropriate language automatically. Differential Revision: https://phabricator.services.mozilla.com/D135816
1165 lines
35 KiB
JavaScript
1165 lines
35 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
/**
|
|
* This file works on the old-style "bookmarks.html" file. It includes
|
|
* functions to import and export existing bookmarks to this file format.
|
|
*
|
|
* Format
|
|
* ------
|
|
*
|
|
* Primary heading := h1
|
|
* Old version used this to set attributes on the bookmarks RDF root, such
|
|
* as the last modified date. We only use H1 to check for the attribute
|
|
* PLACES_ROOT, which tells us that this hierarchy root is the places root.
|
|
* For backwards compatibility, if we don't find this, we assume that the
|
|
* hierarchy is rooted at the bookmarks menu.
|
|
* Heading := any heading other than h1
|
|
* Old version used this to set attributes on the current container. We only
|
|
* care about the content of the heading container, which contains the title
|
|
* of the bookmark container.
|
|
* Bookmark := a
|
|
* HREF is the destination of the bookmark
|
|
* FEEDURL is the URI of the RSS feed. This is deprecated and no more
|
|
* supported, but some old files may still contain it.
|
|
* LAST_CHARSET is stored as an annotation so that the next time we go to
|
|
* that page we remember the user's preference.
|
|
* ICON will be stored in the favicon service
|
|
* ICON_URI is new for places bookmarks.html, it refers to the original
|
|
* URI of the favicon so we don't have to make up favicon URLs.
|
|
* Text of the <a> container is the name of the bookmark
|
|
* Ignored: LAST_VISIT, ID (writing out non-RDF IDs can confuse Firefox 2)
|
|
* Bookmark comment := dd
|
|
* This affects the previosly added bookmark
|
|
* Separator := hr
|
|
* Insert a separator into the current container
|
|
* The folder hierarchy is defined by <dl>/<ul>/<menu> (the old importing code
|
|
* handles all these cases, when we write, use <dl>).
|
|
*
|
|
* Overall design
|
|
* --------------
|
|
*
|
|
* We need to emulate a recursive parser. A "Bookmark import frame" is created
|
|
* corresponding to each folder we encounter. These are arranged in a stack,
|
|
* and contain all the state we need to keep track of.
|
|
*
|
|
* A frame is created when we find a heading, which defines a new container.
|
|
* The frame also keeps track of the nesting of <DL>s, (in well-formed
|
|
* bookmarks files, these will have a 1-1 correspondence with frames, but we
|
|
* try to be a little more flexible here). When the nesting count decreases
|
|
* to 0, then we know a frame is complete and to pop back to the previous
|
|
* frame.
|
|
*
|
|
* Note that a lot of things happen when tags are CLOSED because we need to
|
|
* get the text from the content of the tag. For example, link and heading tags
|
|
* both require the content (= title) before actually creating it.
|
|
*/
|
|
|
|
var EXPORTED_SYMBOLS = ["BookmarkHTMLUtils"];
|
|
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
|
|
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
|
|
const { FileUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/FileUtils.jsm"
|
|
);
|
|
const { PlacesUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/PlacesUtils.jsm"
|
|
);
|
|
|
|
XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]);
|
|
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"PlacesBackups",
|
|
"resource://gre/modules/PlacesBackups.jsm"
|
|
);
|
|
|
|
const Container_Normal = 0;
|
|
const Container_Toolbar = 1;
|
|
const Container_Menu = 2;
|
|
const Container_Unfiled = 3;
|
|
const Container_Places = 4;
|
|
|
|
const MICROSEC_PER_SEC = 1000000;
|
|
|
|
const EXPORT_INDENT = " "; // four spaces
|
|
|
|
function base64EncodeString(aString) {
|
|
let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
|
|
Ci.nsIStringInputStream
|
|
);
|
|
stream.setData(aString, aString.length);
|
|
let encoder = Cc["@mozilla.org/scriptablebase64encoder;1"].createInstance(
|
|
Ci.nsIScriptableBase64Encoder
|
|
);
|
|
return encoder.encodeToString(stream, aString.length);
|
|
}
|
|
|
|
/**
|
|
* Provides HTML escaping for use in HTML attributes and body of the bookmarks
|
|
* file, compatible with the old bookmarks system.
|
|
*/
|
|
function escapeHtmlEntities(aText) {
|
|
return (aText || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Provides URL escaping for use in HTML attributes of the bookmarks file,
|
|
* compatible with the old bookmarks system.
|
|
*/
|
|
function escapeUrl(aText) {
|
|
return (aText || "").replace(/"/g, "%22");
|
|
}
|
|
|
|
function notifyObservers(aTopic, aInitialImport) {
|
|
Services.obs.notifyObservers(
|
|
null,
|
|
aTopic,
|
|
aInitialImport ? "html-initial" : "html"
|
|
);
|
|
}
|
|
|
|
var BookmarkHTMLUtils = Object.freeze({
|
|
/**
|
|
* Loads the current bookmarks hierarchy from a "bookmarks.html" file.
|
|
*
|
|
* @param aSpec
|
|
* String containing the "file:" URI for the existing "bookmarks.html"
|
|
* file to be loaded.
|
|
* @param [options.replace]
|
|
* Whether we should erase existing bookmarks before loading.
|
|
* Defaults to `false`.
|
|
* @param [options.source]
|
|
* The bookmark change source, used to determine the sync status for
|
|
* imported bookmarks. Defaults to `RESTORE` if `replace = true`, or
|
|
* `IMPORT` otherwise.
|
|
*
|
|
* @return {Promise}
|
|
* @resolves When the new bookmarks have been created.
|
|
* @rejects JavaScript exception.
|
|
*/
|
|
async importFromURL(
|
|
aSpec,
|
|
{
|
|
replace: aInitialImport = false,
|
|
source: aSource = aInitialImport
|
|
? PlacesUtils.bookmarks.SOURCES.RESTORE
|
|
: PlacesUtils.bookmarks.SOURCES.IMPORT,
|
|
} = {}
|
|
) {
|
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
|
|
try {
|
|
let importer = new BookmarkImporter(aInitialImport, aSource);
|
|
await importer.importFromURL(aSpec);
|
|
|
|
notifyObservers(
|
|
PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS,
|
|
aInitialImport
|
|
);
|
|
} catch (ex) {
|
|
Cu.reportError("Failed to import bookmarks from " + aSpec + ": " + ex);
|
|
notifyObservers(
|
|
PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED,
|
|
aInitialImport
|
|
);
|
|
throw ex;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Loads the current bookmarks hierarchy from a "bookmarks.html" file.
|
|
*
|
|
* @param aFilePath
|
|
* OS.File path string of the "bookmarks.html" file to be loaded.
|
|
* @param [options.replace]
|
|
* Whether we should erase existing bookmarks before loading.
|
|
* Defaults to `false`.
|
|
* @param [options.source]
|
|
* The bookmark change source, used to determine the sync status for
|
|
* imported bookmarks. Defaults to `RESTORE` if `replace = true`, or
|
|
* `IMPORT` otherwise.
|
|
*
|
|
* @return {Promise}
|
|
* @resolves When the new bookmarks have been created.
|
|
* @rejects JavaScript exception.
|
|
*/
|
|
async importFromFile(
|
|
aFilePath,
|
|
{
|
|
replace: aInitialImport = false,
|
|
source: aSource = aInitialImport
|
|
? PlacesUtils.bookmarks.SOURCES.RESTORE
|
|
: PlacesUtils.bookmarks.SOURCES.IMPORT,
|
|
} = {}
|
|
) {
|
|
notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
|
|
try {
|
|
if (!(await IOUtils.exists(aFilePath))) {
|
|
throw new Error(
|
|
"Cannot import from nonexisting html file: " + aFilePath
|
|
);
|
|
}
|
|
let importer = new BookmarkImporter(aInitialImport, aSource);
|
|
await importer.importFromURL(OS.Path.toFileURI(aFilePath));
|
|
|
|
notifyObservers(
|
|
PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS,
|
|
aInitialImport
|
|
);
|
|
} catch (ex) {
|
|
Cu.reportError(
|
|
"Failed to import bookmarks from " + aFilePath + ": " + ex
|
|
);
|
|
notifyObservers(
|
|
PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED,
|
|
aInitialImport
|
|
);
|
|
throw ex;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Saves the current bookmarks hierarchy to a "bookmarks.html" file.
|
|
*
|
|
* @param aFilePath
|
|
* OS.File path string for the "bookmarks.html" file to be created.
|
|
*
|
|
* @return {Promise}
|
|
* @resolves To the exported bookmarks count when the file has been created.
|
|
* @rejects JavaScript exception.
|
|
*/
|
|
async exportToFile(aFilePath) {
|
|
let [bookmarks, count] = await PlacesBackups.getBookmarksTree();
|
|
let startTime = Date.now();
|
|
|
|
// Report the time taken to convert the tree to HTML.
|
|
let exporter = new BookmarkExporter(bookmarks);
|
|
await exporter.exportToFile(aFilePath);
|
|
|
|
try {
|
|
Services.telemetry
|
|
.getHistogramById("PLACES_EXPORT_TOHTML_MS")
|
|
.add(Date.now() - startTime);
|
|
} catch (ex) {
|
|
Cu.reportError("Unable to report telemetry.");
|
|
}
|
|
|
|
return count;
|
|
},
|
|
|
|
get defaultPath() {
|
|
try {
|
|
return Services.prefs.getCharPref("browser.bookmarks.file");
|
|
} catch (ex) {}
|
|
return OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.html");
|
|
},
|
|
});
|
|
|
|
function Frame(aFolder) {
|
|
this.folder = aFolder;
|
|
|
|
/**
|
|
* How many <dl>s have been nested. Each frame/container should start
|
|
* with a heading, and is then followed by a <dl>, <ul>, or <menu>. When
|
|
* that list is complete, then it is the end of this container and we need
|
|
* to pop back up one level for new items. If we never get an open tag for
|
|
* one of these things, we should assume that the container is empty and
|
|
* that things we find should be siblings of it. Normally, these <dl>s won't
|
|
* be nested so this will be 0 or 1.
|
|
*/
|
|
this.containerNesting = 0;
|
|
|
|
/**
|
|
* when we find a heading tag, it actually affects the title of the NEXT
|
|
* container in the list. This stores that heading tag and whether it was
|
|
* special. 'consumeHeading' resets this._
|
|
*/
|
|
this.lastContainerType = Container_Normal;
|
|
|
|
/**
|
|
* this contains the text from the last begin tag until now. It is reset
|
|
* at every begin tag. We can check it when we see a </a>, or </h3>
|
|
* to see what the text content of that node should be.
|
|
*/
|
|
this.previousText = "";
|
|
|
|
/**
|
|
* true when we hit a <dd>, which contains the description for the preceding
|
|
* <a> tag. We can't just check for </dd> like we can for </a> or </h3>
|
|
* because if there is a sub-folder, it is actually a child of the <dd>
|
|
* because the tag is never explicitly closed. If this is true and we see a
|
|
* new open tag, that means to commit the description to the previous
|
|
* bookmark.
|
|
*
|
|
* Additional weirdness happens when the previous <dt> tag contains a <h3>:
|
|
* this means there is a new folder with the given description, and whose
|
|
* children are contained in the following <dl> list.
|
|
*
|
|
* This is handled in openContainer(), which commits previous text if
|
|
* necessary.
|
|
*/
|
|
this.inDescription = false;
|
|
|
|
/**
|
|
* contains the URL of the previous bookmark created. This is used so that
|
|
* when we encounter a <dd>, we know what bookmark to associate the text with.
|
|
* This is cleared whenever we hit a <h3>, so that we know NOT to save this
|
|
* with a bookmark, but to keep it until
|
|
*/
|
|
this.previousLink = null;
|
|
|
|
/**
|
|
* Contains a reference to the last created bookmark or folder object.
|
|
*/
|
|
this.previousItem = null;
|
|
|
|
/**
|
|
* Contains the date-added and last-modified-date of an imported item.
|
|
* Used to override the values set by insertBookmark, createFolder, etc.
|
|
*/
|
|
this.previousDateAdded = null;
|
|
this.previousLastModifiedDate = null;
|
|
}
|
|
|
|
function BookmarkImporter(aInitialImport, aSource) {
|
|
this._isImportDefaults = aInitialImport;
|
|
this._source = aSource;
|
|
|
|
// This root is where we construct the bookmarks tree into, following the format
|
|
// of the imported file.
|
|
// If we're doing an initial import, the non-menu roots will be created as
|
|
// children of this root, so in _getBookmarkTrees we'll split them out.
|
|
// If we're not doing an initial import, everything gets imported under the
|
|
// bookmark menu folder, so there won't be any need for _getBookmarkTrees to
|
|
// do separation.
|
|
this._bookmarkTree = {
|
|
type: PlacesUtils.bookmarks.TYPE_FOLDER,
|
|
guid: PlacesUtils.bookmarks.menuGuid,
|
|
children: [],
|
|
};
|
|
|
|
this._frames = [];
|
|
this._frames.push(new Frame(this._bookmarkTree));
|
|
}
|
|
|
|
BookmarkImporter.prototype = {
|
|
_safeTrim: function safeTrim(aStr) {
|
|
return aStr ? aStr.trim() : aStr;
|
|
},
|
|
|
|
get _curFrame() {
|
|
return this._frames[this._frames.length - 1];
|
|
},
|
|
|
|
get _previousFrame() {
|
|
return this._frames[this._frames.length - 2];
|
|
},
|
|
|
|
/**
|
|
* This is called when there is a new folder found. The folder takes the
|
|
* name from the previous frame's heading.
|
|
*/
|
|
_newFrame: function newFrame() {
|
|
let frame = this._curFrame;
|
|
let containerTitle = frame.previousText;
|
|
frame.previousText = "";
|
|
let containerType = frame.lastContainerType;
|
|
|
|
let folder = {
|
|
children: [],
|
|
type: PlacesUtils.bookmarks.TYPE_FOLDER,
|
|
};
|
|
|
|
switch (containerType) {
|
|
case Container_Normal:
|
|
// This can only be a sub-folder so no need to set a guid here.
|
|
folder.title = containerTitle;
|
|
break;
|
|
case Container_Places:
|
|
folder.guid = PlacesUtils.bookmarks.rootGuid;
|
|
break;
|
|
case Container_Menu:
|
|
folder.guid = PlacesUtils.bookmarks.menuGuid;
|
|
break;
|
|
case Container_Unfiled:
|
|
folder.guid = PlacesUtils.bookmarks.unfiledGuid;
|
|
break;
|
|
case Container_Toolbar:
|
|
folder.guid = PlacesUtils.bookmarks.toolbarGuid;
|
|
break;
|
|
default:
|
|
// NOT REACHED
|
|
throw new Error("Unknown bookmark container type!");
|
|
}
|
|
|
|
frame.folder.children.push(folder);
|
|
|
|
if (frame.previousDateAdded != null) {
|
|
folder.dateAdded = frame.previousDateAdded;
|
|
frame.previousDateAdded = null;
|
|
}
|
|
|
|
if (frame.previousLastModifiedDate != null) {
|
|
folder.lastModified = frame.previousLastModifiedDate;
|
|
frame.previousLastModifiedDate = null;
|
|
}
|
|
|
|
if (
|
|
!folder.hasOwnProperty("dateAdded") &&
|
|
folder.hasOwnProperty("lastModified")
|
|
) {
|
|
folder.dateAdded = folder.lastModified;
|
|
}
|
|
|
|
frame.previousItem = folder;
|
|
|
|
this._frames.push(new Frame(folder));
|
|
},
|
|
|
|
/**
|
|
* Handles <hr> as a separator.
|
|
*
|
|
* @note Separators may have a title in old html files, though Places dropped
|
|
* support for them.
|
|
* We also don't import ADD_DATE or LAST_MODIFIED for separators because
|
|
* pre-Places bookmarks did not support them.
|
|
*/
|
|
_handleSeparator: function handleSeparator(aElt) {
|
|
let frame = this._curFrame;
|
|
|
|
let separator = {
|
|
type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
|
|
};
|
|
frame.folder.children.push(separator);
|
|
frame.previousItem = separator;
|
|
},
|
|
|
|
/**
|
|
* Called for h2,h3,h4,h5,h6. This just stores the correct information in
|
|
* the current frame; the actual new frame corresponding to the container
|
|
* associated with the heading will be created when the tag has been closed
|
|
* and we know the title (we don't know to create a new folder or to merge
|
|
* with an existing one until we have the title).
|
|
*/
|
|
_handleHeadBegin: function handleHeadBegin(aElt) {
|
|
let frame = this._curFrame;
|
|
|
|
// after a heading, a previous bookmark is not applicable (for example, for
|
|
// the descriptions contained in a <dd>). Neither is any previous head type
|
|
frame.previousLink = null;
|
|
frame.lastContainerType = Container_Normal;
|
|
|
|
// It is syntactically possible for a heading to appear after another heading
|
|
// but before the <dl> that encloses that folder's contents. This should not
|
|
// happen in practice, as the file will contain "<dl></dl>" sequence for
|
|
// empty containers.
|
|
//
|
|
// Just to be on the safe side, if we encounter
|
|
// <h3>FOO</h3>
|
|
// <h3>BAR</h3>
|
|
// <dl>...content 1...</dl>
|
|
// <dl>...content 2...</dl>
|
|
// we'll pop the stack when we find the h3 for BAR, treating that as an
|
|
// implicit ending of the FOO container. The output will be FOO and BAR as
|
|
// siblings. If there's another <dl> following (as in "content 2"), those
|
|
// items will be treated as further siblings of FOO and BAR
|
|
// This special frame popping business, of course, only happens when our
|
|
// frame array has more than one element so we can avoid situations where
|
|
// we don't have a frame to parse into anymore.
|
|
if (frame.containerNesting == 0 && this._frames.length > 1) {
|
|
this._frames.pop();
|
|
}
|
|
|
|
// We have to check for some attributes to see if this is a "special"
|
|
// folder, which will have different creation rules when the end tag is
|
|
// processed.
|
|
if (aElt.hasAttribute("personal_toolbar_folder")) {
|
|
if (this._isImportDefaults) {
|
|
frame.lastContainerType = Container_Toolbar;
|
|
}
|
|
} else if (aElt.hasAttribute("bookmarks_menu")) {
|
|
if (this._isImportDefaults) {
|
|
frame.lastContainerType = Container_Menu;
|
|
}
|
|
} else if (aElt.hasAttribute("unfiled_bookmarks_folder")) {
|
|
if (this._isImportDefaults) {
|
|
frame.lastContainerType = Container_Unfiled;
|
|
}
|
|
} else if (aElt.hasAttribute("places_root")) {
|
|
if (this._isImportDefaults) {
|
|
frame.lastContainerType = Container_Places;
|
|
}
|
|
} else {
|
|
let addDate = aElt.getAttribute("add_date");
|
|
if (addDate) {
|
|
frame.previousDateAdded = this._convertImportedDateToInternalDate(
|
|
addDate
|
|
);
|
|
}
|
|
let modDate = aElt.getAttribute("last_modified");
|
|
if (modDate) {
|
|
frame.previousLastModifiedDate = this._convertImportedDateToInternalDate(
|
|
modDate
|
|
);
|
|
}
|
|
}
|
|
this._curFrame.previousText = "";
|
|
},
|
|
|
|
/*
|
|
* Handles "<a" tags by creating a new bookmark. The title of the bookmark
|
|
* will be the text content, which will be stuffed in previousText for us
|
|
* and which will be saved by handleLinkEnd
|
|
*/
|
|
_handleLinkBegin: function handleLinkBegin(aElt) {
|
|
let frame = this._curFrame;
|
|
|
|
frame.previousItem = null;
|
|
frame.previousText = ""; // Will hold link text, clear it.
|
|
|
|
// Get the attributes we care about.
|
|
let href = this._safeTrim(aElt.getAttribute("href"));
|
|
let icon = this._safeTrim(aElt.getAttribute("icon"));
|
|
let iconUri = this._safeTrim(aElt.getAttribute("icon_uri"));
|
|
let lastCharset = this._safeTrim(aElt.getAttribute("last_charset"));
|
|
let keyword = this._safeTrim(aElt.getAttribute("shortcuturl"));
|
|
let postData = this._safeTrim(aElt.getAttribute("post_data"));
|
|
let dateAdded = this._safeTrim(aElt.getAttribute("add_date"));
|
|
let lastModified = this._safeTrim(aElt.getAttribute("last_modified"));
|
|
let tags = this._safeTrim(aElt.getAttribute("tags"));
|
|
|
|
// Ignore <a> tags that have no href.
|
|
try {
|
|
frame.previousLink = Services.io.newURI(href).spec;
|
|
} catch (e) {
|
|
frame.previousLink = null;
|
|
return;
|
|
}
|
|
|
|
let bookmark = {};
|
|
|
|
// Only set the url for bookmarks.
|
|
if (frame.previousLink) {
|
|
bookmark.url = frame.previousLink;
|
|
}
|
|
|
|
if (dateAdded) {
|
|
bookmark.dateAdded = this._convertImportedDateToInternalDate(dateAdded);
|
|
}
|
|
// Save bookmark's last modified date.
|
|
if (lastModified) {
|
|
bookmark.lastModified = this._convertImportedDateToInternalDate(
|
|
lastModified
|
|
);
|
|
}
|
|
|
|
if (!dateAdded && lastModified) {
|
|
bookmark.dateAdded = bookmark.lastModified;
|
|
}
|
|
|
|
if (tags) {
|
|
bookmark.tags = tags
|
|
.split(",")
|
|
.filter(
|
|
aTag =>
|
|
!!aTag.length && aTag.length <= PlacesUtils.bookmarks.MAX_TAG_LENGTH
|
|
);
|
|
|
|
// If we end up with none, then delete the property completely.
|
|
if (!bookmark.tags.length) {
|
|
delete bookmark.tags;
|
|
}
|
|
}
|
|
|
|
if (lastCharset) {
|
|
bookmark.charset = lastCharset;
|
|
}
|
|
|
|
if (keyword) {
|
|
bookmark.keyword = keyword;
|
|
}
|
|
|
|
if (postData) {
|
|
bookmark.postData = postData;
|
|
}
|
|
|
|
if (icon) {
|
|
bookmark.icon = icon;
|
|
}
|
|
|
|
if (iconUri) {
|
|
bookmark.iconUri = iconUri;
|
|
}
|
|
|
|
// Add bookmark to the tree.
|
|
frame.folder.children.push(bookmark);
|
|
frame.previousItem = bookmark;
|
|
},
|
|
|
|
_handleContainerBegin: function handleContainerBegin() {
|
|
this._curFrame.containerNesting++;
|
|
},
|
|
|
|
/**
|
|
* Our "indent" count has decreased, and when we hit 0 that means that this
|
|
* container is complete and we need to pop back to the outer frame. Never
|
|
* pop the toplevel frame
|
|
*/
|
|
_handleContainerEnd: function handleContainerEnd() {
|
|
let frame = this._curFrame;
|
|
if (frame.containerNesting > 0) {
|
|
frame.containerNesting--;
|
|
}
|
|
if (this._frames.length > 1 && frame.containerNesting == 0) {
|
|
this._frames.pop();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Creates the new frame for this heading now that we know the name of the
|
|
* container (tokens since the heading open tag will have been placed in
|
|
* previousText).
|
|
*/
|
|
_handleHeadEnd: function handleHeadEnd() {
|
|
this._newFrame();
|
|
},
|
|
|
|
/**
|
|
* Saves the title for the given bookmark.
|
|
*/
|
|
_handleLinkEnd: function handleLinkEnd() {
|
|
let frame = this._curFrame;
|
|
frame.previousText = frame.previousText.trim();
|
|
|
|
if (frame.previousItem != null) {
|
|
frame.previousItem.title = frame.previousText;
|
|
}
|
|
|
|
frame.previousText = "";
|
|
},
|
|
|
|
_openContainer: function openContainer(aElt) {
|
|
if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") {
|
|
return;
|
|
}
|
|
switch (aElt.localName) {
|
|
case "h2":
|
|
case "h3":
|
|
case "h4":
|
|
case "h5":
|
|
case "h6":
|
|
this._handleHeadBegin(aElt);
|
|
break;
|
|
case "a":
|
|
this._handleLinkBegin(aElt);
|
|
break;
|
|
case "dl":
|
|
case "ul":
|
|
case "menu":
|
|
this._handleContainerBegin();
|
|
break;
|
|
case "dd":
|
|
this._curFrame.inDescription = true;
|
|
break;
|
|
case "hr":
|
|
this._handleSeparator(aElt);
|
|
break;
|
|
}
|
|
},
|
|
|
|
_closeContainer: function closeContainer(aElt) {
|
|
let frame = this._curFrame;
|
|
|
|
// Although we no longer support importing descriptions, we still need to
|
|
// clear any previous text, so that it doesn't get swallowed into other elements.
|
|
if (frame.inDescription) {
|
|
frame.previousText = "";
|
|
frame.inDescription = false;
|
|
}
|
|
|
|
if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") {
|
|
return;
|
|
}
|
|
switch (aElt.localName) {
|
|
case "dl":
|
|
case "ul":
|
|
case "menu":
|
|
this._handleContainerEnd();
|
|
break;
|
|
case "dt":
|
|
break;
|
|
case "h1":
|
|
// ignore
|
|
break;
|
|
case "h2":
|
|
case "h3":
|
|
case "h4":
|
|
case "h5":
|
|
case "h6":
|
|
this._handleHeadEnd();
|
|
break;
|
|
case "a":
|
|
this._handleLinkEnd();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
|
|
_appendText: function appendText(str) {
|
|
this._curFrame.previousText += str;
|
|
},
|
|
|
|
/**
|
|
* Converts a string date in seconds to a date object
|
|
*/
|
|
_convertImportedDateToInternalDate: function convertImportedDateToInternalDate(
|
|
aDate
|
|
) {
|
|
try {
|
|
if (aDate && !isNaN(aDate)) {
|
|
return new Date(parseInt(aDate) * 1000); // in bookmarks.html this value is in seconds
|
|
}
|
|
} catch (ex) {
|
|
// Do nothing.
|
|
}
|
|
return new Date();
|
|
},
|
|
|
|
_walkTreeForImport(aDoc) {
|
|
if (!aDoc) {
|
|
return;
|
|
}
|
|
|
|
let current = aDoc;
|
|
let next;
|
|
for (;;) {
|
|
switch (current.nodeType) {
|
|
case current.ELEMENT_NODE:
|
|
this._openContainer(current);
|
|
break;
|
|
case current.TEXT_NODE:
|
|
this._appendText(current.data);
|
|
break;
|
|
}
|
|
if ((next = current.firstChild)) {
|
|
current = next;
|
|
continue;
|
|
}
|
|
for (;;) {
|
|
if (current.nodeType == current.ELEMENT_NODE) {
|
|
this._closeContainer(current);
|
|
}
|
|
if (current == aDoc) {
|
|
return;
|
|
}
|
|
if ((next = current.nextSibling)) {
|
|
current = next;
|
|
break;
|
|
}
|
|
current = current.parentNode;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Returns the bookmark tree(s) from the importer. These are suitable for
|
|
* passing to PlacesUtils.bookmarks.insertTree().
|
|
*
|
|
* @returns {Array} An array of bookmark trees.
|
|
*/
|
|
_getBookmarkTrees() {
|
|
// If we're not importing defaults, then everything gets imported under the
|
|
// Bookmarks menu.
|
|
if (!this._isImportDefaults) {
|
|
return [this._bookmarkTree];
|
|
}
|
|
|
|
// If we are importing defaults, we need to separate out the top-level
|
|
// default folders into separate items, for the caller to pass into insertTree.
|
|
let bookmarkTrees = [this._bookmarkTree];
|
|
|
|
// The children of this "root" element will contain normal children of the
|
|
// bookmark menu as well as the places roots. Hence, we need to filter out
|
|
// the separate roots, but keep the children that are relevant to the
|
|
// bookmark menu.
|
|
this._bookmarkTree.children = this._bookmarkTree.children.filter(child => {
|
|
if (
|
|
child.guid &&
|
|
PlacesUtils.bookmarks.userContentRoots.includes(child.guid)
|
|
) {
|
|
bookmarkTrees.push(child);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return bookmarkTrees;
|
|
},
|
|
|
|
/**
|
|
* Imports the bookmarks from the importer into the places database.
|
|
*
|
|
* @param {BookmarkImporter} importer The importer from which to get the
|
|
* bookmark information.
|
|
*/
|
|
async _importBookmarks() {
|
|
if (this._isImportDefaults) {
|
|
await PlacesUtils.bookmarks.eraseEverything();
|
|
}
|
|
|
|
let bookmarksTrees = this._getBookmarkTrees();
|
|
for (let tree of bookmarksTrees) {
|
|
if (!tree.children.length) {
|
|
continue;
|
|
}
|
|
|
|
// Give the tree the source.
|
|
tree.source = this._source;
|
|
await PlacesUtils.bookmarks.insertTree(tree, {
|
|
fixupOrSkipInvalidEntries: true,
|
|
});
|
|
insertFaviconsForTree(tree);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Imports data into the places database from the supplied url.
|
|
*
|
|
* @param {String} href The url to import data from.
|
|
*/
|
|
async importFromURL(href) {
|
|
let data = await fetchData(href);
|
|
|
|
if (this._isImportDefaults && data) {
|
|
// Localize default bookmarks. Find rel="localization" links and manually
|
|
// localize using them.
|
|
let hrefs = [];
|
|
let links = data.head.querySelectorAll("link[rel='localization']");
|
|
for (let link of links) {
|
|
if (link.getAttribute("href")) {
|
|
// We need the text, not the fully qualified URL, so we use `getAttribute`.
|
|
hrefs.push(link.getAttribute("href"));
|
|
}
|
|
}
|
|
|
|
if (hrefs.length) {
|
|
let domLoc = new DOMLocalization(hrefs);
|
|
await domLoc.translateFragment(data.body);
|
|
}
|
|
}
|
|
|
|
this._walkTreeForImport(data);
|
|
await this._importBookmarks();
|
|
},
|
|
};
|
|
|
|
function BookmarkExporter(aBookmarksTree) {
|
|
// Create a map of the roots.
|
|
let rootsMap = new Map();
|
|
for (let child of aBookmarksTree.children) {
|
|
if (child.root) {
|
|
rootsMap.set(child.root, child);
|
|
// Also take the opportunity to get the correctly localised title for the
|
|
// root.
|
|
child.title = PlacesUtils.bookmarks.getLocalizedTitle(child);
|
|
}
|
|
}
|
|
|
|
// For backwards compatibility reasons the bookmarks menu is the root, while
|
|
// the bookmarks toolbar and unfiled bookmarks will be child items.
|
|
this._root = rootsMap.get("bookmarksMenuFolder");
|
|
|
|
for (let key of ["toolbarFolder", "unfiledBookmarksFolder"]) {
|
|
let root = rootsMap.get(key);
|
|
if (root.children && root.children.length) {
|
|
if (!this._root.children) {
|
|
this._root.children = [];
|
|
}
|
|
this._root.children.push(root);
|
|
}
|
|
}
|
|
}
|
|
|
|
BookmarkExporter.prototype = {
|
|
exportToFile: function exportToFile(aFilePath) {
|
|
return (async () => {
|
|
// Create a file that can be accessed by the current user only.
|
|
let out = FileUtils.openAtomicFileOutputStream(
|
|
new FileUtils.File(aFilePath)
|
|
);
|
|
try {
|
|
// We need a buffered output stream for performance. See bug 202477.
|
|
let bufferedOut = Cc[
|
|
"@mozilla.org/network/buffered-output-stream;1"
|
|
].createInstance(Ci.nsIBufferedOutputStream);
|
|
bufferedOut.init(out, 4096);
|
|
try {
|
|
// Write bookmarks in UTF-8.
|
|
this._converterOut = Cc[
|
|
"@mozilla.org/intl/converter-output-stream;1"
|
|
].createInstance(Ci.nsIConverterOutputStream);
|
|
this._converterOut.init(bufferedOut, "utf-8");
|
|
try {
|
|
this._writeHeader();
|
|
await this._writeContainer(this._root);
|
|
// Retain the target file on success only.
|
|
bufferedOut.QueryInterface(Ci.nsISafeOutputStream).finish();
|
|
} finally {
|
|
this._converterOut.close();
|
|
this._converterOut = null;
|
|
}
|
|
} finally {
|
|
bufferedOut.close();
|
|
}
|
|
} finally {
|
|
out.close();
|
|
}
|
|
})();
|
|
},
|
|
|
|
_converterOut: null,
|
|
|
|
_write(aText) {
|
|
this._converterOut.writeString(aText || "");
|
|
},
|
|
|
|
_writeAttribute(aName, aValue) {
|
|
this._write(" " + aName + '="' + aValue + '"');
|
|
},
|
|
|
|
_writeLine(aText) {
|
|
this._write(aText + "\n");
|
|
},
|
|
|
|
_writeHeader() {
|
|
this._writeLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
|
|
this._writeLine("<!-- This is an automatically generated file.");
|
|
this._writeLine(" It will be read and overwritten.");
|
|
this._writeLine(" DO NOT EDIT! -->");
|
|
this._writeLine(
|
|
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">'
|
|
);
|
|
this._writeLine(`<meta http-equiv="Content-Security-Policy"
|
|
content="default-src 'self'; script-src 'none'; img-src data: *; object-src 'none'"></meta>`);
|
|
this._writeLine("<TITLE>Bookmarks</TITLE>");
|
|
},
|
|
|
|
async _writeContainer(aItem, aIndent = "") {
|
|
if (aItem == this._root) {
|
|
this._writeLine("<H1>" + escapeHtmlEntities(this._root.title) + "</H1>");
|
|
this._writeLine("");
|
|
} else {
|
|
this._write(aIndent + "<DT><H3");
|
|
this._writeDateAttributes(aItem);
|
|
|
|
if (aItem.root === "toolbarFolder") {
|
|
this._writeAttribute("PERSONAL_TOOLBAR_FOLDER", "true");
|
|
} else if (aItem.root === "unfiledBookmarksFolder") {
|
|
this._writeAttribute("UNFILED_BOOKMARKS_FOLDER", "true");
|
|
}
|
|
this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</H3>");
|
|
}
|
|
|
|
this._writeLine(aIndent + "<DL><p>");
|
|
if (aItem.children) {
|
|
await this._writeContainerContents(aItem, aIndent);
|
|
}
|
|
if (aItem == this._root) {
|
|
this._writeLine(aIndent + "</DL>");
|
|
} else {
|
|
this._writeLine(aIndent + "</DL><p>");
|
|
}
|
|
},
|
|
|
|
async _writeContainerContents(aItem, aIndent) {
|
|
let localIndent = aIndent + EXPORT_INDENT;
|
|
|
|
for (let child of aItem.children) {
|
|
if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) {
|
|
await this._writeContainer(child, localIndent);
|
|
} else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) {
|
|
this._writeSeparator(child, localIndent);
|
|
} else {
|
|
await this._writeItem(child, localIndent);
|
|
}
|
|
}
|
|
},
|
|
|
|
_writeSeparator(aItem, aIndent) {
|
|
this._write(aIndent + "<HR");
|
|
// We keep exporting separator titles, but don't support them anymore.
|
|
if (aItem.title) {
|
|
this._writeAttribute("NAME", escapeHtmlEntities(aItem.title));
|
|
}
|
|
this._write(">");
|
|
},
|
|
|
|
async _writeItem(aItem, aIndent) {
|
|
try {
|
|
NetUtil.newURI(aItem.uri);
|
|
} catch (ex) {
|
|
// If the item URI is invalid, skip the item instead of failing later.
|
|
return;
|
|
}
|
|
|
|
this._write(aIndent + "<DT><A");
|
|
this._writeAttribute("HREF", escapeUrl(aItem.uri));
|
|
this._writeDateAttributes(aItem);
|
|
await this._writeFaviconAttribute(aItem);
|
|
|
|
if (aItem.keyword) {
|
|
this._writeAttribute("SHORTCUTURL", escapeHtmlEntities(aItem.keyword));
|
|
if (aItem.postData) {
|
|
this._writeAttribute("POST_DATA", escapeHtmlEntities(aItem.postData));
|
|
}
|
|
}
|
|
|
|
if (aItem.charset) {
|
|
this._writeAttribute("LAST_CHARSET", escapeHtmlEntities(aItem.charset));
|
|
}
|
|
if (aItem.tags) {
|
|
this._writeAttribute("TAGS", escapeHtmlEntities(aItem.tags));
|
|
}
|
|
this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>");
|
|
},
|
|
|
|
_writeDateAttributes(aItem) {
|
|
if (aItem.dateAdded) {
|
|
this._writeAttribute(
|
|
"ADD_DATE",
|
|
Math.floor(aItem.dateAdded / MICROSEC_PER_SEC)
|
|
);
|
|
}
|
|
if (aItem.lastModified) {
|
|
this._writeAttribute(
|
|
"LAST_MODIFIED",
|
|
Math.floor(aItem.lastModified / MICROSEC_PER_SEC)
|
|
);
|
|
}
|
|
},
|
|
|
|
async _writeFaviconAttribute(aItem) {
|
|
if (!aItem.iconuri) {
|
|
return;
|
|
}
|
|
let favicon;
|
|
try {
|
|
favicon = await PlacesUtils.promiseFaviconData(aItem.uri);
|
|
} catch (ex) {
|
|
Cu.reportError("Unexpected Error trying to fetch icon data");
|
|
return;
|
|
}
|
|
|
|
this._writeAttribute("ICON_URI", escapeUrl(favicon.uri.spec));
|
|
|
|
if (!favicon.uri.schemeIs("chrome") && favicon.dataLen > 0) {
|
|
let faviconContents =
|
|
"data:image/png;base64," +
|
|
base64EncodeString(String.fromCharCode.apply(String, favicon.data));
|
|
this._writeAttribute("ICON", faviconContents);
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Handles inserting favicons into the database for a bookmark node.
|
|
* It is assumed the node has already been inserted into the bookmarks
|
|
* database.
|
|
*
|
|
* @param {Object} node The bookmark node for icons to be inserted.
|
|
*/
|
|
function insertFaviconForNode(node) {
|
|
if (node.icon) {
|
|
try {
|
|
// Create a fake faviconURI to use (FIXME: bug 523932)
|
|
let faviconURI = Services.io.newURI("fake-favicon-uri:" + node.url);
|
|
PlacesUtils.favicons.replaceFaviconDataFromDataURL(
|
|
faviconURI,
|
|
node.icon,
|
|
0,
|
|
Services.scriptSecurityManager.getSystemPrincipal()
|
|
);
|
|
PlacesUtils.favicons.setAndFetchFaviconForPage(
|
|
Services.io.newURI(node.url),
|
|
faviconURI,
|
|
false,
|
|
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
|
|
null,
|
|
Services.scriptSecurityManager.getSystemPrincipal()
|
|
);
|
|
} catch (ex) {
|
|
Cu.reportError("Failed to import favicon data:" + ex);
|
|
}
|
|
}
|
|
|
|
if (!node.iconUri) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
PlacesUtils.favicons.setAndFetchFaviconForPage(
|
|
Services.io.newURI(node.url),
|
|
Services.io.newURI(node.iconUri),
|
|
false,
|
|
PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
|
|
null,
|
|
Services.scriptSecurityManager.getSystemPrincipal()
|
|
);
|
|
} catch (ex) {
|
|
Cu.reportError("Failed to import favicon URI:" + ex);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles inserting favicons into the database for a bookmark tree - a node
|
|
* and its children.
|
|
*
|
|
* It is assumed the nodes have already been inserted into the bookmarks
|
|
* database.
|
|
*
|
|
* @param {Object} nodeTree The bookmark node tree for icons to be inserted.
|
|
*/
|
|
function insertFaviconsForTree(nodeTree) {
|
|
insertFaviconForNode(nodeTree);
|
|
|
|
if (nodeTree.children) {
|
|
for (let child of nodeTree.children) {
|
|
insertFaviconsForTree(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles fetching data from a URL.
|
|
*
|
|
* @param {String} href The url to fetch data from.
|
|
* @return {Promise} Returns a promise that is resolved with the data once
|
|
* the fetch is complete, or is rejected if it fails.
|
|
*/
|
|
function fetchData(href) {
|
|
return new Promise((resolve, reject) => {
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.onload = () => {
|
|
resolve(xhr.responseXML);
|
|
};
|
|
xhr.onabort = xhr.onerror = xhr.ontimeout = () => {
|
|
reject(new Error("xmlhttprequest failed"));
|
|
};
|
|
xhr.open("GET", href);
|
|
xhr.responseType = "document";
|
|
xhr.overrideMimeType("text/html");
|
|
xhr.send();
|
|
});
|
|
}
|