Paper Clip

Save selection as clean HTML, Markdown or Text file optimized for printing. This program can also cut and edit text. Hotkey: Command + Shift + S to save as HTML.

当前为 2023-05-12 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Paper Clip
  3. // @description Save selection as clean HTML, Markdown or Text file optimized for printing. This program can also cut and edit text. Hotkey: Command + Shift + S to save as HTML.
  4. // @author Schimon Jehudah, Adv.
  5. // @namespace i2p.schimon.paperclip
  6. // @homepageURL https://greasyfork.org/en/scripts/465960-paper-clip
  7. // @supportURL https://greasyfork.org/en/scripts/465960-paper-clip/feedback
  8. // @copyright 2023, Schimon Jehudah (http://schimon.i2p)
  9. // @license MIT; https://opensource.org/licenses/MIT
  10. // @require https://unpkg.com/turndown/dist/turndown.js
  11. // @exclude devtools://*
  12. // @include *
  13. // @version 23.05.12
  14. // @run-at document-end
  15. // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB5PSIuOWVtIiBmb250LXNpemU9IjkwIj7wn5OOPC90ZXh0Pjwvc3ZnPgo=
  16. // ==/UserScript==
  17.  
  18. /* TODO
  19.  
  20. 1) Bookmarklet
  21.  
  22. 2) jsPDF /parallax/jsPDF
  23.  
  24. 3) Button for:
  25. icon send (children: Jabber & Email)
  26. icon paperclip (children: HTML, MD, TXT)
  27. text send (last format chosen)
  28.  
  29. */
  30.  
  31. // Check whether HTML; otherwise, exit.
  32. //if (!document.contentType == 'text/html')
  33. if (document.doctype == null) return;
  34.  
  35. var
  36. originalBackground, originalColor,
  37. originalDisplay, originalOutline;
  38.  
  39. const time = new Date();
  40. const namespace = 'i2p.schimon.paperclip';
  41.  
  42. // FIXME set hotkey
  43. document.onkeyup = function(e) {
  44. //if (e.ctrlKey && e.shiftKey && e.which == 49) { // Ctrl + Shift + 1
  45. if (e.metaKey && e.shiftKey && e.which == 83) { // Command + Shift + S
  46. console.info('Saving selection to HTML.')
  47. generateXHTML();
  48. }
  49. };
  50.  
  51. // event listener
  52. // event "click" and "mouseup" are the most sensible, albeit not accurate
  53. // event "mousemove" is the most manipulative (per user), yet (almost) the most accurate
  54. // event "select" seem to work only inside element input
  55. window.addEventListener('click',event => {
  56. //document.addEventListener('click',event => {
  57. let selection = document.getSelection();
  58. let btn = document.getElementById(namespace);
  59. if (!btn && selection.toString().length) {
  60. btn = createButton(event.pageX, event.pageY);
  61. // TODO Move "append"s to a function
  62. btn.append(actionButton('close'));
  63. btn.append(actionButton('save'));
  64. btn.append(actionButton('edit'));
  65. btn.append(actionButton('send'));
  66. document.body.append(btn);
  67. } else
  68. if (btn && !selection.toString().length) {
  69. btn.remove();
  70. }
  71. }, {passive: true});
  72.  
  73. // TODO declare variables once
  74. // NOTE consider "mousedown"
  75. // NOTE consider moving this functionality into function createButton()
  76. window.addEventListener('mousemove',function(){
  77. let selection = document.getSelection();
  78. let btn = document.getElementById(namespace);
  79. if (btn && !selection.toString().length) {
  80. btn.remove();
  81. }
  82. });
  83.  
  84. function createButton(x, y) {
  85. // create element
  86. let btn = document.createElement(namespace);
  87. // set content
  88. btn.id = namespace;
  89. // btn.textContent = '📎'; // 🖇️ 💾
  90. // set position
  91. btn.style.position = 'absolute';
  92. btn.style.left = x + 5 + 'px';
  93. btn.style.top = y + 'px';
  94. // set appearance
  95. btn.style.fontFamily = 'system-ui'; // cursive sans-serif emoji
  96. btn.style.background = 'black'; // cornflowerblue, grey, rosybrown
  97. btn.style.border = 'thin solid white';
  98. //btn.style.borderWidth = 'thin';
  99. //btn.style.border = 'solid'; // ridge
  100. //btn.style.borderColor = 'darkred';
  101. btn.style.borderRadius = '3px';
  102. btn.style.padding = '3px';
  103. //btn.style.marginTop = '100px';
  104. //btn.style.marginLeft = '10px';
  105. btn.style.minWidth = '30px';
  106. btn.style.minHeight = '30px';
  107. //btn.style.width = '10px';
  108. //btn.style.height = '10px';
  109. //btn.style.fontSize = '20px';
  110. btn.style.zIndex = 10000;
  111. btn.style.opacity = 0.7;
  112. // center character
  113. btn.style.justifyContent = 'center';
  114. btn.style.alignItems = 'center';
  115. btn.style.display = 'flex';
  116. // disable selection marks
  117. btn.style.outline = 'white'; // none
  118. btn.style.userSelect = 'none';
  119. btn.style.cursor = 'default';
  120. btn.onmouseleave = () => {btn.style.opacity = 0.27;};
  121. btn.onmouseover = () => {btn.style.opacity = 1;};
  122. return btn;
  123. }
  124.  
  125. function actionButton(type) {
  126. let content = getSelectedText().outerText; // textContent
  127. content = content.replace(/%0D%0A%0D%0A/g, " ");
  128. content = removeMultipleWhiteSpace(content);
  129. let item = document.createElement('span');
  130. item.id = `${namespace}-${type}`;
  131. //item.style.borderRadius = '50%';
  132. item.style.outline = 'none';
  133. item.style.padding = '3px';
  134. item.style.margin = '3px';
  135. item.style.fontSize = '10px';
  136. item.style.fontWeight = 'bold';
  137. item.style.color = 'white';
  138. item.onmouseleave = () => {resetStyle();};
  139. switch (type) {
  140. case 'back':
  141. item.textContent = '<';
  142. item.onclick = () => {
  143. item.parentElement.replaceChildren(
  144. actionButton('close'),
  145. actionButton('save'),
  146. actionButton('edit'),
  147. actionButton('send')
  148. )
  149. };
  150. break;
  151. case 'close':
  152. item.textContent = 'x';
  153. item.title = 'Double-click to close';
  154. item.ondblclick = () => {item.parentElement.remove();};
  155. break;
  156. case 'delete':
  157. item.textContent = 'Delete';
  158. item.title = 'Double-click to delete content';
  159. item.ondblclick = () => {getSelectedText().remove();};
  160. item.onmouseenter = () => {drawBorder('darkred', 'rgb(255 182 182)', '2px dashed hotpink');};
  161. break;
  162. case 'edit':
  163. item.textContent = 'Edit';
  164. item.onclick = () => {
  165. item.parentElement.replaceChildren(
  166. actionButton('back'),
  167. actionButton('delete'),
  168. actionButton('editable')
  169. )
  170. };
  171. break;
  172. case 'editable':
  173. item.onmouseenter = () => {drawBorder('darkblue', 'rgb(200 182 255)', '2px solid blue');};
  174. if (getSelectedText().contentEditable == 'true') {
  175. item.textContent = 'Stop Edit';
  176. item.title = 'Turn off edit mode';
  177. } else {
  178. item.textContent = 'Start Edit';
  179. item.title = 'Turn on edit mode';
  180. }
  181. item.onclick = () => {
  182. let texts = toggleEditeMode();
  183. item.textContent = texts[0];
  184. item.title = texts[1];
  185. }
  186. break;
  187. case 'email':
  188. item.textContent = 'Email';
  189. item.title = 'Send via Email as reference';
  190. 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}`};
  191. break;
  192. case 'irc':
  193. item.textContent = 'IRC';
  194. item.title = 'Send via IRC as reference';
  195. item.onclick = () => {alert('This button will be supported in next update')};
  196. break;
  197. case 'markdown':
  198. item.textContent = 'Markdown';
  199. item.title = 'Save to Markdown';
  200. item.onclick = () => {generateMD();}; //TODO URL reference to source URL
  201. break;
  202. case 'matrix':
  203. item.textContent = 'Matrix';
  204. item.title = 'Send via Matrix as reference';
  205. item.onclick = () => {alert('This button will be supported in next update')};
  206. break;
  207. case 'text':
  208. item.textContent = 'Text';
  209. item.title = 'Save to Plain Text';
  210. item.onclick = () => {generateTXT();};
  211. break;
  212. case 'xhtml':
  213. item.textContent = 'HTML';
  214. item.title = 'Save to HTML (valid XHTML)';
  215. item.onclick = () => {generateXHTML();};
  216. break;
  217. case 'xmpp':
  218. item.textContent = 'Jabber';
  219. item.title = 'Send via XMPP as reference';
  220. 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}`};
  221. break;
  222. case 'save':
  223. item.textContent = 'Save';
  224. item.onmouseenter = () => {drawBorder('black', 'rgb(250 250 210)', '2px double rosybrown');};
  225. item.onclick = () => {
  226. item.parentElement.replaceChildren(
  227. actionButton('back'),
  228. actionButton('xhtml'),
  229. actionButton('markdown'),
  230. actionButton('text')
  231. )
  232. };
  233. break;
  234. case 'send':
  235. item.textContent = 'Send';
  236. item.onclick = () => {
  237. item.parentElement.replaceChildren(
  238. actionButton('back'),
  239. actionButton('email'),
  240. actionButton('irc'),
  241. actionButton('matrix'),
  242. actionButton('xmpp')
  243. )
  244. };
  245. break;
  246. }
  247. return item;
  248. }
  249.  
  250. function toggleEditeMode() {
  251. let texts;
  252. if (getSelectedText().contentEditable == 'true') {
  253. getSelectedText().contentEditable = 'false';
  254. texts = ['Continue Edit', 'Edit content'];
  255. } else {
  256. getSelectedText().contentEditable = 'true';
  257. texts = ['Stop Edit', 'Turn off edit mode'];
  258. }
  259. return texts;
  260. }
  261.  
  262. function drawBorder(color, background, outline) {
  263. let sel = getSelectedText();
  264. originalColor = sel.style.color;
  265. originalOutline = sel.style.outline;
  266. originalBackground = sel.style.background;
  267. // Draw border around input without affecting style, layout or spacing
  268. // https://overflow.adminforge.de/questions/29990319/draw-border-around-input-without-affecting-style-layout-or-spacing
  269. //sel.style.outline = '3px solid';
  270. //sel.style.background = 'lightgoldenrodyellow';
  271. //sel.style.outline = '3px dashed';
  272. //sel.style.background = 'rgba(250,250,210,0.3)';
  273. //sel.style.outline = '3px double darkblue';
  274. //sel.style.background = 'rgba(210,250,250,0.8)';
  275. sel.style.outline = '2px double rosybrown';
  276. sel.style.outline = outline;
  277. //sel.style.background = 'rgba(250,250,210,0.7)';
  278. sel.style.background = 'rgb(250 250 210)';
  279. sel.style.background = background;
  280. sel.style.color = 'black'; // DarkRed
  281. sel.style.color = color;
  282. }
  283.  
  284. // TODO remove attribute 'style' of first element after 'body'
  285. // FIXME
  286. // http://gothicrichard.synthasite.com/what-i-fond-on-the-net.php
  287. // https://darknetdiaries.com/episode/65/
  288. function resetStyle() {
  289. let sel = getSelectedText();
  290. sel.style.color = originalColor;
  291. sel.style.outline = originalOutline;
  292. sel.style.background = originalBackground;
  293. }
  294.  
  295. function generateTXT() {
  296. let data = getSelectedText().outerText;
  297. data = `${data}
  298.  
  299. Created: ${time.toDateString()} ${time.toLocaleTimeString()}
  300. Source: ${location.href}
  301. Title: ${document.title}
  302.  
  303. Document generated using Paper Clip
  304. Save selected content into clean HTML, Markdown or Text
  305. https://greasyfork.org/en/scripts/465960-paper-clip
  306. `;
  307. savePage(
  308. data,
  309. createFilename('txt'),
  310. "text/plain"
  311. );
  312. }
  313.  
  314. function generateMD() {
  315. let data = getSelectedText().outerHTML;
  316. let turndownService = new TurndownService();
  317. data = turndownService.turndown(data);
  318. data = `${data}
  319. ---
  320. | | |
  321. |---|---|
  322. | Source | [${document.title}](${location.href}) |
  323. | Created | ${time.toDateString()} ${time.toLocaleTimeString()} |
  324. | Software | [Paper Clip](https://greasyfork.org/en/scripts/465960-paper-clip) Save selected HTML content into Markdown and Text |
  325. | Library | [Turndown](https://mixmark-io.github.io/turndown/) Convert HTML to Markdown |
  326. `;
  327. savePage(
  328. data,
  329. createFilename('md'),
  330. "text/plain"
  331. );
  332. }
  333.  
  334. function generateXHTML() {
  335.  
  336. let domParser = new DOMParser();
  337. let data = domParser.parseFromString('', 'text/html');
  338.  
  339. // set title
  340. if (document.title.length > 0) {
  341. data.title = document.title;
  342. }
  343.  
  344. // set base
  345. base = data.createElement('base');
  346. base.href = data.head.baseURI; // location.href;
  347. data.head.append(base);
  348.  
  349. const metaTag = [
  350. 'url',
  351. 'date',
  352. 'creator',
  353. 'user-agent',
  354. //'connection-type',
  355. 'content-type-sourced',
  356. 'charset-sourced'
  357. //'character-count'
  358. //'word-count'
  359. ];
  360.  
  361. const metaValue = [
  362. location.href,
  363. time,
  364. namespace,
  365. navigator.userAgent,
  366. //navigator.connection.effectiveType,
  367. document.contentType,
  368. document.charset
  369. ];
  370.  
  371. for (let i = 0; i < metaTag.length; i++) {
  372. let meta = document.createElement('meta');
  373. meta.name = metaTag[i];
  374. meta.content = metaValue[i];
  375. data.head.append(meta);
  376. }
  377.  
  378. const metaData = [
  379. //'content-type',
  380. 'viewport',
  381. 'description',
  382. 'keywords',
  383. 'generator'
  384. ];
  385.  
  386. for (let i = 0; i < metaData.length; i++) {
  387.  
  388. let meta = document.createElement('meta');
  389. meta.name = metaData[i] + '-imported';
  390.  
  391. try {
  392. meta.content = document.querySelector('meta[name="' + metaData[i] + '" i]')
  393. // .querySelector('meta[http-equiv="' + metaData[i] + '" i]')
  394. .content;
  395. }
  396. catch(err) {
  397. console.warn(metaData[i] + ': Not found.');
  398. continue;
  399. }
  400.  
  401. data.head.append(meta);
  402. }
  403.  
  404. data.body.innerHTML = getSelectedText().outerHTML;
  405. data = listMediaElements(data);
  406. data = removeAttributes(data);
  407. data = removeMediaElements(data);
  408. //data = replaceMediaByLinks(data);
  409. data = correctLinks(data);
  410. data = removeEmptyElements(data);
  411. data = removeCommentNodes(data);
  412. //data = removeWhitespaceFromNodes(data, ['code', 'pre']);
  413. //data = replaceCodeAndPre(data);
  414. data = new XMLSerializer().serializeToString(data);
  415. //data = formatPage(data);
  416. //data = minify(data);
  417. //data = removeComments(data);
  418. data = removeMultipleWhiteSpace(data);
  419. savePage(
  420. data,
  421. // NOTE xhtml is also valid
  422. createFilename('html'),
  423. "text/html"
  424. );
  425. }
  426.  
  427. // TODO Place plain text inside elements <code> <pre> (eliminate <span>, <br> etc.)
  428. // TODO Eliminate all elements without changing original text layout
  429. function replaceCodeAndPre(node) { // correctCodeElements
  430. const codeElements = node.getElementsByTagName('code');
  431. const preElements = node.getElementsByTagName('pre');
  432.  
  433. // Replace content of all code elements with their own outerText
  434. for (let i = 0; i < codeElements.length; i++) {
  435. const element = codeElements[i];
  436. element.outerText = element.outerText;
  437. }
  438.  
  439. // Replace content of all pre elements with their own outerText
  440. for (let i = 0; i < preElements.length; i++) {
  441. const element = preElements[i];
  442. element.outerText = element.outerText;
  443. }
  444. return node;
  445. }
  446.  
  447. function replaceMediaByLinks(node) {
  448. for (const imgElement of node.querySelectorAll('img')) {
  449. // Create a new <a> element
  450. const aElement = node.createElement('a');
  451. aElement.setAttribute.href = imgElement.src;
  452.  
  453. // Copy the attributes and contents of the <img> element to the new <a> element
  454. for (let i = 0, l = imgElement.attributes.length; i < l; i++) {
  455. const name = imgElement.attributes.item(i).name;
  456. const value = imgElement.attributes.item(i).value;
  457. aElement.setAttribute(name, value);
  458. }
  459. aElement.textContent = imgElement.src;
  460.  
  461. // Replace the <img> element with the new <a> element
  462. imgElement.parentNode.replaceChild(aElement, imgElement);
  463. }
  464. return node;
  465. }
  466.  
  467. function listMediaElements(node) {
  468.  
  469. const elements = [
  470. 'audio', 'embed', 'img', 'video',
  471. 'frame', 'frameset', 'iframe',
  472. ];
  473.  
  474. for (let i = 0; i < elements.length; i++) {
  475. for (const element of node.querySelectorAll(elements[i])) {
  476. const attributes = ['src', 'data-img-url'];
  477. for (const attribute of attributes) {
  478. if (element.getAttribute(attribute)) {
  479. let meta = node.createElement('meta');
  480. meta.name = `extracted-media-${elements[i]}`;
  481. meta.content = element.getAttribute(attribute);
  482. node.head.append(meta);
  483. }
  484. }
  485. }
  486. }
  487. return node;
  488. }
  489.  
  490. function removeMediaElements(node) {
  491. // TODO Remove span and preserve its contents
  492. // Movespan content to its parent element/node
  493. // https://overflow.lunar.icu/questions/9848465/js-remove-a-tag-without-deleting-content
  494. // Remove graphics, media and scripts
  495.  
  496. // TODO Replace "iframe" by "a href"
  497.  
  498. const elements = [
  499. 'audio', 'embed', 'img', 'video', 'button',
  500. 'form', 'frame', 'frameset', 'iframe', 'textarea',
  501. 'svg', 'input', 'path',
  502. 'script', 'style',
  503. 'select',
  504. ];
  505.  
  506. for (let i = 0; i < elements.length; i++) {
  507. for (const element of node.querySelectorAll(elements[i])) {
  508. element.remove();
  509. }
  510. }
  511.  
  512. return node;
  513. }
  514.  
  515. // Remove all attributes
  516. function removeAttributes(node) {
  517. // https://stackoverflow.com/questions/1870441/remove-all-attributes
  518. const removeAttributes = (element) => {
  519. for (let i = 0; i < element.attributes.length; i++) {
  520. if (element.attributes[i].name != 'href' &&
  521. element.attributes[i].name != 'name' &&
  522. element.attributes[i].name != 'id') {
  523. element.removeAttribute(element.attributes[i].name);
  524. }
  525. }
  526. };
  527.  
  528. for (const element of node.querySelectorAll('body *')) {
  529. removeAttributes(element);
  530. }
  531.  
  532. return node;
  533. }
  534.  
  535. // Correct links for offline usage
  536. function correctLinks(node) {
  537. for (const element of node.querySelectorAll('a')) {
  538. //if (element.hash) {
  539. //if (element.hostname + element.pathname == location.hostname + location.pathname) {
  540. if (element.href.startsWith(element.baseURI + '#')) {
  541. element.href = element.hash;
  542. }
  543. }
  544. return node;
  545. }
  546.  
  547. function removeEmptyElements (node) {
  548. for (const element of node.body.querySelectorAll('*')) {
  549. //if (/^\s*$/.test(element.outerText)) {
  550. if (element.tagName.toLowerCase() !== 'br' && /^\s*$/.test(element.textContent)) {
  551. element.remove();
  552. }
  553. }
  554. return node;
  555. }
  556.  
  557. function removeCommentNodes(node) {
  558. const nodeIterator = node.createNodeIterator(
  559. node, // Starting node, usually the document body
  560. NodeFilter.SHOW_ALL, // NodeFilter to show all node types
  561. null,
  562. false
  563. );
  564.  
  565. let currentNode;
  566. // Loop through each node in the node iterator
  567. while (currentNode = nodeIterator.nextNode()) {
  568. if (currentNode.nodeName == '#comment') {
  569. currentNode.remove();
  570. console.log(currentNode.nodeName);
  571. }
  572. }
  573. return node;
  574. }
  575.  
  576. function removeComments(str) {
  577. return str.replace(/<!--[\s\S]*?-->/g, '');
  578. }
  579.  
  580. function removeWhitespaceFromNodes(node, excludedTags) {
  581. const removeWhitespace = (node) => {
  582. if (node.nodeType === Node.TEXT_NODE) {
  583. node.textContent = node.textContent.trim();
  584. } else if (
  585. node.nodeType === Node.ELEMENT_NODE &&
  586. !excludedTags.includes(node.tagName.toLowerCase())
  587. ) {
  588. for (let i = 0; i < node.childNodes.length; i++) {
  589. removeWhitespace(node.childNodes[i]);
  590. }
  591. }
  592. };
  593. removeWhitespace(node);
  594. return node;
  595. }
  596.  
  597. function removeMultipleWhiteSpace(str) {
  598. //return str.replace(/\s+/g, ' ');
  599. //return str.replace(/(?<!<code>)\s+(?![^<]*<\/code>)/g, " ");
  600. /*
  601. return str.replace(/(<(code|pre|code-[^\s]+)[^>]*>.*?<\/\2>)|(\s+)/gs, function(match, p1, p2, p3) {
  602. if (p1) { // if the match is a code block
  603. return p1; // return the complete code block as is
  604. } else { // if the match is whitespace outside of a code block
  605. return " "; // replace with a single space
  606. }
  607. });
  608. */
  609. return str.replace(/(<(code|pre)[^>]*>.*?<\/\2>)|(\s+)/gs, function(match, p1, p2, p3) {
  610. if (p1) { // if the match is a code block
  611. return p1; // return the complete code block as is
  612. } else { // if the match is whitespace outside of a code block
  613. return " "; // replace with a single space
  614. }
  615. });
  616. }
  617.  
  618. // Get parent element of beginning (and end) of selected text
  619. // https://stackoverflow.com/questions/32515175/get-parent-element-of-beginning-and-end-of-selected-text
  620. function getSelectedText() {
  621. var selection = document.getSelection();
  622. var selectionBegin = selection.anchorNode.parentNode;
  623. var selectionEnd = selection.focusNode.parentNode;
  624. var selectionCommon =
  625. findFirstCommonAncestor
  626. (
  627. selectionBegin,
  628. selectionEnd
  629. );
  630. return selectionCommon;
  631. }
  632.  
  633. // find common parent
  634. // https://stackoverflow.com/questions/2453742/whats-the-best-way-to-find-the-first-common-parent-of-two-dom-nodes-in-javascri
  635. function findFirstCommonAncestor(nodeA, nodeB) {
  636. let range = new Range();
  637. range.setStart(nodeA, 0);
  638. range.setEnd(nodeB, 0);
  639. // There's a compilication, if nodeA is positioned after
  640. // nodeB in the document, we created a collapsed range.
  641. // That means the start and end of the range are at the
  642. // same position. In that case `range.commonAncestorContainer`
  643. // would likely just be `nodeB.parentNode`.
  644. if(range.collapsed) {
  645. // The old switcheroo does the trick.
  646. range.setStart(nodeB, 0);
  647. range.setEnd(nodeA, 0);
  648. }
  649. return range.commonAncestorContainer;
  650. }
  651.  
  652. // minify html
  653. // /questions/23284784/javascript-minify-html-regex
  654. // TODO Don't apply on code/pre
  655. function minify( s ){
  656. return s ? s
  657. .replace(/\>[\r\n ]+\</g, "><") // Removes new lines and irrelevant spaces which might affect layout, and are better gone
  658. .replace(/(<.*?>)|\s+/g, (m, $1) => $1 ? $1 : ' ')
  659. .trim()
  660. : "";
  661. }
  662.  
  663. // format html
  664. // /questions/3913355/how-to-format-tidy-beautify-in-javascript
  665. // TODO Don't inset span in code/pre
  666. function formatPage(html) {
  667. var tab = '\t';
  668. var result = '';
  669. var indent= '';
  670.  
  671. html.split(/>\s*</).forEach(function(element) {
  672.  
  673. if (element.match( /^\/\w/ )) {
  674. indent = indent.substring(tab.length);
  675. }
  676.  
  677. result += indent + '<' + element + '>\r\n';
  678.  
  679. if (element.match( /^<?\w[^>]*[^\/]$/ ) && !element.startsWith("input") ) {
  680. indent += tab;
  681. }
  682.  
  683. });
  684.  
  685. return result.substring(1, result.length-3);
  686.  
  687. }
  688.  
  689. function createFilename(extension) {
  690.  
  691. let day, now, timestamp, title, filename;
  692.  
  693. day = time
  694. .toISOString()
  695. .split('T')[0];
  696.  
  697. now = [
  698. time.getHours(),
  699. time.getMinutes(),
  700. time.getSeconds()
  701. ];
  702.  
  703. for (let i = 0; i < now.length; i++) {
  704. if (now[i] < 10) {now[i] = '0' + now[i];}
  705. }
  706.  
  707. timestamp = [
  708. day,
  709. now.join('-')
  710. ];
  711.  
  712. /*
  713. address = [
  714. location.hostname,
  715. location.pathname.replace(/\//g,'_')
  716. ]
  717.  
  718. filename =
  719. address.join('') +
  720. '_' +
  721. timestamp.join('_') +
  722. '.html';
  723. */
  724.  
  725. if (document.title) {
  726. title = document.title;
  727. } else {
  728. title = location.pathname.split('/');
  729. title = title[title.length-1];
  730. }
  731.  
  732. title = title.replace(/[\/?<>\\:*|'"\.,]/g, '');
  733. title = title.replace(/ /g, '_');
  734. title = title.replace(/-/g, '_');
  735. title = title.replace(/__/g, '_');
  736.  
  737. filename =
  738. title + // TODO replace whitespace by underscore
  739. '_' +
  740. timestamp.join('_') +
  741. `.${extension}`;
  742.  
  743. return filename.toLowerCase();
  744.  
  745. }
  746.  
  747. // export file
  748. // https://stackoverflow.com/questions/4545311/download-a-file-by-jquery-ajax
  749. // https://stackoverflow.com/questions/43135852/javascript-export-to-text-file
  750. var savePage = (function () {
  751. var a = document.createElement("a");
  752. // document.body.appendChild(a);
  753. // a.style = "display: none";
  754. return function (fileData, fileName, fileType) {
  755. var blob = new Blob([fileData], {type: fileType}),
  756. url = window.URL.createObjectURL(blob);
  757. a.href = url;
  758. a.download = fileName;
  759. a.click();
  760. window.URL.revokeObjectURL(url);
  761. };
  762. }());