8000 feat(iiif): notes and comments as W3C Web Annotations by adeelahmad · Pull Request #696 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
< 8000 /form>
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,9 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
[prompt injection](docs/assistant.md#third-party-content-and-prompt-injection), [HTTPS](docs/https.md),
[remote access](docs/remote-access.md); [checks](docs/requirements.md#stay-private-and-safe))
- **Use open standards.** RDF and Dublin Core for every record, OAuth for apps and OpenID Connect sign-in, MCP, MQTT,
IIIF for publishing, OpenTelemetry and Prometheus for monitoring, an optional Fedora mirror, and a CSV catalogue of
the assets you select in the Library.
IIIF for publishing (with your team's shared notes and comments as W3C Web Annotations, for people with a role on the
asset only), OpenTelemetry and Prometheus for monitoring, an optional Fedora mirror, and a CSV catalogue of the assets
you select in the Library.
([Linked data](docs/rdf.md), [IIIF](docs/iiif.md), [Fedora](docs/fedora.md),
[catalogue](docs/processing.md#finding-by-any-attribute);
[checks](docs/requirements.md#use-open-standards))
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/iiif-notes-annotations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- **Notes and comments in IIIF, as W3C Web Annotations.** With `notes` added to the published annotation layers
(Settings, IIIF; off by default), an asset's IIIF Manifest lists its shared notes and its comments as an
AnnotationPage (`/iiif/<id>/annotations/notes`): each note or thread `commenting` on its moment or on the whole
asset, each reply `replying` to its thread, with who wrote it and when; on a document, per page. Only people with a
role on the asset get it, through their sign-in or the IIIF access cookie: the public, share links and signed links
never do, and unshared notes are never in it ([IIIF](docs/iiif.md)).
15 changes: 14 additions & 1 deletion docs/iiif.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ can open them, and harvesters can follow them.
(`/iiif/<id>/files/<file>.vtt`, whatever their format), an index as a table of contents (a Range), and a thumbnail
as the Manifest's thumbnail. Files that need permission sit behind the Authorization Flow; attachments always do.
- A schema.org record and a Dublin Core record, linked with `seeAlso`.
- **Notes and comments** ([API](api.md#notes)), with `notes` in `iiif.layers` (Settings, IIIF; off by default): the
asset's shared notes and its comments as W3C Web Annotations, in an AnnotationPage at
`/iiif/<id>/annotations/notes`, which the Canvas lists under `annotations`.
- A note, or a comment that starts a thread, is `commenting` on its moment (`canvas/1#t=…`) or on the whole Canvas;
a reply is `replying`, targeting its thread's annotation. Each says who wrote it (their name, never their email)
and when (`created`, `modified`).
- Only people with a role on the asset, in its namespace or on its collection, get it: the requester's own sign-in
or the IIIF access cookie. Public access, share links, signed links and IP groups never do, so a public asset's
Manifest is the same for everyone else, and the layer answers 404 to them. Unshared notes are their writer's alone
and are never in it. A Manifest or page that carries the layer is sent `Cache-Control: private, no-store`.
- On a document, the layer is per page (`?page=<n>`): a note about a moment is on the page its text is on then, one
about the whole document on the first page.
- **Documents and images** ([API](api.md#documents-and-images)): a Canvas per page, each with its page drawn as an
Image (`/iiif/<id>/pages/<n>.jpg`, from 1) and its size; the first page is the thumbnail.
- The text is annotations on the page, each targeting where its block is (`canvas/<n>#xywh=…`), one page at a time
Expand Down Expand Up @@ -48,7 +60,8 @@ An asset's access decides what is published ([Access](access.md)). It's set per
namespace, and only owners change it:

- `public`: published. Its open parts (media, text, index) are plain links; closed ones sit behind the
Authorization Flow. Chapters are ranges when the index is open.
Authorization Flow. Chapters are ranges when the index is open. Notes and comments are never published: only
people with a role on the asset get them.
- `restricted` and `private` (the default): not published. The manifest answers 404 unless the request carries
permission, and collections leave the asset out.

Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ request, to another check that proves the same thing; loosening a requirement or
| OAuth for apps and OpenID Connect sign-in | [test_oauth.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_oauth.py) `test_discovery_says_where_everything_is`, `test_an_app_gets_access_and_acts_with_the_persons_roles`; [test_external_login.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_external_login.py) `test_signing_in_connects_the_account_with_the_same_verified_email` |
| MQTT: a built-in broker, and a client for another one | [test_sensors.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_sensors.py) `test_broker_is_an_ordinary_local_broker`, `test_bridge_client_reads_another_broker` |
| IIIF for publishing | [test_iiif.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_iiif.py) `test_manifests_collections_search_state_discovery`, `test_authorization_flow` |
| Shared notes and comments as W3C Web Annotations in IIIF, for people with a role on the asset only (never unshared notes, never the public) | [test_iiif.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_iiif.py) `test_notes_and_comments_as_web_annotations`; [test_documents.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_documents.py) `test_notes_on_a_documents_pages` |
| OpenTelemetry and Prometheus for monitoring | [test_telemetry.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_telemetry.py) `test_traces_and_metrics_go_to_the_endpoint`; [test_prometheus.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_prometheus.py) `test_what_it_measures` |
| An optional Fedora mirror | [test_fedora.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_fedora.py) `test_off_by_default`, `test_sync_sends_changes_and_deletes` |
| A CSV catalogue of the assets selected in the Library, one row each with a link to its page, safe to open in a spreadsheet | [library-catalogue.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/library-catalogue.test.ts) `has a row per asset with its people, tags and a link to its page`, `quotes what needs it and never lets a cell run as a formula` |
Expand Down
37 changes: 36 additions & 1 deletion fastapi_backend/app/api/iiif.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,34 @@ def _member(request: Request, db: DB, user: Principal | None, space: int) -> boo
return bool(acct and auth.allows(auth.roles(db, acct), space))


def _on_asset(request: Request, db: DB, cfg: Config, user: Principal | None, rid: int) -> bool:
"""Whether the asset's notes and comments are published to this request: iiif.layers has `notes`, and the requester
(or the IIIF access cookie's account) has a role on the asset, in its namespace or on its collection, as the API
asks of them (docs/api.md#notes). Public access, share, signed and IP-group links never reach them."""
if iiif.NOTES not in (cfg["iiif"].get("layers") or []):
return False
rec = db.one("SELECT space, collection FROM $r", r=R("recording", rid))
if not rec:
return False
space: int = rec["space"]
cid = rec.get("collection")

def role(roles: dict[int, str], collections: dict[int, dict[int, str]]) -> bool:
return auth.allows(roles, space) or (cid is not None and bool(collections.get(space, {}).get(cid)))

if user and role(user.roles, user.collections):
return True
acct, _ = iiif_auth.cookie_account(db, request.cookies.get(iiif_auth.COOKIE))
return bool(acct and role(auth.roles(db, acct), hierarchy.roles_of(db, acct["id"])))


def _personal(resp: JSONResponse) -> JSONResponse:
"""A response that carries notes and comments differs by who asks: no shared cache keeps it."""
resp.headers["Cache-Control"] = "private, no-store"
resp.headers["Vary"] = "Authorization, Cookie"
return resp


def _picture(db: DB, cfg: dict[str, Any], rec: dict[str, Any], rid: int, name: str, path: pathlib.Path, member: bool) -> Response:
"""A frame or a page: where the namespace pixelates faces, visitors get the faces found on it pixelated."""
if not member and faces.pixelates(db, rec["space"]):
Expand Down Expand Up @@ -414,7 +442,9 @@ def iiif_logout(request: Request, db: Db, cfg: Cfg) -> HTMLResponse:
@router.get("/iiif/{rid}/manifest")
def iiif_manifest(rid: int, request: Request, user: OptionalUser, db: Db, cfg: Cfg) -> JSONResponse:
_rec(request, db, cfg, user, rid)
return _ld(iiif.manifest(db, cfg, rid, base_url(request, cfg)))
notes = _on_asset(request, db, cfg, user, rid)
resp = _ld(iiif.manifest(db, cfg, rid, base_url(request, cfg), notes=notes))
return _personal(resp) if notes else resp


@router.get("/iiif/{rid}/annotations/{layer}")
Expand All @@ -427,6 +457,11 @@ def iiif_annotations(
cfg: Cfg,
page: int | None = Query(None, ge=1, description="a document's page (from 1): only the annotations on it"),
) -> JSONResponse:
8BD0 if layer == iiif.NOTES: # people's notes and comments: for those with a role on the asset only
_rec(request, db, cfg, user, rid)
if not _on_asset(request, db, cfg, user, rid):
raise HTTPException(404, "not found")
return _personal(_ld(iiif.notes_page(db, rid, base_url(request, cfg), page)))
rec, a = _rec(request, db, cfg, user, rid, "transcript")
if layer not in iiif.LAYERS or not _content_ok(request, db, cfg, user, rec, rid, a, "transcript"):
raise HTTPException(404, "not found")
Expand Down
89 changes: 87 additions & 2 deletions fastapi_backend/app/domain/iiif.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
IIIF publishes public recordings (see access.py and docs/access.md). A public recording's open parts are plain links;
closed ones carry IIIF Authorization Flow 2.0 probe services (see iiif_auth), transcript layers are only published
when the transcript is open, and chapters only when the index is. Restricted and private recordings are left out of
collections unless the requester may read them.
collections unless the requester may read them. People's shared notes and comments (the `notes` layer) are never
published: only people with a role on the recording get them, as W3C Web Annotations.
"""

from __future__ import annotations
Expand Down Expand Up @@ -193,7 +194,9 @@ def _provider(meta, base):
return [agent]


def manifest(db, cfg, rid, base):
def manifest(db, cfg, rid, base, notes=False):
"""A recording's Manifest. With `notes` (the requester has a role on it, and iiif.layers has `notes`), its canvases
also list the notes-and-comments layer where there are any."""
rec = db.one("SELECT * FROM $r", r=R("recording", rid))
if not rec:
raise KeyError(rid)
Expand Down Expand Up @@ -293,6 +296,15 @@ def manifest(db, cfg, rid, base):
cv["thumbnail"] = thumb
doc_layers = [k for k in ("transcript", "entities") if k in layers and not text_locked]
page_items = [_page_canvas(cfg, base, m, rid, p, canvases, audio_locked, doc_layers) for p in pages]
if notes:
on = {p for p, _ in _notes(db, rid, m, canvases)}
ref = {"id": f"{m}/annotations/{NOTES}", "type": "AnnotationPage", "label": lm(NOTES_LABEL, "en")}
if paged and canvases:
for c, p in zip(page_items, pages, strict=True):
if p["idx"] in on:
c.setdefault("annotations", []).append({**ref, "id": f"{ref['id']}?page={p['idx'] + 1}"})
elif on:
annotations.append(ref)
if page_items and page_items[0].get("thumbnail"):
thumb = page_items[0]["thumbnail"] # a document's first page stands for it
pic = next((f for f in kept if f["role"] == "thumbnail"), None)
Expand Down Expand Up @@ -591,6 +603,79 @@ def annotation_page(db, cfg, rid, base, layer, page=None):
return {"@context": P3, "id": own, "type": "AnnotationPage", "label": lm(label, "en"), "items": items}


NOTES = "notes" # the layer of notes and comments (iiif.layers), for people with a role on the asset only
NOTES_LABEL = "Notes and comments"


def _when(t):
return t.replace("+00:00", "Z") if t else None


def _notes(db, rid, m, canvases):
"""The asset's shared notes and its comments as W3C Web Annotations: [(page index or None, annotation)]. A note or
a thread is `commenting` on its moment (on a document, the page its text at that moment is on) or on the whole
asset; a reply is `replying` to its thread. Unshared notes are their writer's alone and never in here."""
from . import comments, notes

shared = notes.shared(db, rid)
said = comments.on(db, rid)
people = {}
ids = {x["account"] for x in shared + said if x.get("account") is not None}
if ids:
rows = db.rows("SELECT record::id(id) AS id, name FROM account WHERE id IN $ids", ids=[R("account", a) for a in ids])
people = {a["id"]: a.get("name") for a in rows}
segs = render.player_data(db, rid)["segments"] if canvases else []

def page_at(t0): # a document's page at a moment of its reading-pace clock: its first for the whole of it
if not canvases:
return None
at = [s["p"] for s in segs if t0 is not None and s.get("p") in canvases and s["t0"] <= t0]
return at[-1] if at else min(canvases)

def where(x, page):
if page is not None:
return canvases.get(page, (f"{m}/canvas/1",))[0]
return f"{m}/canvas/1" if x.get("t0") is None else f"{m}/canvas/1#t={_t(x['t0'])},{_t(x['t1'])}"

def anno(aid, x, motivation, tgt, agent=False):
name = "Lens" if agent else people.get(x.get("account"))
return _prune(
{
"id": aid,
"type": "Annotation",
"motivation": motivation,
"body": {"type": "TextualBody", "value": x["text"], "format": "text/plain"},
"target": tgt,
"creator": {"type": "Software" if agent else "Person", "name": name} if name else None,
"created": _when(x.get("created_at")),
"modified": _when(x.get("edited_at")),
}
)

out, threads = [], {}
for n in shared:
page = page_at(n.get("t0"))
out.append((page, anno(f"{m}/annotations/notes/n{n['id']}", n, "commenting", where(n, page))))
for c in said:
aid, agent = f"{m}/annotations/notes/c{c['id']}", c.get("author") == "assistant"
if c.get("parent") is None:
page = threads[c["id"]] = page_at(c.get("t0"))
out.append((page, anno(aid, c, "commenting", where(c, page), agent)))
else: # a reply goes on its thread (comments.py), on the thread's page
out.append((threads.get(c["parent"]), anno(aid, c, "replying", f"{m}/annotations/notes/c{c['parent']}", agent)))
return out


def notes_page(db, rid, base, page=None):
"""The notes-and-comments layer (_notes); `page` (from 1) keeps to a document's page's."""
rec = db.one("SELECT source FROM $r", r=R("recording", rid)) or {}
m = f"{base}/iiif/{rid}"
canvases = page_canvases(m, documents.pages(db, rid)) if rec.get("source") in documents.KINDS else {}
items = [a for p, a in _notes(db, rid, m, canvases) if page is None or p == page - 1]
own = f"{m}/annotations/{NOTES}" + (f"?page={page}" if page is not None else "")
return {"@context": P3, "id": own, "type": "AnnotationPage", "label": lm(NOTES_LABEL, "en"), "items": items}


def collection_url(base, ns, cid=None):
"""A namespace's Collection, or one of its collections'."""
return f"{base}/iiif/collection/{ns}" + (f"/{cid}" if cid is not None else "")
Expand Down
9 changes: 9 additions & 0 deletions fastapi_backend/app/domain/notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ def visible(db, rid, account):
"""This person's notes on the recording and the ones shared on it: notes about the whole recording first, then by
moment; the earliest written first."""
rows = db.rows(f"SELECT {FIELDS} FROM note WHERE recording = $r AND (account = $a OR shared = true)", r=int(rid), a=account)
return _order(rows)


def shared(db, rid):
"""Just the notes shared on the recording (everyone who can read it sees them), in the same order."""
return _order(db.rows(f"SELECT {FIELDS} FROM note WHERE recording = $r AND shared = true", r=int(rid)))


def _order(rows):
return sorted(rows, key=lambda n: (n.get("t0") is not None, n.get("t0") or 0, n.get("created_at") or "", n["id"]))


Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/archive.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ sources: # bootstrap-only: the web app can't change t
iiif: # can also be changed in the app
base_url: null # the stable public HTTPS address; identifiers are built from it (null: the request's address)
default_language: none # language of titles and summaries when not given, e.g. en
layers: [transcript, speakers, entities, chapters, screen] # what published manifests carry (when the transcript is open)
layers: [transcript, speakers, entities, chapters, screen] # what published manifests carry (when the transcript is open); notes: shared notes and comments, for people with a role only
rights: null # default licence, e.g. http://creativecommons.org/licenses/by/4.0/
attribution: null # default required statement
provider: null # {name: ..., homepage: ..., logo: ...}
Expand Down
26 changes: 26 additions & 0 deletions fastapi_backend/tests/api/test_documents.py
A558
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,32 @@ def test_documents_in_iiif(app, client, env, db, cfg):
assert own["location"]["format"] == "application/pdf" and own["location"]["type"] == "Text"


@POPPLER
def test_notes_on_a_documents_pages(client, env, db, cfg):
"""On a document, the notes-and-comments layer is per page: a note about a moment is on the page its text then is,
one about the whole document on the first page."""
from app.domain import comments, iiif, notes

rid = _upload(client, env["he"], text_pdf(HARBOUR), "harbour.pdf", title="Harbour report")["recording"]
drain(db, cfg)
sid = store.ns_id(db, "pods")
ed = db.values("SELECT VALUE record::id(id) FROM account WHERE email = 'ed@x.io'")[0]
second = db.one("SELECT t0, idx FROM segment WHERE recording = $r AND page = 1 ORDER BY idx LIMIT 1", r=rid)["t0"]
notes.create(db, rid, sid, ed, "The keeper's name?", t0=second, t1=second + 500, shared=True)
comments.create(db, rid, sid, ed, "Whole report looks right")
layers = ["transcript", "entities", "chapters", "notes"]
assert client.put("/api/v1/settings/iiif", headers=env["ha"], json={"layers": layers}).status_code == 200
m = f"http://127.0.0.1/iiif/{rid}"
doc = client.get(f"/iiif/{rid}/manifest", headers=env["hv"]).json()
assert iiif.validate(doc) in (None, [])
on = [[a["id"] for a in c.get("annotations", []) if "/notes" in a["id"]] for c in doc["items"]]
assert on == [[f"{m}/annotations/notes?page=1"], [f"{m}/annotations/notes?page=2"]]
two = client.get(f"/iiif/{rid}/annotations/notes", params={"page": 2}, headers=env["hv"]).json()
assert [(a["body"]["value"], a["target"]) for a in two["items"]] == [("The keeper's name?", f"{m}/canvas/2")]
params={"page": 1}, headers=env["hv"]).json()
assert [(a["body"]["value"], a["target"]) for a in one["items"]] == [("Whole report looks right", f"{m}/canvas/1")]


@POPPLER
def test_documents_on_public_pages(client, new_client, env, db, cfg):
"""A public document's page shows its pages (signed, like a recording's media), its text page by page and the file
Expand Down
Loading
Loading
0