Initial commit

This commit is contained in:
TJ Horner
2021-06-30 00:32:00 -04:00
commit 5421aebc02
25 changed files with 4629 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
node_modules

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 TJ Horner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# 🗃 ArchiveBox Exporter Browser Extension
This is a browser extension (works in Chrome, Firefox, and Chrome-like browsers) that lets you automatically send pages from domains you specify to your ArchiveBox instance. This has a couple of benefits:
- You have a fulltext search of your browsing history ready at your fingertips
- Prevent link rot for important information!
- Access important information even if you're offline
**Warning:** This extension is not yet complete! I am waiting for ArchiveBox to implement an API that can accept URLs to archive. But at the moment, everything else is ready! You can check the status in [this GitHub issue](https://github.com/ArchiveBox/ArchiveBox/issues/577#issuecomment-870974915).
## Features
- Different archive modes
- Allowlist mode doesn't archive pages by default, and lets you specify domains or regexes to archive
- Blocklist mode archives all visited pages by default, but lets you specify domains or regexes to not archive
- Archive any arbitrary page with the "Archive Current Page" context menu item
- Archive any link with the "Archive Link" context menu item
## License
MIT

3923
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "archivebox-exporter",
"version": "1.0.0",
"description": "Automatically or manually send pages to your ArchiveBox for archival.",
"main": "index.js",
"scripts": {
"build": "webpack",
"dev": "NODE_ENV=development webpack --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@tsconfig/svelte": "^2.0.1",
"@types/chrome": "^0.0.145",
"@types/uuid": "^8.3.0",
"clean-webpack-plugin": "^4.0.0-alpha.0",
"copy-webpack-plugin": "^9.0.1",
"svelte": "^3.38.3",
"svelte-loader": "^3.1.2",
"ts-loader": "^9.2.3",
"typescript": "^4.3.4",
"webpack": "^5.41.1",
"webpack-cli": "^4.7.2"
},
"dependencies": {
"uuid": "^8.3.2",
"wildcard-domain-matcher": "^1.0.0"
}
}

34
src/action/App.svelte Normal file
View File

@@ -0,0 +1,34 @@
<script>
import DomainLists from "./components/DomainLists.svelte"
import Config from "./components/Config.svelte"
let archiveMode
function onArchiveModeChanged(newMode) {
archiveMode = newMode
}
</script>
<div class="main">
<Config {onArchiveModeChanged} />
<hr>
<DomainLists {archiveMode} />
</div>
<style>
* {
box-sizing: border-box;
}
.main {
width: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
color: #2d2d2d;
}
hr {
margin: 10px 0;
border: none;
border-top: 1px solid lightgray;
}
</style>

View File

@@ -0,0 +1,51 @@
<script>
export let onArchiveModeChanged
import { GlobalConfigKey } from "../../common/interfaces/config"
import SyncedConfig from "../../common/services/config"
import ChromeSyncStorage from "../../common/services/storage"
const config = new SyncedConfig(new ChromeSyncStorage())
let selectedArchiveMode
async function changeArchiveMode() {
await config.set(GlobalConfigKey.ArchiveMode, selectedArchiveMode)
onArchiveModeChanged(selectedArchiveMode)
}
async function load() {
selectedArchiveMode = await config.get(GlobalConfigKey.ArchiveMode, "allowlist")
onArchiveModeChanged(selectedArchiveMode)
}
load()
</script>
<div class="config">
<h2>Config</h2>
<div>
<label for={GlobalConfigKey.ArchiveMode}>Archive Mode</label><br>
<!-- svelte-ignore a11y-no-onchange -->
<select id={GlobalConfigKey.ArchiveMode} bind:value={selectedArchiveMode} on:change={changeArchiveMode}>
<option value="allowlist">Allowlist (don't archive by default)</option>
<option value="blocklist">Blocklist (archive by default)</option>
</select>
</div>
</div>
<style>
h2 {
margin-top: 0;
margin-bottom: 10px;
}
label {
font-weight: bold;
}
select {
margin-top: 3px;
}
</style>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
export let options = [ ]
export let onAdded
export let onRemoved
export let onCleared
export let id
let isRegex
let domainInput = ""
let selectedEntries
function addDomain() {
if (domainInput.trim() === "") return
onAdded(domainInput, isRegex)
domainInput = ""
}
function removeDomain() {
onRemoved(selectedEntries)
}
function handleKeydown(e) {
if (e.code === "Enter") addDomain()
}
</script>
<div class="list">
<div class="add-remove">
<input type="checkbox" id="isRegex_{id}" bind:checked={isRegex}> <label for="isRegex_{id}">Regex?</label>
<input
type="text"
class="domain-input"
placeholder="{isRegex ? "Regex" : "Domain"} to add"
bind:value={domainInput}
on:keydown={handleKeydown}>
<button on:click={addDomain}>+</button>
<button on:click={removeDomain}>-</button>
<button on:click={onCleared}>Clear</button>
</div>
<select multiple bind:value={selectedEntries}>
{#each options as { id, value, type } }
<option value="{id}">{type === "regex" ? "[regex] " : ""}{value}</option>
{/each}
</select>
</div>
<style>
select {
width: 100%;
min-height: 150px;
}
.add-remove {
margin-bottom: 5px;
display: flex;
align-items: center;
}
.domain-input {
flex-grow: 1;
margin: 0 5px;
}
button:not(:last-of-type) {
margin-right: 5px;
}
label {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
export let archiveMode
import { v4 } from "uuid"
import DomainList from "./DomainList.svelte"
import DomainListStorage from "../../common/services/domainList"
import { ListType } from "../../common/interfaces/domainList"
import ChromeSyncStorage from "../../common/services/storage"
const storage = new DomainListStorage(new ChromeSyncStorage())
let allowlist = [ ]
let blocklist = [ ]
async function load() {
allowlist = await storage.getList(ListType.Allowlist)
blocklist = await storage.getList(ListType.Blocklist)
}
function onAdded(listType) {
return async function(domain, isRegex) {
await storage.addEntry({
id: v4(),
type: isRegex? "regex" : "domain",
value: domain
}, listType)
await load()
}
}
function onRemoved(listType) {
return async function(ids) {
for (let i = 0; i < ids.length; i++) {
await storage.removeEntry(ids[i], listType)
}
await load()
}
}
function onCleared(listType) {
return async function() {
await storage.clearEntries(listType)
await load()
}
}
load()
</script>
<div class="lists">
<div class="list" class:hidden="{archiveMode !== 'allowlist'}">
<h2>Archived Domains</h2>
<p>
Pages you visit on these domains will be sent to your ArchiveBox.
Wildcard subdomains are allowed, e.g. <code>*.google.com</code>
</p>
<DomainList
id="allowlist"
options={allowlist}
onAdded={onAdded(ListType.Allowlist)}
onRemoved={onRemoved(ListType.Allowlist)}
onCleared={onCleared(ListType.Allowlist)} />
</div>
<div class="list" class:hidden="{archiveMode !== 'blocklist'}">
<h2>Ignored Domains</h2>
<p>
Every page you visit <strong>except on these domains</strong> will be sent to your ArchiveBox.
Wildcard subdomains are allowed, e.g. <code>*.google.com</code>
</p>
<DomainList
id="blocklist"
options={blocklist}
onAdded={onAdded(ListType.Blocklist)}
onRemoved={onRemoved(ListType.Blocklist)}
onCleared={onCleared(ListType.Blocklist)} />
</div>
</div>
<style>
h2 {
margin: 0;
}
p {
margin-top: 5px;
}
.hidden {
display: none;
}
</style>

7
src/action/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import App from "./App.svelte"
const app = new App({
target: document.body
})
export default app

51
src/background/index.ts Normal file
View File

@@ -0,0 +1,51 @@
import ArchiveBoxArchiver from "../common/services/archiver"
import SyncedConfig from "../common/services/config"
import ChromeSyncStorage from "../common/services/storage"
import DomainList from "../common/services/domainList"
async function main() {
const storage = new ChromeSyncStorage()
const config = new SyncedConfig(storage)
const domainList = new DomainList(storage)
const archiver = new ArchiveBoxArchiver(domainList, config)
chrome.history.onVisited.addListener(async historyItem => {
const shouldArchive = await archiver.shouldArchive(historyItem.url)
if (!shouldArchive) return
await archiver.queueForArchival(historyItem.url)
})
await chrome.alarms.clearAll()
chrome.alarms.create({
periodInMinutes: 15
})
chrome.alarms.onAlarm.addListener(async () => {
await archiver.submitQueue()
})
chrome.contextMenus.create({
id: "archivePage",
title: "Archive Current Page",
contexts: [ "all" ],
onclick: async (info) => {
await archiver.archiveImmediately(info.pageUrl)
}
})
chrome.contextMenus.create({
id: "archiveLink",
title: "Archive Link",
contexts: [ "link" ],
onclick: async (info) => {
await archiver.archiveImmediately(info.linkUrl)
}
})
}
main()
export default main

View File

@@ -0,0 +1,6 @@
export default interface IArchiver {
shouldArchive(url: string): Promise<boolean>
queueForArchival(url: string): Promise<void>
submitQueue(): Promise<void>
archiveImmediately(url: string): Promise<void>
}

View File

@@ -0,0 +1,8 @@
export enum GlobalConfigKey {
ArchiveMode = "archiveMode"
}
export default interface IConfig {
get<T>(key: string, defaultValue: T): Promise<T>
set<T>(key: string, value: T): Promise<void>
}

View File

@@ -0,0 +1,18 @@
export interface DomainEntry {
id: string
type: "domain" | "regex"
value: string
}
export enum ListType {
Allowlist = "allowlist",
Blocklist = "blocklist"
}
export default interface IDomainList {
urlMatchesList(url: string, list: ListType): Promise<boolean>
getList(list: ListType): Promise<DomainEntry[]>
addEntry(entry: DomainEntry, list: ListType): Promise<void>
removeEntry(entryId: string, list: ListType): Promise<void>
clearEntries(list: ListType): Promise<void>
}

View File

@@ -0,0 +1,5 @@
export default interface IStorage {
get<T>(key: string, defaultValue: T): Promise<T>
set<T>(key: string, value: T): Promise<void>
remove(key: string): Promise<void>
}

View File

@@ -0,0 +1,45 @@
import IArchiver from "../interfaces/archiver"
import IDomainList, { ListType } from "../interfaces/domainList"
import IConfig, { GlobalConfigKey } from "../interfaces/config"
export enum ConfigKey {
ArchiveBoxUrl = "archiveBoxUrl",
ArchiveBoxKey = "archiveBoxKey"
}
export default class ArchiveBoxArchiver implements IArchiver {
private domainList: IDomainList
private config: IConfig
private urlQueue: string[] = [ ]
constructor(domainList: IDomainList, config: IConfig) {
this.domainList = domainList
this.config = config
}
async shouldArchive(url: string): Promise<boolean> {
const mode = await this.config.get(GlobalConfigKey.ArchiveMode, "allowlist")
if (mode === "allowlist")
return await this.domainList.urlMatchesList(url, ListType.Allowlist)
else if (mode === "blocklist")
return !(await this.domainList.urlMatchesList(url, ListType.Blocklist))
}
async queueForArchival(url: string): Promise<void> {
if (this.urlQueue.indexOf(url) !== -1) return
this.urlQueue.push(url)
console.log(url)
}
async submitQueue(): Promise<void> {
console.warn("Queue submittal not actually implemented yet!")
this.urlQueue = [ ]
}
async archiveImmediately(url: string): Promise<void> {
console.log("Archiving url immediately:", url)
}
}

View File

@@ -0,0 +1,22 @@
import IConfig from "../interfaces/config"
import IStorage from "../interfaces/storage"
export default class SyncedConfig implements IConfig {
private storage: IStorage
constructor(storage: IStorage) {
this.storage = storage
}
get<T>(key: string, defaultValue: T): Promise<T> {
return this.storage.get<T>(this.keyFor(key), defaultValue)
}
set<T>(key: string, value: T): Promise<void> {
return this.storage.set(this.keyFor(key), value)
}
private keyFor(key: string): string {
return `config_${key}`
}
}

View File

@@ -0,0 +1,59 @@
import IDomainList, { DomainEntry, ListType } from "../interfaces/domainList"
import IStorage from "../interfaces/storage"
import Matcher from "wildcard-domain-matcher"
export default class DomainList implements IDomainList {
private storage: IStorage
private matcher: Matcher = new Matcher()
constructor(storage: IStorage) {
this.storage = storage
}
async urlMatchesList(url: string, list: ListType): Promise<boolean> {
const entries = await this.getList(list)
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
if (this.urlMatchesEntry(url, entry)) return true
}
return false
}
async getList(list: ListType): Promise<DomainEntry[]> {
return await this.storage.get<DomainEntry[]>(this.keyFor(list), [ ])
}
async addEntry(entry: DomainEntry, list: ListType): Promise<void> {
const oldList = await this.getList(list)
oldList.push(entry)
await this.storage.set(this.keyFor(list), oldList)
}
async removeEntry(entryId: string, list: ListType): Promise<void> {
const oldList = await this.getList(list)
const idx = oldList.findIndex(entry => entry.id === entryId)
if (idx === -1) return
oldList.splice(idx, 1)
await this.storage.set(this.keyFor(list), oldList)
}
async clearEntries(list: ListType): Promise<void> {
await this.storage.set(this.keyFor(list), [ ])
}
private urlMatchesEntry(url: string, entry: DomainEntry): boolean {
if (entry.type === "domain") {
const whatwgUrl = new URL(url)
return this.matcher.test(whatwgUrl.host.toLowerCase(), entry.value.toLowerCase())
} else {
const regex = new RegExp(entry.value, "i")
return regex.test(url)
}
}
private keyFor(list: ListType): string {
return `domainList_${list}`
}
}

View File

@@ -0,0 +1,32 @@
import IStorage from "../interfaces/storage"
export default class ChromeSyncStorage implements IStorage {
async get<T>(key: string, defaultValue: T): Promise<T> {
return new Promise((resolve, reject) => {
chrome.storage.sync.get(key, items => {
if (chrome.runtime.lastError) return reject(chrome.runtime.lastError)
resolve(items[key] || defaultValue)
})
})
}
async set<T>(key: string, value: T): Promise<void> {
return new Promise((resolve, reject) => {
chrome.storage.sync.set({
[key]: value
}, () => {
if (chrome.runtime.lastError) return reject(chrome.runtime.lastError)
resolve()
})
})
}
async remove(key: string): Promise<void> {
return new Promise((resolve, reject) => {
chrome.storage.sync.remove(key, () => {
if (chrome.runtime.lastError) return reject(chrome.runtime.lastError)
resolve()
})
})
}
}

33
src/manifest.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "ArchiveBox Exporter",
"description": "",
"version": "0.0.0",
"manifest_version": 2,
"browser_specific_settings": {
"gecko": {
"id": "archivebox@tjhorner.dev",
"strict_min_version": "42.0"
}
},
"icons": {
"128": "static/128.png"
},
"permissions": [
"history",
"contextMenus",
"storage",
"alarms"
],
"browser_action": {
"default_title": "ArchiveBox",
"default_popup": "static/action.html"
},
"background": {
"scripts": [ "background.js" ]
}
}

BIN
static/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

11
static/action.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script defer src="../action.js"></script>
<title>ArchiveBox Exporter</title>
</head>
<body></body>
</html>

BIN
static/archive.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"outDir": "./dist/",
"module": "esnext",
"allowJs": true,
"types": [ "svelte", "chrome" ],
"lib": [ "es6", "dom" ],
"esModuleInterop": true,
"importsNotUsedAsValues": "remove"
}
}

70
webpack.config.js Normal file
View File

@@ -0,0 +1,70 @@
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin')
const { CleanWebpackPlugin: CleanPlugin } = require('clean-webpack-plugin')
const package = require('./package.json')
module.exports = {
devtool: false,
mode: process.env.NODE_ENV || "production",
entry: {
background: "./src/background/index.ts",
action: "./src/action/index.ts"
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/
},
{
test: /\.(html|svelte)$/,
use: "svelte-loader"
},
{
test: /node_modules\/svelte\/.*\.mjs$/,
resolve: {
fullySpecified: false
}
}
]
},
optimization: {
usedExports: true
},
resolve: {
alias: {
svelte: path.resolve("node_modules", "svelte")
},
extensions: [ ".tsx", ".ts", ".js", ".mjs", ".svelte" ],
mainFields: [ "svelte", "browser", "module", "main" ]
},
plugins: [
new CleanPlugin({
cleanStaleWebpackAssets: false
}),
new CopyPlugin({
patterns: [
{
from: "./src/manifest.json",
to: "manifest.json",
transform(content) {
const manifest = JSON.parse(content.toString())
manifest.version = package.version
manifest.description = package.description
return JSON.stringify(manifest)
}
},
{
from: "./static/**",
to: "."
}
]
})
]
}