├── .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 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/options/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
15 |
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 |
2 |
3 |
4 |
7 |
12 |
13 |
106 |
107 |
108 |
109 |
266 |
267 |
297 |
--------------------------------------------------------------------------------