parser.js

/**
 * @module parser
 * @description
 * Serialization and deserialization
 * @example
 * import { Parser } from "@thoughtsunificator/bookmarks-document"
 */

/**
 * @todo Move to another repository, this parser will be used by the server
 * This module is responsible for the parsing of internal and external bookmark documents.
 * @todo The Parser module should not have to know any details about the BookmarksDocument. It should work with data payload (DataBookmarkLink & DataBookmarkFolder)
 * @todo Fix netscape bookmark document
 *
 * @typedef {import("./bookmark-folder.js").default}      BookmarkFolder
 * @typedef {import("./bookmark-link.js").default}        BookmarkLink
 *
 * @typedef  {object}                                     DataBookmarkFolder
 * @property {str}                                        type
 * @property {str}                                        title
 * @property {str}                                        createdAt   - Valid ISO 8601 date string
 * @property {str}                                        [updatedAt] - Valid ISO 8601 date string
 * @property {Array<DataBookmarkLink|DataBookmarkFolder>} children
 *
 * @typedef  {object}                                     DataBookmarkLink
 * @property {str}                                        type
 * @property {str}                                        title
 * @property {str}                                        createdAt   - Valid ISO 8601 date string
 * @property {str}                                        [updatedAt] - Valid ISO 8601 date string
 * @property {str}                                        url
 * @property {str}                                        icon
 *
 * BookmarkPayload is used to serialize from and deserialize to a BookmarksDocument
 * @typedef  {Array<DataBookmarkLink|DataBookmarkFolder>} BookmarkPayload
 *
 */
import { BookmarksDocument, Bookmark } from "../index.js"

/**
* Create a BookmarksDocument from a BookmarkPayload
* @param   {BookmarkPayload}  data
* @returns {BookmarksDocument}
*/
export function parseInternalJSON(items) {
	const bookmarksDocument = new BookmarksDocument()
	for(const item of items) {
		const bookmark = deserialize(item, bookmarksDocument.documentElement)
		bookmarksDocument.documentElement.appendChild(bookmark)
	}
	return bookmarksDocument
}

/**
* Create a BookmarksDocument from a Netscape Bookmark File
* @see https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa753582(v=vs.85)
* @param   {str}         		  html
* @param   {Document}         document
* @returns {BookmarksDocument}
*/
export function parseHTML(html, document) {
	const div = document.createElement("div")
	div.innerHTML = html
	const bookmarksDocument = new BookmarksDocument(div.querySelector("h1").textContent)
	/**
	* @param {BookmarkFolder}  parent
	* @param {Element}         dlNode
	* @returns {BookmarkFolder}
	*/
	function walk(parent, dlNode) {
		for(const dlChild of dlNode.children) {
			if(dlChild.tagName === "DT") {
				const dtChild = dlChild.firstElementChild
				if(dtChild.tagName === "A") {
					const bookmarkLink = bookmarksDocument.createLink(
						dtChild.textContent,
						dtChild.getAttribute("ICON"),
						dtChild.href,
						new Date(dtChild.getAttribute("ADD_DATE") * 1000)
					)
					const lastModified = dtChild.getAttribute("LAST_MODIFIED")
					if(lastModified > "0") {
						bookmarkLink.updatedAt = new Date(lastModified * 1000)
					}
					parent.appendChild(bookmarkLink)
				} else if(dtChild.tagName === "H3"
					&& dtChild.nextElementSibling && dtChild.nextElementSibling.tagName === "DL") {
					/**
					 * ^ Check nextElementSibling because sometimes there are no dl placeholder, meaning if there is no children the dl node is not added
					 */
					const bookmarkFolder = bookmarksDocument.createFolder(
						dtChild.textContent,
						new Date(dtChild.getAttribute("ADD_DATE") * 1000),
					)
					const lastModified = dtChild.getAttribute("LAST_MODIFIED")
					if(lastModified > "0") {
						bookmarkFolder.updatedAt = new Date(lastModified * 1000)
					}
					parent.appendChild(bookmarkFolder)
					walk(bookmarkFolder, dtChild.nextElementSibling)
				}
			}
		}
	}
	walk(bookmarksDocument.documentElement, div.querySelector("dl"))
	return bookmarksDocument
}

/**
 * @param   {DataBookmarkFolder|DataBookmarkLink} data
 * @param   {BookmarkFolder}                      parentBookmark
 * @returns {BookmarkFolder|BookmarkLink}
 */
export function deserialize(data, parentBookmark) {
	let bookmark = null
	if(data.type === "folder") {
		bookmark = parentBookmark.ownerDocument.createFolder(data.title, new Date(data.createdAt))
		for(const child of data.children) {
			bookmark.appendChild(deserialize(child, bookmark))
		}
	} else if(data.type === "link") {
		bookmark = parentBookmark.ownerDocument.createLink(data.title, data.icon, data.url, new Date(data.createdAt))
	} else {
		throw new Error(`Unknown Bookmark type: ${data.type}. Type must be one of following Symbol: Bookmark.FOLDER or Bookmark.LINK.`)
	}
	if(data.updatedAt) {
		bookmark.updatedAt = new Date(data.updatedAt)
	}
	if(data.attributes) {
		bookmark.attributes = Object.assign(bookmark.attributes, data.attributes)
	}
	return bookmark
}

/**
 * Create a Netscape bookmark document from a bookmarkFolder given a Document is provided
 * @todo untested
 * @todo not working atm because the Netscape Bookmark Document is not valid HTML.
 * There is need for an additional Parser
 * @param   {BookmarksDocument} bookmarksDocument
 * @param   {Document}          document
 * @returns {Document}
 */
export function createNetscapeBookmarkDocument(bookmarksDocument, document) {
	// Creating a new Document type won't be enough because there's still the issue of having multiple root elements
	const netscapeBookmarkDocument = document.implementation.createHTMLDocument("Bookmarks")
	netscapeBookmarkDocument.head.innerHTML +=(`
		<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
		<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'none'; img-src data: *; object-src 'none'"></meta>
	`)
	const rootNode = netscapeBookmarkDocument.createElement("dl")
	netscapeBookmarkDocument.body.appendChild(rootNode)
	const h1 = netscapeBookmarkDocument.createElement("h1")
	const { documentElement } = bookmarksDocument
	h1.textContent = documentElement.title
	rootNode.appendChild(h1)
	/**
	 *
	 * @param {Element}        parentNode
	 * @param {BookmarkFolder} parentBookmarkFolder
	 */
	function walk(parentNode, parentBookmarkFolder) {
		const dl = netscapeBookmarkDocument.createElement("dl")
		const p = netscapeBookmarkDocument.createElement("p")
		dl.appendChild(p)
		for(const childBookmarkFolder of parentBookmarkFolder.children) {
			const dt = netscapeBookmarkDocument.createElement("dt")
			if(childBookmarkFolder.type === Bookmark.FOLDER) {
				const h3 = netscapeBookmarkDocument.createElement("h3")
				h3.textContent = childBookmarkFolder.title
				h3.setAttribute("ADD_DATE", Math.round(childBookmarkFolder.createdAt / 1000))
				if(childBookmarkFolder.updatedAt) {
					h3.setAttribute("LAST_MODIFIED", Math.round((childBookmarkFolder.updatedAt) / 1000))
				} else {
					h3.setAttribute("LAST_MODIFIED", "0")
				}
				dt.appendChild(h3)
				/**
				 * Do not add an empty dl node if there are no children
				 */
				walk(dt, childBookmarkFolder)
			} else if(childBookmarkFolder.type === Bookmark.LINK) {
				const a = netscapeBookmarkDocument.createElement("a")
				a.href = childBookmarkFolder.url
				a.textContent = childBookmarkFolder.title
				if(childBookmarkFolder.icon) {
					a.setAttribute("ICON", childBookmarkFolder.icon)
				}
				a.setAttribute("ADD_DATE", Math.round(childBookmarkFolder.createdAt / 1000))
				if(childBookmarkFolder.updatedAt) {
					a.setAttribute("LAST_MODIFIED", Math.round((childBookmarkFolder.updatedAt) / 1000))
				}
				dt.appendChild(a)
			}
			p.appendChild(dt)
		}
		parentNode.appendChild(dl)
	}
	walk(rootNode, documentElement)
	return netscapeBookmarkDocument
}