Browser extension that demonstrates the Web Annotation Discovery mechanism: subscribe to people’s annotation collections/‘feeds’, to see their notes on the web; and create & publish annotations yourself.
Browse Source

Initial publication

tags/v0.1.0
Gerben 1 year ago
commit
6d24bb334a
58 changed files with 22988 additions and 0 deletions
  1. +3
    -0
      .gitignore
  2. +77
    -0
      Readme.md
  3. +19497
    -0
      package-lock.json
  4. +38
    -0
      package.json
  5. +14
    -0
      src/_locales/en/messages.json
  6. +41
    -0
      src/assets/icon/icon.svg
  7. BIN
      src/assets/icon/icon128.png
  8. BIN
      src/assets/icon/icon256.png
  9. BIN
      src/assets/icon/icon48.png
  10. BIN
      src/assets/icon/icon96.png
  11. +5
    -0
      src/assets/icons/download.svg
  12. +5
    -0
      src/assets/icons/info.svg
  13. +6
    -0
      src/assets/icons/refresh.svg
  14. +5
    -0
      src/assets/icons/rss.svg
  15. +35
    -0
      src/background/context-menu.ts
  16. +60
    -0
      src/background/detect-annotation.ts
  17. +84
    -0
      src/background/index.ts
  18. +42
    -0
      src/common-classes.scss
  19. +53
    -0
      src/components/AnnotationSourcesList.module.scss
  20. +173
    -0
      src/components/AnnotationSourcesList.tsx
  21. +22
    -0
      src/components/AnnotationsList.module.scss
  22. +93
    -0
      src/components/AnnotationsList.tsx
  23. +25
    -0
      src/content_script/AnnotationBody.module.scss
  24. +164
    -0
      src/content_script/AnnotationBody.tsx
  25. +72
    -0
      src/content_script/AnnotationCreationHelper.tsx
  26. +232
    -0
      src/content_script/AnnotationTargetHighlight.tsx
  27. +204
    -0
      src/content_script/App.tsx
  28. +132
    -0
      src/content_script/MarginalAnnotationCard.tsx
  29. +183
    -0
      src/content_script/MarginalAnnotations.module.scss
  30. +82
    -0
      src/content_script/MarginalAnnotations.tsx
  31. +133
    -0
      src/content_script/ToolbarButtons.tsx
  32. +18
    -0
      src/content_script/discovery.ts
  33. +5
    -0
      src/content_script/index.module.scss
  34. +47
    -0
      src/content_script/index.tsx
  35. +14
    -0
      src/css-modules.d.ts
  36. +169
    -0
      src/discovery.ts
  37. +44
    -0
      src/manifest.json.ts
  38. +10
    -0
      src/overview/Overview.module.scss
  39. +19
    -0
      src/overview/Overview.tsx
  40. +3
    -0
      src/overview/index.html
  41. +9
    -0
      src/overview/index.tsx
  42. +12
    -0
      src/page-style.scss
  43. +1
    -0
      src/popup/AnnotationStoreSelector.module.scss
  44. +145
    -0
      src/popup/AnnotationStoreSelector.tsx
  45. +15
    -0
      src/popup/Popup.module.scss
  46. +33
    -0
      src/popup/Popup.tsx
  47. +4
    -0
      src/popup/index.html
  48. +9
    -0
      src/popup/index.tsx
  49. +119
    -0
      src/storage/Annotation.ts
  50. +482
    -0
      src/storage/AnnotationSource.ts
  51. +18
    -0
      src/storage/db.ts
  52. +150
    -0
      src/util/dom-selectors.ts
  53. +52
    -0
      src/util/niceTime.ts
  54. +49
    -0
      src/util/notify.ts
  55. +34
    -0
      src/util/unique.ts
  56. +2
    -0
      src/webextension-polyfill.ts
  57. +16
    -0
      tsconfig.json
  58. +29
    -0
      vite.config.ts

+ 3
- 0
.gitignore View File

@@ -0,0 +1,3 @@
/dist
/web-ext-artifacts
/node_modules

+ 77
- 0
Readme.md View File

@@ -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.

+ 19497
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 38
- 0
package.json View File

@@ -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"
}
}

+ 14
- 0
src/_locales/en/messages.json View File

@@ -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"
}
}

+ 41
- 0
src/assets/icon/icon.svg View File

@@ -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>

BIN
src/assets/icon/icon128.png View File

Before After
Width: 128  |  Height: 128  |  Size: 2.8 KiB

BIN
src/assets/icon/icon256.png View File

Before After
Width: 256  |  Height: 256  |  Size: 5.7 KiB

BIN
src/assets/icon/icon48.png View File

Before After
Width: 48  |  Height: 48  |  Size: 1.1 KiB

BIN
src/assets/icon/icon96.png View File

Before After
Width: 96  |  Height: 96  |  Size: 2.0 KiB

+ 5
- 0
src/assets/icons/download.svg View File

@@ -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>

+ 5
- 0
src/assets/icons/info.svg View File

@@ -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>

+ 6
- 0
src/assets/icons/refresh.svg View File

@@ -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>

+ 5
- 0
src/assets/icons/rss.svg View File

@@ -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>

+ 35
- 0
src/background/context-menu.ts View File

@@ -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();

+ 60
- 0
src/background/detect-annotation.ts View File

@@ -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'] }],
});

+ 84
- 0
src/background/index.ts View 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();
}

+ 42
- 0
src/common-classes.scss View File

@@ -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: ' ✔️';
}
}
}

+ 53
- 0
src/components/AnnotationSourcesList.module.scss View File

@@ -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;
}
}

+ 173
- 0
src/components/AnnotationSourcesList.tsx View File

@@ -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>
);
}
}

+ 22
- 0
src/components/AnnotationsList.module.scss View File

@@ -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;
}
}

+ 93
- 0
src/components/AnnotationsList.tsx View File

@@ -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>
)}
</>
);
}
}

+ 25
- 0
src/content_script/AnnotationBody.module.scss View File

@@ -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;
}
}
}

+ 164
- 0
src/content_script/AnnotationBody.tsx View File

@@ -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>
);
}
}

+ 72
- 0
src/content_script/AnnotationCreationHelper.tsx View File

@@ -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;
}
}

+ 232
- 0
src/content_script/AnnotationTargetHighlight.tsx View File

@@ -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}
/>
);
}
}

+ 204
- 0
src/content_script/App.tsx View File

@@ -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)
}
/>
</>
);
}
}

+ 132
- 0
src/content_script/MarginalAnnotationCard.tsx View File

@@ -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;
}
}
}

+ 183
- 0
src/content_script/MarginalAnnotations.module.scss View File

@@ -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;
}
}

+ 82
- 0
src/content_script/MarginalAnnotations.tsx View File

@@ -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>
);
}
}

+ 133
- 0
src/content_script/ToolbarButtons.tsx View File

@@ -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;
}

+ 18
- 0
src/content_script/discovery.ts View File

@@ -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',
}));
}

+ 5
- 0
src/content_script/index.module.scss View File

@@ -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;
}

+ 47
- 0
src/content_script/index.tsx View File

@@ -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);
}

+ 14
- 0
src/css-modules.d.ts View File

@@ -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;
}

+ 169
- 0
src/discovery.ts View File

@@ -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];
}

+ 44
- 0
src/manifest.json.ts View File

@@ -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;

+ 10
- 0
src/overview/Overview.module.scss View File

@@ -0,0 +1,10 @@
@import '../page-style.scss';

body {
margin: 2em;
}

.overview {
margin: auto;
max-width: 40em;
}

+ 19
- 0
src/overview/Overview.tsx View File

@@ -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>
);
}
}

+ 3
- 0
src/overview/index.html View File

@@ -0,0 +1,3 @@
<!DOCTYPE html>
<script src="./index.tsx" type="module"></script>
<div id="app"></div>

+ 9
- 0
src/overview/index.tsx View File

@@ -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);
}

+ 12
- 0
src/page-style.scss View File

@@ -0,0 +1,12 @@
html {
background-color: #f8ffff;
}

body {
font-family: sans-serif;
font-size: 17px;
}

a {
color: unset;
}

+ 1
- 0
src/popup/AnnotationStoreSelector.module.scss View File

@@ -0,0 +1 @@
@import '../common-classes.scss';

+ 145
- 0
src/popup/AnnotationStoreSelector.tsx View File

@@ -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} &lt;
{source.data.url}&gt;
</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>
)}
</>
)}
</>
);
}
}

+ 15
- 0
src/popup/Popup.module.scss View File

@@ -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;
}

+ 33
- 0
src/popup/Popup.tsx View File

@@ -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>
</>
);
}
}

+ 4
- 0
src/popup/index.html View File

@@ -0,0 +1,4 @@
<!DOCTYPE html>
<script src="./index.tsx" type="module"></script>

<div id="app"></div>

+ 9
- 0
src/popup/index.tsx View File

@@ -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);
}

+ 119
- 0
src/storage/Annotation.ts View File

@@ -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));
}
}

+ 482
- 0
src/storage/AnnotationSource.ts View File

@@ -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.`,
);
}
}

+ 18
- 0
src/storage/db.ts View File

@@ -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();

+ 150
- 0
src/util/dom-selectors.ts View File

@@ -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);
}

+ 52
- 0
src/util/niceTime.ts View File

@@ -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)}`
}

+ 49
- 0
src/util/notify.ts View File

@@ -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=';

+ 34
- 0
src/util/unique.ts View File

@@ -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;
}

+ 2
- 0
src/webextension-polyfill.ts View File

@@ -0,0 +1,2 @@
import browser from 'webextension-polyfill'
globalThis.browser = browser

+ 16
- 0
tsconfig.json View File

@@ -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" }
]
}
}

+ 29
- 0
vite.config.ts View File

@@ -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'],
},
}),
],
});

Loading…
Cancel
Save