Paper Clip (Save pages as XHTML/Markdown/Text)

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

目前為 2025-03-30 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript== 
// @name        Paper Clip (Save pages as XHTML/Markdown/Text)
// @description Edit and save selection as clean XHTML, Markdown or Text file optimized for printing. Hotkey: Command + Shift + S.
// @author      Schimon Jehudah, Adv.
// @namespace   i2p.schimon.paperclip
// @homepageURL https://greasyfork.org/scripts/465960-paper-clip
// @supportURL  https://greasyfork.org/scripts/465960-paper-clip/feedback
// @copyright   2023 - 2025, 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     25.01.24
// @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/

5) Code (pre) is realized as one line questions/72294299/multiple-top-level-packages-discovered-in-a-flat-layout

*/

// 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('highlight'));
    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.all = 'unset';
  btn.style.position = 'absolute';
  btn.style.left = x + 5 + 'px';
  btn.style.top = y + '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('highlight'),
          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 'highlight':
      item.textContent = 'Mark';
      item.title = 'Highlight text';
      //item.onclick = () => {highlightSelection('black', 'khaki');};
      item.onclick = () => {drawBorder('black', 'khaki', 'unset');};
      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 = 'XMPP';
      item.title = 'Send via Jabber 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;
}

// FIXME
function highlightSelection(color, background) {
  let sel = document.getSelection();
  originalColor = sel.style.color;
  originalBackground = sel.style.background;
  //sel.style.background = 'rgb(250 250 210)';
  sel.style.background = background;
  //sel.style.color = 'black'; // DarkRed
  sel.style.color = color;
}

function drawBorder(color, background, outline) {
//function drawBorder(color, background, outline, method) {
  let sel = getSelectedText();
  //if (method == 'exactly') {
  //  let sel = getSelectedText();
  //} else {
  //  let sel = document.getSelection();
  //}
  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,
    createFilename('xhtml'),
    "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);
  };
}());