├── .gitignore ├── README.md ├── icons └── clipper.png ├── manifest.json ├── options.css ├── options.html ├── package-lock.json ├── package.json ├── popup.css ├── popup.html ├── src ├── action.ts ├── content.ts ├── notes ├── options.ts └── shared.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | *node_modules/ 2 | *dist/ 3 | *web-ext-artifacts/ 4 | *.DS_Store 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Building 2 | 3 | Original build was conducted on OSX 11.2.1 with 4 | ``` 5 | node --version 6 | v14.7.0 7 | ``` 8 | ``` 9 | npm --version 10 | 6.14.7 11 | ``` 12 | 13 | 14 | 1. Install dependencies. 15 | ``` 16 | npm install 17 | ``` 18 | 2. Build the extension 19 | ``` 20 | npm run build 21 | ``` 22 | 23 | 24 | -------------------------------------------------------------------------------- /icons/clipper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ewestern/obsidian-clipper/309f6c837325fa9aa614e84977a766645d1dae6e/icons/clipper.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "manifest_version": 2, 4 | "name": "Obsidian Clipper", 5 | "version": "0.1", 6 | "description": "A tool to clip web pages into Obsidian.md", 7 | "developer": { 8 | "name": "ewestern", 9 | "url": "https://github.com/ewestern/obsidian-clipper" 10 | }, 11 | "permissions": [ 12 | "activeTab", 13 | "tabs", 14 | "storage" 15 | ], 16 | "icons": { 17 | "48": "icons/clipper.png" 18 | }, 19 | "browser_action": { 20 | "default_icon": { 21 | "48": "icons/clipper.png" 22 | }, 23 | "default_title": "Clip to Obsidian", 24 | "default_popup": "popup.html" 25 | }, 26 | "options_ui": { 27 | "page": "options.html", 28 | "browser_style": true, 29 | "chrome_style": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /options.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgb(32, 32, 32); 3 | /* color: rgb(220, 221, 222); */ 4 | color: rgb(168, 156, 246); 5 | min-height: 300px; 6 | } 7 | #options-content { 8 | border-color: rgb(220, 221, 222); 9 | } 10 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |

12 | Obsidian Clipper 13 |

14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 | 23 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-clipper", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "node_modules/webpack-cli/bin/cli.js", 9 | "dev": "node_modules/webpack-cli/bin/cli.js watch" 10 | }, 11 | "author": "pfrance@gmail.com", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@babel/cli": "^7.12.16", 15 | "@babel/core": "^7.12.16", 16 | "@babel/preset-env": "^7.12.16", 17 | "@babel/preset-typescript": "^7.12.16", 18 | "ts-loader": "^8.0.17", 19 | "typescript": "^4.1.3", 20 | "web-ext-types": "^3.2.1", 21 | "webpack": "^5.21.2", 22 | "webpack-cli": "^4.5.0" 23 | }, 24 | "dependencies": { 25 | "@postlight/mercury-parser": "^2.2.0", 26 | "@types/postlight__mercury-parser": "^2.2.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /popup.css: -------------------------------------------------------------------------------- 1 | #popup-content { 2 | background-color: rgb(22, 22, 22); 3 | display: inline-block; 4 | } 5 | h1 { 6 | padding: 0 6px; 7 | color: rgb(220, 221, 222); 8 | } 9 | .menu-container { 10 | /* display: flex; */ 11 | text-align: center; 12 | } 13 | .menu { 14 | padding: 8px 0; 15 | position: relative; 16 | display: inline-block; 17 | } 18 | .menu-item button { 19 | cursor: default; 20 | border-radius: 5px; 21 | border: 1px solid rgb(153, 153, 153); 22 | background-color: rgb(153, 153, 153); /** gray **/ 23 | font-size: 1rem; 24 | width: auto; 25 | padding: 6px; 26 | display: flex; 27 | position: relative; 28 | text-align: left; 29 | align-items: center; 30 | justify-content: flex-start; 31 | color: rgb(220, 221, 222); 32 | margin: 10px 10px; 33 | } 34 | li.active button { 35 | background-color: rgb(72, 54, 153); 36 | border: 1px solid rgb(72, 54, 153); 37 | cursor: pointer; 38 | } 39 | 40 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 35 | 36 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Message 3 | , ClipOption 4 | , MESSAGE_GET_OPTIONS 5 | , MESSAGE_SELECT_OPTION 6 | , OPTION_CLIP_LINK 7 | , OPTION_CLIP_PAGE 8 | , OPTION_CLIP_SELECTION 9 | , GetOptionsMessage 10 | , Clipping 11 | , MESSAGE_SEND_CLIP 12 | , SendClipMessage 13 | , retrieveOptions 14 | } from "./shared"; 15 | 16 | function activateOptions(opts: ClipOption[]): void { 17 | opts.forEach((value: ClipOption) => { 18 | switch (value) { 19 | case OPTION_CLIP_LINK: 20 | document.querySelector("#clip-link")?.classList.add("active") 21 | break; 22 | case OPTION_CLIP_PAGE: 23 | document.querySelector("#clip-page")?.classList.add("active") 24 | break; 25 | case OPTION_CLIP_SELECTION: 26 | document.querySelector("#clip-selection")?.classList.add("active") 27 | break; 28 | default: 29 | break; 30 | } 31 | }) 32 | } 33 | 34 | function listenForClicks(sender: browser.runtime.MessageSender): void { 35 | document.querySelectorAll("li").forEach((node) => { 36 | let nodeId = node.id 37 | function listener(event: Event) { 38 | let e = event as MouseEvent 39 | switch (nodeId) { 40 | case "clip-link": 41 | makeSelection(sender.tab!.id!, OPTION_CLIP_LINK); 42 | break 43 | case "clip-selection": 44 | makeSelection(sender.tab!.id!, OPTION_CLIP_SELECTION); 45 | break 46 | case "clip-page": 47 | makeSelection(sender.tab!.id!, OPTION_CLIP_PAGE); 48 | break 49 | } 50 | e.target?.removeEventListener("click", listener); 51 | } 52 | node.addEventListener("click", listener); 53 | }) 54 | } 55 | 56 | function makeSelection(id: number, opt: ClipOption) { 57 | browser.tabs.sendMessage(id, { 58 | messageType: MESSAGE_SELECT_OPTION, 59 | value: opt 60 | }) 61 | } 62 | 63 | async function openObsidian(clip: Clipping) { 64 | let options = await retrieveOptions(); 65 | let modifiedTitle = clip.title 66 | .split(":").join("") 67 | .split("/").join(""); 68 | let encodedTitle = encodeURI(modifiedTitle); 69 | let encodedVault = encodeURI(options.vaultName); 70 | let encodedContent = encodeURIComponent(clip.content); 71 | let uri = `obsidian://new?vault=${encodedVault}&name=${encodedTitle}&content=${encodedContent}`; 72 | browser.tabs.create({url: uri, active: true}).then((tab: browser.tabs.Tab) => { 73 | // TODO: maybe close tab?? 74 | console.log(tab) 75 | }); 76 | } 77 | 78 | function listenForMessages(obj: object, sender: browser.runtime.MessageSender): void { 79 | let message = obj as Message 80 | switch (message.messageType) { 81 | case MESSAGE_GET_OPTIONS: 82 | activateOptions((message as GetOptionsMessage).value) 83 | listenForClicks(sender); 84 | break 85 | case MESSAGE_SEND_CLIP: 86 | openObsidian((message as SendClipMessage).value); 87 | break; 88 | } 89 | } 90 | 91 | browser.runtime.onMessage.addListener(listenForMessages); 92 | browser.tabs.executeScript(undefined, {file: "/dist/content.js"}); 93 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import Mercury from '@postlight/mercury-parser'; 2 | import { 3 | ClipOption 4 | , Clipping 5 | , Message 6 | , MESSAGE_GET_OPTIONS 7 | , MESSAGE_SELECT_OPTION 8 | , MESSAGE_SEND_CLIP 9 | , OPTION_CLIP_LINK 10 | , OPTION_CLIP_PAGE 11 | , OPTION_CLIP_SELECTION 12 | , SelectOptionMessage 13 | } from './shared'; 14 | 15 | 16 | function getSelectionText(): string { 17 | return window.getSelection()!.toString(); 18 | } 19 | 20 | function getLinkContent(url: string): string { 21 | return `Link:\n${url}` 22 | } 23 | 24 | 25 | function getOptions(): ClipOption[] { 26 | switch (document.contentType) { 27 | case "application/pdf": 28 | case "image/jpeg": 29 | return [OPTION_CLIP_LINK]; 30 | case "text/html": 31 | var options: ClipOption[] = [OPTION_CLIP_LINK, OPTION_CLIP_PAGE]; 32 | if (getSelectionText() !== "") { 33 | options.push(OPTION_CLIP_SELECTION) 34 | } 35 | return options; 36 | default: 37 | return [] 38 | } 39 | } 40 | 41 | async function getClipping(option: ClipOption): Promise { 42 | switch (option) { 43 | case OPTION_CLIP_LINK: 44 | return { 45 | title: document.title, 46 | content: getLinkContent(document.URL) 47 | } 48 | case OPTION_CLIP_SELECTION: 49 | return { 50 | title: document.title, 51 | content: getSelectionText() 52 | } 53 | case OPTION_CLIP_PAGE: 54 | let result = await Mercury.parse(document.URL, {contentType: 'markdown'}) 55 | return { 56 | title: result.title!, 57 | content: result.content! 58 | } 59 | } 60 | } 61 | 62 | 63 | function messageListener(obj: object) { 64 | 65 | let message = obj as Message; 66 | switch (message.messageType) { 67 | case MESSAGE_SELECT_OPTION: 68 | return getClipping((message as SelectOptionMessage).value).then(sendClip); 69 | default: 70 | return null 71 | } 72 | } 73 | 74 | async function sendClip(clip: Clipping) { 75 | return browser.runtime.sendMessage({ 76 | messageType: MESSAGE_SEND_CLIP, 77 | value: clip 78 | }).then((_) => { 79 | browser.runtime.onMessage.removeListener(messageListener) 80 | }); 81 | } 82 | 83 | browser.runtime.onMessage.addListener(messageListener) 84 | 85 | browser.runtime.sendMessage({ 86 | messageType: MESSAGE_GET_OPTIONS, 87 | value: getOptions() 88 | }); 89 | -------------------------------------------------------------------------------- /src/notes: -------------------------------------------------------------------------------- 1 | Content scripts can only access a small subset of the WebExtension APIs, but they can communicate with background scripts using a messaging system, and thereby indirectly access the WebExtension APIs. 2 | 3 | 4 | https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts#communicating_with_background_scripts 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import {Options, retrieveOptions} from "./shared"; 2 | 3 | 4 | 5 | 6 | function setFields(options: Options): void { 7 | for (const [key, value] of Object.entries(options)) { 8 | switch (key) { 9 | case "vaultName": 10 | let element = document.querySelector("input#vault-name")! as HTMLInputElement; 11 | element.value = value; 12 | } 13 | } 14 | } 15 | 16 | retrieveOptions().then(setFields); 17 | 18 | document.querySelectorAll("input").forEach((node) => { 19 | node.addEventListener("input", (_: Event) => { 20 | switch (node.id) { 21 | case "vault-name": 22 | browser.storage.local.set({ 23 | 'vaultName': node.value 24 | }); 25 | break; 26 | } 27 | }) 28 | }); 29 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | const OPTION_CLIP_LINK = 'OPTION_CLIP_LINK'; 4 | const OPTION_CLIP_SELECTION = 'OPTION_CLIP_SELECTION'; 5 | const OPTION_CLIP_PAGE = 'OPTION_CLIP_PAGE'; 6 | 7 | type ClipOption 8 | = typeof OPTION_CLIP_LINK 9 | | typeof OPTION_CLIP_SELECTION 10 | | typeof OPTION_CLIP_PAGE 11 | 12 | 13 | interface Clipping { 14 | title: string; 15 | content: string; 16 | } 17 | 18 | const MESSAGE_GET_OPTIONS = 'MESSAGE_GET_OPTIONS'; 19 | const MESSAGE_SELECT_OPTION = 'MESSAGE_SELECT_OPTION'; 20 | const MESSAGE_SEND_CLIP = 'MESSAGE_SEND_CLIP'; 21 | 22 | type MessageType 23 | = typeof MESSAGE_GET_OPTIONS 24 | | typeof MESSAGE_SELECT_OPTION 25 | | typeof MESSAGE_SEND_CLIP 26 | 27 | interface Message { 28 | messageType: MessageType 29 | } 30 | 31 | interface GetOptionsMessage extends Message{ 32 | messageType: typeof MESSAGE_GET_OPTIONS; 33 | value: ClipOption[] 34 | } 35 | 36 | interface SelectOptionMessage extends Message{ 37 | messageType: typeof MESSAGE_SELECT_OPTION; 38 | value: ClipOption 39 | } 40 | interface SendClipMessage extends Message{ 41 | messageType: typeof MESSAGE_SELECT_OPTION; 42 | value: Clipping 43 | } 44 | 45 | interface Options { 46 | vaultName: string; 47 | } 48 | 49 | const defaultOptions: Options = { 50 | "vaultName": "Web" 51 | } 52 | 53 | async function retrieveOptions(): Promise { 54 | return browser.storage.local.get(Object.keys(defaultOptions)).then((obj: browser.storage.StorageObject) => { 55 | return { 56 | ...defaultOptions, 57 | ...obj 58 | } 59 | }) 60 | } 61 | 62 | export { 63 | ClipOption 64 | , Message 65 | , MessageType 66 | , MESSAGE_SELECT_OPTION 67 | , MESSAGE_GET_OPTIONS 68 | , MESSAGE_SEND_CLIP 69 | , GetOptionsMessage 70 | , SelectOptionMessage 71 | , SendClipMessage 72 | , Clipping 73 | , OPTION_CLIP_PAGE 74 | , OPTION_CLIP_SELECTION 75 | , OPTION_CLIP_LINK 76 | , Options 77 | , defaultOptions 78 | , retrieveOptions 79 | } 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es2020", 5 | "strict": true, 6 | "typeRoots": ["node_modules/@types", "node_modules/web-ext-types"], 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | entry: { 4 | content: "./src/content.ts", 5 | action: "./src/action.ts", 6 | options: "./src/options.ts", 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: "[name].js", 11 | }, 12 | resolve: { 13 | extensions: [".tsx", ".ts", ".js", ".json"], 14 | }, 15 | module: { 16 | rules: [ 17 | // all files with a '.ts' or '.tsx' extension will be handled by 'ts-loader' 18 | { test: /\.tsx?$/, use: ["ts-loader"], exclude: /node_modules/ }, 19 | ], 20 | }, 21 | }; 22 | --------------------------------------------------------------------------------