8000 feat(notes): pages for notes and every resource, with @ and # links by adeelahmad · Pull Request #118 · 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
Diff view
29 changes: 29 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

Plans and progress for work in flight. Newest first.

## 2026-10-04 · Notes: a page for everything

Goal (Adeel): every resource, entity and topic has its own page (its note), next to free notes written by people or
the assistant. The assistant is the main writer and organiser; people can do everything it can. Notes link to anything
with @ (resources, people, entities) and # (topics). Free notes sit in a tree in the left navigation, like Notion;
pages of resources are opened from those resources. docs/notes.md.

Model: a `page` has a title, a one-line summary (the context the assistant reads; refined by the AI when switched
on), a date, a place (PARA: project, area, resource, archive), a parent for the tree, the thing it's about (if any), a
body and who wrote it (a person or the assistant). Links found in the body are kept as edges, with backlinks, and show
in the graph.

Todo:

- [x] Pages: create, read, change, move in the tree, delete; a page per resource made on first open
- [x] @ and # mentions: links and backlinks, a search to pick what to link
- [ ] Web app: tree explorer in the left navigation, page view with title, summary, date, place and backlinks; the
page of a recording or entity reached from its detail view
- [ ] Editor: BlockSuite (AFFiNE's block editor, OctoBase/Yjs documents) behind one component; plain text kept for
search and the assistant. Waits on Adeel's link for the exact editor
- [ ] AI title and summary refinement (notes.refine, on by default, can be switched off); costs to the activity ledger
- [ ] Assistant and MCP tools: find, read, write, link and file notes; changes by the assistant are undoable
- [ ] Self-organising: a routine files notes into PARA and links them to entities and topics, proposing what it isn't
sure of
- [ ] # topics move to the SKOS topics once the graph thread splits them from entities
- [ ] Attachments on a page, encrypted through the keyring
- [ ] Object storage (S3, or an rclone remote served as S3) in the setup wizard, no local storage; a downloadable
256-bit storage key. Design proposal waiting on Adeel (changes existing installs)

## 2026-10-04 · The graph, end to end

Goal (Adeel): make the graph the one focus and nail it: a human explorer canvas, agent queries with rights, questions
Expand Down
58 changes: 58 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Notes

Every resource, entity and topic in Lens has a page of its own, and next to them sit free notes that people and the
assistant write. The assistant is the main writer and organiser; people can do everything it can.

Status: **built**: pages over the API (free notes in a tree, a page per recording, entity, collection or speaker),
@ and # links with backlinks. **In progress**: the tree in the left navigation, the page view and its editor, AI
refinement of titles and summaries. **Planned**: assistant and MCP tools, a routine that files notes (PARA and the
ontology), attachments on encrypted object storage, pages in the graph, # links to SKOS topics.

## A page

| Field | What it is |
| --- | --- |
| title | One line, up to 200 characters |
| summary | One line on what the page holds: the context the assistant reads first. `summary_by` says who wrote it |
| date | The day it's about (default: the day it was written) |
| place | Where it's filed, in PARA: `project`, `area`, `resource` or `archive` |
| parent, position | Its place in the tree (free notes only) |
| about | What it's the page of, like `recording:12` or `entity:5`; empty for a free note |
| body | Markdown with mentions (below) |
| doc | The editor's own document state, if it keeps one (opaque to the server) |
| author | `person` or `assistant` |

A thing's page is made the first time someone writes on it; until then `GET /api/v1/notes/about/{kind}/{id}` returns a
draft with its name and the pages that already link to it. A page follows its recording to another namespace. When a
thing is deleted, or an entity is merged into another, its page stays as a free note at the top of the tree, so
nothing written on it is lost.

## Links

The body links with mention tokens, which the editor and the assistant both write:

```text
@[Weekly call](recording:12) @[Ada Lovelace](entity:5) @[Plans](page:3) #[Capsids](entity:9)
```

`@` links resources, people and other pages; `#` links topics (entities of type Topic, until topics become a SKOS
vocabulary of their own; see [the graph](graph.md)). `GET /api/v1/notes/targets?ns=&sign=&q=` offers what to link.
Links are kept as rows (`note_link`), so every page lists its backlinks; links to things that are gone or that the
reader can't see show without a name.

## Access

Pages belong to a namespace: anyone with a role there reads them, editors write, move and delete them. The pages of
recordings also need access to the recording.

## Editor

The page view uses AFFiNE's BlockSuite editor, whose documents are Yjs CRDTs, the format OctoBase stores and syncs.
The editor saves its document state in `doc` next to the Markdown; a change to the Markdown alone (the assistant's)
drops `doc`, so the editor rebuilds it from the Markdown. OctoBase is AGPL-3.0, so it would run as a separate service
for live co-editing rather than inside Lens.

## Refine later

Pages for people who see only some collections of a namespace; page history and undo; attachments; live co-editing
through OctoBase; pages and their links as nodes and relationships in the graph.
2 changes: 2 additions & 0 deletions fastapi_backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
jobs,
metadata,
namespaces,
notebook,
notes,
notifications,
oauth,
Expand Down Expand Up @@ -68,6 +69,7 @@
hierarchy,
recordings,
notes,
notebook,
comments,
files,
fields,
Expand Down
154 changes: 154 additions & 0 deletions fastapi_backend/app/api/v1/routes/notebook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Notes as pages (docs/notes.md): free notes in a tree, and the page of every resource, entity and topic.

Pages belong to a namespace: anyone with a role there reads them, editors write them. A thing's own page is made the
first time someone writes on it; until then it reads as a draft.
"""

from __future__ import annotations

from typing import Any

from fastapi import APIRouter, HTTPException, Query

from app.api.deps import Access, Acl, CurrentUser, Db, Principal, Writer, domain_errors
from app.domain import auth, notebook, store
from app.domain.store import DB
from app.schemas.common import Ok
from app.schemas.notebook import (
NoteLinkTarget,
NotePage,
NotePageCreate,
NotePageDraft,
NotePageItem,
NotePageMove,
NotePageUpdate,
NoteTree,
)

router = APIRouter(prefix="/notes", tags=["notes"])


def _page(acl: Access, db: DB, pid: int, role: str = "viewer") -> dict[str, Any]:
try:
p = notebook.get(db, pid)
except KeyError:
raise HTTPException(404, "not found") from None
acl.need(p["space"], role)
return p


def _backlinks(acl: Access, db: DB, sid: int, targets: list[str]) -> list[dict[str, Any]]:
return notebook.backlinks(db, sid, targets) if targets else []


def _out(acl: Access, db: DB, p: dict[str, Any], user: Principal) -> NotePage:
names = store.space_names(db)
links = notebook.links(db, p["id"])
found = notebook.labels(db, [x["target"] for x in links])
readable = set(acl.spaces()) if not user.admin else set(names)
out_links = []
for x in links:
t = found.get(x["target"])
seen = t is not None and t.get("space") in readable
out_links.append({**x, "name": t["name"] if seen else None, "namespace": names.get(t["space"]) if seen else None})
writer = db.one("SELECT email FROM $r", r=store.R("account", p["created_by"])) if p.get("created_by") else None
targets = [f"page:{p['id']}"] + ([p["about"]] if p.get("about") else [])
return NotePage(
**{k: v for k, v in p.items() if k not in ("space", "created_by", "updated_by")},
namespace=names.get(p["space"], ""),
created_by=(writer or {}).get("email"),
links=out_links,
backlinks=[b for b in _backlinks(acl, db, p["space"], targets) if b["page"] != p["id"]],
can_edit=user.can_write and auth.allows(acl.roles, p["space"], "editor"),
)


@router.get("")
def list_pages(ns: str, user: CurrentUser, acl: Acl, db: Db, everything: bool = Query(False, alias="all")) -> NoteTree:
"""The namespace's free notes for the tree (no bodies), in order; `all=true` adds the pages of things."""
sid = acl.namespace(ns)
return NoteTree(namespace=ns, pages=[NotePageItem(**p) for p in notebook.tree(db, sid, everything)])


@router.post("")
def create_page(body: NotePageCreate, user: Writer, acl: Acl, db: Db) -> NotePage:
"""Write a free note (optionally inside `parent`), or the page of a thing (`about`, like "recording:12"), which
each thing has one of. Needs editor access to the namespace."""
sid = acl.namespace(body.ns, "editor")
with domain_errors():
pid = notebook.create(
db, sid, user.id, body.title, body.body, body.summary, body.date, body.place, body.parent, body.about, doc=body.doc
)
return _out(acl, db, notebook.get(db, pid), user)


@router.get("/targets")
def link_targets(
ns: str, user: CurrentUser, acl: Acl, db: Db, q: str = "", sign: str = "@", limit: int = Query(20, ge=1, le=50)
) -> list[NoteLinkTarget]:
"""What a mention can link to, best matches first: `#` for topics; `@` for pages, recordings, people and other
entities, collections and speakers."""
sid = acl.namespace(ns)
if sign not in ("@", "#"):
raise HTTPException(400, "sign is @ or #")
return [NoteLinkTarget(**t) for t in notebook.search_targets(db, sid, q, sign, limit)]


@router.get("/about/{kind}/{key}")
def page_about(kind: str, key: int, user: CurrentUser, acl: Acl, db: Db) -> NotePage | NotePageDraft:
"""The page of a recording, entity, collection or speaker: its page, or a draft while nobody has written one."""
thing = f"{kind}:{key}"
with domain_errors():
notebook._about(thing)
sid = notebook.owner(db, kind, key)
acl.need(sid, "viewer")
if kind == "recording":
rec = db.one("SELECT space, collection FROM $r", r=store.R("recording", key))
acl.need_in(rec["space"], rec.get("collection"))
p = notebook.about(db, sid, thing)
if p:
return _out(acl, db, p, user)
name = notebook.labels(db, [thing]).get(thing, {}).get("name") or thing
return NotePageDraft(
namespace=store.space_names(db).get(sid, ""),
about=thing,
title=str(name).rsplit("/", 1)[-1],
backlinks=_backlinks(acl, db, sid, [thing]),
can_edit=user.can_write and auth.allows(acl.roles, sid, "editor"),
)


@router.get("/{pid}")
def get_page(pid: int, user: CurrentUser, acl: Acl, db: Db) -> NotePage:
"""A page with its body, its links (with their targets' current names) and the pages linking to it."""
return _out(acl, db, _page(acl, db, pid), user)


@router.patch("/{pid}")
def update_page(pid: int, body: NotePageUpdate, user: Writer, acl: Acl, db: Db) -> NotePage:
"""Change what's given. A new body without `doc` drops the editor's state, so it's rebuilt from the Markdown."""
_page(acl, db, pid, "editor")
given = body.model_fields_set
kw: dict[str, Any] = {k: getattr(body, k) for k in ("summary", "date", "doc") if k in given}
if "place" in given:
kw["place"] = body.place or None
with domain_errors():
notebook.update(db, pid, user.id, title=body.title, body=body.body, **kw)
return _out(acl, db, notebook.get(db, pid), user)


@router.post("/{pid}/move")
def move_page(pid: int, body: NotePageMove, user: Writer, acl: Acl, db: Db) -> NotePage:
"""Move a free note in the tree: inside `parent` (null: the top), before `before` (null: at the end)."""
_page(acl, db, pid, "editor")
with domain_errors():
notebook.move(db, pid, body.parent, body.before)
return _out(acl, db, notebook.get(db, pid), user)


@router.delete("/{pid}")
def delete_page(pid: int, user: Writer, acl: Acl, db: Db) -> Ok:
"""Delete a page; the pages inside it move up a level. Needs editor access."""
_page(acl, db, pid, "editor")
notebook.delete(db, pid)
return Ok()
3 changes: 2 additions & 1 deletion fastapi_backend/app/domain/deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import pathlib
import shutil

from . import access as acc, files as filemod, render, store, video
from . import access as acc, files as filemod, notebook, render, store, video

R = store.R
# rows that belong to the recording
Expand Down Expand Up @@ -173,6 +173,7 @@ def delete(db, cfg, rid, by=None):
target=f"recording:{rid}",
rec=R("recording", rid),
)
notebook.release(db, f"recording:{rid}")
shutil.rmtree(video.frames_dir(cfg, rid), ignore_errors=True)
shutil.rmtree(filemod.folder(cfg, rid), ignore_errors=True)
for f in files:
Expand Down
4 changes: 3 additions & 1 deletion fastapi_backend/app/domain/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import re
from collections import Counter, defaultdict, deque

from . import analyze, entity_setup, render, store
from . import analyze, entity_setup, notebook, render, store

R = store.R
TYPES = {
Expand Down Expand Up @@ -795,6 +795,7 @@ def merge(db, keep, others, user=None):
]
stmts = ["DELETE mentions WHERE entity = $o"] + (["INSERT RELATION INTO mentions $rows"] if rows else []) + ["DELETE $oref"]
db.run(stmts, o=o["id"], oref=R("entity", o["id"]), rows=rows)
notebook.release(db, f"entity:{o['id']}")
for key in [o["key"]] + sn["aliases"]:
_add_alias(db, k["space"], key, k["id"])
db.q("UPDATE entity_override SET target = $k WHERE target = $o", k=k["id"], o=o["id"])
Expand Down Expand Up @@ -937,6 +938,7 @@ def remove(db, eid):
if db.rows("SELECT id FROM mentions WHERE entity = $e LIMIT 1", e=e["id"]):
raise ValueError(f"{e['name']} is mentioned: merge it into another entity, or hide it.")
db.run(["DELETE entity_alias WHERE entity = $e", "DELETE $r"], e=e["id"], r=R("entity", e["id"]))
notebook.release(db, f"entity:{e['id']}")


def link(db, a, b, check_spaces=True):
Expand Down
3 changes: 2 additions & 1 deletion fastapi_backend/app/domain/moving.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import pathlib
import shutil

from . import access as acc, auth, deletion, faces as facemod, jobs, metadata as md, render, speakers as spk, store
from . import access as acc, auth, deletion, faces as facemod, jobs, metadata as md, notebook, render, speakers as spk, store

R = store.R
# rows that belong to the recording and say which namespace they are in
Expand Down Expand Up @@ -198,6 +198,7 @@ def move(db, cfg, rid, dst, rediarize=False, revoke_shares=False, by=None, colle
home=home,
)
deletion.forget(db, dst, fp, rec.get("path")) # it's in the new namespace now: its scans may find it again
notebook.follow(db, f"recording:{rid}", dst)
revoked = auth.revoke_shares(db, rid, by) if revoke_shares else 0
_files(db, cfg, rec, rid, names[src], names[dst])
deletion.orphans(db, old_speakers, old_faces) # unnamed ones nothing else has any more
Expand Down
Loading
Loading
0