|
// ==UserScript== |
|
// @name Send selected content to Obsidian as markdown |
|
// @version 0.6.8 |
|
// @match *://*/* |
|
// @author Flashwalker |
|
// @description Gareth Stretton https://medium.com/@gareth.stretton/obsidian-create-your-own-web-clipper-add83c7662d0 + StackOverflow https://stackoverflow.com/questions/4176923/html-of-selected-text/4177234#4177234 |
|
// @updateURL https://gist.github.com/Flashwalker/40f23e01942cc72a47df61bb86821714/raw/obsidian-webclip-as-markdown.user.js |
|
// @downloadURL https://gist.github.com/Flashwalker/40f23e01942cc72a47df61bb86821714/raw/obsidian-webclip-as-markdown.user.js |
|
// @homepage https://gist.github.com/Flashwalker/40f23e01942cc72a47df61bb86821714 |
|
// @require https://unpkg.com/turndown/dist/turndown.js |
|
// @require https://unpkg.com/turndown-plugin-gfm/dist/turndown-plugin-gfm.js |
|
// ==/UserScript== |
|
|
|
(function(window, undefined ){ |
|
'use strict' |
|
if (window.self !== window.top){ |
|
return |
|
} |
|
// ============ Options ============ |
|
// Set your vault name here |
|
let vault = "Obsidian" |
|
// Set the path to folder for web-clipped notes (path in vault) |
|
// e.g.: "notes" or "path/to/nested/folder" or empty |
|
let folder = "webclips" |
|
// Add domain name to the note title? |
|
let domainName = false // true or false |
|
// Add blank new line at very top of the note? |
|
let newLine = false // true or false |
|
// Create the note title (file name) from the first line of the selection? |
|
let firstLineAsTitle = true // true or false |
|
// Drop first the line if note title set to the first line? |
|
let dropFirstLine = true // true or false |
|
// ================================= |
|
|
|
|
|
let domain = location.href.split("://")[1].split("/")[0].split(":")[0] |
|
|
|
// `CTRL + KEY` - do the clip |
|
function shortcutPressed(e) { |
|
let operatingSystem = navigator.userAgent.toLowerCase().search("mac") !== -1 ? "mac" : "pc" |
|
// You can choose another key by code: https://www.toptal.com/developers/keycode/ |
|
// here the ` (tilde) is used |
|
let activationKey = "Backquote" |
|
if (operatingSystem === "mac") { |
|
// CMD + KEY |
|
return e.code === activationKey && e.metaKey && !e.altKey && !e.shiftKey && !e.ctrlKey; |
|
} else { |
|
// CTRL + KEY |
|
return e.code === activationKey && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey; |
|
} |
|
} |
|
// `CTRL + SHIFT + KEY` - also do the clip |
|
function shortcutShiftPressed(e) { |
|
let operatingSystem = navigator.userAgent.toLowerCase().search("mac") !== -1 ? "mac" : "pc" |
|
// You can choose another key by code: https://www.toptal.com/developers/keycode/for/` |
|
// here the ` (tilde) is used |
|
let activationKey = "Backquote" |
|
if (operatingSystem === "mac") { |
|
// CMD + SHIFT + KEY |
|
return e.code === activationKey && e.metaKey && e.shiftKey && !e.altKey && !e.ctrlKey; |
|
} else { |
|
// CTRL + SHIFT + KEY |
|
return e.code === activationKey && e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey; |
|
} |
|
} |
|
|
|
function createFilename(txt) { |
|
let name = "" |
|
// If first line from selection as a title is set - make it title |
|
if (typeof(txt) !== 'boolean') { |
|
name = txt |
|
} else { |
|
// Otherwise use the page title (and add the domain according to the option) |
|
domainName &&= domain + " - " |
|
let pageTitle = document.title |
|
name = domainName + pageTitle |
|
} |
|
// Keep Emoji |
|
let emoName = name.replace(/<img [^<>]+?alt=['"]([\p{Emoji}\p{S}\p{M}\u200d]+)['"][^<>]*?\/?>/gimu, '$1') |
|
// Clean and truncate file name to fit the limits |
|
let cleanName = emoName.replace(/[\(\)\[\]\\/?%*:'|"<>!]/g, "-") |
|
let shortName = cleanName.substring(0, 140) |
|
// If the first line (as a title) is longer than 140 characters, |
|
// it would be kept (wouldn't be removed from the note body) |
|
// to prevent text loss |
|
if (cleanName.length > shortName.length) { dropFirstLine = false } |
|
return shortName |
|
} |
|
|
|
function rel_to_abs(url){ |
|
/* Only accept commonly trusted protocols: |
|
* Only data-image URLs are accepted, Exotic flavours (escaped slash, |
|
* html-entitied characters) are not supported to keep the function fast */ |
|
if(/^(https?|file|ftps?|mailto|javascript|data:image\/[^;]{2,9};):/i.test(url)) |
|
return url; //Url is already absolute |
|
|
|
var base_url = location.href.match(/^(.+)\/?(?:#.+)?$/)[0]+"/"; |
|
if(url.substring(0,2) == "//") |
|
return location.protocol + url; |
|
else if(url.charAt(0) == "/") |
|
return location.protocol + "//" + location.host + url; |
|
else if(url.substring(0,2) == "./") |
|
url = "." + url; |
|
else if(/^\s*$/.test(url)) |
|
return ""; //Empty = Return nothing |
|
else url = "../" + url; |
|
|
|
url = base_url + url; |
|
var i=0 |
|
while(/\/\.\.\//.test(url = url.replace(/[^\/]+\/+\.\.\//g,""))); |
|
|
|
/* Escape certain characters to prevent XSS */ |
|
url = url.replace(/\.$/,"").replace(/\/\./g,"").replace(/"/g,"%22") |
|
.replace(/'/g,"%27").replace(/</g,"%3C").replace(/>/g,"%3E"); |
|
return url; |
|
} |
|
|
|
function getSelectionHtml() { |
|
let html = "" |
|
if (typeof window.getSelection !== "undefined") { |
|
let sel = window.getSelection() |
|
if (sel.rangeCount) { |
|
let container = document.createElement("div") |
|
for (let i = 0, len = sel.rangeCount; i < len; ++i) { |
|
let documentFragment = sel.getRangeAt(i).cloneContents() |
|
// Edit selected HTML |
|
edithtml(documentFragment) |
|
|
|
container.appendChild(documentFragment) |
|
} |
|
html = container.innerHTML |
|
} |
|
} else if (typeof document.selection !== "undefined") { |
|
if (document.selection.type === "Text") { |
|
const range = document.selection.createRange() |
|
html = range.htmlText |
|
documentFragment = range.createContextualFragment(html) |
|
// Edit selected HTML |
|
edithtml(documentFragment) |
|
// Get selectedHTML as a text again |
|
html = Array.prototype.reduce.call( |
|
documentFragment.childNodes, |
|
(result, node) => result + (node.outerHTML || node.nodeValue), |
|
'' |
|
) |
|
} |
|
} |
|
return html |
|
} |
|
|
|
function edithtml(fragment) { |
|
// Make all urls absolute |
|
const links = [...fragment.querySelectorAll('a')] |
|
const imgs = [...fragment.querySelectorAll('img')] |
|
const sources = [...fragment.querySelectorAll('source')] |
|
links.map((link) => { |
|
link.setAttribute('href', rel_to_abs(link.getAttribute('href'))) |
|
}) |
|
imgs.map((img) => { |
|
img.setAttribute('src', rel_to_abs(img.getAttribute('src'))) |
|
}) |
|
sources.map((source) => { |
|
source.setAttribute('srcset', rel_to_abs(source.getAttribute('srcset'))) |
|
}) |
|
} |
|
|
|
document.addEventListener('keydown', e => { |
|
if (shortcutPressed(e) || shortcutShiftPressed(e)) { |
|
newLine ? newLine = "%0A" : newLine = "" |
|
|
|
// Selected text as HTML |
|
let selectedHTML = getSelectionHtml() |
|
// Drop the empty html tags which contains only spaces |
|
selectedHTML = selectedHTML.replace(/<[^<>]+?>[\u00A0\u1680\u180E\u2000-\u200B\u202F\u205F\u3000\uFEFF\u0020\uFFFC]+<\/[^<>]+?>/gm, '') |
|
// Drop the anchor html tags which contains only dots, commas (<a ...>.<br><br></a>) |
|
selectedHTML = selectedHTML.replace(/<a [^<>]+?>(<[^<>]+?\/?>)*([.,:;`'"]+)(<[^<>]+?\/?>)*?((<br ?\/?>)+)*(<[^<>]+?\/?>)*?<\/a>/gim, '$2$4') |
|
// Drop some more |
|
if (domain === 'web.telegram.org') { |
|
// drop emoji/unicode images, keep emoji/unicode chars (from alt attr) (<img ... alt="▫️" ...>) |
|
selectedHTML = selectedHTML.replace(/<img [^<>]+?alt=['"]([\p{Emoji}\p{S}\p{M}\u200d]+)['"][^<>]*?\/?>/gimu, '$1') |
|
} |
|
// Workaround to avoid breaking the markdown's markup (put the 'br', that precedes closing tag, out of tag) |
|
selectedHTML = selectedHTML.replace(/((<br ?\/?>)+)(<\/[^<>]+?>)/gi, '$3$1') |
|
// Also put the 'br', that follows b,i,strong,span,font opening tags, out of tag |
|
selectedHTML = selectedHTML.replace(/(<([bi]|strong|span|font)+?>)((<br ?\/?>)+)/gi, '$3$1') |
|
|
|
// Selected HTML as Markdown |
|
// Import plugins from turndown-plugin-gfm |
|
//let gfm = turndownPluginGfm.gfm |
|
//let strikethrough = turndownPluginGfm.strikethrough |
|
let tables = turndownPluginGfm.tables |
|
let taskListItems = turndownPluginGfm.taskListItems |
|
// Some conversion options |
|
let turndownService = new TurndownService({ |
|
hr: '---', |
|
headingStyle: 'atx', |
|
bulletListMarker: '-' |
|
}) |
|
turndownService.addRule('strikethrough', { |
|
filter: ['del', 's', 'strike'], |
|
replacement: function (content) { |
|
return '~~' + content + '~~' |
|
} |
|
}) |
|
turndownService.addRule('paragraph', { |
|
filter: 'p', |
|
replacement: function (content) { |
|
return '\n\n' + content + '\n\n' |
|
} |
|
}) |
|
/*turndownService.addRule('fenceAllPreformattedText', { |
|
filter: ['pre'], |
|
replacement: function (content, node, options) { |
|
return ( |
|
'\n\n' + options.fence + '\n' + |
|
node.textContent + |
|
'\n' + options.fence + '\n\n' |
|
) |
|
}, |
|
});*/ |
|
const getExt = (node) => { |
|
// Simple match where the <pre> has the `highlight-source-js` tags |
|
const getFirstTag = (node) => node.outerHTML.split(">").shift() + ">" |
|
const match = getFirstTag(node).match(/highlight-source-[a-z]+/) |
|
if (match) return match[0].split("-").pop() |
|
|
|
// More complex match where the _parent_ (single) has that. |
|
// The parent of the <pre> is not a "wrapping" parent, so skip those |
|
if (node.parentNode.childNodes.length !== 1) return "" |
|
|
|
// Check the parent just in case |
|
const parent = getFirstTag(node.parentNode).match(/highlight-source-[a-z]+/) |
|
if (parent) return parent[0].split("-").pop() |
|
|
|
// Nothing was found... |
|
return "" |
|
}; |
|
|
|
turndownService.addRule("fenceAllPreformattedText", { |
|
filter: ["pre"], |
|
replacement: function (content, node) { |
|
const ext = getExt(node); |
|
const code = [...node.childNodes].map((c) => c.textContent).join(""); |
|
return "\n```" + ext + "\n" + code + "\n```\n\n"; |
|
}, |
|
}); |
|
turndownService.remove(['style', 'script']) |
|
// turndownService.remove('script') |
|
turndownService.keep(['kbd']) |
|
// Use the all plugins |
|
//turndownService.use(gfm) |
|
// Use the table and taskListItems plugins only |
|
turndownService.use([tables, taskListItems]) |
|
// Make markdown |
|
let selectedMd = turndownService.turndown(selectedHTML) |
|
|
|
// Get selected text as plain text and get the first line |
|
// Without emoji: |
|
//firstLineAsTitle &&= document.getSelection().toString().split('\n')[0] |
|
// With emoji: |
|
if (firstLineAsTitle) { |
|
firstLineAsTitle = selectedMd.toString().split('\n')[0] |
|
firstLineAsTitle = firstLineAsTitle |
|
.replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, '$1') |
|
.replace(/^[#]{1,5} ?([^#]+)/g, '$1') |
|
} |
|
|
|
let filename = createFilename(firstLineAsTitle) |
|
|
|
// Delete the first line if title was set to the first line |
|
if (firstLineAsTitle.length && dropFirstLine) { |
|
//selectedMd = selectedMd.replace(/^.+\n/, '').replace(/^\s*\n/, '') |
|
selectedMd = selectedMd.replace(/^.+\n(\s*\n)?/, '') |
|
} |
|
// URL encode |
|
selectedMd = newLine + encodeURIComponent(selectedMd) |
|
|
|
// Use Obsidian URI (https://help.obsidian.md/Advanced+topics/Using+obsidian+URI) |
|
location.href = `obsidian://new?vault=${vault}&file=${folder}/${filename}&content=${selectedMd}&append=true` |
|
// Test |
|
// console.log(`obsidian://new?vault = ${vault}`) |
|
// console.log(`file = ${folder}/${filename}`) |
|
// console.log(`content = ${selectedMd}`) |
|
|
|
} |
|
}) |
|
})(window); |
|
|
|
// background: url() absolute path |
|
// TODO: skip <div> block tags out of <a> and set <a> to the first line |
|
// exmp: https://michael.forster.pro/posts/ |
I just fixed today, try it