1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-02-19 19:59:59 +02:00

[GH-438] Add basic web cliper browser extension (#1582)

This adds a rudimentary web clipper browser extension. It allows to save
page titles and URLs into cards. URLs will be written into the first
found card property of type 'url' (if any).

Relates to: #438
This commit is contained in:
Johannes Marbach 2021-11-09 01:07:08 +01:00 committed by GitHub
parent 2113cac6fd
commit 11c667b9bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 24125 additions and 0 deletions

2
experiments/webext/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.parcel-cache
web-ext-artifacts

View File

@ -0,0 +1,3 @@
{
"extends": "@parcel/config-webextension"
}

View File

@ -0,0 +1,65 @@
# Focalboard Web Clipper Browser Extension ✂️
This is the Focalboard Web Clipper browser extension. It aims at supporting various use cases around converting web content from your browser directly into Focalboard cards.
⚠️ **Warning:** The extension is currently in an early and experimental state. Use it at your own risk only. Don't expect any eye candy.
## Status
The extension currently is in a proof-of-concept state with minimal functionality. The only supported use case at the time is building a read-later list. Things that work:
- Logging in to the Focalboard server from the extension settings
- Selecting a board to capture cards into from the extension settings
- Saving websites (title & URL) into cards from a page action (like e.g. Pocket does it)
Only Firefox was tested so far but polyfills have already been enabled so there's a good chance that it'll work in Chrome and maybe even Safari, too.
### Next Steps
We're really at the very beginning here so there's a lot to be done. Notable tasks include:
- Improve the React code by extracting components
- Style the options and popup pages to mimic the look and feel of Focalboard
- Replace the logo with something better (the current one was snatched from the Focalboard Windows app)
- Link to the extension's options page from page action error messages
- Clip parts of a website into image attachments on cards
- Extract website content in reader mode into card descriptions
- Optimise the logic for finding the first URL property (currently the whole board subtree has to be requested because there is no other API available)
- Add some tests
- Test the extension on Chrome / Safari and add infrastructure to facilitate this in future (e.g. `.web-ext-config.js`)
- Add an onboarding (displayed after first install) and upboarding (displayed after update) page
- Distribute the extension via the various browser add-on stores (ok, maybe too early 😜)
## Hacking
First, install dependencies with
```
$ npm i
```
You can then compile and bundle the code with
```
$ npm run watchdev
```
This will write output into `dist/dev/` and automatically recompile and bundle on any source change.
To run the extension in a separate Firefox instance, use
```
$ npm run servedev
```
Note that in the above commands you can substitue `dev` with `prod` to build and run the extension with production settings.
## Distribution
To build a distributable ZIP archive, run
```
$ npm run build
```
The archive will be placed into the `web-ext-artifacts` folder.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -0,0 +1,34 @@
{
"manifest_version": 2,
"name": "Focalboard Web Clipper",
"version": "0.1.0",
"description": "Save websites directly into Focalboard",
"icons": {
"48": "icons/48.png",
"96": "icons/96.png"
},
"page_action": {
"browser_style": true,
"default_icon": {
"19": "icons/19.png",
"38": "icons/38.png"
},
"default_title": "Save to Focalboard",
"default_popup": "src/views/popup.html",
"show_matches": ["<all_urls>"]
},
"options_ui": {
"page": "src/views/options.html",
"browser_style": true
},
"web_accessible_resources": [],
"permissions": [
"<all_urls>",
"storage"
],
"browser_specific_settings": {
"gecko": {
"id": "focalboard-web-clipper@mattermost.com"
}
}
}

23525
experiments/webext/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
{
"name": "focalboard-web-clipper",
"version": "0.0.0",
"targets": {
"dev": {
"sourceMap": {
"inline": true,
"inlineSources": true
}
},
"prod": {}
},
"scripts": {
"watchdev": "parcel watch manifest.json --target dev",
"servedev": "web-ext run -s dist/dev/",
"watchprod": "parcel watch manifest.json --target prod",
"serveprod": "web-ext run -s dist/prod/",
"build": "parcel build manifest.json --target prod && web-ext build -s dist/prod/"
},
"devDependencies": {
"@parcel/config-webextension": "^2.0.0",
"@parcel/transformer-sass": "^2.0.0",
"@types/react": "^17.0.32",
"@types/react-dom": "^17.0.10",
"parcel": "^2.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.4.4",
"web-ext": "^6.4.0",
"webextension-polyfill-ts": "^0.26.0"
}
}

View File

@ -0,0 +1,9 @@
interface BoardFields {
isTemplate: boolean
}
export default interface Board {
id: string
title: string
fields: BoardFields
}

View File

@ -0,0 +1,132 @@
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Board from "../utils/Board"
declare global {
interface Window {
msCrypto: Crypto
}
}
async function request(method: string, host: string, resource: string, body: any, token: string | null) {
const response = await fetch(`${host}/api/v1/${resource}`, {
'credentials': 'include',
'headers': {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Authorization': token ? `Bearer ${token}` : null
} as HeadersInit,
'body': body ? JSON.stringify(body) : null,
'method': method
})
const json = await response.json()
if (json.error) {
throw json.error
}
return json
}
export async function logIn(host: string, username: string, password: string) {
const json = await request('POST', host, 'login', { username: username, password: password, type: 'normal' }, null)
return json.token
}
export async function getBoards(host: string, token: string) {
const json = await request('GET', host, 'workspaces/0/blocks?type=board', null, token) as Board[]
return json.filter(board => !board.fields.isTemplate)
}
export async function findUrlPropertyId(host: string, token: string, boardId: string) {
const json = await request('GET', host, `workspaces/0/blocks/${boardId}/subtree`, null, token)
for (let obj of json) {
if (obj.type === 'board') {
for (let property of obj.fields.cardProperties) {
if (property.type === 'url') {
return property.id
}
}
break // Only one board in subtree, no need to continue
}
}
return null
}
export async function createCard(host: string, token: string, boardId: string, urlPropertyId: string, title: string, url: string) {
let properties = {} as any
if (urlPropertyId) {
properties[urlPropertyId] = url
}
const card = {
id: createGuid(),
schema: 1,
workspaceId: '',
parentId: boardId,
rootId: boardId,
createdBy: '',
modifiedBy: '',
type: 'card',
fields: {
icon: null,
properties: properties,
contentOrder: [],
isTemplate: false
},
title: title,
createAt: Date.now(),
updateAt: Date.now(),
deleteAt: 0
}
await request('POST', host, 'workspaces/0/blocks', [card], token)
}
function createGuid(): string {
const data = randomArray(16)
return '7' + base32encode(data, false)
}
function randomArray(size: number): Uint8Array {
const crypto = window.crypto || window.msCrypto
const rands = new Uint8Array(size)
if (crypto && crypto.getRandomValues) {
crypto.getRandomValues(rands)
} else {
for (let i = 0; i < size; i++) {
rands[i] = Math.floor((Math.random() * 255))
}
}
return rands
}
const base32Alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'
function base32encode(data: Int8Array | Uint8Array | Uint8ClampedArray, pad: boolean): string {
const dview = new DataView(data.buffer, data.byteOffset, data.byteLength)
let bits = 0
let value = 0
let output = ''
// adapted from https://github.com/LinusU/base32-encode
for (let i = 0; i < dview.byteLength; i++) {
value = (value << 8) | dview.getUint8(i)
bits += 8
while (bits >= 5) {
output += base32Alphabet[(value >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) {
output += base32Alphabet[(value << (5 - bits)) & 31]
}
if (pad) {
while ((output.length % 8) !== 0) {
output += '='
}
}
return output
}

View File

@ -0,0 +1,29 @@
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import browser from 'webextension-polyfill'
interface Settings {
host: string | null
username: string | null
token: string | null
boardId: string | null
}
export function loadSettings(): Settings {
return browser.storage.sync.get(['host', 'username', 'token', 'boardId'])
}
export function storeSettings(host: string, username: string, token: string | null, boardId: string | null) {
console.log(`storing host ${host}`)
return browser.storage.sync.set({ host: host, username: username, token: token, boardId: boardId })
}
export function storeToken(value: string | null) {
return browser.storage.sync.set({ token: value })
}
export function storeBoardId(value: string | null) {
return browser.storage.sync.set({ boardId: value })
}

View File

@ -0,0 +1,40 @@
/* Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. */
/* See LICENSE.txt for license information. */
.OptionsApp {
label {
display: block;
font-size: 90%;
font-style: italic;
}
input[type=text], input[type=password], select {
width: 20em;
max-width: 100%;
margin-bottom: 1em;
}
input[type=submit] {
display: block;
}
.status {
margin: 1em 0 1em 0;
.in-progress {
background-color: grey;
padding: 0.5em;
}
.success {
background-color: lightgreen;
padding: 0.5em;
}
.error {
background-color: lightpink;
padding: 0.5em;
}
}
}

View File

@ -0,0 +1,111 @@
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, { ChangeEvent, MouseEvent, useEffect, useState } from "react"
import Board from "../utils/Board"
import { getBoards, logIn } from "../utils/networking";
import { loadSettings, storeSettings, storeToken, storeBoardId } from "../utils/settings";
import "./OptionsApp.scss"
export default function OptionsApp() {
const [host, setHost] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [boards, setBoards] = useState([] as Board[])
const [boardId, setBoardId] = useState(null as string | null)
const [inProgress, setInProgress] = useState(false)
const [error, setError] = useState(null as string | null)
useEffect(() => {
async function initialiseBoards() {
const settings = await loadSettings()
if (settings.host) {
setHost(settings.host)
}
if (settings.username) {
setUsername(settings.username)
}
if (settings.token) {
setToken(settings.token)
}
if (settings.boardId) {
setBoardId(settings.boardId)
}
if (!settings.host || !settings.username || !settings.token) {
setError('Unauthenticated')
return
}
setInProgress(true)
try {
setBoards(await getBoards(settings.host, settings.token))
} catch (error) {
setError(`${error}`)
} finally {
setInProgress(false)
}
}
initialiseBoards();
}, [])
function onAuthenticateButtonClicked(event: MouseEvent) {
authenticate(host, username, password)
event.preventDefault()
event.stopPropagation()
}
async function authenticate(host: string, username: string, password: string) {
storeSettings(host, username, null, null)
setBoards([])
setBoardId(null)
setInProgress(true)
setError(null)
try {
const token = await logIn(host, username, password)
storeToken(token)
setToken(token)
setBoards(await getBoards(host, token))
const select = document.querySelector('select') as any
select.value = null
} catch (error) {
setError(`${error}`)
} finally {
setInProgress(false)
}
}
function onBoardSelectionChanged(event: ChangeEvent) {
const id = (event.target as HTMLSelectElement).value
storeBoardId(id)
setBoardId(id)
event.preventDefault()
event.stopPropagation()
}
return <div className="OptionsApp">
<label>Focalboard host</label>
<input type="text" value={host} onChange={e => setHost(e.target.value)}/>
<label>Username</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)}/>
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)}/>
<input type="submit" value="Authenticate" onClick={onAuthenticateButtonClicked}/>
<div className="status">
{inProgress && <div className="in-progress">
Connecting to Focalboard server...
</div>}
{!inProgress && !error && <div className="success">
Token: <span>{token}</span>
</div>}
{!inProgress && error && <div className="error">{error}</div>}
</div>
<br/>
<br/>
<label>Board</label>
<select onChange={onBoardSelectionChanged}>
{boards.map(board => <option value={board.id} selected={board.id === boardId}>{board.title}</option>)}
</select>
</div>
}

View File

@ -0,0 +1,21 @@
/* Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. */
/* See LICENSE.txt for license information. */
.PopupApp {
.status {
.in-progress {
background-color: grey;
padding: 1em;
}
.success {
background-color: lightgreen;
padding: 1em;
}
.error {
background-color: lightpink;
padding: 1em;
}
}
}

View File

@ -0,0 +1,55 @@
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, { useEffect, useState } from "react"
import browser from 'webextension-polyfill'
import { createCard, findUrlPropertyId } from "../utils/networking";
import { loadSettings } from "../utils/settings";
import "./PopupApp.scss"
export default function OptionsApp() {
const [board, setBoard] = useState('')
const [inProgress, setInProgress] = useState(false)
const [error, setError] = useState(null as string | null)
useEffect(() => {
async function createCardFromCurrentTab() {
const settings = await loadSettings()
if (!settings.host || !settings.token) {
setError('Looks like you\'re unauthenticated. Please configure the extension\'s settings first.')
return
}
if (!settings.boardId) {
setError('Looks like you haven\'t selected a board to save to yet. Please configure the extension\'s settings first.')
return
}
setInProgress(true)
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true })
const urlPropertyId = await findUrlPropertyId(settings.host as string, settings.token as string, settings.boardId as string)
await createCard(settings.host as string, settings.token as string, settings.boardId as string, urlPropertyId, tabs[0].title, tabs[0].url)
setBoard(`${settings.host}/${settings.boardId}`)
} catch (error) {
setError(`${error}`)
} finally {
setInProgress(false)
}
}
createCardFromCurrentTab();
}, [])
return <div className="PopupApp">
<div className="status">
{inProgress && <div className="in-progress">
Saving to Focalboard...
</div>}
{!inProgress && !error && <div className="success">
Saved to <a href={board} target="_blank">board</a>
</div>}
{!inProgress && error && <div className="error">{error}</div>}
</div>
</div>
}

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
</head>
<body>
<div id="app"></div>
<script type="module" src="options.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from "react"
import ReactDOM from "react-dom"
import OptionsApp from "./OptionsApp"
const app = document.getElementById("app")
ReactDOM.render(<OptionsApp/>, app)

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
</head>
<body>
<div id="app"></div>
<script type="module" src="popup.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from "react"
import ReactDOM from "react-dom"
import PopupApp from "./PopupApp"
const app = document.getElementById("app")
ReactDOM.render(<PopupApp/>, app)

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"jsx": "react",
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"allowJs": true,
"resolveJsonModule": true,
"incremental": false,
"outDir": "./dist",
"moduleResolution": "node"
},
"include": [
"."
],
"exclude": [
".git",
"**/node_modules/*",
"dist",
"pack"
]
}