diff --git a/package-lock.json b/package-lock.json index 5e55efd2a..b42387a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@joplin/turndown-plugin-gfm": "^1.0.43", "@types/voca": "^1.4.1", "evernote": "^2.0.5", "html-entities": "^2.3.2", @@ -139,6 +140,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@joplin/turndown-plugin-gfm": { + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.43.tgz", + "integrity": "sha512-d0Qji9JT8vyCjPMfWCPgOCOA9naJlY9Yihumi2n3+FEnVJuu55gTpJzUNzA0TY5f1EDTbHtQYiFM56vkisjwgw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5906,6 +5912,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@joplin/turndown-plugin-gfm": { + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.43.tgz", + "integrity": "sha512-d0Qji9JT8vyCjPMfWCPgOCOA9naJlY9Yihumi2n3+FEnVJuu55gTpJzUNzA0TY5f1EDTbHtQYiFM56vkisjwgw==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index b5389ae8f..4fc089950 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "uglify-js": "^3.14.2" }, "dependencies": { + "@joplin/turndown-plugin-gfm": "^1.0.43", "@types/voca": "^1.4.1", "evernote": "^2.0.5", "html-entities": "^2.3.2", diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/>md.png b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/>md.png new file mode 100644 index 000000000..b003792d9 Binary files /dev/null and b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/>md.png differ diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/@joplin+turndown-plugin-gfm.js b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/@joplin+turndown-plugin-gfm.js new file mode 100644 index 000000000..104ecf826 --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/@joplin+turndown-plugin-gfm.js @@ -0,0 +1,243 @@ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.joplinturndownPluginGfm = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i= node.childNodes.length ? null : node.childNodes[i]; + var border = '---'; + var align = childNode ? (childNode.getAttribute('align') || '').toLowerCase() : ''; + + if (align) border = alignMap[align] || border; + + if (childNode) { + borderCells += cell(border, node.childNodes[i]); + } else { + borderCells += cell(border, null, i); + } + } + } + return '\n' + content + (borderCells ? '\n' + borderCells : '') + } +}; + +rules.table = { + // Only convert tables with a heading row. + // Tables with no heading row are kept using `keep` (see below). + filter: function (node) { + return node.nodeName === 'TABLE' + }, + + replacement: function (content, node) { + if (tableShouldBeSkipped(node)) return content; + + // Ensure there are no blank lines + content = content.replace(/\n+/g, '\n'); + + // If table has no heading, add an empty one so as to get a valid Markdown table + var secondLine = content.trim().split('\n'); + if (secondLine.length >= 2) secondLine = secondLine[1]; + var secondLineIsDivider = secondLine.indexOf('| ---') === 0; + + var columnCount = tableColCount(node); + var emptyHeader = ''; + if (columnCount && !secondLineIsDivider) { + emptyHeader = '|' + ' |'.repeat(columnCount) + '\n' + '|' + ' --- |'.repeat(columnCount); + } + + return '\n\n' + emptyHeader + content + '\n\n' + } +}; + +rules.tableSection = { + filter: ['thead', 'tbody', 'tfoot'], + replacement: function (content) { + return content + } +}; + +// A tr is a heading row if: +// - the parent is a THEAD +// - or if its the first child of the TABLE or the first TBODY (possibly +// following a blank THEAD) +// - and every cell is a TH +function isHeadingRow (tr) { + var parentNode = tr.parentNode; + return ( + parentNode.nodeName === 'THEAD' || + ( + parentNode.firstChild === tr && + (parentNode.nodeName === 'TABLE' || isFirstTbody(parentNode)) && + every.call(tr.childNodes, function (n) { return n.nodeName === 'TH' }) + ) + ) +} + +function isFirstTbody (element) { + var previousSibling = element.previousSibling; + return ( + element.nodeName === 'TBODY' && ( + !previousSibling || + ( + previousSibling.nodeName === 'THEAD' && + /^\s*$/i.test(previousSibling.textContent) + ) + ) + ) +} + +function cell (content, node = null, index = null) { + if (index === null) index = indexOf.call(node.parentNode.childNodes, node); + var prefix = ' '; + if (index === 0) prefix = '| '; + let filteredContent = content.trim().replace(/\n\r/g, '
').replace(/\n/g, "
"); + filteredContent = filteredContent.replace(/\|+/g, '\\|'); + while (filteredContent.length < 3) filteredContent += ' '; + if (node) filteredContent = handleColSpan(filteredContent, node, ' '); + return prefix + filteredContent + ' |' +} + +function nodeContainsTable(node) { + if (!node.childNodes) return false; + + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + if (child.nodeName === 'TABLE') return true; + if (nodeContainsTable(child)) return true; + } + return false; +} + +// Various conditions under which a table should be skipped - i.e. each cell +// will be rendered one after the other as if they were paragraphs. +function tableShouldBeSkipped(tableNode) { + if (!tableNode) return true; + if (!tableNode.rows) return true; + if (tableNode.rows.length === 1 && tableNode.rows[0].childNodes.length <= 1) return true; // Table with only one cell + if (nodeContainsTable(tableNode)) return true; + return false; +} + +function nodeParentTable(node) { + let parent = node.parentNode; + while (parent.nodeName !== 'TABLE') { + parent = parent.parentNode; + if (!parent) return null; + } + return parent; +} + +function handleColSpan(content, node, emptyChar) { + const colspan = node.getAttribute('colspan') || 1; + for (let i = 1; i < colspan; i++) { + content += ' | ' + emptyChar.repeat(3); + } + return content +} + +function tableColCount(node) { + let maxColCount = 0; + for (let i = 0; i < node.rows.length; i++) { + const row = node.rows[i]; + const colCount = row.childNodes.length; + if (colCount > maxColCount) maxColCount = colCount; + } + return maxColCount +} + +function tables (turndownService) { + turndownService.keep(function (node) { + return node.nodeName === 'TABLE' + }); + for (var key in rules) turndownService.addRule(key, rules[key]); +} + +function taskListItems (turndownService) { + turndownService.addRule('taskListItems', { + filter: function (node) { + return node.type === 'checkbox' && node.parentNode.nodeName === 'LI' + }, + replacement: function (content, node) { + return (node.checked ? '[x]' : '[ ]') + ' ' + } + }); +} + +function gfm (turndownService) { + turndownService.use([ + highlightedCodeBlock, + strikethrough, + tables, + taskListItems + ]); +} + +exports.gfm = gfm; +exports.highlightedCodeBlock = highlightedCodeBlock; +exports.strikethrough = strikethrough; +exports.tables = tables; +exports.taskListItems = taskListItems; + +},{}],2:[function(require,module,exports){ +module.exports=require("@joplin/turndown-plugin-gfm") + +},{"@joplin/turndown-plugin-gfm":1}]},{},[2])(2) +}); diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/Config.json b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/Config.json new file mode 100644 index 000000000..bc2bd421c --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/Config.json @@ -0,0 +1,12 @@ +{ + "popclip version": 3785, + "identifier": "com.pilotmoon.popclip.extension.copy-as-markdown", + "name": "Copy as Markdown", + "icon": ">md.png", + "capture html": true, + "module": "copy-as-markdown.js", + "after": "copy-result", + "long name": "Copy as Markdown", + "description": "Copy web content as Markdown.", + "note": "Updated 3 Feb 2022 for compatibility with macOS 12.3." +} diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/CopyAsMarkdown-demo.gif b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/CopyAsMarkdown-demo.gif new file mode 100644 index 000000000..5cad31003 Binary files /dev/null and b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/CopyAsMarkdown-demo.gif differ diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/CopyAsMarkdown-demo.mp4 b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/CopyAsMarkdown-demo.mp4 new file mode 100644 index 000000000..417fcb90d Binary files /dev/null and b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/CopyAsMarkdown-demo.mp4 differ diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/README.md b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/README.md new file mode 100644 index 000000000..2149a3b32 --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/README.md @@ -0,0 +1,12 @@ +# Copy as Markdown — Tables Test + +And attempt at integrating @joplin/turndown-plugin-jfm into turndown +in order to get Markdown tables. + +So far it seems that perhaps linkedom (which we use instead of jsdom) +does not produce the required output. Specificaly the table node has no +rows property. + +So it doesn't work. + +Nick, 2 May 2022 diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/copy-as-markdown.js b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/copy-as-markdown.js new file mode 100644 index 000000000..393fd0121 --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/copy-as-markdown.js @@ -0,0 +1,20 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.action = exports.htmlToMarkdown = void 0; +const linkedom = require("@popclip/linkedom"); +const TurndownService = require("@popclip/turndown"); +const turndownPluginGfm = require("./@joplin+turndown-plugin-gfm"); +function htmlToMarkdown(html) { + // generate DOM object from HTML + function JSDOM(html) { return linkedom.parseHTML(html); } // facade to work like jsdom + const { document } = new JSDOM(html); + const options = { headingStyle: 'atx' }; + var turndownService = new TurndownService(options); + turndownService.use(turndownPluginGfm.gfm); + return turndownService.turndown(document); +} +exports.htmlToMarkdown = htmlToMarkdown; +const action = (input) => { + return htmlToMarkdown(input.html); +}; +exports.action = action; diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/copy-as-markdown.ts b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/copy-as-markdown.ts new file mode 100644 index 000000000..ff9fd862a --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/copy-as-markdown.ts @@ -0,0 +1,17 @@ +import linkedom = require('@popclip/linkedom') +import TurndownService = require('@popclip/turndown') +import turndownPluginGfm = require('./@joplin+turndown-plugin-gfm') + +export function htmlToMarkdown (html: string): string { + // generate DOM object from HTML + function JSDOM (html): any { return linkedom.parseHTML(html) } // facade to work like jsdom + const { document } = new (JSDOM as any)(html) + const options = { headingStyle: 'atx' } + var turndownService = new TurndownService(options) + turndownService.use(turndownPluginGfm.gfm) + return turndownService.turndown(document) +} + +export const action: Action = (input) => { + return htmlToMarkdown(input.html) +} diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/test.js b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/test.js new file mode 100644 index 000000000..e9b2df38c --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/test.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +// run with /Applications/PopClip.app/Contents/MacOS/PopClip runjs test.js +const copy_as_markdown_1 = require("./copy-as-markdown"); +print((0, copy_as_markdown_1.htmlToMarkdown)('

hello

')); +print((0, copy_as_markdown_1.htmlToMarkdown)('

head1

')); +print((0, copy_as_markdown_1.htmlToMarkdown)('strike')); +const table = ` + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionValid valuesDefault
blankReplacementrule replacement functionSee Special Rules below
keepReplacementrule replacement functionSee Special Rules below
defaultReplacementrule replacement functionSee Special Rules below
`; +print((0, copy_as_markdown_1.htmlToMarkdown)(table)); diff --git a/source-contrib/CopyAsMarkdown-TablesTest.popclipext/test.ts b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/test.ts new file mode 100644 index 000000000..fc831a659 --- /dev/null +++ b/source-contrib/CopyAsMarkdown-TablesTest.popclipext/test.ts @@ -0,0 +1,34 @@ +// run with /Applications/PopClip.app/Contents/MacOS/PopClip runjs test.js +import { htmlToMarkdown } from './copy-as-markdown' +print(htmlToMarkdown('

hello

')) +print(htmlToMarkdown('

head1

')) +print(htmlToMarkdown('strike')) + +const table = ` + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionValid valuesDefault
blankReplacementrule replacement functionSee Special Rules below
keepReplacementrule replacement functionSee Special Rules below
defaultReplacementrule replacement functionSee Special Rules below
` +print(htmlToMarkdown(table))