├── .nvmrc ├── .gitignore ├── src ├── icons │ ├── icon.png │ ├── icon@2x.png │ ├── pinboard-icon_128.png │ ├── pinboard-icon_32.png │ ├── pinboard-icon_64.png │ ├── pinboard.svg │ └── pinboard_inactive.svg ├── page-action_vue.js ├── converters │ └── postConverter.js ├── pageAction │ ├── pageAction-style.css │ └── index.html ├── options │ ├── script.js │ └── index.html ├── content_script.js ├── extension-page_script.js ├── manifest.json ├── services │ └── pinboardService.js ├── styles │ └── global.css ├── background_script.js └── components │ └── AddBookmark │ └── AddBookmark.vue ├── .prettierrc ├── jsconfig.json ├── README.md ├── package.json └── webpack.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | package-lock.json 3 | /dist/ 4 | .vscode 5 | /web-ext-artifacts/ -------------------------------------------------------------------------------- /src/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariofink/epic-pinboard/HEAD/src/icons/icon.png -------------------------------------------------------------------------------- /src/icons/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariofink/epic-pinboard/HEAD/src/icons/icon@2x.png -------------------------------------------------------------------------------- /src/icons/pinboard-icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariofink/epic-pinboard/HEAD/src/icons/pinboard-icon_128.png -------------------------------------------------------------------------------- /src/icons/pinboard-icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariofink/epic-pinboard/HEAD/src/icons/pinboard-icon_32.png -------------------------------------------------------------------------------- /src/icons/pinboard-icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mariofink/epic-pinboard/HEAD/src/icons/pinboard-icon_64.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/pinboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/pinboard_inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "checkJs": false, 5 | }, 6 | "include": [ 7 | "./*.js", 8 | "./src/**/*.js" 9 | ], 10 | "exclude": [ 11 | "node_modules", 12 | "web-ext-artifacts", 13 | "dist" 14 | ], 15 | "module": "es6" 16 | } -------------------------------------------------------------------------------- /src/page-action_vue.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import AddBookmark from "./components/AddBookmark/AddBookmark.vue"; 3 | 4 | console.log("ADD", AddBookmark); 5 | new Vue({ 6 | el: "#pinboardAddBookmark", 7 | render(h) { 8 | return h("AddBookmark"); 9 | }, 10 | components: { AddBookmark }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/converters/postConverter.js: -------------------------------------------------------------------------------- 1 | export default function convertPost(post) { 2 | return { 3 | description: post.attributes.description, 4 | extended: post.attributes.extended, 5 | hash: post.attributes.hash, 6 | href: post.attributes.href, 7 | tag: post.attributes.tag, 8 | time: post.attributes.time, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/pageAction/pageAction-style.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | overflow-x: hidden; 4 | } 5 | 6 | form label { 7 | display: block; 8 | } 9 | 10 | form label.inline { 11 | display: inline-block; 12 | vertical-align: middle; 13 | } 14 | 15 | form input[type="checkbox"] { 16 | vertical-align: middle; 17 | } 18 | 19 | form input[type="text"], 20 | form textarea { 21 | width: 100%; 22 | } 23 | -------------------------------------------------------------------------------- /src/options/script.js: -------------------------------------------------------------------------------- 1 | const apiTokenInput = document.querySelector("#apitoken"); 2 | 3 | function saveOptions(e) { 4 | e.preventDefault(); 5 | browser.storage.sync.set({ 6 | apitoken: apiTokenInput.value, 7 | }); 8 | } 9 | 10 | function restoreOptions() { 11 | browser.storage.sync.get("apitoken").then((res) => { 12 | apiTokenInput.value = res.apitoken || ""; 13 | }); 14 | } 15 | 16 | document.addEventListener("DOMContentLoaded", restoreOptions); 17 | document.querySelector("form").addEventListener("submit", saveOptions); 18 | -------------------------------------------------------------------------------- /src/content_script.js: -------------------------------------------------------------------------------- 1 | browser.runtime.onMessage.addListener((request) => { 2 | if (request.action === "GET_DESCRIPTION") { 3 | let description = window.getSelection().toString(); 4 | if (description.length < 1) { 5 | // if nothing is selected, try to get description from meta tag 6 | const metaDescription = document.querySelector( 7 | "meta[name='description']" 8 | ); 9 | if (metaDescription) { 10 | description = metaDescription.getAttribute("content"); 11 | } 12 | } 13 | return Promise.resolve(description); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📌 Epic Pinboard 2 | 3 | A Pinboard extension with a clean user interface. 4 | 5 | ## Install dependencies 6 | 7 | npm install 8 | 9 | ## Development 10 | 11 | npm run dev 12 | 13 | This runs and watches the Webpack build and starts web-ext run to open a Firefox instance that has the extension installed. When you make changes, the extension will be updated automatically. 14 | 15 | ## Building the extension 16 | 17 | npm run build-extension 18 | 19 | This bumps the patch version, builds the Webpack bundles and then runs the web-ext build – resulting in a newly built extension zip ready for upload inside the web-ext-artifacts folder. 20 | -------------------------------------------------------------------------------- /src/pageAction/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | Pinboard icon 17 |
18 |
Add to Pinboard
19 |
20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |

10 | Please provide your 11 | Pinboard API token 12 | below: 13 |

14 |
15 |
16 |
17 |
18 | 19 | 26 |
27 | 30 |
31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/extension-page_script.js: -------------------------------------------------------------------------------- 1 | const background = browser.extension.getBackgroundPage(); 2 | 3 | const status = document.getElementById("status"); 4 | const bookmarkContainer = document.getElementById("bookmarks"); 5 | 6 | const loadBookmarksButton = document.getElementById("loadBookmarks"); 7 | loadBookmarksButton.addEventListener("click", (e) => { 8 | background.loadBookmarks().then((posts) => { 9 | const markup = ` 10 | 18 | `; 19 | bookmarkContainer.innerHTML = markup; 20 | posts.map((post) => { 21 | return post; 22 | }); 23 | }); 24 | }); 25 | 26 | const loginButton = document.getElementById("login"); 27 | loginButton.addEventListener("click", (e) => { 28 | e.preventDefault(); 29 | background 30 | .login() 31 | .then((response) => { 32 | status.innerHTML = "Successfully logged in"; 33 | }) 34 | .catch((err) => { 35 | console.error(err); 36 | status.innerHTML = "Error during login"; 37 | }); 38 | status.innerHTML = "Submitted"; 39 | }); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epic-pinboard", 3 | "version": "1.0.13", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "build": "webpack", 8 | "watch": "webpack --watch", 9 | "prettier": "prettier --write ./src/**", 10 | "web-ext:run": "web-ext run --source-dir=dist --verbose", 11 | "web-ext:build": "web-ext build --source-dir=dist", 12 | "dev": "npm-run-all --parallel watch web-ext:run", 13 | "build-extension": "npm version patch && npm run build && npm run web-ext:build", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mariofink/epic-pinboard.git" 19 | }, 20 | "author": "Mario Fink", 21 | "bugs": { 22 | "url": "https://github.com/mariofink/epic-pinboard/issues" 23 | }, 24 | "homepage": "https://github.com/mariofink/epic-pinboard#readme", 25 | "devDependencies": { 26 | "copy-webpack-plugin": "^9.0.1", 27 | "css-loader": "^6.3.0", 28 | "mini-css-extract-plugin": "^2.3.0", 29 | "npm-run-all": "^4.1.5", 30 | "prettier": "2.4.1", 31 | "vue-loader": "^15.7.0", 32 | "vue-template-compiler": "^2.6.8", 33 | "web-ext": "^6.4.0", 34 | "webpack": "^5.56.1", 35 | "webpack-cli": "^4.8.0" 36 | }, 37 | "dependencies": { 38 | "@johmun/vue-tags-input": "^2.0.1", 39 | "urlcat": "^2.0.2", 40 | "vue": "^2.6.8", 41 | "xml-js": "^1.6.11" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Epic Pinboard", 4 | "description": "Browser extension for Pinboard.in", 5 | "version": "1.0.8", 6 | "applications": { 7 | "gecko": { 8 | "id": "epic-pinboard@mariofink.de", 9 | "strict_min_version": "59.0" 10 | } 11 | }, 12 | "icons": { 13 | "48": "icons/icon.png", 14 | "96": "icons/icon@2x.png" 15 | }, 16 | "background": { 17 | "scripts": ["background.bundle.js"] 18 | }, 19 | "content_scripts": [ 20 | { 21 | "matches": ["*://*/*"], 22 | "js": ["content.bundle.js"] 23 | } 24 | ], 25 | "page_action": { 26 | "show_matches": ["*://*/*"], 27 | "browser_style": true, 28 | "default_icon": { 29 | "19": "icons/pinboard_inactive.svg", 30 | "38": "icons/pinboard_inactive.svg" 31 | }, 32 | "default_popup": "pageAction/index.html", 33 | "default_title": "Bookmark on Pinboard" 34 | }, 35 | "options_ui": { 36 | "page": "options/index.html" 37 | }, 38 | "permissions": [ 39 | "*://api.pinboard.in/*", 40 | "activeTab", 41 | "tabs", 42 | "menus", 43 | "notifications", 44 | "storage" 45 | ], 46 | "commands": { 47 | "_execute_page_action": { 48 | "suggested_key": { 49 | "default": "Alt+P" 50 | }, 51 | "description": "Add bookmark to Pinboard" 52 | }, 53 | "submit_add_bookmark_form": { 54 | "suggested_key": { 55 | "default": "Alt+A" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/services/pinboardService.js: -------------------------------------------------------------------------------- 1 | import urlcat from "urlcat"; 2 | 3 | function doRequest(url) { 4 | return new Promise((resolve, reject) => { 5 | fetch(url) 6 | .then((response) => { 7 | resolve(response.json()); 8 | }) 9 | .catch((err) => { 10 | reject(err); 11 | }); 12 | }); 13 | } 14 | 15 | export default class PinboardService { 16 | constructor(baseApiUrl) { 17 | this.baseApiUrl = baseApiUrl; 18 | } 19 | login(token) { 20 | const url = urlcat(this.baseApiUrl, "/user/api_token", { 21 | auth_token: token, 22 | format: "json", 23 | }); 24 | return doRequest(url); 25 | } 26 | 27 | getSuggestedTagsForUrl(token, bookmarkUrl) { 28 | const url = urlcat(this.baseApiUrl, "/posts/suggest", { 29 | auth_token: token, 30 | url: bookmarkUrl, 31 | format: "json", 32 | }); 33 | return doRequest(url); 34 | } 35 | 36 | getBookmarksForUrl(token, bookmarkUrl) { 37 | const url = urlcat(this.baseApiUrl, "/posts/get", { 38 | auth_token: token, 39 | url: bookmarkUrl, 40 | format: "json", 41 | }); 42 | return doRequest(url); 43 | } 44 | 45 | getAllTags(token) { 46 | const url = urlcat(this.baseApiUrl, "/tags/get", { 47 | auth_token: token, 48 | format: "json", 49 | }); 50 | return doRequest(url); 51 | } 52 | 53 | addBookmark(token, bookmark) { 54 | const url = urlcat(this.baseApiUrl, "/posts/add", { 55 | auth_token: token, 56 | url: bookmark.url, 57 | description: bookmark.title, 58 | extended: bookmark.notes, 59 | tags: bookmark.tags, 60 | shared: bookmark.shared, 61 | toread: bookmark.toread, 62 | format: "json", 63 | }); 64 | return doRequest(url); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, 3 | Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 4 | } 5 | 6 | @-webkit-keyframes scale { 7 | 0% { 8 | -webkit-transform: scale(1); 9 | transform: scale(1); 10 | opacity: 1; 11 | } 12 | 45% { 13 | -webkit-transform: scale(0.1); 14 | transform: scale(0.1); 15 | opacity: 0.7; 16 | } 17 | 80% { 18 | -webkit-transform: scale(1); 19 | transform: scale(1); 20 | opacity: 1; 21 | } 22 | } 23 | @keyframes scale { 24 | 0% { 25 | -webkit-transform: scale(1); 26 | transform: scale(1); 27 | opacity: 1; 28 | } 29 | 45% { 30 | -webkit-transform: scale(0.1); 31 | transform: scale(0.1); 32 | opacity: 0.7; 33 | } 34 | 80% { 35 | -webkit-transform: scale(1); 36 | transform: scale(1); 37 | opacity: 1; 38 | } 39 | } 40 | 41 | /* from https://github.com/ConnorAtherton/loaders.css */ 42 | .ball-pulse { 43 | margin: 1rem auto; 44 | text-align: center; 45 | } 46 | 47 | .ball-pulse > div:nth-child(1) { 48 | -webkit-animation: scale 0.75s -0.24s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); 49 | animation: scale 0.75s -0.24s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); 50 | } 51 | 52 | .ball-pulse > div:nth-child(2) { 53 | -webkit-animation: scale 0.75s -0.12s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); 54 | animation: scale 0.75s -0.12s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); 55 | } 56 | 57 | .ball-pulse > div:nth-child(3) { 58 | -webkit-animation: scale 0.75s 0s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); 59 | animation: scale 0.75s 0s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); 60 | } 61 | 62 | .ball-pulse > div { 63 | background-color: hsla(210.3, 100%, 63.5%, 0.5); 64 | width: 15px; 65 | height: 15px; 66 | border-radius: 100%; 67 | margin: 2px; 68 | -webkit-animation-fill-mode: both; 69 | animation-fill-mode: both; 70 | display: inline-block; 71 | } 72 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const package = require("./package.json"); 2 | const path = require("path"); 3 | const CopyPlugin = require("copy-webpack-plugin"); 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 5 | const VueLoaderPlugin = require("vue-loader/lib/plugin"); 6 | 7 | module.exports = { 8 | mode: "development", 9 | devtool: "source-map", 10 | entry: { 11 | background: "./src/background_script.js", 12 | "page-action": "./src/page-action_vue.js", 13 | content: "./src/content_script.js", 14 | }, 15 | output: { 16 | filename: "[name].bundle.js", 17 | path: path.resolve(__dirname, "dist"), 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.vue$/, 23 | loader: "vue-loader", 24 | }, 25 | { 26 | test: /\.css$/, 27 | use: [ 28 | { 29 | loader: MiniCssExtractPlugin.loader, 30 | options: { 31 | // you can specify a publicPath here 32 | // by default it use publicPath in webpackOptions.output 33 | publicPath: "../", 34 | }, 35 | }, 36 | "css-loader", 37 | ], 38 | }, 39 | ], 40 | }, 41 | plugins: [ 42 | new VueLoaderPlugin(), 43 | new MiniCssExtractPlugin({ 44 | // Options similar to the same options in webpackOptions.output 45 | // both options are optional 46 | filename: "[name].css", 47 | chunkFilename: "[id].css", 48 | }), 49 | new CopyPlugin({ 50 | patterns: [ 51 | { from: "./src/pageAction", to: "pageAction" }, 52 | { from: "./src/icons", to: "icons" }, 53 | { from: "./src/components", to: "components" }, 54 | { from: "./src/options", to: "options" }, 55 | { from: "./src/styles", to: "styles" }, 56 | { 57 | from: "./src/manifest.json", 58 | to: "manifest.json", 59 | transform(buffer) { 60 | const manifest = JSON.parse(buffer.toString()); 61 | // sync the manifest version with the npm package.json version 62 | manifest.version = package.version; 63 | return JSON.stringify(manifest, null, 2); 64 | }, 65 | }, 66 | ], 67 | }), 68 | ], 69 | }; 70 | -------------------------------------------------------------------------------- /src/background_script.js: -------------------------------------------------------------------------------- 1 | import PinboardService from "./services/pinboardService"; 2 | const baseApiUrl = "https://api.pinboard.in/v1"; 3 | const svc = new PinboardService(baseApiUrl); 4 | 5 | function retrieveApiToken() { 6 | return new Promise((resolve, reject) => { 7 | browser.storage.sync.get("apitoken").then((res) => { 8 | resolve(res.apitoken || ""); 9 | }); 10 | }); 11 | } 12 | 13 | async function login() { 14 | const token = await retrieveApiToken(); 15 | return svc.login(token); 16 | } 17 | 18 | async function loadBookmarks() { 19 | const token = await retrieveApiToken(); 20 | return svc.loadRecent(token); 21 | } 22 | 23 | async function addBookmark(bookmark) { 24 | const token = await retrieveApiToken(); 25 | return svc.addBookmark(token, bookmark); 26 | } 27 | 28 | async function getBookmarksForUrl(bookmarkUrl) { 29 | const token = await retrieveApiToken(); 30 | return svc.getBookmarksForUrl(token, bookmarkUrl); 31 | } 32 | 33 | async function getSuggestedTagsForUrl(bookmarkUrl) { 34 | const token = await retrieveApiToken(); 35 | return svc.getSuggestedTagsForUrl(token, bookmarkUrl); 36 | } 37 | 38 | async function getAllTags() { 39 | const token = await retrieveApiToken(); 40 | const allTags = await svc.getAllTags(token); 41 | const tagArray = Object.keys(allTags).map((k) => { 42 | return { name: k, count: allTags[k] }; 43 | }); 44 | const tagsByCount = tagArray.sort((a, b) => { 45 | return b.count - a.count; 46 | }); 47 | return tagsByCount; 48 | } 49 | 50 | function setActiveIcon(params) { 51 | const path = params.active 52 | ? "icons/pinboard.svg" 53 | : "icons/pinboard_inactive.svg"; 54 | browser.pageAction.setIcon({ 55 | tabId: params.tabId, 56 | path, 57 | }); 58 | } 59 | 60 | browser.tabs.onUpdated.addListener((id, changeInfo, tab) => { 61 | if (changeInfo.url) { 62 | getBookmarksForUrl(tab.url) 63 | .then((bookmarks) => { 64 | if (bookmarks.posts.length > 0) { 65 | setActiveIcon({ active: true, tabId: tab.id }); 66 | } else { 67 | setActiveIcon({ active: false, tabId: tab.id }); 68 | } 69 | }) 70 | .finally((e) => { 71 | console.warn("Error while getting bookmarks", e); 72 | }); 73 | } 74 | }); 75 | 76 | const actions = { 77 | retrieveApiToken, 78 | loadBookmarks, 79 | login, 80 | addBookmark, 81 | getSuggestedTagsForUrl, 82 | getAllTags, 83 | getBookmarksForUrl, 84 | setActiveIcon, 85 | }; 86 | 87 | browser.runtime.onMessage.addListener((message, sender, response) => { 88 | const action = actions[message.action]; 89 | if (typeof action === "function") { 90 | return action(message.payload); 91 | } else { 92 | console.warn(`action ${message.action} not supported`); 93 | return; 94 | } 95 | }); 96 | -------------------------------------------------------------------------------- /src/components/AddBookmark/AddBookmark.vue: -------------------------------------------------------------------------------- 1 | 108 | 109 | 266 | 267 | 297 | --------------------------------------------------------------------------------