Paper Clip (Save as HTML, Markdown and Text)

Edit and save selection as clean HTML, Markdown or Text file optimized for printing. Hotkey: Command + Shift + S.

// ==UserScript== 
// @name        Paper Clip (Save as HTML, Markdown and Text)
// @description Edit and save selection as clean HTML, Markdown or Text file optimized for printing. Hotkey: Command + Shift + S.
// @author      Schimon Jehudah, Adv.
// @namespace   i2p.schimon.paperclip
// @homepageURL https://greasyfork.org/en/scripts/465960-paper-clip
// @supportURL  https://greasyfork.org/en/scripts/465960-paper-clip/feedback
// @copyright   2023, Schimon Jehudah (http://schimon.i2p)
// @license     MIT; https://opensource.org/licenses/MIT
// @require     https://unpkg.com/turndown/dist/turndown.js
// @exclude     devtools://*
// @match       file:///*
// @match       *://*/*
// @version     24.01.29
// @run-at      document-end
// @icon        data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB5PSIuOWVtIiBmb250LXNpemU9IjkwIj7wn5OOPC90ZXh0Pjwvc3ZnPgo=
// ==/UserScript==

/*

TODO

0) ePUB and HTMLZ

1) Display preview

2) Bookmarklet

3) jsPDF /parallax/jsPDF

4) Remove style="background-image: url('https://torrentfreak.com/images/canada-featured.jpg');"
   https://torrentfreak.com/movie-companies-cannot-use-piracy-notice-scheme-to-facilitate-class-action-230629/
   
5) Save description too for MD and TXT. example: restoreprivacy.com

6) Footnotes for TXT
   * Collect all hyperlinks (array)
   * Scan for text
   NO
   AGAIN
   * For each hyperlink detected, enter an pointer of some sort
   NO
   AGAIN
   * Detect hyperlink
   * Place links into an array
   * Replace each hyperlink by text and a number (i.e. [1])
     * Replace hyperlink
     * Append link to array
   * Extract all links from array as footnotes

FIXME

1) https://vision.gel.ulaval.ca/~klein/duke3d/

2) Replace element-img enclosed in element-a by href of element-a
   NO
   Better use place these as footnotes

3) Rense.com & epubbooks.com

4) Replace element img with attribute alt to element span
   https://datawrapper.dwcdn.net/qBzPR/6/

*/

// Check whether HTML; otherwise, exit.
//if (!document.contentType == 'text/html')
if (document.doctype == null) return;

var
  originalBackground, originalColor,
  originalDisplay, originalOutline;

const time = new Date();
const namespace = 'i2p.schimon.paperclip';

// FIXME set hotkey
document.onkeyup = function(e) {
  //if (e.ctrlKey && e.shiftKey && e.which == 49) { // Ctrl + Shift + 1
  if (e.metaKey && e.shiftKey && e.which == 83) { // Command + Shift + S
    console.info('Saving selection to HTML.')
    generateXHTML();
  }
};

// TODO https://community.arm.com/support-forums/f/architectures-and-processors-forum/5814/what-is-difference-between-arm7-and-arm-cortex-m-series/13573#13573
// event listener
// event "click" and "mouseup" are the most sensible, albeit not accurate
// event "mousemove" is the most manipulative (per user), yet (almost) the most accurate
// event "select" seem to work only inside element input
window.addEventListener('click',event => {
//document.addEventListener('click',event => {
  let selection = document.getSelection();
  let btn = document.getElementById(namespace);
  if (!btn && selection.toString().length) {
    btn = createButton(event.pageX, event.pageY);
    // TODO Move "append"s to a function
    btn.append(actionButton('close'));
    btn.append(actionButton('save'));
    btn.append(actionButton('edit'));
    btn.append(actionButton('send'));
    document.body.append(btn);
  } else
  if (btn && !selection.toString().length) {
    btn.remove();
  }
}, {passive: true});

// TODO declare variables once
// NOTE consider "mousedown"
// NOTE consider moving this functionality into function createButton()
window.addEventListener('mousemove',function(){
  let selection = document.getSelection();
  let btn = document.getElementById(namespace);
  if (btn && !selection.toString().length) {
    btn.remove();
  }
});

function createButton(x, y) {
  // create element
  let btn = document.createElement(namespace);
  // set content
  btn.id = namespace;
  // btn.textContent = '📎'; // 🖇️ 💾
  // set position
  btn.style.position = 'absolute';
  //btn.style.left = x + 5 + 'px';
  //btn.style.top = y + 'px';
  btn.style.left = x + 'px';
  btn.style.top = y - 50 + 'px';
  // set appearance
  btn.style.direction = 'ltr';
  btn.style.fontFamily = 'system-ui'; // cursive sans-serif emoji
  btn.style.background = 'black'; // cornflowerblue, grey, rosybrown
  btn.style.border = 'thin solid white';
  //btn.style.borderWidth = 'thin';
  //btn.style.border = 'solid'; // ridge
  //btn.style.borderColor = 'darkred';
  btn.style.borderRadius = '3px';
  btn.style.padding = '1%';
  //btn.style.marginTop = '100px';
  //btn.style.marginLeft = '10px';
  btn.style.minWidth = '30px';
  btn.style.minHeight = '30px';
  //btn.style.width = '10px';
  //btn.style.height = '10px';
  //btn.style.fontSize = '20px';
  btn.style.zIndex = 10000;
  btn.style.opacity = 0.7;
  // center character
  btn.style.justifyContent = 'center';
  btn.style.alignItems = 'center';
  btn.style.display = 'flex';
  // disable selection marks
  btn.style.outline = 'white'; // none
  btn.style.userSelect = 'none';
  btn.style.cursor = 'default';
  btn.onmouseleave = () => {btn.style.opacity = 0.27;};
  btn.onmouseover = () => {btn.style.opacity = 1;};
  return btn;
}

function actionButton(type) {
  let content = getSelectedText().outerText; // textContent
  content = content.replace(/%0D%0A%0D%0A/g, " ");
  content = removeMultipleWhiteSpace(content);
  let item = document.createElement('span');
  item.id = `${namespace}-${type}`;
  //item.style.borderRadius = '50%';
  item.style.outline = 'none';
  item.style.padding = '3px';
  item.style.margin = '3px';
  //item.style.fontSize = '10px';
  item.style.fontWeight = 'bold';
  item.style.color = 'white';
  item.onmouseleave = () => {resetStyle();};
  switch (type) {
    case 'back':
      item.textContent = '<';
      item.onclick = () => {
        item.parentElement.replaceChildren(
          actionButton('close'),
          actionButton('save'),
          actionButton('edit'),
          actionButton('send')
        )
      };
      break;
    case 'close':
      item.textContent = 'x';
      item.title = 'Double-click to close';
      item.ondblclick = () => {item.parentElement.remove();};
      break;
    case 'delete':
      item.textContent = 'Delete';
      item.title = 'Double-click to delete content';
      item.ondblclick = () => {getSelectedText().remove();};
      item.onmouseenter = () => {drawBorder('darkred', 'rgb(255 182 182)', '2px dashed hotpink');};
      break;
    case 'edit':
      item.textContent = 'Edit';
      //item.style.cursor = 'context-menu';
      item.onclick = () => {
        item.parentElement.replaceChildren(
          actionButton('back'),
          actionButton('delete'),
          actionButton('editable')
        )
      };
      break;
    case 'editable':
      item.onmouseenter = () => {drawBorder('darkblue', 'rgb(200 182 255)', '2px solid blue');};
      if (getSelectedText().contentEditable == 'true') {
        item.textContent = 'Stop Editing';
        item.title = 'Turn off edit mode';
      } else {
        item.textContent = 'Start Editing';
        item.title = 'Turn on edit mode';
      }
      item.onclick = () => {
        let texts = toggleEditeMode();
        item.textContent = texts[0];
        item.title = texts[1];
      }
      break;
    case 'email':
      item.textContent = 'Email';
      item.title = 'Send via Email as reference';
      item.onclick = () => {window.location = `mailto:?subject=Content on ${location.hostname}&body=${document.title}%0D%0A%0D%0A${content}%0D%0A%0D%0A${location.hostname}${location.pathname}`};
      break;
    case 'irc':
      item.textContent = 'IRC';
      item.title = 'Send via IRC as reference';
      item.onclick = () => {alert('This button will be supported in next update')};
      break;
    case 'markdown':
      item.textContent = 'Markdown';
      item.title = 'Save to Markdown';
      item.onclick = () => {generateMD();}; //TODO URL reference to source URL
      break;
    case 'text':
      item.textContent = 'Text';
      item.title = 'Save to Plain Text';
      item.onclick = () => {generateTXT();};
      break;
    case 'xhtml':
      item.textContent = 'HTML';
      item.title = 'Save to HTML (valid XHTML)';
      item.onclick = () => {generateXHTML();};
      break;
    case 'xmpp':
      item.textContent = 'Jabber';
      item.title = 'Send via XMPP as reference';
      item.onclick = () => {window.location = `xmpp:?subject=Content on ${location.hostname}&body=${document.title}%0D%0A%0D%0A${content}%0D%0A%0D%0A${location.hostname}${location.pathname}`};
      break;
    case 'save':
      item.textContent = 'Save';
      //item.style.cursor = 'context-menu';
      item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
      item.onclick = () => {
      item.parentElement.replaceChildren(
        actionButton('back'),
        actionButton('xhtml'),
        actionButton('markdown'),
        actionButton('text')
        )
      };
      break;
    case 'send':
      item.textContent = 'Send';
      //item.style.cursor = 'context-menu';
      item.onclick = () => {
      item.parentElement.replaceChildren(
        actionButton('back'),
        actionButton('email'),
        actionButton('irc'),
        actionButton('xmpp')
        )
      };
      break;
  }
  return item;
}

function toggleEditeMode() {
  let texts;
  if (getSelectedText().contentEditable == 'true') {
    getSelectedText().contentEditable = 'false';
    texts = ['Continue Editing', 'Edit content'];
  }
  else {
    getSelectedText().contentEditable = 'true';
    texts = ['Stop Editing', 'Turn off edit mode'];
  }
  return texts;
}

function drawBorder(color, background, outline) {
  let sel = getSelectedText();
  originalColor = sel.style.color;
  originalOutline = sel.style.outline;
  originalBackground = sel.style.background;
  // Draw border around input without affecting style, layout or spacing
  // /questions/29990319/draw-border-around-input-without-affecting-style-layout-or-spacing
  //sel.style.outline = '3px solid';
  //sel.style.background = 'lightgoldenrodyellow';
  //sel.style.outline = '3px dashed';
  //sel.style.background = 'rgba(250,250,210,0.3)';
  //sel.style.outline = '3px double darkblue';
  //sel.style.background = 'rgba(210,250,250,0.8)';
  sel.style.outline = '2px double rosybrown';
  sel.style.outline = outline;
  //sel.style.background = 'rgba(250,250,210,0.7)';
  sel.style.background = 'rgb(250 250 210)';
  sel.style.background = background;
  sel.style.color = 'black'; // DarkRed
  sel.style.color = color;
}

// TODO remove attribute 'style' of first element after 'body'
// FIXME
// http://gothicrichard.synthasite.com/what-i-fond-on-the-net.php
// https://darknetdiaries.com/episode/65/
function resetStyle() {
  let sel = getSelectedText();
  sel.style.color = originalColor;
  sel.style.outline = originalOutline;
  sel.style.background = originalBackground;
}

function generateTXT() {
  let data = getSelectedText().outerText;
  data = `${data}

Created: ${time.toDateString()} ${time.toLocaleTimeString()}
Source:  ${location.href}
Title:   ${document.title}

Document generated using Paper Clip
https://greasyfork.org/en/scripts/465960-paper-clip
Save selected content into clean HTML, Markdown or Text
`;
  savePage(
    data,
    createFilename('txt'),
    "text/plain"
  );
}

function generateMD() {
  let domParser = new DOMParser();
  let data = domParser.parseFromString('', 'text/html'); // Falkon: TrustedHTML
  data.body.innerHTML = getSelectedText().outerHTML;
  console.log(data)
  let elementsToRemove = [
    'button', 'form', 'frame', 'frameset', 'iframe', 'textarea',
    //'svg', 'input', 'path',
    'script', 'style',
    'select'];
  data = removeMediaElements(data, elementsToRemove);
  let turndownService = new TurndownService();
  data = turndownService.turndown(data);

  let title;
  if (document.title) {
    title = document.title;
  } else {
    title = location.pathname.split('/');
    title = title[title.length-1];
    //title = location.pathname.split('/');
    //title = title[title.length-1];
  }

  data = `# [${title}](${location.href})
${getDescription()}
${data}

---

This page was saved at ${time.toDateString()} ${time.toLocaleTimeString()} from [${location.hostname}](${location.href})
using [Paper Clip](https://greasyfork.org/en/scripts/465960-paper-clip) and converted into Markdown with [Turndown](https://mixmark-io.github.io/turndown/)
`;
  console.log(data)
  savePage(
    data,
    createFilename('md'),
    "text/plain"
  );
}

function generateXHTML() {
  let domParser = new DOMParser();
  let data = domParser.parseFromString('', 'text/html'); // Falkon: TrustedHTML
  // set title
  if (document.title.length > 0) {
    data.title = document.title;
  }
  // set base
  // NOTE do not "set base".
  // TODO Complete links of ./ and / etc. by fetching complete
  //      url and replace href with it (it = complete url)
  base = data.createElement('base');
  base.href = data.head.baseURI; // location.href;
  data.head.append(base);
  const metaTag = [
    'url',
    'date',
    'creator',
    'user-agent',
    //'connection-type',
    'content-type-sourced',
    'charset-sourced'
    //'character-count'
    //'word-count'
  ];
  const metaValue = [
    location.href,
    time,
    namespace,
    navigator.userAgent,
    //navigator.connection.effectiveType,
    document.contentType,
    document.charset
  ];
  for (let i = 0; i < metaTag.length; i++) {
    let meta = document.createElement('meta');
    meta.name = metaTag[i];
    meta.content = metaValue[i];
    data.head.append(meta);
  }
  const metaData = [
    //'content-type',
    'viewport',
    'description',
    'keywords',
    'generator'
  ];
  for (let i = 0; i < metaData.length; i++) {
    let meta = document.createElement('meta');
    meta.name = metaData[i] + '-imported';
       try {
         meta.content = document.querySelector('meta[name="' + metaData[i] + '" i]')
           // .querySelector('meta[http-equiv="' + metaData[i] + '" i]')
           .content;
       }
       catch(err) {
         console.warn(metaData[i] + ': Not found.');
         continue;
       }
    data.head.append(meta);
  }
  if (document.dir == 'rtl') {
    data.dir = 'rtl';
  }
  data.body.innerHTML = getSelectedText().outerHTML;
  data = listMediaElements(data);
  let elementsToRemove = [
    'audio', 'embed', 'img', 'video', 'button',
    'form', 'frame', 'frameset', 'iframe', 'textarea',
    'svg', 'input', 'path',
    'script', 'style',
    'select'];
  data = removeMediaElements(data, elementsToRemove);
  data = removeAttributes(data);
  //data = replaceMediaByLinks(data);
  data = correctLinks(data);
  data = removeEmptyElements(data);
  data = removeCommentNodes(data);
  //data = removeWhitespaceFromNodes(data, ['code', 'pre']);
  //data = replaceCodeAndPre(data);
  //data = setStylesheet(data);
  data = new XMLSerializer().serializeToString(data);
  //data = formatPage(data);
  //data = minify(data);
  //data = removeComments(data);
  data = removeMultipleWhiteSpace(data);
  savePage(
    data,
    // NOTE xhtml is also valid
    createFilename('html'),
    "text/html"
  );
}

// FIXME
// body::-webkit-scrollbar{width:10.666666666666666px;height:10.666666666666666px;}
function setStylesheet(node) {
  let cssStylesheet = document.createElement('style');
  document.head.append(cssStylesheet);
  cssStylesheet.type = 'text/css';
  if (node.querySelector('code') ||
      node.querySelector('pre')) {
    cssStylesheet.textContent = 'code, pre {overflow: auto; display: grid; max-width: 100vw;}';
  }
  return node;
}

// TODO Place plain text inside elements <code> <pre> (eliminate <span>, <br> etc.)
// TODO Eliminate all elements without changing original text layout
function replaceCodeAndPre(node) { // correctCodeElements
  const codeElements = node.getElementsByTagName('code');
  const preElements = node.getElementsByTagName('pre');
  // Replace content of all code elements with their own outerText
  for (let i = 0; i < codeElements.length; i++) {
    const element = codeElements[i];
    element.outerText = element.outerText;
  }
  // Replace content of all pre elements with their own outerText
  for (let i = 0; i < preElements.length; i++) {
    const element = preElements[i];
    element.outerText = element.outerText;
  }
  return node;
}

function replaceMediaByLinks(node) {
  for (const imgElement of node.querySelectorAll('img')) {
    // Create a new <a> element
    const aElement = node.createElement('a');
    aElement.setAttribute.href = imgElement.src;
    // Copy the attributes and contents of the <img> element to the new <a> element
    for (let i = 0, l = imgElement.attributes.length; i < l; i++) {
      const name = imgElement.attributes.item(i).name;
      const value = imgElement.attributes.item(i).value;
      aElement.setAttribute(name, value);
    }
    aElement.textContent = imgElement.src;
    // Replace the <img> element with the new <a> element
    imgElement.parentNode.replaceChild(aElement, imgElement);
  }
  return node;
}

// TODO
// Catch all elements with attribute
// contains URL, and
// starts with / (add hostname), and
// contains / (add hostname with first parent path), and
// validate using URL API
function listMediaElements(node) {
  let unique = []
  const elements = [
    'audio', 'embed', 'img', 'svg', 'video',
    'frame', 'frameset', 'iframe', '[data-source]',
  ];
  // Find element and add its URL as metadata
  for (let i = 0; i < elements.length; i++) {
    for (const element of node.querySelectorAll(elements[i])) {
      const attributes = ['src', 'data-img-url', 'data-source'];
      for (const attribute of attributes) {
        if (element.getAttribute(attribute) &&
            !unique.includes(element.getAttribute(attribute))) {
          let attr = element.getAttribute(attribute)
          unique.push(attr)
          let meta = node.createElement('meta');
          meta.name = `extracted-media-${element.nodeName.toLowerCase()}`; // Was ${elements[i]}
          meta.content = attr;
          node.head.append(meta);
        }
      }
    }
  }
  return node;
}

//Remove graphics, media and scripts
function removeMediaElements(node, elements) {
  /*
  TODO Remove span and preserve its contents
  Movespan content to its parent element/node
  /questions/9848465/js-remove-a-tag-without-deleting-content

  FIXME Couldn't remove "iframe" for
  https://www.dailymail.co.uk/health/article-3460321/How-Big-Pharma-greed-killing-tens-thousands-world-Patients-medicated-given-profitable-drugs-little-proven-benefits-leading-doctors-warn.html
  */
  media = ['audio', 'embed', 'frame', 'frameset', 'iframe', 'img', 'video'];
  for (let i = 0; i < elements.length; i++) {
    for (const element of node.querySelectorAll(elements[i])) {
      if (media.includes(elements[i])) {
        let ele = node.createElement('a');
        //ele.textContent = `Click to view removed HTML <${elements[i]}> media element.`.toUpperCase();

        //ele.textContent = `[HIDDEN MEDIA]`;
        //ele.title = `${elements[i]} media element`;
        //ele.href = element.getAttribute('src');

        //let ele = node.createElement('pre')
        //let ele = node.createElement('code')
        //ele.textContent = `${element.getAttribute('src')}`;
        //ele.title = 'Hidden media';
        element.parentNode.insertBefore(ele, element.nextSibling); // insertAfter
      }
      element.remove();
    }
  }
  return node;
}

// Remove all attributes
function removeAttributes(node) {
  // /questions/1870441/remove-all-attributes
  const removeAttributes = (element) => {
    for (let i = 0; i < element.attributes.length; i++) {
      if (element.attributes[i].name != 'href' &&
          element.attributes[i].name != 'name' &&
          element.attributes[i].name != 'id') {
        element.removeAttribute(element.attributes[i].name);
      }
    }
  };
  for (const element of node.querySelectorAll('body *')) {
    removeAttributes(element);
  }
  return node;
}

// Correct links for offline usage
function correctLinks(node) {
  for (const element of node.querySelectorAll('a')) {
    //if (element.hash) {
    //if (element.hostname + element.pathname == location.hostname + location.pathname) {
    if (element.href.startsWith(element.baseURI + '#')) {
      element.href = element.hash;
    }
  }
  return node;
}

function removeEmptyElements (node) {
  for (const element of node.body.querySelectorAll('*')) {
    //if (/^\s*$/.test(element.outerText)) {
    if (element.tagName.toLowerCase() != 'br' && /^\s*$/.test(element.textContent)) {
      element.remove();
    }
  }
  return node;
}

function removeCommentNodes(node) {
  const nodeIterator = node.createNodeIterator(
    node,  // Starting node, usually the document body
    NodeFilter.SHOW_ALL,  // NodeFilter to show all node types
    null,  
    false  
  );
  let currentNode;
  // Loop through each node in the node iterator
  while (currentNode = nodeIterator.nextNode()) {
    if (currentNode.nodeName == '#comment') {
      currentNode.remove();
      console.log(currentNode.nodeName);
    }
  }
  return node;
}

function removeComments(str) {
  return str.replace(/<!--[\s\S]*?-->/g, '');
}

function removeWhitespaceFromNodes(node, excludedTags) {
  const removeWhitespace = (node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      node.textContent = node.textContent.trim();
    } else if (
      node.nodeType === Node.ELEMENT_NODE &&
      !excludedTags.includes(node.tagName.toLowerCase())
    ) {
      for (let i = 0; i < node.childNodes.length; i++) {
        removeWhitespace(node.childNodes[i]);
      }
    }
  };
  removeWhitespace(node);
  return node;
}

function removeMultipleWhiteSpace(str) {
  //return str.replace(/\s+/g, ' ');
  //return str.replace(/(?<!<code>)\s+(?![^<]*<\/code>)/g, " ");
  /*
  return str.replace(/(<(code|pre|code-[^\s]+)[^>]*>.*?<\/\2>)|(\s+)/gs, function(match, p1, p2, p3) {
  if (p1) { // if the match is a code block
    return p1; // return the complete code block as is
  } else { // if the match is whitespace outside of a code block
    return " "; // replace with a single space
  }
  });
  */
  return str.replace(/(<(code|pre)[^>]*>.*?<\/\2>)|(\s+)/gs, function(match, p1, p2, p3) {
    if (p1) { // if the match is a code block
      return p1; // return the complete code block as is
    } else { // if the match is whitespace outside of a code block
      return " "; // replace with a single space
    }
  });
}

// Get parent element of beginning (and end) of selected text
// /questions/32515175/get-parent-element-of-beginning-and-end-of-selected-text
function getSelectedText() {
  var selection = document.getSelection();
  var selectionBegin = selection.anchorNode.parentNode;
  var selectionEnd = selection.focusNode.parentNode;
  var selectionCommon =
    findFirstCommonAncestor
    (
      selectionBegin,
      selectionEnd
    );
  return selectionCommon;
}

// find common parent
// /questions/2453742/whats-the-best-way-to-find-the-first-common-parent-of-two-dom-nodes-in-javascri
function findFirstCommonAncestor(nodeA, nodeB) {
  let range = new Range();
  range.setStart(nodeA, 0);
  range.setEnd(nodeB, 0);
  // There's a compilication, if nodeA is positioned after
  // nodeB in the document, we created a collapsed range.
  // That means the start and end of the range are at the
  // same position. In that case `range.commonAncestorContainer`
  // would likely just be `nodeB.parentNode`.
  if(range.collapsed) {
    // The old switcheroo does the trick.
    range.setStart(nodeB, 0);
    range.setEnd(nodeA, 0);
  }
  return range.commonAncestorContainer;
}

// minify html
// /questions/23284784/javascript-minify-html-regex
// TODO Don't apply on code/pre
function minify( s ){
  return s ? s
    .replace(/\>[\r\n ]+\</g, "><")  // Removes new lines and irrelevant spaces which might affect layout, and are better gone
    .replace(/(<.*?>)|\s+/g, (m, $1) => $1 ? $1 : ' ')
    .trim()
    : "";
}

// format html
// /questions/3913355/how-to-format-tidy-beautify-in-javascript
// TODO Don't inset span in code/pre
function formatPage(html) {
  var tab = '\t';
  var result = '';
  var indent= '';
  html.split(/>\s*</).forEach(function(element) {
    if (element.match( /^\/\w/ )) {
        indent = indent.substring(tab.length);
    }
    result += indent + '<' + element + '>\r\n';
    if (element.match( /^<?\w[^>]*[^\/]$/ ) && !element.startsWith("input")  ) { 
      indent += tab;              
    }
  });
  return result.substring(1, result.length-3);
}

function getDescription() {
  let desc;
  if (document.querySelector('meta[name=description]')) {
    desc = '## ' + document.querySelector('meta[name=description]').content + '\n';
  } else
  if (document.querySelector('meta[itemprop=description]')) {
    desc = '## ' + document.querySelector('meta[itemprop=description]').content + '\n';
  } else {
    desc = '';
  }
  return desc;
}

function createFilename(extension) {

  let day, now, timestamp, title, filename;

  day = time
    .toISOString()
    .split('T')[0];

  now = [
    time.getHours(),
    time.getMinutes(),
    time.getSeconds()
  ];

  for (let i = 0; i < now.length; i++) { 
    if (now[i] < 10) {now[i] = '0' + now[i];}
  }

  timestamp = [
    day,
    now.join('-')
  ];

/*
  address = [
    location.hostname,
    location.pathname.replace(/\//g,'_')
  ]

  filename =
    address.join('') +
    '_' +
    timestamp.join('_') +
    '.html';
*/

  if (document.title) {
    title = document.title;
  } else {
    title = location.pathname.split('/');
    title = title[title.length-1];
  }

  // TODO ‘ ’ ·
  title = title.replace(/[\/?<>\\:*|'"\.,]/g, '');
  title = title.replace(/ /g, '_');
  title = title.replace(/-/g, '_');
  title = title.replace(/__/g, '_');

  filename =
    title + // TODO replace whitespace by underscore
    '_' +
    timestamp.join('_') +
    `.${extension}`;

  return filename.toLowerCase();

}

// export file
// /questions/4545311/download-a-file-by-jquery-ajax
// /questions/43135852/javascript-export-to-text-file
var savePage = (function () {
  var a = document.createElement("a");
  // document.body.appendChild(a);
  // a.style = "display: none";
  return function (fileData, fileName, fileType) {
    var blob = new Blob([fileData], {type: fileType}),
        url = window.URL.createObjectURL(blob);
    a.href = url;
    a.download = fileName;
    a.click();
    window.URL.revokeObjectURL(url);
  };
}());