@@ -0,0 +1,3 @@ | |||
/dist | |||
/web-ext-artifacts | |||
/node_modules |
@@ -0,0 +1,77 @@ | |||
# Web Annotation Discovery WebExtension | |||
This browser extension implements the [Web Annotation Discovery][] mechanism, enabling you to discover annotations and subscribe to annotation ‘feeds’ while browsing the web; then view the collected annotations on other pages you visit. | |||
It also lets you create annotations yourself, and store/publish them on an annotation collection, so that other people view the notes you take on web pages. | |||
[Web Annotation Discovery]: https://code.treora.com/gerben/web-annotation-discovery | |||
## Status | |||
Minimum viable product, or primarily a proof-of-concept: This software demonstrates a mechanism for web annotation in practice, enabling people to try it out and hopefully move forward with the idea. | |||
The software can be used for collaborative annotation, but be prepared for possible technical glitches (including data loss or leakage), mediocre user experience, and awful interface design. | |||
## Try it out | |||
Install the extension in a recent Firefox(-based browser). It should work in Chromium(-based browsers) too, but is not as well-tested there. | |||
Note that you may need to jump some hoops to install an extension that has not been approved by the browser vendor itself. (An approved version may be available soon.) | |||
- Firefox (temporary): Go to `about:debugging`, choose ‘This Firefox’, and ‘Load Temporary Add-on…’. The extension will be removed again when you close the browser. | |||
- Firefox (permanently): You need a ‘developer’ or ‘nightly’ edition, set `xpinstall.signatures.required` to false in `about:config` and drag & drop the file onto the list at `about:addons`. | |||
- Chromium & co.: You need to enable ‘developer mode’ in `about:extensions`, and drag & drop the extension onto this page. It might be removed again when you close the browser. | |||
If one of those worked for you, fun can start. | |||
### Discovering & viewing annotations | |||
Visit a compatible annotation server. For now that means running a [Web Annotation Discovery Server][]. See its documentation for details. | |||
Then subscribe to a collection, visit one of the pages that any of its annotations target, and the yellow notes should show up on the right side. | |||
### Creating annotations | |||
If you have write access to an annotation collection, you can create and edit annotations in it. | |||
Open the extension’s popup and select that collection, then click “Connect” to try it out. If you had not authenticated to the server already, your browser may prompt you for your credentials. (In Chromium-based browsers, you may have to authenticate first on the website itself, as it blocks authentication to third parties.) | |||
Once you have connected to an annotation collection, you can create annotations by selecting text on any web page, ‘right’-clicking to get the context menu and choosing “Annotate Selection”. Write your notes and voilà, it should be stored/published on your annotation server. | |||
[Web Annotation Discovery Server]: https://code.treora.com/gerben/web-annotation-discovery-server | |||
## Develop | |||
Clone this repository, and install dependencies using `npm install` or equivalent. (Tested on Node 16 + npm 8.) | |||
You can build it with `npm run build`. The extension should appear as a zip file in a folder `web-ext-artifacts`. | |||
Running `npm run dev` should spin it up in a browser (choose which browser in `vite.config.ts`), with live reloading upon source file changes. | |||
### Code tour | |||
This browser extension is written in [TypeScript][], transpiled by [Vite][] (with [vite-webextension-plugin][]), using [Preact][] for the UI. It uses, and is co-developed with, [Apache Annotator][]. | |||
The extension’s local storage (abstracted by [Dexie][]) keeps a list of known annotations and of the annotation sources they come from. Each source that is an Annotation Container is considered a ‘feed’, and its annotations are downloaded again every couple of minutes (yes that could be made more efficient). | |||
For authentication to an annotation source (presumably required for creating annotations), this extension depends on the web browser’s capabilities; for simplicity (and because this would ideally be the browser’s job anyway), this extension has no clue about accounts, passwords, etc. This has been tested with HTTP Basic Auth, but possibly a server requiring e.g. client-side TLS certificates would work too. When the user “connects” to an annotation collection they want to store annotations in, the extension simply tests whether it can create a bogus annotation (which it directly deletes again). | |||
[TypeScript]: https://typescriptlang.org/ | |||
[Vite]: https://vitejs.dev/ | |||
[vite-webextension-plugin]: https://github.com/aklinker1/vite-plugin-web-extension/ | |||
[Preact]: https://preactjs.com/ | |||
[Dexie]: https://dexie.org/ | |||
[Apache Annotator]: https://annotator.apache.org/ | |||
## Licence | |||
This is free and unencumbered software released into the public domain. | |||
## Acknowledgements | |||
This project was [funded](https://nlnet.nl/project/WebAnnotation/) through the NGI0 Discovery Fund, a fund established by NLnet with financial support from the European Commission's Next Generation Internet programme. |
@@ -0,0 +1,38 @@ | |||
{ | |||
"name": "web-annotation-discovery-webextension", | |||
"version": "0.1.0", | |||
"scripts": { | |||
"build": "vite build && web-ext build -s dist --overwrite-dest", | |||
"dev": "vite dev" | |||
}, | |||
"author": "", | |||
"license": "Unlicense", | |||
"devDependencies": { | |||
"@types/deep-equal": "^1.0.1", | |||
"@types/firefox-webext-browser": "^94.0.1", | |||
"@types/lodash.debounce": "^4.0.7", | |||
"@types/uuid": "^8.3.4", | |||
"@types/webextension-polyfill": "^0.9.0", | |||
"@types/whatwg-mimetype": "^3.0.0", | |||
"typescript": "^4.7.4", | |||
"typescript-plugin-css-modules": "^3.4.0", | |||
"vite": "^2.9.14", | |||
"vite-plugin-web-extension": "^1.4.2", | |||
"web-ext": "^7.2.0" | |||
}, | |||
"dependencies": { | |||
"@apache-annotator/dom": "^0.3.0-dev.23", | |||
"@apache-annotator/selector": "^0.3.0-dev.23", | |||
"classnames": "^2.3.1", | |||
"deep-equal": "^2.0.5", | |||
"dexie": "^3.2.2", | |||
"lodash.debounce": "^4.0.8", | |||
"preact": "^10.10.0", | |||
"scroll-into-view-if-needed": "^2.2.29", | |||
"uuid": "^9.0.0", | |||
"web-annotation-utils": "git+https://code.treora.com/gerben/web-annotation-utils#latest", | |||
"webextension-polyfill": "^0.9.0", | |||
"webextension-rpc": "^0.3.0", | |||
"whatwg-mimetype": "^3.0.0" | |||
} | |||
} |
@@ -0,0 +1,14 @@ | |||
{ | |||
"appName": { | |||
"message": "Web Annotation Discovery", | |||
"description": "The name of the application" | |||
}, | |||
"appShortName": { | |||
"message": "WAD", | |||
"description": "The short_name (maximum of 12 characters recommended) is a short version of the app's name." | |||
}, | |||
"appDescription": { | |||
"message": "Browser extension to discover, display, and create Web Annotations", | |||
"description": "The description of the application" | |||
} | |||
} |
@@ -0,0 +1,41 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg | |||
width="10mm" | |||
height="10mm" | |||
viewBox="0 0 10 10" | |||
version="1.1" | |||
xmlns="http://www.w3.org/2000/svg" | |||
xmlns:svg="http://www.w3.org/2000/svg" | |||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | |||
xmlns:cc="http://creativecommons.org/ns#"> | |||
<rect | |||
style="display:inline;fill:#ffff00;stroke:#000000;stroke-width:1.39897" | |||
width="10.617116" | |||
height="8.6010437" | |||
x="2.9194891" | |||
y="0.69947922" | |||
ry="2.0371485" | |||
rx="2.0371485" | |||
clip-path="none" /> | |||
<path | |||
d="M 11.327955,8.1663486 H 10.41402 q -0.153787,0 -0.250453,-0.074451 -0.09667,-0.07883 -0.145,-0.1926961 L 9.5440248,6.6072619 H 6.9120694 L 6.4375265,7.8992017 q -0.035151,0.1007275 -0.1362114,0.1839371 -0.10106,0.08321 -0.2504531,0.08321 H 5.1281396 L 7.6282776,1.8336543 H 8.8322105 Z M 7.2152495,5.7751652 H 9.2408446 L 8.4675154,3.6686466 Q 8.4103946,3.528504 8.3488798,3.3401874 8.287365,3.1474912 8.2258501,2.9241389 8.1643353,3.1474912 8.1028205,3.3401874 8.0456995,3.5328834 7.9885787,3.6774055 Z" | |||
style="font-weight:bold;font-size:8.77336px;line-height:1.25;font-family:Lato;stroke-width:0.427093" /> | |||
<metadata> | |||
<rdf:RDF> | |||
<cc:License | |||
rdf:about="http://creativecommons.org/publicdomain/zero/1.0/"> | |||
<cc:permits | |||
rdf:resource="http://creativecommons.org/ns#Reproduction" /> | |||
<cc:permits | |||
rdf:resource="http://creativecommons.org/ns#Distribution" /> | |||
<cc:permits | |||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> | |||
</cc:License> | |||
<cc:Work | |||
rdf:about=""> | |||
<cc:license | |||
rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" /> | |||
</cc:Work> | |||
</rdf:RDF> | |||
</metadata> | |||
</svg> |
@@ -0,0 +1,5 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-download" viewBox="0 0 16 16"> | |||
<path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773 16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318 1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/> | |||
<path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708l3 3z"/> | |||
</svg> |
@@ -0,0 +1,5 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-question-circle" viewBox="0 0 16 16"> | |||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> | |||
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/> | |||
</svg> |
@@ -0,0 +1,6 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16"> | |||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/> | |||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/> | |||
</svg> |
@@ -0,0 +1,5 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rss" viewBox="0 0 16 16"> | |||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/> | |||
<path d="M5.5 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-3-8.5a1 1 0 0 1 1-1c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1-1-1zm0 4a1 1 0 0 1 1-1 6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1-1-1z"/> | |||
</svg> |
@@ -0,0 +1,35 @@ | |||
import { RpcClient } from 'webextension-rpc'; | |||
import { contentScriptRpcServer } from '../content_script'; | |||
async function handleAnnotateSelection( | |||
info: browser.contextMenus.OnClickData, | |||
tab: browser.tabs.Tab, | |||
) { | |||
await new RpcClient<typeof contentScriptRpcServer>({ tabId: tab.id }).func( | |||
'annotateSelection', | |||
)(); | |||
} | |||
function onContextMenuClick( | |||
info: browser.contextMenus.OnClickData, | |||
tab?: browser.tabs.Tab, | |||
) { | |||
if (!tab) return; | |||
switch (info.menuItemId) { | |||
case 'annotate-selection': | |||
handleAnnotateSelection(info, tab); | |||
break; | |||
} | |||
} | |||
async function init() { | |||
// Create context menu item | |||
browser.contextMenus.create({ | |||
id: 'annotate-selection', | |||
title: 'Annotate selection', | |||
contexts: ['selection'], | |||
}); | |||
browser.contextMenus.onClicked.addListener(onContextMenuClick); | |||
} | |||
init(); |
@@ -0,0 +1,60 @@ | |||
import { isAnnotationMimeType } from '../discovery'; | |||
import notify from '../util/notify'; | |||
import { AnnotationSource } from '../storage/AnnotationSource'; | |||
// When the user navigates to an annotation (collection) directly, suggest | |||
// whether they want to import the annotation(s). | |||
function onHeadersReceived({ | |||
responseHeaders, | |||
url, | |||
}: { | |||
responseHeaders?: browser.webRequest.HttpHeaders; | |||
url: string; | |||
}) { | |||
const isAnnotation = responseHeaders?.some( | |||
(header) => | |||
header.name.toLowerCase() === 'content-type' && | |||
header.value && | |||
isAnnotationMimeType(header.value), | |||
); | |||
if (isAnnotation) { | |||
notify({ | |||
title: 'Import Web Annotation(s)', | |||
iconUrl: '/assets/icon/icon.svg', | |||
message: | |||
'You opened a Web Annotation (Collection). Click here to import it to your annotation library.', | |||
onClicked() { | |||
// Note we do not have the response yet at this point. For simplicity, just | |||
// add it as an (inactive) source, then it will be fetched again. | |||
AnnotationSource.addSource( | |||
{ | |||
type: 'container', | |||
url, | |||
title: 'Manually imported annotation', | |||
}, | |||
false, | |||
); | |||
}, | |||
}); | |||
} | |||
} | |||
browser.webRequest.onHeadersReceived.addListener( | |||
onHeadersReceived, | |||
{ | |||
urls: ['<all_urls>'], | |||
types: ['main_frame'], | |||
}, | |||
['responseHeaders'], | |||
); | |||
// As files loaded through file://… URLs do not have headers, we run a separate check for these. | |||
async function onVisitFileUrl({ url, tabId }: { url: string; tabId: number }) { | |||
// TODO (likely to be quite a hassle, if possible at all..) | |||
} | |||
browser.webNavigation.onCommitted.addListener(onVisitFileUrl, { | |||
url: [{ schemes: ['file'] }], | |||
}); |
@@ -0,0 +1,84 @@ | |||
import '../webextension-polyfill'; | |||
import { RpcServer } from 'webextension-rpc'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
import { Annotation, IAnnotation, IAnnotationWithSource } from '../storage/Annotation'; | |||
import { | |||
AnnotationSource, | |||
AnnotationSourceDescriptor, | |||
} from '../storage/AnnotationSource'; | |||
import './detect-annotation'; | |||
import './context-menu'; | |||
const backgroundRpcServer = new RpcServer({ | |||
async getAnnotationsForTargetUrls(urls: string[]): Promise<IAnnotationWithSource[]> { | |||
const annotations = await Annotation.getAnnotationsForUrls(urls); | |||
const annotationsWithSources = await Promise.all(annotations.map(async (a) => a.expand())); | |||
return annotationsWithSources | |||
}, | |||
refreshAnnotationSource: async ( | |||
source: AnnotationSourceDescriptor, | |||
force = false, | |||
) => { | |||
const sourceObj = await AnnotationSource.getByUrl(source.url); | |||
await sourceObj.refresh(force); | |||
}, | |||
refreshAnnotationSources, | |||
addAnnotationSource: async (source: AnnotationSourceDescriptor) => | |||
AnnotationSource.addSource(source), | |||
isSourceSubscribed: async (source: AnnotationSourceDescriptor) => | |||
AnnotationSource.exists(source), | |||
removeSource: async (sourceDescriptor: AnnotationSourceDescriptor) => { | |||
const sourceObj = await AnnotationSource.getByUrl(sourceDescriptor.url); | |||
await sourceObj.delete(); | |||
}, | |||
async createAnnotation(annotationStub: Partial<WebAnnotation>) { | |||
const annotation = await AnnotationSource.createAnnotation(annotationStub); | |||
return await annotation.expand(); | |||
}, | |||
async updateAnnotation(id: IAnnotation['_id'], webAnnotation: WebAnnotation) { | |||
const annotation = await Annotation.get(id); | |||
await annotation.update(webAnnotation); | |||
}, | |||
async deleteAnnotation(id: IAnnotation['_id']) { | |||
const annotation = await Annotation.get(id); | |||
await annotation.delete(); | |||
}, | |||
}); | |||
export type { backgroundRpcServer }; | |||
async function refreshAnnotationSources({ | |||
forceAll = false, | |||
}: { forceAll?: boolean } = {}) { | |||
const sourcesToUpdate = await (forceAll | |||
? AnnotationSource.getActiveSources() | |||
: AnnotationSource.getSourcesNeedingUpdate()); | |||
console.log(`Will update ${sourcesToUpdate.length} sources.`); | |||
await Promise.all( | |||
sourcesToUpdate.map(async (source) => { | |||
try { | |||
await source.refresh(); | |||
} catch (error) { | |||
console.log( | |||
`Failed to refresh source “${source.data.title}” <${source.data.url}>`, | |||
error, | |||
); | |||
} | |||
}), | |||
); | |||
} | |||
main(); | |||
async function main() { | |||
await refreshAnnotationSources(); | |||
await browser.alarms.clearAll(); | |||
browser.alarms.create('annotationPeriodicRefresh', { periodInMinutes: 1 }); | |||
if (!browser.alarms.onAlarm.hasListener(handleAlarm)) { | |||
browser.alarms.onAlarm.addListener(handleAlarm); | |||
} | |||
} | |||
async function handleAlarm(alarm: browser.alarms.Alarm) { | |||
if (alarm.name !== 'annotationPeriodicRefresh') return; | |||
await refreshAnnotationSources(); | |||
} |
@@ -0,0 +1,42 @@ | |||
html body { | |||
.select, | |||
.button { | |||
max-width: 100%; | |||
box-sizing: border-box; | |||
text-decoration: none; | |||
color: unset; | |||
// background-color: #eee; | |||
// border: 1px solid lightgrey; | |||
// box-shadow: 1px 1px 1px grey; | |||
// background-color: rgb(255, 235, 123); | |||
background-color: lightyellow; | |||
border: 1px solid rgb(179, 179, 0); | |||
box-shadow: 1px 1px 1px grey; | |||
border-radius: 4px; | |||
margin: 4px; | |||
padding: 6px; | |||
} | |||
.button { | |||
cursor: pointer; | |||
&.loading, | |||
&:active { | |||
transform: translate(1px, 1px); | |||
box-shadow: 0.5px 0.5px 0.5px grey; | |||
} | |||
&.loading, | |||
&:disabled { | |||
color: grey; | |||
} | |||
&.success { | |||
background-color: lightgreen; | |||
border-color: green; | |||
} | |||
&.success::after { | |||
content: ' ✔️'; | |||
} | |||
} | |||
} |
@@ -0,0 +1,53 @@ | |||
@import '../common-classes.scss'; | |||
.sourceList { | |||
padding: unset; | |||
list-style: none; | |||
li.source { | |||
margin: 2em 0; | |||
.infoAndButtons { | |||
display: flex; | |||
align-content: space-between; | |||
} | |||
.info { | |||
flex-grow: 1; | |||
} | |||
.buttons { | |||
flex-shrink: 0; | |||
display: flex; | |||
flex-direction: column; | |||
} | |||
} | |||
details.showAnnotations { | |||
& > summary { | |||
cursor: pointer; | |||
} | |||
margin-top: 1em; | |||
margin-bottom: 4em; | |||
} | |||
.title { | |||
} | |||
.url { | |||
max-width: 20em; | |||
overflow-x: hidden; | |||
text-overflow: ellipsis; | |||
font-size: smaller; | |||
} | |||
.sourceType { | |||
margin-top: 1em; | |||
font-size: smaller; | |||
} | |||
.lastUpdate { | |||
font-size: smaller; | |||
margin-top: 1em; | |||
font-style: italic; | |||
} | |||
} |
@@ -0,0 +1,173 @@ | |||
import { Component, h, Fragment, createRef } from 'preact'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import type { backgroundRpcServer } from '../background'; | |||
import { AnnotationSource } from '../storage/AnnotationSource'; | |||
import classes from './AnnotationSourcesList.module.scss'; | |||
import cls from 'classnames'; | |||
import niceTime from '../util/niceTime'; | |||
import { Annotation } from '../storage/Annotation'; | |||
import { AnnotationsList } from './AnnotationsList'; | |||
import infoIcon from '../assets/icons/info.svg'; | |||
import rssIcon from '../assets/icons/rss.svg'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
// Run refresh in the background: should continue if the page/popup is closed. | |||
const refreshAnnotationSource = backgroundRpc.func('refreshAnnotationSource'); | |||
const refreshAnnotationSources = backgroundRpc.func('refreshAnnotationSources'); | |||
interface AnnotationSourcesListProps { | |||
withAnnotations?: boolean; | |||
} | |||
interface AnnotationSourcesListState { | |||
sources?: AnnotationSource[]; | |||
totalAnnotationCount?: number; | |||
} | |||
export class AnnotationSourcesList extends Component< | |||
AnnotationSourcesListProps, | |||
AnnotationSourcesListState | |||
> { | |||
state: AnnotationSourcesListState = {}; | |||
refreshButton = createRef<HTMLButtonElement>(); | |||
async componentDidMount() { | |||
await this.loadData(); | |||
} | |||
async loadData() { | |||
this.setState({ | |||
sources: await AnnotationSource.getAll(), | |||
// sources: await AnnotationSource.getActiveSources(), | |||
totalAnnotationCount: await Annotation.count(), | |||
}); | |||
} | |||
refreshAll = async () => { | |||
const button = this.refreshButton.current!; | |||
try { | |||
button.classList.add(classes.loading); | |||
button.disabled = true; | |||
await refreshAnnotationSources({ forceAll: true }); | |||
await this.loadData(); | |||
} catch (error) { | |||
alert(`Refreshing failed: ${error}`); | |||
throw error; | |||
} finally { | |||
button.disabled = false; | |||
button.classList.remove(classes.loading); | |||
} | |||
}; | |||
refreshSource = async (source: AnnotationSource) => { | |||
try { | |||
await refreshAnnotationSource(source.data, true); | |||
} catch (error) { | |||
alert(`Refreshing failed: ${error}`); | |||
} | |||
await this.loadData(); | |||
}; | |||
removeSource = async (source: AnnotationSource) => { | |||
await source.delete(); | |||
await this.loadData(); | |||
}; | |||
render( | |||
{ withAnnotations }: AnnotationSourcesListProps, | |||
{ sources }: AnnotationSourcesListState, | |||
) { | |||
const sourceList = sources?.map((source) => ( | |||
<li | |||
key={source.data._id} | |||
class={cls(classes.source, { [classes.active]: source.data.active })} | |||
> | |||
<div class={classes.infoAndButtons}> | |||
<div class={classes.info}> | |||
<div class={classes.title}> | |||
{source.data.active && ( | |||
<> | |||
<img src={rssIcon} />{' '} | |||
</> | |||
)} | |||
“{source.data.title}” | |||
</div> | |||
<div class={classes.url}> | |||
{source.data.active ? 'Feed: ' : 'Source: '} | |||
<a href={source.data.url} target="_blank"> | |||
{source.data.url} | |||
</a> | |||
</div>{' '} | |||
<div class={classes.lastUpdate}> | |||
{source.data.active ? 'Last update: ' : 'Imported: '} | |||
{source.data.lastUpdate ? niceTime(source.data.lastUpdate) : '?'} | |||
{source.data.active || ( | |||
<small> (this source is not refreshed automatically)</small> | |||
)} | |||
</div> | |||
</div> | |||
<div class={classes.buttons}> | |||
<button | |||
class={classes.button} | |||
onClick={(e) => this.removeSource(source)} | |||
> | |||
❌ Remove | |||
</button> | |||
<button | |||
class={classes.button} | |||
onClick={(e) => this.refreshSource(source)} | |||
title={ | |||
source.data.type === 'embeddedJsonld' | |||
? `To refresh annotations embedded in a page, first open that page again.` | |||
: undefined | |||
} | |||
> | |||
🗘 Refresh | |||
{source.data.type === 'embeddedJsonld' && ( | |||
<> | |||
{' '} | |||
<img src={infoIcon} /> | |||
</> | |||
)} | |||
</button> | |||
</div> | |||
</div> | |||
{withAnnotations && ( | |||
<details class={classes.showAnnotations} open> | |||
<summary>Stored annotations from this source</summary> | |||
<div style="margin-left: 2em;"> | |||
<AnnotationsList source={source.data} /> | |||
</div> | |||
</details> | |||
)} | |||
</li> | |||
)); | |||
return ( | |||
<div> | |||
{sourceList ? ( | |||
<ul class={classes.sourceList}>{sourceList}</ul> | |||
) : ( | |||
<p> | |||
<i>Loading list of sources…</i> | |||
</p> | |||
)} | |||
<div style="display: flex; align-items: center;"> | |||
<div style="flex-grow: 1;"> | |||
Total number of annotations:{' '} | |||
{this.state.totalAnnotationCount ?? <i>'counting…'</i>} | |||
</div> | |||
<button | |||
class={classes.button} | |||
ref={this.refreshButton} | |||
onClick={this.refreshAll} | |||
> | |||
🗘 Refresh all sources | |||
</button> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,22 @@ | |||
.annotationList { | |||
padding: unset; | |||
list-style: none; | |||
.annotationWrapper { | |||
margin: 1em 0 3em 0; | |||
} | |||
.viewAsJson { | |||
font-size: smaller; | |||
} | |||
.annotationBody { | |||
max-width: 300px; | |||
border: #999 1px solid; | |||
border-radius: 10px; | |||
box-shadow: 10px 10px 10px #9996; | |||
margin: 10px 0; | |||
padding: 10px; | |||
background-color: lightyellow; | |||
} | |||
} |
@@ -0,0 +1,93 @@ | |||
import { Component, h, Fragment } from 'preact'; | |||
import classes from './AnnotationsList.module.scss'; | |||
import { Annotation } from '../storage/Annotation'; | |||
import { getTargetUrls } from 'web-annotation-utils'; | |||
import { IAnnotationSource } from '../storage/AnnotationSource'; | |||
import { AnnotationBody } from '../content_script/AnnotationBody'; | |||
interface AnnotationsListProps { | |||
source?: IAnnotationSource; | |||
withTotal?: boolean; | |||
} | |||
interface AnnotationsListState { | |||
annotations?: Annotation[]; | |||
totalAnnotationCount?: number; | |||
} | |||
export class AnnotationsList extends Component< | |||
AnnotationsListProps, | |||
AnnotationsListState | |||
> { | |||
state: AnnotationsListState = {}; | |||
async componentDidMount() { | |||
await this.loadData(); | |||
} | |||
async componentDidUpdate(previousProps: Readonly<AnnotationsListProps>) { | |||
if ( | |||
previousProps.source?.lastModified !== this.props.source?.lastModified | |||
) { | |||
this.loadData(); | |||
} | |||
} | |||
async loadData() { | |||
let annotations; | |||
if (this.props.source) { | |||
if (this.props.source._id === undefined) | |||
throw new Error(`Got source without an _id`); | |||
annotations = await Annotation.getAnnotationsFromSource( | |||
this.props.source._id, | |||
); | |||
} else { | |||
annotations = await Annotation.getAll(); | |||
} | |||
this.setState({ annotations }); | |||
} | |||
render( | |||
{ withTotal }: AnnotationsListProps, | |||
{ annotations }: AnnotationsListState, | |||
) { | |||
const annotationList = annotations?.map((annotation) => { | |||
const webAnnotation = annotation.data.annotation; | |||
return ( | |||
<li class={classes.annotationWrapper} key={annotation.data._id}> | |||
<div class={classes.annotationBody}> | |||
<AnnotationBody | |||
body={webAnnotation.body} | |||
bodyValue={webAnnotation.bodyValue} | |||
/> | |||
</div> | |||
<div> | |||
On:{' '} | |||
{getTargetUrls(webAnnotation.target).map((url) => ( | |||
<><a href={url}>{url}</a>{' '}</> | |||
))} | |||
</div> | |||
{' '} | |||
{/* <div class={classes.viewAsJson}> | |||
<a target="_blank" href={`data:application/json,${encodeURIComponent(JSON.stringify(annotation.data.annotation, null, 2))}`}>View annotation JSON</a> | |||
</div> */} | |||
</li> | |||
); | |||
}); | |||
return ( | |||
<> | |||
{annotationList ? ( | |||
<ul class={classes.annotationList}>{annotationList}</ul> | |||
) : ( | |||
<p> | |||
<i>Loading…</i> | |||
</p> | |||
)} | |||
{withTotal && ( | |||
<p>Total: {this.state.annotations?.length ?? <i>'counting…'</i>}</p> | |||
)} | |||
</> | |||
); | |||
} | |||
} |
@@ -0,0 +1,25 @@ | |||
html body { /* Increase specifity above that of our style reset */ | |||
.annotationBodyIframe { | |||
width: 100%; | |||
height: auto; | |||
border: none; | |||
background-color: transparent; | |||
} | |||
.annotationBodyAudio { | |||
width: 100%; | |||
border-radius: 20px; | |||
} | |||
.annotationBodyVideo { | |||
width: 100%; | |||
height: auto; | |||
} | |||
.annotationBodyText { | |||
padding: 5px 10px; | |||
min-height: 1em; | |||
white-space: pre-wrap; | |||
&[contenteditable=true]:hover { | |||
background: #fffc; | |||
} | |||
} | |||
} |
@@ -0,0 +1,164 @@ | |||
import { h, Component, ComponentChild, createRef } from 'preact'; | |||
import { asArray } from 'web-annotation-utils'; | |||
import type { BodyChoice, TextualBody, WebAnnotation } from 'web-annotation-utils'; | |||
import classes from './AnnotationBody.module.scss'; | |||
interface AnnotationBodyProps { | |||
body: WebAnnotation['body']; | |||
bodyValue: WebAnnotation['bodyValue']; | |||
editable?: boolean; | |||
onChange?: (newBody: WebAnnotation['body']) => void; | |||
onBlur?: () => void; | |||
inFocus?: boolean; | |||
} | |||
interface AnnotationBodyState {} | |||
export class AnnotationBody extends Component< | |||
AnnotationBodyProps, | |||
AnnotationBodyState | |||
> { | |||
editorElement = createRef<HTMLDivElement>(); | |||
componentDidMount() { | |||
if (this.props.inFocus) { | |||
this.editorElement.current?.focus({ preventScroll: true }); | |||
} | |||
} | |||
componentDidUpdate(previousProps: Readonly<AnnotationBodyProps>) { | |||
if (this.props.inFocus && !previousProps.inFocus) { | |||
this.editorElement.current?.focus(); | |||
} | |||
} | |||
render({ body, bodyValue, editable }: AnnotationBodyProps) { | |||
// An annotation either contains a `bodyValue` (simply a string), or an actual `body`. | |||
if (bodyValue !== undefined) return this.renderBodyValue(bodyValue); | |||
const result = this.renderBody(body); | |||
if (result === null && editable) { | |||
// For an empty but editable body, render an empty text field. | |||
return this.renderBodyValue(''); | |||
} else { | |||
return result; | |||
} | |||
} | |||
renderBodyValue(bodyValue: string) { | |||
// A bodyValue is defined as equivalent to a TextualBody containing this value. | |||
return this.renderTextualBody({ | |||
type: 'TextualBody', | |||
value: bodyValue, | |||
format: 'text/plain', | |||
}); | |||
} | |||
renderTextualBody(body: TextualBody) { | |||
// TODO use other available information: textDirection, format, …? | |||
return ( | |||
<div | |||
ref={this.editorElement} | |||
class={classes.annotationBodyText} | |||
contentEditable={this.props.editable} | |||
spellcheck={false} | |||
onInput={(e) => | |||
this.props.onChange?.({ | |||
...body, | |||
value: (e.target as HTMLParagraphElement).textContent!, | |||
}) | |||
} | |||
onBlur={() => this.props.onBlur?.()} | |||
> | |||
{body.value} | |||
</div> | |||
); | |||
} | |||
renderBody(body: WebAnnotation['body']): ComponentChild { | |||
if (!body) { | |||
return null; | |||
} | |||
// A body can take many forms. Handle each as well as we can. | |||
// Firstly, it could be a string, identifying the body resource. | |||
if (typeof body === 'string') { | |||
// We assume the body is the URL of the body content. | |||
// TODO Handle the case where this string instead refers to a JSON-LD node (e.g. a SpecificResource or Choice). | |||
return this.renderIframe(body); | |||
} | |||
// There can be multiple bodies (according to the spec, each body is equally applicable to each target). | |||
if (Array.isArray(body)) { | |||
// We simply concatenate the bodies. Perhaps not the clearest/prettiest, but simple. | |||
return body.map((actualBody) => this.renderBody(actualBody)); | |||
} | |||
// TextualBody, a body consisting of a simple text value. | |||
if ('type' in body && body.type === 'TextualBody') { | |||
return this.renderTextualBody(body as TextualBody); | |||
} | |||
if ('type' in body && body.type === 'Choice') { | |||
const bodyOptions = (body as BodyChoice).items; | |||
if (bodyOptions.length === 0) return null; | |||
// The default option is listed first; take that. | |||
return this.renderBody(bodyOptions[0]); | |||
} | |||
if ('source' in body) { | |||
// The body is a Specific Resource. | |||
// TODO Try render exactly that part of the resource indicated by body.selector. | |||
return this.renderIframe(body.source); | |||
} | |||
// The body is an External Web Resource. Depending on its type, render an appropriate element. | |||
if ( | |||
asArray(body.format).every((item) => item.startsWith('image/')) || | |||
asArray(body.type).every((item) => item === 'Image') | |||
) { | |||
return this.renderImage(body.id); | |||
} | |||
if ( | |||
asArray(body.format).every((item) => item.startsWith('audio/')) || | |||
asArray(body.type).every((item) => item === 'Sound') | |||
) { | |||
return this.renderAudio(body.id); | |||
} | |||
if ( | |||
asArray(body.format).every((item) => item.startsWith('video/')) || | |||
asArray(body.type).every((item) => item === 'Video') | |||
) { | |||
return this.renderAudio(body.id); | |||
} | |||
return this.renderIframe(body.id); | |||
} | |||
renderImage(bodyUrl: string) { | |||
return <img src={bodyUrl}></img>; | |||
} | |||
renderAudio(bodyUrl: string) { | |||
return ( | |||
<audio class={classes.annotationBodyAudio} controls src={bodyUrl}></audio> | |||
); | |||
} | |||
renderVideo(bodyUrl: string) { | |||
return <video class={classes.annotationBodyVideo} src={bodyUrl}></video>; | |||
} | |||
renderIframe(bodyUrl: string) { | |||
return ( | |||
<iframe | |||
class={classes.annotationBodyIframe} | |||
sandbox="" | |||
src={bodyUrl} | |||
></iframe> | |||
); | |||
} | |||
} |
@@ -0,0 +1,72 @@ | |||
import { Component } from 'preact'; | |||
import type { IAnnotationWithSource } from '../storage/Annotation'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import { describeTextQuote } from '@apache-annotator/dom'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
import type { backgroundRpcServer } from '../background'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
const createAnnotation = backgroundRpc.func('createAnnotation'); | |||
export async function annotateSelection() { | |||
// TODO Use something else than document, so the page’s own scripts cannot observe the reader’s annotation activity. | |||
document.dispatchEvent(new CustomEvent('createAnnotation')); | |||
} | |||
function deselect() { | |||
const selection = window.getSelection(); | |||
if (selection) selection.removeAllRanges(); | |||
} | |||
interface AnnotationCreationHelperProps { | |||
onAnnotationCreated: (annotation: IAnnotationWithSource) => void; | |||
} | |||
interface AnnotationCreationHelperState {} | |||
export class AnnotationCreationHelper extends Component< | |||
AnnotationCreationHelperProps, | |||
AnnotationCreationHelperState | |||
> { | |||
state: AnnotationCreationHelperState = {}; | |||
constructor() { | |||
super(); | |||
this.onCreateAnnotation = this.onCreateAnnotation.bind(this); | |||
} | |||
override componentDidMount(): void { | |||
document.addEventListener('createAnnotation', this.onCreateAnnotation); | |||
} | |||
override componentWillUnmount(): void { | |||
document.removeEventListener('createAnnotation', this.onCreateAnnotation); | |||
} | |||
async onCreateAnnotation() { | |||
const target: WebAnnotation['target'] = { | |||
source: document.URL.split('#')[0], | |||
}; | |||
const selection = window.getSelection(); | |||
if (selection && !selection.isCollapsed) { | |||
const range = selection.getRangeAt(0); | |||
const selector = await describeTextQuote(range); | |||
target.selector = selector; | |||
deselect(); | |||
} | |||
let createdAnnotation; | |||
const annotationStub = { target } | |||
try { | |||
createdAnnotation = await createAnnotation(annotationStub); | |||
} catch (error: any) { | |||
const newError = new Error(`Error creating the annotation: ${error.message}`) | |||
alert(newError.message); | |||
throw newError; | |||
} | |||
this.props.onAnnotationCreated(createdAnnotation); | |||
} | |||
render() { | |||
return undefined; | |||
} | |||
} |
@@ -0,0 +1,232 @@ | |||
import { Component, ComponentChild, RenderableProps, h } from 'preact'; | |||
import deepEqual from 'deep-equal'; | |||
import scrollIntoView from 'scroll-into-view-if-needed'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import cls from 'classnames'; | |||
import { asArray, targetsUrl } from 'web-annotation-utils'; | |||
import { highlightText } from '@apache-annotator/dom'; | |||
import { | |||
MarginalAnnotationCard, | |||
MarginalAnnotationCardProps, | |||
} from './MarginalAnnotationCard'; | |||
import { findTargetsInDocument } from '../util/dom-selectors' | |||
import type { IAnnotationWithSource } from '../storage/Annotation'; | |||
interface AnnotationTargetHighlightProps extends MarginalAnnotationCardProps { | |||
appContainer: Node; | |||
annotation: IAnnotationWithSource; | |||
inFocus?: boolean; | |||
} | |||
interface AnnotationTargetHighlightState { | |||
highlightHovered: boolean; | |||
highlightClicked: boolean; | |||
anchorPosition?: number; | |||
} | |||
// This component renders the annotation card, but as a side | |||
// effect highlights the annotation’s target in the page. It adds classes and | |||
// event listeners to the wrapped annotation, to light up matching bodies and | |||
// targets on clicking&hovering. | |||
export class AnnotationTargetHighlight extends Component< | |||
AnnotationTargetHighlightProps, | |||
AnnotationTargetHighlightState | |||
> { | |||
state: AnnotationTargetHighlightState = { | |||
highlightHovered: false, | |||
highlightClicked: false, | |||
}; | |||
highlightCleanupFunctions: (() => void)[] = []; | |||
highlightMarkElements: HTMLElement[] = []; | |||
constructor() { | |||
super(); | |||
this.handleMouseClickOnCard = this.handleMouseClickOnCard.bind(this); | |||
this.handleHighlightMouseEnter = this.handleHighlightMouseEnter.bind(this); | |||
this.handleHighlightMouseLeave = this.handleHighlightMouseLeave.bind(this); | |||
this.handleKeyUp = this.handleKeyUp.bind(this); | |||
} | |||
componentDidMount() { | |||
this.highlightTargets(); | |||
// if (this.props.inFocus) { | |||
// this.setState({ highlightClicked: true }); | |||
// } | |||
} | |||
componentDidUpdate( | |||
previousProps: Readonly<AnnotationTargetHighlightProps>, | |||
previousState: Readonly<AnnotationTargetHighlightState>, | |||
) { | |||
if (this.state.highlightClicked !== previousState.highlightClicked) { | |||
this.highlightMarkElements.forEach((markElement) => | |||
this.state.highlightClicked | |||
? markElement.classList.add(classes.clicked) | |||
: markElement.classList.remove(classes.clicked), | |||
); | |||
} | |||
if (this.state.highlightHovered !== previousState.highlightHovered) { | |||
this.highlightMarkElements.forEach((markElement) => | |||
this.state.highlightHovered | |||
? markElement.classList.add(classes.hovered) | |||
: markElement.classList.remove(classes.hovered), | |||
); | |||
} | |||
if ( | |||
!deepEqual( | |||
this.props.annotation.annotation.target, | |||
previousProps.annotation.annotation.target, | |||
) | |||
) { | |||
this.removeHighlights(); | |||
this.highlightTargets(); | |||
} | |||
if (this.props.inFocus && !previousProps.inFocus) { | |||
this.setState({ highlightClicked: true }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.removeHighlights(); | |||
} | |||
async highlightTargets() { | |||
const targets = asArray(this.props.annotation.annotation.target); | |||
// Annotations may have multiple targets; ignore those not on this page. | |||
const targetsForThisPage = targets.filter((target) => | |||
targetsUrl(target, document.URL), | |||
); | |||
// Try find the annotation targets within the document (if the target has a Selector). | |||
const domTargets = await findTargetsInDocument(targetsForThisPage); | |||
// Highlight the found targets. | |||
this.highlightCleanupFunctions = domTargets.flatMap((domTarget) => { | |||
// Ignore any matches inside our own annotations etc. | |||
if ( | |||
(domTarget instanceof Range && | |||
domTarget.intersectsNode(this.props.appContainer)) || | |||
(domTarget instanceof Node && | |||
domTarget.compareDocumentPosition(this.props.appContainer) & | |||
Node.DOCUMENT_POSITION_CONTAINS) | |||
) { | |||
return []; | |||
} | |||
// Highlight the text contained in the Range using <mark> elements. | |||
const highlightCleanupFunction = highlightText(domTarget, 'mark', { | |||
class: classes.highlight, | |||
'data-annotation-highlight-temp': 'true', | |||
}); | |||
// TODO tweak highlightText upstream to not need this silly workaround. | |||
const markElements = [ | |||
...document.querySelectorAll('[data-annotation-highlight-temp]'), | |||
] as HTMLElement[]; | |||
// React to mouse interactions with the highlight. | |||
markElements.forEach((markElement) => { | |||
markElement.removeAttribute('data-annotation-highlight-temp'); | |||
markElement.addEventListener('click', () => this.handleMouseClick()); | |||
markElement.addEventListener('mouseenter', () => | |||
this.handleHighlightMouseEnter(), | |||
); | |||
markElement.addEventListener('mouseleave', () => | |||
this.handleHighlightMouseLeave(), | |||
); | |||
}); | |||
this.highlightMarkElements.push(...markElements); | |||
return [highlightCleanupFunction]; | |||
}); | |||
if (this.highlightMarkElements.length > 0) { | |||
const top = this.highlightMarkElements[0].getBoundingClientRect().top + window.scrollY; | |||
this.setState({ | |||
anchorPosition: top, | |||
}); | |||
} | |||
} | |||
removeHighlights() { | |||
this.highlightCleanupFunctions.forEach((removeHighlight) => | |||
removeHighlight(), | |||
); | |||
this.highlightCleanupFunctions = []; | |||
this.highlightMarkElements = []; | |||
this.setState({ | |||
highlightClicked: false, | |||
highlightHovered: false, | |||
}); | |||
} | |||
handleMouseClick() { | |||
this.setState((currentState) => ({ | |||
highlightClicked: !currentState.highlightClicked, | |||
})); | |||
} | |||
handleMouseClickOnCard() { | |||
if (!this.state.highlightClicked && this.highlightMarkElements.length > 0) { | |||
const firstMarkElement = this.highlightMarkElements[0]; | |||
if (firstMarkElement.getBoundingClientRect().top) | |||
scrollIntoView(firstMarkElement, { behavior: 'smooth', block: 'center', scrollMode: 'if-needed' }); | |||
} | |||
this.setState({ | |||
highlightClicked: false, | |||
}); | |||
} | |||
handleHighlightMouseEnter() { | |||
this.setState({ highlightHovered: true }); | |||
} | |||
handleHighlightMouseLeave() { | |||
this.setState({ highlightHovered: false }); | |||
} | |||
handleKeyUp(event: KeyboardEvent) { | |||
if (event.key === 'Escape') { | |||
this.setState({ highlightClicked: false }); | |||
// TODO move focus out of annotation to hide it. | |||
} | |||
} | |||
render( | |||
{ | |||
annotation, | |||
inFocus, | |||
...otherProps | |||
}: RenderableProps<AnnotationTargetHighlightProps>, | |||
{ | |||
highlightHovered, | |||
highlightClicked, | |||
anchorPosition, | |||
}: Readonly<AnnotationTargetHighlightState>, | |||
): ComponentChild { | |||
const extraClasses = cls({ | |||
[classes.highlightClicked]: highlightClicked, | |||
[classes.highlightHovered]: highlightHovered, | |||
}); | |||
// Pass the extra class to the wrapped annotation. | |||
return ( | |||
<MarginalAnnotationCard | |||
{...otherProps} | |||
annotation={annotation} | |||
onclick={this.handleMouseClickOnCard} | |||
onmouseenter={this.handleHighlightMouseEnter} | |||
onmouseleave={this.handleHighlightMouseLeave} | |||
onkeyup={this.handleKeyUp} | |||
anchorPosition={anchorPosition} | |||
extraClasses={extraClasses} | |||
inFocus={inFocus} | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,204 @@ | |||
import { h, Fragment, Component } from 'preact'; | |||
import type { AnnotationSourceDescriptor } from '../storage/AnnotationSource'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import type { backgroundRpcServer } from '../background'; | |||
import { MarginalAnnotations } from './MarginalAnnotations'; | |||
import { ToolbarButtons } from './ToolbarButtons'; | |||
import { | |||
discoverAnnotationsEmbeddedAsJSONLD, | |||
discoverAnnotationSources, | |||
} from './discovery'; | |||
import { targetsUrl } from 'web-annotation-utils'; | |||
import { unique } from '../util/unique'; | |||
import type { IAnnotationWithSource } from '../storage/Annotation'; | |||
import { AnnotationCreationHelper } from './AnnotationCreationHelper'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
const getAnnotationsForTargetUrls = backgroundRpc.func( | |||
'getAnnotationsForTargetUrls', | |||
); | |||
const isSourceSubscribed = backgroundRpc.func('isSourceSubscribed'); | |||
const updateAnnotation = backgroundRpc.func('updateAnnotation'); | |||
const deleteAnnotation = backgroundRpc.func('deleteAnnotation'); | |||
interface AppProps { | |||
appContainer: Node; | |||
} | |||
interface AppState { | |||
storedAnnotations: IAnnotationWithSource[]; | |||
embeddedAnnotations: IAnnotationWithSource[]; | |||
annotationInFocus?: IAnnotationWithSource; | |||
discoveredLinkedSources: (AnnotationSourceDescriptor & { | |||
subscribed: boolean; | |||
})[]; | |||
discoveredEmbeddedSources: (AnnotationSourceDescriptor & { | |||
subscribed: boolean; | |||
})[]; | |||
} | |||
export class App extends Component<AppProps, AppState> { | |||
state: AppState = { | |||
storedAnnotations: [], | |||
embeddedAnnotations: [], | |||
discoveredLinkedSources: [], | |||
discoveredEmbeddedSources: [], | |||
}; | |||
async componentDidMount() { | |||
try { | |||
await Promise.all([ | |||
this.loadStoredAnnotations(), | |||
this.discoverEmbeddedAnnotations(), | |||
this.discoverLinkedAnnotationSources(), | |||
]); | |||
} catch (error) { | |||
console.log(error); | |||
} | |||
} | |||
async loadStoredAnnotations() { | |||
// Find annotations in our storage that target the current page. | |||
const urls = [document.URL]; | |||
const canonicalLink = document.querySelector( | |||
'link[rel~="canonical"]', | |||
) as HTMLLinkElement | null; | |||
if (canonicalLink) urls.push(canonicalLink.href); | |||
const storedAnnotations = await getAnnotationsForTargetUrls(urls); | |||
console.log( | |||
`We got these annotations for <${document.URL}>:`, | |||
storedAnnotations, | |||
); | |||
this.setState({ | |||
storedAnnotations, | |||
}); | |||
} | |||
async discoverEmbeddedAnnotations() { | |||
// Find annotations embedded inside the page. | |||
const embeddedAnnotations = discoverAnnotationsEmbeddedAsJSONLD(); | |||
const embeddedAnnotationsTargetingThisPage = embeddedAnnotations.filter( | |||
(annotation) => targetsUrl(annotation.target, document.URL), | |||
); | |||
console.log( | |||
`Found ${embeddedAnnotations.length} embedded annotations in the page, of which ${embeddedAnnotationsTargetingThisPage.length} target this page itself.`, | |||
); | |||
this.setState({ | |||
embeddedAnnotations: embeddedAnnotationsTargetingThisPage.map( | |||
(annotation) => ({ | |||
_id: 0, | |||
source: { | |||
_id: -1, | |||
active: false, | |||
type: 'embeddedJsonld', | |||
url: document.URL, | |||
}, | |||
annotation, | |||
}), | |||
), | |||
}); | |||
// A page with embedded annotations targeting *other* pages is considered an annotation source. | |||
if ( | |||
embeddedAnnotations.length > embeddedAnnotationsTargetingThisPage.length | |||
) { | |||
const pageAsAnnotationSource: AnnotationSourceDescriptor = { | |||
title: document.title, | |||
url: document.URL.split('#')[0], | |||
type: 'embeddedJsonld', | |||
}; | |||
this.setState({ | |||
discoveredEmbeddedSources: await this.checkDiscoveredAnnotationSources([ | |||
pageAsAnnotationSource, | |||
]), | |||
}); | |||
} | |||
} | |||
async discoverLinkedAnnotationSources() { | |||
// Find annotations sources advertised by the current page. | |||
const discoveredSources = discoverAnnotationSources(); | |||
this.setState({ | |||
discoveredLinkedSources: await this.checkDiscoveredAnnotationSources( | |||
discoveredSources, | |||
), | |||
}); | |||
} | |||
async checkDiscoveredAnnotationSources( | |||
discoveredSources: AnnotationSourceDescriptor[], | |||
) { | |||
// For each discovered source, note if we already have it in our database. | |||
return await Promise.all( | |||
discoveredSources.map(async (source) => ({ | |||
...source, | |||
subscribed: await isSourceSubscribed(source), | |||
})), | |||
); | |||
} | |||
async onAnnotationCreated(annotation: IAnnotationWithSource) { | |||
await this.loadStoredAnnotations(); | |||
this.setState({ annotationInFocus: annotation }); | |||
} | |||
onSubscriptionChange() { | |||
this.discoverLinkedAnnotationSources(); | |||
this.discoverEmbeddedAnnotations(); | |||
} | |||
render( | |||
{ appContainer }: AppProps, | |||
{ | |||
storedAnnotations, | |||
embeddedAnnotations, | |||
annotationInFocus, | |||
discoveredLinkedSources, | |||
discoveredEmbeddedSources, | |||
}: AppState, | |||
) { | |||
const annotationsToShow = unique( | |||
[...storedAnnotations, ...embeddedAnnotations], | |||
(obj) => obj.annotation.canonical || obj.annotation.id, | |||
); | |||
const discoveredSources = [ | |||
...discoveredLinkedSources, | |||
...discoveredEmbeddedSources, | |||
]; | |||
const toolbarButtons = | |||
discoveredSources.length > 0 ? ( | |||
<ToolbarButtons | |||
{...{ | |||
onChange: () => this.onSubscriptionChange(), | |||
discoveredSources, | |||
}} | |||
/> | |||
) : undefined; | |||
return ( | |||
<> | |||
<MarginalAnnotations | |||
{...{ | |||
annotations: annotationsToShow, | |||
annotationInFocus, | |||
toolbarButtons, | |||
appContainer, | |||
onUpdateAnnotation: async (...args) => { | |||
await updateAnnotation(...args); | |||
// messes up text while editing. | |||
// await this.loadStoredAnnotations(); | |||
}, | |||
onDeleteAnnotation: async (...args) => { | |||
await deleteAnnotation(...args); | |||
await this.loadStoredAnnotations(); | |||
}, | |||
}} | |||
/> | |||
<AnnotationCreationHelper | |||
onAnnotationCreated={(annotation) => | |||
this.onAnnotationCreated(annotation) | |||
} | |||
/> | |||
</> | |||
); | |||
} | |||
} |
@@ -0,0 +1,132 @@ | |||
import cls from 'classnames'; | |||
import debounce from 'lodash.debounce'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import { AnnotationBody } from './AnnotationBody'; | |||
import { Component, h } from 'preact'; | |||
import { getSingleCreatorName } from 'web-annotation-utils'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
import type { IAnnotation, IAnnotationWithSource } from '../storage/Annotation'; | |||
export interface MarginalAnnotationCardProps { | |||
annotation: IAnnotationWithSource; | |||
anchorPosition?: number | undefined; | |||
extraClasses?: string; | |||
inFocus?: boolean; | |||
onUpdateAnnotation: ( | |||
id: IAnnotation['_id'], | |||
newWebAnnotation: WebAnnotation, | |||
) => Promise<void>; | |||
onDeleteAnnotation: (id: IAnnotation['_id']) => Promise<void>; | |||
[other: string]: any; | |||
} | |||
interface MarginalAnnotationCardState {} | |||
export class MarginalAnnotationCard extends Component< | |||
MarginalAnnotationCardProps, | |||
MarginalAnnotationCardState | |||
> { | |||
state = {}; | |||
onBodyChange = debounce( | |||
async (newBody: WebAnnotation['body']) => { | |||
const newWebAnnotation: WebAnnotation = { | |||
...this.props.annotation.annotation, | |||
bodyValue: undefined, | |||
body: newBody, | |||
modified: new Date().toISOString(), | |||
}; | |||
await this.props.onUpdateAnnotation( | |||
this.props.annotation._id, | |||
newWebAnnotation, | |||
); | |||
}, | |||
1_000, | |||
{ maxWait: 10_000 }, | |||
); | |||
render( | |||
{ | |||
annotation, | |||
anchorPosition, | |||
extraClasses, | |||
inFocus, | |||
...otherProps | |||
}: MarginalAnnotationCardProps, | |||
{}: MarginalAnnotationCardState, | |||
) { | |||
const editable = !!annotation.source.writable; | |||
const anchorPositionStyle = | |||
anchorPosition !== undefined | |||
? { | |||
style: { marginTop: anchorPosition }, | |||
} | |||
: {}; | |||
const annotationCard = ( | |||
<div | |||
class={classes.annotationWrapper} | |||
{...anchorPositionStyle} | |||
key={annotation._id} | |||
> | |||
<aside | |||
class={cls( | |||
classes.annotation, | |||
{ [classes.dirty]: annotation.dirty, [classes.editable]: editable }, | |||
extraClasses, | |||
)} | |||
{...otherProps} | |||
> | |||
<AnnotationBody | |||
body={annotation.annotation.body} | |||
bodyValue={annotation.annotation.bodyValue} | |||
editable={editable} | |||
onChange={(newBody) => this.onBodyChange(newBody)} | |||
onBlur={() => this.onBodyChange.flush()} | |||
inFocus={inFocus} | |||
/> | |||
<div class={classes.cardBottom}> | |||
<span class={classes.creator}> | |||
{getSingleCreatorName(annotation.annotation)} | |||
</span> | |||
<span> | |||
{annotation.dirty && ( | |||
<span | |||
title={`Your edits on this annotation have not yet been stored; if this problem persists, try manually refresh its annotation collection (“${annotation.source?.title}”).`} | |||
> | |||
🖉{' '} | |||
</span> | |||
)} | |||
{editable && ( | |||
<button | |||
onClick={() => | |||
this.props.onDeleteAnnotation(this.props.annotation._id) | |||
} | |||
class={classes.button} | |||
title="Delete this annotation" | |||
> | |||
🗑 | |||
</button> | |||
)} | |||
</span> | |||
</div> | |||
</aside> | |||
</div> | |||
); | |||
// If it has a specified vertical position, wrap it in a separate absolutely positioned container | |||
if (anchorPosition !== undefined) { | |||
return ( | |||
<div | |||
class={classes.singleAnnotationOuterContainer} | |||
key={annotation._id} | |||
> | |||
<div class={cls(classes.singleAnnotationContainer)}> | |||
{annotationCard} | |||
</div> | |||
</div> | |||
); | |||
} else { | |||
return annotationCard; | |||
} | |||
} | |||
} |
@@ -0,0 +1,183 @@ | |||
html { | |||
// Support hidden overflow into the right margin. | |||
// (would be better to not have to change the page’s style; but not sure how) | |||
position: relative; | |||
} | |||
html body { | |||
/* Increase specifity above that of our style reset */ | |||
.annotationOuterContainer { | |||
--container-width: 300px; | |||
--toolbar-height: 40px; | |||
z-index: 999998; | |||
position: absolute; | |||
top: 0; | |||
right: 0; | |||
bottom: 0; | |||
width: var(--container-width); | |||
max-width: 50vw; | |||
display: flex; | |||
flex-flow: column; | |||
pointer-events: none; | |||
box-sizing: border-box; | |||
font-family: sans-serif; | |||
} | |||
.singleAnnotationOuterContainer { | |||
position: absolute; | |||
top: 0; | |||
right: 0; | |||
bottom: 0; | |||
width: var(--container-width); | |||
max-width: 50vw; | |||
display: flex; | |||
flex-flow: column; | |||
pointer-events: none; | |||
box-sizing: border-box; | |||
} | |||
.annotationContainer { | |||
flex-grow: 1; | |||
pointer-events: none; | |||
& > *:not(.singleAnnotationOuterContainer) { | |||
pointer-events: all; | |||
} | |||
} | |||
.singleAnnotationContainer { | |||
flex-grow: 1; | |||
pointer-events: none; | |||
& .annotation { | |||
pointer-events: all; | |||
} | |||
} | |||
.annotationWrapper { | |||
overflow: hidden; | |||
position: sticky; // TODO Fix sticky position. | |||
top: --toolbar-height; | |||
bottom: 0; | |||
} | |||
.annotationContainer > :nth-last-child(2) .annotationWrapper { | |||
top: calc(var(--toolbar-height) + 4px); | |||
} | |||
.annotationContainer > :nth-last-child(1) .annotationWrapper { | |||
top: calc(var(--toolbar-height) + 8px); | |||
} | |||
.annotationContainer > :nth-child(1) .annotationWrapper { | |||
bottom: 8px; | |||
} | |||
.annotationContainer > :nth-child(2) .annotationWrapper { | |||
bottom: 4px; | |||
} | |||
.annotation { | |||
border: #999 1px solid; | |||
border-radius: 10px; | |||
box-shadow: 10px 10px 10px #9996; | |||
margin: 10px 0px; | |||
padding: 5px 40px 5px 10px; | |||
background-color: lightyellow; | |||
transition: transform 200ms, background-color 200ms; | |||
transform: translateX(calc(var(--container-width) - 40px)); | |||
&.dirty { | |||
opacity: 0.8; | |||
} | |||
.dirtyNotice { | |||
font-size: smaller; | |||
font-style: italic; | |||
} | |||
.cardBottom { | |||
padding-left: 10px; | |||
display: flex; | |||
justify-content: space-between; | |||
} | |||
.creator { | |||
font-style: italic; | |||
} | |||
} | |||
.annotationContainer.showAll .annotation, | |||
.annotation.highlightClicked, | |||
.annotation.highlightHovered, | |||
.annotation:hover, | |||
.annotation:focus-within { | |||
transform: none; | |||
} | |||
.annotation button, | |||
.toolbar button { | |||
font-size: 1em; | |||
border: none; | |||
background: none; | |||
cursor: pointer; | |||
&:hover { | |||
background: yellow; | |||
} | |||
.icon { | |||
width: 0.9em; | |||
} | |||
} | |||
.toolbarContainer { | |||
z-index: 999999; | |||
position: sticky; | |||
top: 0; | |||
display: flex; | |||
justify-content: end; | |||
.toolbar { | |||
max-width: var(--container-width); | |||
display: flex; | |||
flex-flow: column nowrap; | |||
align-items: flex-end; | |||
background-color: lightyellow; | |||
border-bottom-left-radius: 10px; | |||
border-left: #999 1px solid; | |||
border-bottom: #999 1px solid; | |||
box-shadow: 10px 10px 10px #9996; | |||
overflow: hidden; | |||
& > * { | |||
padding: 10px; | |||
} | |||
details { | |||
font-size: smaller; | |||
} | |||
summary { | |||
font-size: larger; | |||
list-style: none; | |||
cursor: pointer; | |||
} | |||
} | |||
} | |||
.hiddenWhenClosed { | |||
display: none; | |||
} | |||
.annotationContainer.showAll .hiddenWhenClosed { | |||
display: unset; | |||
} | |||
mark.highlight { | |||
background: #ff99; | |||
} | |||
mark.highlight.clicked, | |||
.annotation.highlightClicked, | |||
.showAll .showAllButton { | |||
background: #ff9; | |||
} | |||
mark.highlight.hovered, | |||
.annotation.highlightHovered { | |||
background: yellow; | |||
} | |||
} |
@@ -0,0 +1,82 @@ | |||
import { h, Component, ComponentChildren } from 'preact'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import cls from 'classnames'; | |||
import { AnnotationTargetHighlight } from './AnnotationTargetHighlight'; | |||
import type { IAnnotation, IAnnotationWithSource } from '../storage/Annotation'; | |||
import { WebAnnotation } from 'web-annotation-utils'; | |||
interface MarginalAnnotationsProps { | |||
appContainer: Node; | |||
annotations: IAnnotationWithSource[]; | |||
annotationInFocus?: IAnnotationWithSource; | |||
toolbarButtons?: ComponentChildren; | |||
onUpdateAnnotation: ( | |||
id: IAnnotation['_id'], | |||
newWebAnnotation: WebAnnotation, | |||
) => Promise<void>; | |||
onDeleteAnnotation: (id: IAnnotation['_id']) => Promise<void>; | |||
} | |||
interface MarginalAnnotationsState { | |||
showAll: boolean; | |||
} | |||
export class MarginalAnnotations extends Component< | |||
MarginalAnnotationsProps, | |||
MarginalAnnotationsState | |||
> { | |||
state: MarginalAnnotationsState = { showAll: false }; | |||
toggleShowAll = () => { | |||
this.setState(({ showAll }) => ({ showAll: !showAll })); | |||
}; | |||
render( | |||
{ | |||
annotations, | |||
annotationInFocus, | |||
toolbarButtons, | |||
appContainer, | |||
onUpdateAnnotation, | |||
onDeleteAnnotation, | |||
}: MarginalAnnotationsProps, | |||
{ showAll }: MarginalAnnotationsState, | |||
) { | |||
const annotationElements = annotations.map((annotation) => ( | |||
<AnnotationTargetHighlight | |||
key={annotation._id} | |||
annotation={annotation} | |||
appContainer={appContainer} | |||
inFocus={annotation._id === annotationInFocus?._id} | |||
onUpdateAnnotation={onUpdateAnnotation} | |||
onDeleteAnnotation={onDeleteAnnotation} | |||
/> | |||
)); | |||
return ( | |||
<div class={classes.annotationOuterContainer}> | |||
<div | |||
class={cls(classes.annotationContainer, { | |||
[classes.showAll]: showAll, | |||
})} | |||
> | |||
<div class={classes.toolbarContainer}> | |||
<div class={classes.toolbar}> | |||
{toolbarButtons} | |||
{(toolbarButtons || annotations.length > 0) && ( | |||
<button | |||
class={classes.showAllButton} | |||
onClick={this.toggleShowAll} | |||
title="Show/hide all annotations" | |||
> | |||
{showAll ? '❯' : '❮'} | |||
</button> | |||
)} | |||
</div> | |||
</div> | |||
{annotationElements} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,133 @@ | |||
import { h, Component, ComponentChild } from 'preact'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import type { backgroundRpcServer } from '../background'; | |||
import type { AnnotationSourceDescriptor } from '../storage/AnnotationSource'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import rssIcon from '../assets/icons/rss.svg'; | |||
import infoIcon from '../assets/icons/info.svg'; | |||
import downloadIcon from '../assets/icons/download.svg'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
const addAnnotationSource = backgroundRpc.func('addAnnotationSource'); | |||
const removeSource = backgroundRpc.func('removeSource'); | |||
const refreshAnnotationSource = backgroundRpc.func('refreshAnnotationSource'); | |||
interface ToolbarButtonsProps { | |||
discoveredSources: (AnnotationSourceDescriptor & { subscribed: boolean })[]; | |||
onChange: () => void; | |||
} | |||
interface ToolbarButtonsState { | |||
showAll: boolean; | |||
} | |||
export class ToolbarButtons extends Component< | |||
ToolbarButtonsProps, | |||
ToolbarButtonsState | |||
> { | |||
render( | |||
{ discoveredSources, onChange }: ToolbarButtonsProps, | |||
{}: ToolbarButtonsState, | |||
) { | |||
const buttons: ComponentChild[] = []; | |||
for (const source of discoveredSources) { | |||
let explanationSummary, explanationFull; | |||
if (source.type === 'container') { | |||
explanationSummary = ( | |||
<span> | |||
<img src={rssIcon} /> This page provides an annotation source{' '} | |||
<i>“{source.title}”</i>. <img src={infoIcon} /> | |||
</span> | |||
); | |||
explanationFull = `If you subscribe to it, annotations published by this website will be stored in your browser, and displayed on the web pages they target. Its annotations are automatically refreshed periodically.`; | |||
} else if (source.type === 'embeddedJsonld') { | |||
explanationSummary = ( | |||
<span> | |||
This page contains embedded annotations. <img src={infoIcon} /> | |||
</span> | |||
); | |||
explanationFull = `If you import them, they will be stored in your browser, and displayed on the web pages they target. Note they will not be refreshed automatically.`; | |||
} | |||
buttons.push( | |||
<details class={classes.hiddenWhenClosed}> | |||
<summary>{explanationSummary}</summary> | |||
{explanationFull} | |||
</details>, | |||
); | |||
if (source.subscribed) { | |||
if (source.type === 'container') { | |||
buttons.push( | |||
<button | |||
title="Unsubscribe from this annotation source (forget its annotations)" | |||
onClick={async () => { | |||
await removeSource(source); | |||
onChange(); | |||
}} | |||
> | |||
❌<span class={classes.hiddenWhenClosed}> Unsubscribe</span> | |||
</button>, | |||
<button | |||
title="Refresh annotations from this source." | |||
onClick={() => refreshAnnotationSource(source, true)} | |||
> | |||
🗘<span class={classes.hiddenWhenClosed}> Refresh</span> | |||
</button>, | |||
); | |||
} else if (source.type === 'embeddedJsonld') { | |||
buttons.push( | |||
<button | |||
title="Remove annotations from this source." | |||
onClick={async () => { | |||
await removeSource(source); | |||
onChange(); | |||
}} | |||
> | |||
❌<span class={classes.hiddenWhenClosed}> Remove</span> | |||
</button>, | |||
<button | |||
title="Refresh annotations from this source." | |||
onClick={() => refreshAnnotationSource(source, true)} | |||
> | |||
🗘<span class={classes.hiddenWhenClosed}> Refresh</span> | |||
</button>, | |||
); | |||
} | |||
} else { | |||
if (source.type === 'container') { | |||
buttons.push( | |||
<button | |||
onClick={async () => { | |||
await addAnnotationSource(source); | |||
onChange(); | |||
}} | |||
title="Subscribe to annotations from this website." | |||
> | |||
<img src={rssIcon} class={classes.icon} /> | |||
<span class={classes.hiddenWhenClosed}> Subscribe</span> | |||
</button>, | |||
); | |||
} else if (source.type === 'embeddedJsonld') { | |||
buttons.push( | |||
<button | |||
onClick={async () => { | |||
await addAnnotationSource(source); | |||
onChange(); | |||
}} | |||
title="Import the annotations embedded in this page." | |||
> | |||
<img src={downloadIcon} class={classes.icon} /> | |||
<span class={classes.hiddenWhenClosed}> Import annotations</span> | |||
</button>, | |||
); | |||
} | |||
} | |||
} | |||
return buttons.length > 0 ? buttons : null; | |||
} | |||
} | |||
// TODO Refresh components properly. instead of reload the whole page. :) | |||
function pageReload() { | |||
window.location = window.location; | |||
} |
@@ -0,0 +1,18 @@ | |||
import type { AnnotationSourceDescriptor } from '../storage/AnnotationSource'; | |||
import { discoverAnnotationFeeds, discoverEmbeddedAnnotations } from '../discovery'; | |||
import { WebAnnotation } from 'web-annotation-utils'; | |||
export function discoverAnnotationsEmbeddedAsJSONLD(): WebAnnotation[] { | |||
// TODO data validation | |||
return discoverEmbeddedAnnotations() as WebAnnotation[]; | |||
}; | |||
// Find any link like this: <link rel="alternate" type='application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"'> | |||
export function discoverAnnotationSources( | |||
): AnnotationSourceDescriptor[] { | |||
return discoverAnnotationFeeds().map(({ url, title }) => ({ | |||
url, | |||
title, | |||
type: 'container', | |||
})); | |||
} |
@@ -0,0 +1,5 @@ | |||
/* Protect our widgets from page style rules (but this won’t work if those rules have higher specificity..). */ | |||
.container, | |||
.container * { | |||
all: revert; | |||
} |
@@ -0,0 +1,47 @@ | |||
import '../webextension-polyfill'; | |||
import { RpcServer } from 'webextension-rpc'; | |||
import { h, render } from 'preact'; | |||
import classes from './index.module.scss'; | |||
import { App } from './App'; | |||
import { discoverAnnotationsEmbeddedAsJSONLD } from './discovery'; | |||
import { annotateSelection } from './AnnotationCreationHelper'; | |||
export const contentScriptRpcServer = new RpcServer({ | |||
annotateSelection, | |||
discoverAnnotationsEmbeddedAsJSONLD: async () => | |||
discoverAnnotationsEmbeddedAsJSONLD(), | |||
// fetchJson: async (...args: Parameters<typeof fetch>) => { | |||
// const response = await fetch(...args); | |||
// let json; | |||
// try { | |||
// json = await response.json(); | |||
// } catch (error) { | |||
// json = undefined; | |||
// } | |||
// const { ok, redirected, status, statusText, type, url } = response; | |||
// return { | |||
// json, | |||
// headers: response.headers.entries(), | |||
// ok, | |||
// redirected, | |||
// status, | |||
// statusText, | |||
// type, | |||
// url, | |||
// }; | |||
// }, | |||
}); | |||
main(); | |||
async function main() { | |||
const containerId = classes.container; | |||
let container = document.getElementById(containerId); | |||
if (!container) { | |||
container = document.createElement('div'); | |||
// use class as id too. | |||
container.id = containerId; | |||
container.setAttribute('class', classes.container); | |||
document.body.append(container); | |||
} | |||
render(<App appContainer={container} />, container); | |||
} |
@@ -0,0 +1,14 @@ | |||
declare module '*.module.css' { | |||
const classes: { [key: string]: string }; | |||
export default classes; | |||
} | |||
declare module '*.module.scss' { | |||
const classes: { [key: string]: string }; | |||
export default classes; | |||
} | |||
declare module "*.svg" { | |||
const content: string; | |||
export default content; | |||
} |
@@ -0,0 +1,169 @@ | |||
/** | |||
* This code provides a reference implementation in TypeScript for the Web | |||
* Anotation Discovery mechanism. | |||
* | |||
* It implements functionality on the browser side, to detect annotations | |||
* embedded in the page, and to detect links to annotation ‘feeds’. | |||
*/ | |||
import MIMEType from 'whatwg-mimetype'; | |||
/** | |||
* The type used here for Web Annotation objects. For valid input, the object | |||
* should include other properties (e.g. the target, id, …). However, this type | |||
* defines only the minimum outline required for the functions below; hence the | |||
* “should” in the name. | |||
*/ | |||
type ShouldBeAnnotation = { | |||
'@context': | |||
| 'http://www.w3.org/ns/anno.jsonld' | |||
| [...any, 'http://www.w3.org/ns/anno.jsonld', ...any]; | |||
type: 'Annotation' | [...any, 'Annotation', ...any]; | |||
}; | |||
/** | |||
* Likewise for an Annotation Collection. | |||
*/ | |||
type ShouldBeAnnotationCollection = { | |||
'@context': | |||
| 'http://www.w3.org/ns/anno.jsonld' | |||
| [...any, 'http://www.w3.org/ns/anno.jsonld', ...any]; | |||
type: 'AnnotationCollection' | [...any, 'AnnotationCollection', ...any]; | |||
first?: | |||
| string | |||
| { | |||
items: Omit<ShouldBeAnnotation, '@context'>[]; | |||
}; | |||
}; | |||
function isAnnotation(value: any): value is ShouldBeAnnotation { | |||
if (typeof value !== 'object') return false; | |||
const hasCorrectContext = asArray(value['@context']).some( | |||
(context) => context === 'http://www.w3.org/ns/anno.jsonld', | |||
); | |||
const hasCorrectType = asArray(value.type).some( | |||
(type) => type === 'Annotation', | |||
); | |||
return hasCorrectContext && hasCorrectType; | |||
} | |||
function isAnnotationCollection( | |||
value: any, | |||
): value is ShouldBeAnnotationCollection { | |||
if (typeof value !== 'object') return false; | |||
const hasCorrectContext = asArray(value['@context']).some( | |||
(context) => context === 'http://www.w3.org/ns/anno.jsonld', | |||
); | |||
const hasCorrectType = asArray(value.type).some( | |||
(type) => type === 'AnnotationCollection', | |||
); | |||
return hasCorrectContext && hasCorrectType; | |||
} | |||
/** | |||
* Helper function to detect if a script/link has the media type. While an extract string match may be simple and tempting, | |||
* many type strings are equivalent. Some examples: | |||
* | |||
* application/ld+json;profile="http://www.w3.org/ns/anno.jsonld" | |||
* application/ld+json;profile="something and http://www.w3.org/ns/anno.jsonld" | |||
* application/ld+json;profile="\"with\\escapes\" http://www.w3.org/ns/anno.jsonld" | |||
* application/ld+json; charset="utf-8"; profile="http://www.w3.org/ns/anno.jsonld" | |||
*/ | |||
export function isAnnotationMimeType(type: string): boolean { | |||
let mimeType: MIMEType; | |||
try { | |||
mimeType = new MIMEType(type); | |||
} catch (error) { | |||
return false; | |||
} | |||
if (mimeType.essence !== 'application/ld+json') return false; | |||
const profile = mimeType.parameters.get('profile'); | |||
if (!profile) return false; | |||
return profile.split(' ').includes('http://www.w3.org/ns/anno.jsonld'); | |||
} | |||
/** | |||
* To discover annotations when navigating to a URL, simply check the content | |||
* type of the response. | |||
* | |||
* If positive, response.json() can be passed into getAnnotationsFromParsedJson(). | |||
*/ | |||
export function responseContainsAnnotations(response: Response) { | |||
return ( | |||
response.ok && | |||
isAnnotationMimeType(response.headers.get('Content-Type') || '') | |||
); | |||
} | |||
export function getAnnotationsFromParsedJson(value: any): ShouldBeAnnotation[] { | |||
// The content could be one annotation, or a collection of annotations. | |||
if (isAnnotation(value)) { | |||
return [value]; | |||
} else if (isAnnotationCollection(value) && typeof value.first === 'object') { | |||
return value.first.items.map((annotation) => ({ | |||
'@context': value['@context'], | |||
...annotation, | |||
})); | |||
} else { | |||
// Perhaps we got invalid data or an empty collection. | |||
return []; | |||
} | |||
} | |||
/** | |||
* Find annotations embedded as JSON within <script> tags. | |||
* See “Embedding Web Annotations in HTML - W3C Working Group Note 23 February 2017” | |||
* <https://www.w3.org/TR/annotation-html/> | |||
*/ | |||
export function discoverEmbeddedAnnotations( | |||
document = window.document, | |||
): ShouldBeAnnotation[] { | |||
let scripts = [ | |||
...document.getElementsByTagName('script'), | |||
] as HTMLScriptElement[]; | |||
scripts = scripts.filter((script) => isAnnotationMimeType(script.type)); | |||
const annotations: ShouldBeAnnotation[] = []; | |||
for (const script of scripts) { | |||
try { | |||
const parsed = JSON.parse(script.textContent ?? ''); | |||
annotations.push(...getAnnotationsFromParsedJson(parsed)); | |||
} catch (error) { | |||
console.error('Cannot read annotation(s) of script', script, error); | |||
} | |||
} | |||
return annotations; | |||
} | |||
/** | |||
* Find links to annotation feeds. | |||
* | |||
* An example of such a link: | |||
* <link | |||
* rel="alternate" | |||
* type='application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"' | |||
* title="My annotation feed!" | |||
* href="https://myfeed.example/annotations/" | |||
* /> | |||
*/ | |||
export function discoverAnnotationFeeds(document = window.document) { | |||
let links = [...document.getElementsByTagName('link')] as HTMLLinkElement[]; | |||
links = links.filter( | |||
(link) => | |||
link.relList.contains('alternate') && isAnnotationMimeType(link.type), | |||
); | |||
return links.map((link) => ({ | |||
url: link.href, | |||
title: link.title, | |||
})); | |||
} | |||
/** | |||
* Helper function to treat a non-array value as an array with one item. | |||
*/ | |||
function asArray<T>(value: T | T[] | undefined): T[] { | |||
if (Array.isArray(value)) return value; | |||
if (value === undefined || value === null) return []; | |||
return [value]; | |||
} |
@@ -0,0 +1,44 @@ | |||
import type { Manifest } from 'webextension-polyfill'; | |||
import { version } from '../package.json'; | |||
const manifest: () => Manifest.WebExtensionManifest = () => ({ | |||
name: 'Web Annotation Discovery', | |||
version, | |||
manifest_version: 2, | |||
icons: { | |||
'512': 'assets/icon/icon.svg', | |||
'256': 'assets/icon/icon256.png', | |||
'128': 'assets/icon/icon128.png', | |||
'96': 'assets/icon/icon96.png', | |||
'48': 'assets/icon/icon48.png', | |||
}, | |||
browser_action: { | |||
default_icon: 'assets/icon/icon.svg', | |||
default_popup: 'popup/index.html', | |||
}, | |||
background: { | |||
scripts: ['webextension-polyfill.ts', 'background/index.ts'], | |||
}, | |||
content_scripts: [ | |||
{ | |||
matches: ['<all_urls>'], | |||
js: ['content_script/index.tsx'], | |||
css: ['generated:content_script/style.css'], | |||
}, | |||
], | |||
permissions: [ | |||
'alarms', | |||
'<all_urls>', | |||
'contextMenus', | |||
'notifications', | |||
'webNavigation', | |||
'webRequest', | |||
], | |||
browser_specific_settings: { | |||
gecko: { | |||
id: 'web-annotation-discovery@extension', | |||
}, | |||
}, | |||
}); | |||
export default manifest; |
@@ -0,0 +1,10 @@ | |||
@import '../page-style.scss'; | |||
body { | |||
margin: 2em; | |||
} | |||
.overview { | |||
margin: auto; | |||
max-width: 40em; | |||
} |
@@ -0,0 +1,19 @@ | |||
import { Component, h } from 'preact'; | |||
import { AnnotationSourcesList } from '../components/AnnotationSourcesList'; | |||
import classes from './Overview.module.scss'; | |||
interface OverviewProps {} | |||
interface OverviewState {} | |||
export class Overview extends Component<OverviewProps, OverviewState> { | |||
render({}: OverviewProps, {}: OverviewState) { | |||
return ( | |||
<main class={classes.overview}> | |||
<h2>Your annotation library</h2> | |||
<p>Below are the annotation from feeds you are subscribed to, and other annotations you imported to your annotation library.</p> | |||
<AnnotationSourcesList withAnnotations /> | |||
</main> | |||
); | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
<!DOCTYPE html> | |||
<script src="./index.tsx" type="module"></script> | |||
<div id="app"></div> |
@@ -0,0 +1,9 @@ | |||
import '../webextension-polyfill'; | |||
import { h, render } from 'preact'; | |||
import { Overview } from './Overview'; | |||
main(); | |||
async function main() { | |||
const container = document.getElementById('app')!; | |||
render(<Overview />, container); | |||
} |
@@ -0,0 +1,12 @@ | |||
html { | |||
background-color: #f8ffff; | |||
} | |||
body { | |||
font-family: sans-serif; | |||
font-size: 17px; | |||
} | |||
a { | |||
color: unset; | |||
} |
@@ -0,0 +1 @@ | |||
@import '../common-classes.scss'; |
@@ -0,0 +1,145 @@ | |||
import { Component, h, Fragment, createRef } from 'preact'; | |||
import cls from 'classnames'; | |||
import { AnnotationSource } from '../storage/AnnotationSource'; | |||
import classes from './AnnotationStoreSelector.module.scss'; | |||
interface AnnotationStoreSelectorProps {} | |||
interface AnnotationStoreSelectorState { | |||
possiblyWritableSources?: AnnotationSource[]; | |||
selectedSource?: AnnotationSource | null; | |||
testSuccess?: boolean; | |||
} | |||
export class AnnotationStoreSelector extends Component< | |||
AnnotationStoreSelectorProps, | |||
AnnotationStoreSelectorState | |||
> { | |||
state: AnnotationStoreSelectorState = {}; | |||
selectElement = createRef<HTMLSelectElement>(); | |||
testButton = createRef<HTMLButtonElement>(); | |||
override async componentDidMount() { | |||
await this.loadData(); | |||
} | |||
async loadData() { | |||
const possiblyWritableSources = | |||
await AnnotationSource.getPossiblyWritableSources(); | |||
const selectedSource = possiblyWritableSources.find( | |||
(source) => source.data.useForNewAnnotations, | |||
); | |||
this.setState({ | |||
possiblyWritableSources: possiblyWritableSources, | |||
selectedSource, | |||
}); | |||
} | |||
async onChange() { | |||
const optionValue = this.selectElement.current?.value; | |||
const selectedSourceId = optionValue ? +optionValue : null; | |||
const selectedSource = selectedSourceId | |||
? await AnnotationSource.get(selectedSourceId) | |||
: null; | |||
this.setState({ selectedSource, testSuccess: false }); | |||
} | |||
async testCreateAnnotation() { | |||
this.testButton.current?.classList.add(classes.loading); | |||
const { selectedSource } = this.state; | |||
try { | |||
if (!selectedSource) throw new Error('No source selected to test.'); | |||
await selectedSource.testWritable(); | |||
this.setState({ testSuccess: true }); | |||
// Flag the selected source, unflag the others. | |||
const allSources = await AnnotationSource.getAll(); | |||
await Promise.all( | |||
allSources.map((source) => | |||
source.useForNewAnnotations( | |||
source.data._id === selectedSource.data._id, | |||
), | |||
), | |||
); | |||
await this.loadData(); | |||
} catch (error) { | |||
this.setState({ testSuccess: false }); | |||
alert(error); | |||
} finally { | |||
this.testButton.current?.classList.remove(classes.loading); | |||
} | |||
} | |||
render( | |||
{}: AnnotationStoreSelectorProps, | |||
{ | |||
possiblyWritableSources, | |||
selectedSource, | |||
testSuccess, | |||
}: AnnotationStoreSelectorState, | |||
) { | |||
// List sources, sorting previously connected ones first. | |||
const optionsList = possiblyWritableSources | |||
?.sort((a, b) => | |||
a.data.writable === b.data.writable ? 0 : b.data.writable ? 1 : -1, | |||
) | |||
.map((source) => ( | |||
<option | |||
value={source.data._id} | |||
selected={source.data._id === selectedSource?.data._id} | |||
> | |||
{source.data.writable ? '✓' : '?'} {source.data.title} < | |||
{source.data.url}> | |||
</option> | |||
)); | |||
if (optionsList?.length === 0) { | |||
return ( | |||
<p> | |||
To create annotations, first subscribe to a collection (that you have | |||
write access to). | |||
</p> | |||
); | |||
} | |||
return ( | |||
<> | |||
<p>Choose the collection to store/publish annotations you create:</p> | |||
<select | |||
ref={this.selectElement} | |||
value={`${selectedSource?.data._id ?? ''}`} | |||
onChange={(e) => this.onChange()} | |||
class={classes.select} | |||
> | |||
<option value="">None</option> | |||
{optionsList} | |||
</select> | |||
{selectedSource && ( | |||
<> | |||
<button | |||
onClick={() => this.testCreateAnnotation()} | |||
title="Click to test if you can create an annotation in the chosen collection. Your browser may prompt you to provide login credentials." | |||
ref={this.testButton} | |||
class={cls(classes.button, { [classes.success]: testSuccess })} | |||
> | |||
{testSuccess | |||
? 'Connected' | |||
: selectedSource.data.writable | |||
? selectedSource.data.needsAuth | |||
? 'Reconnect' | |||
: 'Check' | |||
: 'Connect'} | |||
</button> | |||
{selectedSource.data.writable && selectedSource.data.needsAuth && ( | |||
<p> | |||
Your browser appears to have lost write access to the | |||
collection. Please click reconnect to try log in again. | |||
</p> | |||
)} | |||
</> | |||
)} | |||
</> | |||
); | |||
} | |||
} |
@@ -0,0 +1,15 @@ | |||
@import '../page-style.scss'; | |||
@import '../common-classes.scss'; | |||
body { | |||
margin: 1em; | |||
width: 25em; | |||
} | |||
h1 { | |||
font-size: 18px; | |||
} | |||
h2 { | |||
font-size: 16px; | |||
} |
@@ -0,0 +1,33 @@ | |||
import { Component, h, Fragment } from 'preact'; | |||
import { AnnotationSourcesList } from '../components/AnnotationSourcesList'; | |||
import { AnnotationStoreSelector } from './AnnotationStoreSelector'; | |||
import classes from './Popup.module.scss'; | |||
interface PopupProps {} | |||
interface PopupState {} | |||
export class Popup extends Component<PopupProps, PopupState> { | |||
state: PopupState = {}; | |||
render({}: PopupProps, {}: PopupState) { | |||
return ( | |||
<> | |||
<h1>Web Annotator</h1> | |||
<h2>Creating annotations</h2> | |||
<AnnotationStoreSelector /> | |||
<h2>Your annotation library:</h2> | |||
<AnnotationSourcesList withAnnotations={false} /> | |||
<br /> | |||
<a | |||
class={classes.button} | |||
href={browser.runtime.getURL('overview/index.html')} | |||
target="_blank" | |||
> | |||
Show all stored annotations. | |||
</a> | |||
</> | |||
); | |||
} | |||
} |
@@ -0,0 +1,4 @@ | |||
<!DOCTYPE html> | |||
<script src="./index.tsx" type="module"></script> | |||
<div id="app"></div> |
@@ -0,0 +1,9 @@ | |||
import '../webextension-polyfill'; | |||
import { h, render } from 'preact'; | |||
import { Popup } from './Popup'; | |||
main(); | |||
async function main() { | |||
const container = document.getElementById('app')!; | |||
render(<Popup />, container); | |||
} |
@@ -0,0 +1,119 @@ | |||
import { db } from './db'; | |||
import { AnnotationSource, IAnnotationSource } from './AnnotationSource'; | |||
import { targetsUrl } from 'web-annotation-utils'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
// Annotation model in database | |||
export class IAnnotation { | |||
constructor( | |||
public _id: number, | |||
public annotation: WebAnnotation, | |||
public source: IAnnotationSource['_id'], | |||
public dirty?: boolean, | |||
public toDelete?: boolean, | |||
public lastModified?: Date, | |||
) {} | |||
} | |||
// Expanded form, i.e. with source nested. | |||
export type IAnnotationWithSource = Omit<IAnnotation, 'source'> & { | |||
source: IAnnotationSource; | |||
}; | |||
export class Annotation { | |||
constructor(public data: IAnnotation) {} | |||
async save() { | |||
await db.annotations.put({ | |||
...this.data, | |||
lastModified: new Date(), | |||
}); | |||
} | |||
async setDirty(value: boolean, data?: Partial<IAnnotation>) { | |||
this.data.dirty = value; | |||
Object.assign(this.data, data); | |||
await this.save(); | |||
} | |||
async update(webAnnotation: WebAnnotation) { | |||
await this.setDirty(true, { annotation: webAnnotation }); | |||
const source = await this.source(); | |||
await source.uploadAnnotation(this.data.annotation); | |||
await this.setDirty(false); | |||
} | |||
async delete() { | |||
// Could not delete it upstream. Mark it as waiting for deletion. | |||
await this.setDirty(true, { toDelete: true }); | |||
const source = await this.source(); | |||
try { | |||
await source.deleteAnnotationRemotely(this.data.annotation); | |||
await db.annotations.delete(this.data._id); | |||
} catch (error: any) { | |||
throw new Error( | |||
`Failed to delete: ${error.message} (The annotation should be deleted on a subsequent refresh)`, | |||
); | |||
} | |||
} | |||
async source() { | |||
return await AnnotationSource.get(this.data.source); | |||
} | |||
async expand(): Promise<IAnnotationWithSource> { | |||
return { | |||
...this.data, | |||
source: (await this.source()).data, | |||
}; | |||
} | |||
static async new(data: Omit<IAnnotation, '_id'>) { | |||
// @ts-ignore: _id is not needed in put() | |||
const annotation: IAnnotation = { | |||
...data, | |||
lastModified: new Date(), | |||
} | |||
const key = (await db.annotations.put(annotation)) as IAnnotation['_id']; | |||
return new this({ ...data, _id: key }); | |||
} | |||
static async count() { | |||
return await db.annotations.count(); | |||
} | |||
static async get(id: IAnnotation['_id']): Promise<Annotation> { | |||
const data = await db.annotations.get(id); | |||
if (!data) throw new Error(`No annotation exists with id ${id}.`); | |||
return new Annotation(data); | |||
} | |||
static async getAll(): Promise<Annotation[]> { | |||
const annotations = await db.annotations.toArray(); | |||
return annotations.map((data) => new this(data)); | |||
} | |||
static async getAnnotationsForUrls(urls: string[]) { | |||
// TODO Use the index again, somehow. May need a separate field with a multiEntry index. | |||
const matches = await db.annotations | |||
// .where('annotation.target') | |||
// .startsWith(pageUrl) | |||
// .or('annotation.target.source') | |||
// .startsWith(pageUrl) | |||
// .or('annotation.target.id') | |||
// .startsWith(pageUrl) | |||
.filter((item) => | |||
urls.some((url) => targetsUrl(item.annotation.target, url)), | |||
) | |||
.toArray(); | |||
return matches.map((data) => new this(data)); | |||
} | |||
static async getAnnotationsFromSource(source: number): Promise<Annotation[]> { | |||
const annotations = await db.annotations | |||
.where('source') | |||
.equals(source) | |||
.toArray(); | |||
return annotations.map((data) => new this(data)); | |||
} | |||
} |
@@ -0,0 +1,482 @@ | |||
import { RpcClient } from 'webextension-rpc'; | |||
import { contentScriptRpcServer } from '../content_script'; | |||
import { asArray, asSingleValue, completeAnnotationStub } from 'web-annotation-utils'; | |||
import type { WebAnnotation, ZeroOrMore } from 'web-annotation-utils'; | |||
import { Annotation } from './Annotation'; | |||
import { db } from './db'; | |||
export type AnnotationSourceType = 'container' | 'embeddedJsonld'; | |||
export type AnnotationSourceAuthType = 'HttpBasicAuth'; | |||
export class IAnnotationSource { | |||
constructor( | |||
public _id: number, | |||
public url: string, | |||
public type: AnnotationSourceType, | |||
public active?: boolean, | |||
public title?: string, | |||
public writable?: boolean, | |||
public useForNewAnnotations?: boolean, | |||
public needsAuth?: boolean, | |||
public lastUpdate?: Date, | |||
public lastModified?: Date, | |||
) {} | |||
} | |||
/** | |||
* The information needed to subscribe to a source. | |||
*/ | |||
export type AnnotationSourceDescriptor = Pick< | |||
IAnnotationSource, | |||
'url' | 'title' | 'type' | |||
>; | |||
export class AnnotationSource { | |||
static sourceUpdatePeriod = 10 * 60; | |||
constructor(public data: IAnnotationSource) {} | |||
protected async save() { | |||
await db.annotationSources.put({ | |||
...this.data, | |||
lastModified: new Date(), | |||
}); | |||
} | |||
async delete() { | |||
await this.deleteAnnotationsLocally(); | |||
await db.annotationSources.delete(this.data._id); | |||
console.log(`Deleted source ${this.data.url}`); | |||
} | |||
protected async deleteAnnotationsLocally() { | |||
const count = await db.annotations | |||
.where('source') | |||
.equals(this.data._id) | |||
.delete(); | |||
console.log(`Deleted ${count} annotations for source ${this.data.url}`); | |||
} | |||
/** | |||
* Reload all annotations from this source. | |||
* @param force Also refresh if the source is not active. | |||
*/ | |||
async refresh(force = false) { | |||
if (!(this.data.active || force)) return; | |||
if (this.data.writable) { | |||
await this.uploadDirtyAnnotations(); | |||
} | |||
const webAnnotations = await this.fetchAllAnnotations(); | |||
// Delete all existing items from this source, to avoid duplicates/zombies. | |||
// TODO Make this a little smarter. | |||
await this.deleteAnnotationsLocally(); | |||
// Insert annotations. | |||
await Promise.all( | |||
webAnnotations.map(async (webAnnotation) => { | |||
await Annotation.new({ | |||
annotation: webAnnotation, | |||
source: this.data._id, | |||
}); | |||
}), | |||
); | |||
console.log( | |||
`Inserted ${webAnnotations.length} annotations for source ${this.data.url}`, | |||
); | |||
// Update source metadata. | |||
this.data.lastUpdate = new Date(); | |||
await this.save(); | |||
} | |||
protected async fetchAllAnnotations(): Promise<WebAnnotation[]> { | |||
if (this.data.type === 'container') | |||
return await getAllAnnotationsFromContainerSource(this.data.url); | |||
if (this.data.type === 'embeddedJsonld') | |||
return await getAnnotationsFromEmbeddedJsonld(this.data.url); | |||
throw new Error( | |||
`Getting annotations from source of type '${this.data.type}' is not yet implemented.`, | |||
); | |||
} | |||
protected async createAnnotation(annotationStub: Partial<WebAnnotation>) { | |||
const webAnnotation = completeAnnotationStub(annotationStub); | |||
const annotation = await Annotation.new({ | |||
annotation: webAnnotation, | |||
source: this.data._id, | |||
dirty: true, | |||
}); | |||
try { | |||
const createdAnnotation = await this.postAnnotation( | |||
annotation.data.annotation, | |||
); | |||
await annotation.setDirty(false, { annotation: createdAnnotation }); | |||
return annotation; | |||
} catch (error: any) { | |||
throw new Error( | |||
`Error while uploading created annotation: ${error.message}`, | |||
); | |||
} | |||
} | |||
async uploadAnnotation(annotation: WebAnnotation) { | |||
if (!annotation.id) await this.postAnnotation(annotation); | |||
else await this.putAnnotation(annotation); | |||
} | |||
protected async postAnnotation(annotation: WebAnnotation) { | |||
const webAnnotation: Omit<WebAnnotation, 'id'> & { | |||
id?: WebAnnotation['id']; | |||
} = { ...annotation }; | |||
delete webAnnotation.id; | |||
// This one was not yet uploaded. POST it. | |||
const response = await fetch(this.data.url, { | |||
method: 'POST', | |||
credentials: 'include', | |||
headers: { | |||
'Content-Type': | |||
'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
Accept: | |||
'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
}, | |||
body: JSON.stringify(webAnnotation), | |||
}); | |||
if (response.status !== 201) { | |||
if (response.status === 401) { | |||
this.data.needsAuth = true; | |||
} | |||
throw new Error( | |||
`Could not POST the annotation, got: ${response.status} ${response.statusText} (expected: 201 Created)`, | |||
); | |||
} | |||
const location = response.headers.get('Location'); | |||
if (!location) | |||
throw new Error( | |||
'Server did not provide Location header for created annotation.', | |||
); | |||
const locationUrl = new URL(location, response.url).href; | |||
const contentLocation = response.headers.get('Content-Location'); | |||
const contentLocationUrl = | |||
contentLocation && new URL(contentLocation, response.url).href; | |||
// Replace the local annotation with the one from the server, to update its id (and possibly other properties). | |||
let createdAnnotation: WebAnnotation; | |||
if (contentLocationUrl === locationUrl) { | |||
// Easy: the server responded with the annotation itself. | |||
createdAnnotation = await response.json(); | |||
} else { | |||
// If we did not receive it, then we fetch it. | |||
createdAnnotation = await resolveSingle(locationUrl); | |||
} | |||
// TODO better validation. | |||
if (!createdAnnotation.target) { | |||
throw new Error('Server returned something else than an annotation.'); | |||
} | |||
return createdAnnotation; | |||
} | |||
protected async putAnnotation(annotation: WebAnnotation) { | |||
// This annotation exists already. PUT it. | |||
const annotationUrl = annotation.id; | |||
if (!annotationUrl.startsWith(this.data.url)) { | |||
throw new Error( | |||
`Annotation to be updated is not part of this collection.`, | |||
); | |||
} | |||
const response = await fetch(annotationUrl, { | |||
method: 'PUT', | |||
credentials: 'include', | |||
headers: { | |||
'Content-Type': | |||
'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
Accept: | |||
'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
}, | |||
body: JSON.stringify(annotation), | |||
}); | |||
if (!response.ok) { | |||
if (response.status === 401) { | |||
this.data.needsAuth = true; | |||
} | |||
throw new Error( | |||
`Could not POST the annotation, got: ${response.status} ${response.statusText}`, | |||
); | |||
} | |||
} | |||
async uploadDirtyAnnotations() { | |||
const dirtyAnnotations = await db.annotations | |||
.where('source') | |||
.equals(this.data._id) | |||
.filter(({ dirty }) => !!dirty) | |||
.toArray(); | |||
// PUT/POST each one individually. | |||
await Promise.all( | |||
dirtyAnnotations | |||
.map((annotation) => new Annotation(annotation)) | |||
.map(async (annotation) => { | |||
if (annotation.data.toDelete) await annotation.delete(); | |||
else await this.uploadAnnotation(annotation.data.annotation); | |||
}), | |||
); | |||
} | |||
async deleteAnnotationRemotely(annotation: WebAnnotation) { | |||
const annotationUrl = annotation.id; | |||
if (!annotationUrl.startsWith(this.data.url)) { | |||
throw new Error( | |||
`Annotation to be deleted is not part of this collection.`, | |||
); | |||
} | |||
const response = await fetch(annotationUrl, { | |||
method: 'DELETE', | |||
credentials: 'include', | |||
headers: { | |||
'Content-Type': | |||
'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
Accept: | |||
'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
}, | |||
}); | |||
if (!response.ok) { | |||
if (response.status === 401) { | |||
this.data.needsAuth = true; | |||
} | |||
throw new Error( | |||
`Could not delete the annotation, got: ${response.status} ${response.statusText}`, | |||
); | |||
} | |||
} | |||
async testWritable() { | |||
const createdAnnotation = await this.postAnnotation( | |||
completeAnnotationStub({ | |||
target: 'http://example.com/page1', | |||
bodyValue: 'Test annotation, should have been deleted directly', | |||
}), | |||
); | |||
this.data.writable = true; | |||
await this.save(); | |||
await this.deleteAnnotationRemotely(createdAnnotation); | |||
} | |||
async useForNewAnnotations(value: boolean) { | |||
this.data.useForNewAnnotations = value; | |||
await this.save(); | |||
} | |||
static async new(data: Omit<IAnnotationSource, '_id'>) { | |||
// @ts-ignore: _id is not needed in put() | |||
const source: IAnnotationSource = { | |||
...data, | |||
lastModified: new Date(), | |||
}; | |||
const key = (await db.annotationSources.put( | |||
source, | |||
)) as IAnnotationSource['_id']; | |||
return new this({ ...data, _id: key }); | |||
} | |||
static async addSource( | |||
sourceDescriptor: AnnotationSourceDescriptor, | |||
active?: boolean, | |||
) { | |||
if (await this.exists(sourceDescriptor)) return; | |||
// Auto-update from containers, but not from pages with embedded annotations. | |||
if (active === undefined) { | |||
active = sourceDescriptor.type === 'container'; | |||
} | |||
const source = await AnnotationSource.new({ | |||
url: sourceDescriptor.url, | |||
title: sourceDescriptor.title, | |||
type: sourceDescriptor.type, | |||
active, | |||
}); | |||
await source.refresh(true); | |||
return source; | |||
} | |||
static async exists(sourceDescriptor: AnnotationSourceDescriptor) { | |||
const source = await db.annotationSources | |||
.filter((source) => source.url === sourceDescriptor.url) | |||
.first(); | |||
return source !== undefined; | |||
} | |||
static async get(id: IAnnotationSource['_id']) { | |||
const source = await db.annotationSources.get(id); | |||
if (!source) throw new Error(`No annotation source exists with id ${id}.`); | |||
return new AnnotationSource(source); | |||
} | |||
static async getByUrl(url: string) { | |||
const source = await db.annotationSources | |||
.filter((source) => source.url === url) | |||
.first(); | |||
if (!source) | |||
throw new Error(`No annotation source exists with url ${url}.`); | |||
return new AnnotationSource(source); | |||
} | |||
static async getAll() { | |||
const sources = await db.annotationSources.toArray(); | |||
return sources.map((source) => new AnnotationSource(source)); | |||
} | |||
static async getActiveSources() { | |||
// How to do this nicely in Dexie? | |||
const sources = await db.annotationSources.toArray(); | |||
const activeSources = sources.filter(({ active }) => active); | |||
return activeSources.map((source) => new AnnotationSource(source)); | |||
} | |||
static async getPossiblyWritableSources() { | |||
const sources = await this.getActiveSources(); | |||
return sources.filter((source) => source.data.type === 'container'); | |||
} | |||
static async getSourcesNeedingUpdate() { | |||
const cutoffDate = new Date(Date.now() - this.sourceUpdatePeriod * 1000); | |||
// Using filters; the where() clause cannot get items with lastUpdate===undefined | |||
const sources = await db.annotationSources | |||
// .where('lastUpdate') | |||
// .belowOrEqual(cutoffDate) | |||
// .and((annotationSource) => annotationSource.active) | |||
.filter(({ lastUpdate }) => !lastUpdate || lastUpdate < cutoffDate) | |||
.filter(({ active }) => !!active) | |||
.toArray(); | |||
return sources.map((source) => new AnnotationSource(source)); | |||
} | |||
static async createAnnotation( | |||
annotationStub: Partial<WebAnnotation>, | |||
sourceId?: AnnotationSource['data']['_id'], | |||
) { | |||
let sourceObjs; | |||
if (sourceId) { | |||
sourceObjs = [await AnnotationSource.get(sourceId)]; | |||
} else { | |||
sourceObjs = await AnnotationSource.getPossiblyWritableSources(); | |||
if (sourceObjs.length === 0) | |||
throw new Error( | |||
'Please first subscribe to the annotation collection where you want to store your annotations.', | |||
); | |||
sourceObjs = sourceObjs.filter( | |||
(sourceObj) => sourceObj.data.useForNewAnnotations, | |||
); | |||
if (sourceObjs.length === 0) | |||
throw new Error( | |||
'Please select (in the extension’s popup menu) in which collection to store your annotations.', | |||
); | |||
} | |||
// There should only be one source marked with useForNewAnnotations. | |||
const createdAnnotation = await sourceObjs[0].createAnnotation( | |||
annotationStub, | |||
); | |||
return createdAnnotation; | |||
} | |||
} | |||
async function getAllAnnotationsFromContainerSource( | |||
sourceUrl: string, | |||
): Promise<WebAnnotation[]> { | |||
console.log(`Fetching annotations from ${sourceUrl}`); | |||
const annotationSourceData = await resolveSingle(sourceUrl); | |||
// Check what type of source we got. | |||
const nodeTypes = asArray(annotationSourceData.type); | |||
// If the source is a single annotation, import that one. | |||
if (nodeTypes.includes('Annotation')) { | |||
return [annotationSourceData]; | |||
} | |||
// If the source is an annotation container/collection, import all its items. | |||
if (nodeTypes.includes('AnnotationCollection')) { | |||
// Read the first page of annotations. | |||
let page = await resolveSingle(annotationSourceData.first); | |||
const annotations: WebAnnotation[] = asArray(page.items); | |||
// Fetch the subsequent pages, if any. | |||
while ((page = await resolveSingle(page.next))) { | |||
annotations.push(...asArray(page.items)); | |||
} | |||
return annotations; | |||
} | |||
throw new Error( | |||
`Annotation source is neither AnnotationCollection nor Annotation.`, | |||
); | |||
} | |||
/** | |||
* Given the value of an `@id`-property, get the ‘actual’ value: | |||
* - if the node is nested, return value as-is. | |||
* - if the value is a string (= a URL), fetch and return its data. | |||
* - if there is no value, return `undefined`. | |||
* - if there are multiple values, process only the first. | |||
* | |||
* TODO Consider using json-ld tools: | |||
* - https://github.com/LDflex/LDflex/ | |||
* - https://github.com/assemblee-virtuelle/LDP-navigator | |||
*/ | |||
async function resolveSingle( | |||
valuesOrIds: ZeroOrMore<string> | object, | |||
): Promise<undefined | any> { | |||
const valueOrId = asSingleValue(valuesOrIds); | |||
// If it’s a value (or undefined), we are done. | |||
if (typeof valueOrId !== 'string') return valueOrId; | |||
// It’s an id, i.e. a URL. (TODO use correct base for relative URLs) | |||
const response = await fetch(valueOrId, { | |||
headers: { | |||
Accept: 'application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"', | |||
Prefer: | |||
'return=representation;include="http://www.w3.org/ns/oa#PreferContainedDescriptions"', | |||
}, | |||
cache: 'no-cache', | |||
}); | |||
let data; | |||
try { | |||
data = await response.json(); | |||
} catch (error) { | |||
throw new Error(`Received invalid JSON from URL <${valueOrId}>, ${error}`); | |||
} | |||
if (typeof data !== 'object') | |||
throw new Error('Response is valid JSON but not an object.'); | |||
return data; | |||
} | |||
/** | |||
* Extract the annotations embedded in a page, via the content script. | |||
* Only works if the page is opened. (though we could fetch&parse the html ourselves) | |||
*/ | |||
async function getAnnotationsFromEmbeddedJsonld( | |||
url: string, | |||
): Promise<WebAnnotation[]> { | |||
const tabs = await browser.tabs.query({}); | |||
const sourceTab = tabs.find((tab) => tab.url?.startsWith(url.split('#')[0])); | |||
if (sourceTab) { | |||
const contentScriptRpc = new RpcClient<typeof contentScriptRpcServer>({ | |||
tabId: sourceTab.id, | |||
}); | |||
const annotations = await contentScriptRpc.func( | |||
'discoverAnnotationsEmbeddedAsJSONLD', | |||
)(); | |||
return annotations; | |||
} else { | |||
throw new Error( | |||
`To refresh annotations extracted from a web page, first open that page.`, | |||
); | |||
} | |||
} |
@@ -0,0 +1,18 @@ | |||
import Dexie, { Table } from 'dexie'; | |||
import { IAnnotation } from './Annotation'; | |||
import { IAnnotationSource } from './AnnotationSource'; | |||
export class AnnotationDexie extends Dexie { | |||
annotations!: Table<IAnnotation>; | |||
annotationSources!: Table<IAnnotationSource>; | |||
constructor() { | |||
super('AnnotationDB'); | |||
this.version(3).stores({ | |||
annotations: '++_id, source', | |||
annotationSources: '++_id', | |||
}); | |||
} | |||
} | |||
export const db = new AnnotationDexie(); |
@@ -0,0 +1,150 @@ | |||
import { | |||
createCssSelectorMatcher, | |||
createTextPositionSelectorMatcher, | |||
createTextQuoteSelectorMatcher, | |||
makeCreateRangeSelectorMatcher as AAmakeCreateRangeSelectorMatcher, | |||
} from '@apache-annotator/dom'; | |||
import { | |||
makeRefinable, | |||
Matcher, | |||
TextPositionSelector, | |||
TextQuoteSelector, | |||
} from '@apache-annotator/selector'; | |||
import { asArray } from 'web-annotation-utils'; | |||
import type { OnlyOne, Selector, WebAnnotation } from 'web-annotation-utils'; | |||
/** | |||
* Find the Elements and/or Ranges in the document the annotation targets, if | |||
* any. | |||
* | |||
* This supports the following selector types: | |||
* - CssSelector | |||
* - TextQuoteSelector | |||
* - TextPositionSelector | |||
* - RangeSelector | |||
*/ | |||
export async function findTargetsInDocument( | |||
target: WebAnnotation['target'], | |||
document = window.document, | |||
): Promise<DomMatch[]> { | |||
// Process all targets (there may be multiple) | |||
const targets = await Promise.all( | |||
asArray(target).map((target) => findTargetInDocument(target, document)), | |||
); | |||
// A target might match in multiple places (e.g. TextQuoteSelector), each of which counts as a target. | |||
return targets.flat(); | |||
} | |||
/** | |||
* Find the Elements and/or Ranges in the document the annotation targets, if | |||
* any, given a single target. | |||
* | |||
* This supports the following selector types: | |||
* - CssSelector | |||
* - TextQuoteSelector | |||
* - TextPositionSelector | |||
* - RangeSelector | |||
*/ | |||
export async function findTargetInDocument( | |||
target: OnlyOne<WebAnnotation['target']>, | |||
document = window.document, | |||
): Promise<DomMatch[]> { | |||
// If it targets the whole document, there are no targets. | |||
if (typeof target === 'string') { | |||
// This string *could* be referring to a non-nested node that contains the actual target URL. | |||
// We’re not going to fetch that here, but simply assume it refers to the target document. | |||
return []; | |||
} | |||
// An External Resource: also targets the whole document. | |||
if (!('source' in target)) return []; | |||
// A SpecificResource without a selector, no fun either. | |||
if (!target.selector) return []; | |||
// The selector could be an external node. We’ll not bother fetching that here. | |||
if (typeof target.selector === 'string') { | |||
throw new Error( | |||
'Annotation target does not include its selector; fetching it is not implemented.', | |||
); | |||
} | |||
// Use the first selector we understand. (“Multiple Selectors SHOULD select the same content”) | |||
// TODO Take the more precise one; retry with others if the first fails; perhaps combine e.g. Position+Quote for speedup. | |||
const selector = asArray(target.selector).find( | |||
(selector) => selector.type && selector.type in supportedSelectorTypes, | |||
); | |||
if (!selector) return []; | |||
const targetInDom = await matchSelector(selector, document); | |||
return targetInDom; | |||
} | |||
const supportedSelectorTypes = { | |||
CssSelector: null, | |||
TextQuoteSelector: null, | |||
TextPositionSelector: null, | |||
RangeSelector: null, | |||
}; | |||
type SupportedSelector = MaybeRefined< | |||
TextQuoteSelector | TextPositionSelector | RangeSelector<SupportedSelector> | |||
>; | |||
type DomScope = Node | Range; | |||
type DomMatch = Element | Range; | |||
type DomMatcher = Matcher<DomScope, DomMatch>; | |||
// TODO fix type issues | |||
const createMatcher: (selector: SupportedSelector) => DomMatcher = | |||
// @ts-ignore | |||
makeRefinable<SupportedSelector, DomScope, DomMatch>((selector) => { | |||
const createMatcherFunctions = { | |||
CssSelector: createCssSelectorMatcher, | |||
TextQuoteSelector: createTextQuoteSelectorMatcher, | |||
TextPositionSelector: createTextPositionSelectorMatcher, | |||
RangeSelector: | |||
// @ts-ignore | |||
makeCreateRangeSelectorMatcher<SupportedSelector>(createMatcher), | |||
}; | |||
const innerCreateMatcher = createMatcherFunctions[selector.type]; | |||
// @ts-ignore | |||
return innerCreateMatcher(selector); | |||
}); | |||
async function matchSelector( | |||
selector: Selector, | |||
scope: DomScope = window.document, | |||
): Promise<DomMatch[]> { | |||
if (!(selector.type && selector.type in supportedSelectorTypes)) | |||
throw new Error(`Unsupported selector type: ${selector.type}`); | |||
const matches: DomMatch[] = []; | |||
const matchGenerator = createMatcher(selector as SupportedSelector)(scope); | |||
for await (const match of matchGenerator) { | |||
matches.push(match); | |||
} | |||
return matches; | |||
} | |||
// Type modifications for Apache Annotator (TODO apply upstream) | |||
type MaybeRefined<T extends Selector> = T & { refinedBy?: T }; | |||
interface RangeSelector<T extends Selector = Selector> extends Selector { | |||
type: 'RangeSelector'; | |||
startSelector: T; | |||
endSelector: T; | |||
} | |||
function makeCreateRangeSelectorMatcher<T extends Selector>( | |||
createMatcher: <TMatch extends Node | Range>( | |||
selector: T, | |||
) => Matcher<Node | Range, TMatch>, | |||
): (selector: RangeSelector<T>) => Matcher<DomScope, Range> { | |||
// @ts-ignore | |||
return AAmakeCreateRangeSelectorMatcher(createMatcher); | |||
} |
@@ -0,0 +1,52 @@ | |||
function hourString(date: Date) { | |||
// return date.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'}) | |||
return `${('0' + date.getHours()).slice(-2)}:${('0' + date.getMinutes()).slice(-2)}` | |||
} | |||
function dayString(date: Date) { | |||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] | |||
return days[date.getDay()] | |||
} | |||
function monthString(date: Date) { | |||
const months = ['Jan', 'Feb', 'March', 'Apr', 'May', 'June', 'July', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec'] | |||
return months[date.getMonth()] | |||
} | |||
// Get something nicely readable - at least to my personal taste. | |||
export default function niceTime(date: Date, { now = undefined }: { now?: Date } = {}) { | |||
const then = new Date(date) | |||
now = now || new Date() | |||
const secondsAgo = (+now - +then) / 1000 | |||
if (secondsAgo < -60 * 60 * 24) { | |||
return 'in the future?!' | |||
} | |||
if (secondsAgo < -60) { | |||
return 'soon?!' | |||
} | |||
if (secondsAgo < 2) { return 'now' } | |||
if (secondsAgo < 60) { return `seconds ago` } | |||
// if (secondsAgo < 60) { return `${Math.round(secondsAgo)} seconds ago` } | |||
if (secondsAgo < 120) { return 'a minute ago' } | |||
if (secondsAgo < 60 * 10) { return `${Math.round(secondsAgo / 60)} minutes ago` } | |||
if (secondsAgo < 60 * 60) { return `${Math.round(secondsAgo / 60 / 5) * 5} minutes ago` } | |||
if (secondsAgo < 60 * 60 * 24 | |||
&& (now.getDay() === then.getDay() || secondsAgo < 60 * 60 * 6)) { return hourString(then) } | |||
if (secondsAgo < 60 * 60 * 24) { return `Yesterday ${hourString(then)}` } | |||
if (secondsAgo < 60 * 60 * 24 * 3) { return `${dayString(then)} ${hourString(then)}` } | |||
if (then.getFullYear() === now.getFullYear()) { return `${then.getDate()} ${monthString(then)}` } | |||
return `${then.getDate()} ${monthString(then)} ${then.getFullYear()}` | |||
} | |||
export function niceDate(date: Date, { now = new Date() } = {}) { | |||
const then = new Date(date) | |||
if (then.getFullYear() !== now.getFullYear()) { | |||
return `${then.getDate()} ${monthString(then)} ${then.getFullYear()}` | |||
} | |||
if (then.getMonth() === now.getMonth()) { | |||
const daysAgo = now.getDate() - then.getDate() | |||
if (daysAgo === 0) return `Today` | |||
if (daysAgo === 1) return `Yesterday` | |||
} | |||
return `${dayString(then)} ${then.getDate()} ${monthString(then)}` | |||
} |
@@ -0,0 +1,49 @@ | |||
const notifications = new Map(); | |||
export default async function notify({ | |||
type = 'basic', | |||
iconUrl = transparent1pixelPng, | |||
message = '', | |||
title = '', | |||
onClicked = () => {}, | |||
...otherProps | |||
}: Partial< | |||
browser.notifications.CreateNotificationOptions & { | |||
onClicked: () => void; | |||
} | |||
>) { | |||
const notificationId = await browser.notifications.create({ | |||
type, | |||
iconUrl, | |||
title, | |||
message, | |||
...otherProps, | |||
}); | |||
notifications.set(notificationId, { onClicked }); | |||
if (!browser.notifications.onClicked.hasListener(clickedListener)) { | |||
browser.notifications.onClicked.addListener(clickedListener); | |||
} | |||
if (!browser.notifications.onClosed.hasListener(closedListener)) { | |||
browser.notifications.onClosed.addListener(closedListener); | |||
} | |||
} | |||
function clickedListener(notificationId: string) { | |||
if (notifications.has(notificationId)) { | |||
notifications.get(notificationId).onClicked(); | |||
} | |||
} | |||
function closedListener(notificationId: string) { | |||
if (notifications.has(notificationId)) { | |||
notifications.delete(notificationId); | |||
if (notifications.size === 0) { | |||
browser.notifications.onClosed.removeListener(clickedListener); | |||
browser.notifications.onClosed.removeListener(closedListener); | |||
} | |||
} | |||
} | |||
const transparent1pixelPng = | |||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIAAAUAAeImBZsAAAAASUVORK5CYII='; |
@@ -0,0 +1,34 @@ | |||
/** | |||
* Return an array equal to the given one, but with duplicate items removed. | |||
* @param items The array to deduplicate. Not mutated. | |||
* @param getItemId Get the identifier to compare sameness of two items. | |||
* Defaults to the item itself, so sameness means strict equality | |||
* (`item1 === item2`). | |||
* @param merge Callback function to merge duplicate items if desired; the | |||
* returned value will take the place of the first item. | |||
* @returns A new array. | |||
*/ | |||
export function unique<T>( | |||
items: T[], | |||
getItemId: (item: T) => any = (item) => item, | |||
merge?: (item1: T, item2: T) => T, | |||
): T[] { | |||
const seen = new Set(); | |||
const newItems: T[] = []; | |||
for (const item of items) { | |||
const id = getItemId(item); | |||
if (id === undefined || !seen.has(id)) { | |||
newItems.push(item); | |||
seen.add(id); | |||
} else if (merge) { | |||
const indexOfConflictingItem = newItems.findIndex( | |||
(newItem) => getItemId(newItem) === id, | |||
); | |||
newItems[indexOfConflictingItem] = merge( | |||
newItems[indexOfConflictingItem], | |||
item, | |||
); | |||
} | |||
} | |||
return newItems; | |||
} |
@@ -0,0 +1,2 @@ | |||
import browser from 'webextension-polyfill' | |||
globalThis.browser = browser |
@@ -0,0 +1,16 @@ | |||
{ | |||
"compilerOptions": { | |||
"strict": true, | |||
"lib": ["DOM", "DOM.Iterable"], | |||
"target": "ES2020", | |||
"esModuleInterop": true, | |||
"moduleResolution": "Node", | |||
"resolveJsonModule": true, | |||
"jsx": "react", | |||
"jsxFactory": "h", | |||
"jsxFragmentFactory": "Fragment", | |||
"plugins": [ | |||
{ "name": "typescript-plugin-css-modules" } | |||
] | |||
} | |||
} |
@@ -0,0 +1,29 @@ | |||
import path from 'path'; | |||
import { defineConfig } from 'vite'; | |||
import webExtension from 'vite-plugin-web-extension'; | |||
import manifest from './src/manifest.json'; | |||
export default defineConfig({ | |||
root: 'src', | |||
build: { | |||
outDir: path.resolve(__dirname, 'dist'), | |||
emptyOutDir: true, | |||
minify: false, | |||
sourcemap: true, | |||
}, | |||
plugins: [ | |||
webExtension({ | |||
manifest, | |||
skipManifestValidation: true, | |||
additionalInputs: [ | |||
'overview/index.html', | |||
], | |||
assets: 'assets', | |||
browser: 'firefox', | |||
// browser: 'chrome', | |||
webExtConfig: { | |||
startUrl: ['http://example.com/page1', 'about:debugging#/runtime/this-firefox'], | |||
}, | |||
}), | |||
], | |||
}); |