Skip to content

Instantly share code, notes, and snippets.

@Flashwalker
Last active June 4, 2023 04:21
Show Gist options
  • Save Flashwalker/40f23e01942cc72a47df61bb86821714 to your computer and use it in GitHub Desktop.
Save Flashwalker/40f23e01942cc72a47df61bb86821714 to your computer and use it in GitHub Desktop.
Obsidian web clipper. Send selected content to Obsidian as markdown
// ==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/

Obsidian web clipper

Send selected content to Obsidian as markdown

How to use the script:

Select the portion of the page and press Ctrl + ` or Ctrl + Shift + `     (Control + tilde) or (Control + Shift + tilde)

Options (at the script start)

Set your vault name:
vault = "Obsidian"
Set the path to folder for web-clipped notes (path in vault)
e.g.: "notes" or "path/to/nested/folder" or empty:
folder = "webclips"
Add domain name to the note title?
domainName = false
Add blank new line at very top of the note?
newLine = false
Create the note title (file name) from the first line of the selection?
firstLineAsTitle = true
Drop the first line if note title set to the first line? (to avoid double title)
dropFirstLine = true

@Flashwalker
Copy link
Author

Images are copying but the code blocks now has \ before =

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment