├── .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 |
24 |
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 |
--------------------------------------------------------------------------------