Files
FabledGrimoire/src/scribe/services/notes.py
T
bvandeusenandClaude Opus 5 69d93898d9
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 48s
CI & Build / Build & push image (push) Skipped
fix(tasks): a task's kind is correctable — the Kind select stops lying (#3129)
`kind` was accepted at CREATE on both doors and dropped at UPDATE on both:
update_task had no such parameter, and the REST PATCH allow-list never read
the field. So a task filed under the wrong kind could never be corrected.

The frontend made it worse by looking like it worked. TaskEditorView binds a
Kind select, marks the form dirty, and HAS ALWAYS SENT `kind` in the update
payload — the store even types it. The route ignored it, returned 200, the
view optimistically updated, the toast said "Task saved", and the old value
came back on reload. Silent success, same class as #2709.

Found by trying to re-file #3126 as a spike after deploying 0091. It could
not be done; the task had to be recreated as #3128 and the original
cancelled.

One seam, not two doors. `minted_kind()` lives in services/notes.py because
the REST route cannot import an MCP tool module and a second spelling of the
list is how the doors would come to disagree. Both create and update route
through it, so a bogus kind is now a readable error rather than a
CheckViolationError surfacing as a 500.

TaskKind joins TaskStatus and TaskPriority as a real enum, and update_note
validates task_kind exactly as it already validated those two — the field
had been reaching setattr through the hasattr guard with no validation at
all, unnoticed only because no door ever offered it.

The `-> plan` question #3129 raised is answered in code rather than left
implicit: MINTABLE_KINDS is work/issue/spike, deliberately NARROWER than the
column's CHECK. `plan` stays a valid stored value because historical
plan-tasks carry it and must stay writable; it is simply not a value any
door hands out, and the refusal names start_planning because a caller
reaching for it wants a plan. The whitelist and the policy answer different
questions and are not the same list.

Every new test reads the value BACK. One that only asserted the call
succeeded would have passed against the broken code — the route returned 200
while dropping the field, which is how this survived long enough to be found
by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:55:04 -04:00

731 lines
30 KiB
Python

import logging
import re
from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
from scribe.models import async_session
from scribe.models.note import Note, TaskKind, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def embed_note(note) -> None:
"""Refresh a note's embedding, fire-and-forget.
Lives HERE — at the service, not the route — so every caller gets it by
construction. Previously each REST route made this call itself and the MCP
tools did not, so a record created through MCP stayed out of semantic search
and auto-inject until the next restart's backfill ran (#2056). That is
invisible on an instance that redeploys constantly and permanent on one that
doesn't, which is the worst shape a bug can have: it only appears where
nobody is looking.
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
to the record, and a collaborator editing a shared note must refresh the
owner's row rather than mint a second one under their own id.
Import is lazy so importing this module doesn't pull in the embedding model;
exceptions are swallowed because a record that saved must not fail on its
index refresh. No running loop (unit tests, scripts) is an ordinary case,
not an error.
"""
try:
import asyncio
from scribe.services.embeddings import upsert_note_embedding
# Chunking and the empty-record gate live inside upsert_note_embedding —
# one path for every writer (#280).
asyncio.create_task(
upsert_note_embedding(note.id, note.user_id, note.title, note.body)
)
except RuntimeError:
pass # no running loop — a sync caller, not a failure
except Exception: # noqa: BLE001 - never let indexing break a write
logger.exception("embedding refresh failed for note %s", note.id)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
out: list[str] = []
for t in tags:
normalized = t.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
out.append(normalized)
return out
# Type-nouns the LLM tends to include in search queries. Treating them as
# required ILIKE terms drops literal-title matches; we strip them server-side
# and let the `type` / `project` parameters scope results instead.
_SEARCH_TYPE_NOUNS = {"task", "tasks", "note", "notes", "project", "projects"}
def _strip_type_nouns(q: str) -> list[str]:
"""Return q's tokens with type-nouns removed (case-insensitive)."""
return [t for t in q.split() if t.lower() not in _SEARCH_TYPE_NOUNS]
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from scribe.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def create_note(
user_id: int,
title: str = "",
body: str = "",
description: str | None = None,
tags: list[str] | None = None,
parent_id: int | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
recurrence_rule: dict | None = None,
note_type: str = "note",
task_kind: str = "work",
arose_from_id: int | None = None,
data: dict | None = None,
) -> Note:
# Validate status/priority here so the MCP create_task path (which passes
# them straight through) can't persist an out-of-enum value that the REST
# route would have rejected — there's no DB CHECK on notes.status.
if isinstance(status, str):
try:
status = TaskStatus(status).value
except ValueError:
raise ValueError(f"Invalid status: {status!r}. Must be one of: {[s.value for s in TaskStatus]}")
if isinstance(priority, str):
try:
priority = TaskPriority(priority).value
except ValueError:
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
from scribe.models.milestone import Milestone
async with async_session() as lookup:
result = await lookup.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
ms = result.scalars().first()
if ms is not None:
project_id = ms.project_id
async with async_session() as session:
note = Note(
user_id=user_id,
title=title,
body=body,
description=description,
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
note_type=note_type,
task_kind=task_kind,
arose_from_id=arose_from_id,
data=data,
)
session.add(note)
await session.commit()
await session.refresh(note)
embed_note(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
return note
async def get_note(user_id: int, note_id: int) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(
Note.id == note_id, Note.user_id == user_id, Note.deleted_at.is_(None)
)
)
return result.scalars().first()
async def list_notes(
user_id: int,
q: str | None = None,
tags: list[str] | None = None,
is_task: bool | None = None,
status: str | list[str] | None = None,
priority: str | list[str] | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
milestone_ids: list[int] | None = None,
parent_id: int | None = None,
task_kind: str | None = None,
no_project: bool = False,
exclude_paused_projects: bool = False,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
offset: int = 0,
) -> tuple[list[Note], int]:
"""Lifecycle-shaped listing. Two contracts worth knowing:
VISIBILITY is the shared browse clause (#47, #2462): the caller's own
records plus anything in a project they can reach — the same reach
query_knowledge has always had. Before this, list_notes was silently
owner-only, so a task in a shared project was invisible in list_tasks,
enter_project's open-task list and the web UI while the same project's
notes appeared. Browse, not read, per decision #2094: an ambient list must
never surface a record someone shared one-to-one — those stay search-only.
`q` is SEMANTIC (operator decision, 2026-08-06: "make it match") — the same
meaning-based match as Browse search, at the interactive floor. When `q` is
present, relevance ordering wins and `sort` is ignored; a query is a
relevance claim and sorting its results by date would shuffle the answer.
Falls back to ILIKE substring match only when the embedder is unavailable —
degraded but never empty. Superseded records are not demoted here: the
penalty reorders a top-k, and reordering a paginated, counted list would
make page boundaries lie. The search surfaces carry the demotion.
"""
from scribe.services.access import notes_visibility_clause
visible = notes_visibility_clause(user_id, "browse")
async with async_session() as session:
query = select(Note).where(visible, Note.deleted_at.is_(None))
count_query = select(func.count(Note.id)).where(
visible, Note.deleted_at.is_(None)
)
# Filter by task vs note
if is_task is True:
query = query.where(Note.status.isnot(None))
count_query = count_query.where(Note.status.isnot(None))
elif is_task is False:
query = query.where(Note.status.is_(None))
count_query = count_query.where(Note.status.is_(None))
semantic_order = None
if q:
query_vec = None
try:
from scribe.services.embeddings import get_embedding
query_vec = await get_embedding(q)
except Exception:
query_vec = None # embedder down → keyword fallback below
if query_vec is not None:
from scribe.models.embedding import NoteEmbedding
from scribe.services.embeddings import INTERACTIVE_SEARCH_THRESHOLD
# Best-chunk-per-note as a correlated MIN, not a join (#280):
# a note stores one embedding row PER CHUNK, so the plain join
# this used to be would repeat a long note once per matching
# chunk — duplicated list rows and a total that counts chunks.
# This query is filter-heavy and paginated, never HNSW-bound,
# so the scalar subquery costs what the join did.
best_distance = (
select(
func.min(
NoteEmbedding.embedding.cosine_distance(query_vec)
)
)
.where(NoteEmbedding.note_id == Note.id)
.scalar_subquery()
)
sem_filter = best_distance <= (1.0 - INTERACTIVE_SEARCH_THRESHOLD)
query = query.where(sem_filter)
count_query = count_query.where(sem_filter)
semantic_order = best_distance.asc()
else:
terms = _strip_type_nouns(q)
for term in terms:
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_term}%"
term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
query = query.where(term_filter)
count_query = count_query.where(term_filter)
if tags:
for i, tag in enumerate(tags):
param_tag = f"tag_{i}"
param_prefix = f"tag_prefix_{i}"
tag_filter = text(
f"EXISTS (SELECT 1 FROM unnest(notes.tags) AS t"
f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})"
).bindparams(**{param_tag: tag, param_prefix: tag + "/%"})
query = query.where(tag_filter)
count_query = count_query.where(tag_filter)
if status:
statuses = [status] if isinstance(status, str) else status
query = query.where(Note.status.in_(statuses))
count_query = count_query.where(Note.status.in_(statuses))
if priority:
priorities = [priority] if isinstance(priority, str) else priority
query = query.where(Note.priority.in_(priorities))
count_query = count_query.where(Note.priority.in_(priorities))
if due_before is not None:
query = query.where(Note.due_date < due_before)
count_query = count_query.where(Note.due_date < due_before)
if due_after is not None:
query = query.where(Note.due_date >= due_after)
count_query = count_query.where(Note.due_date >= due_after)
if project_id is not None:
if milestone_ids:
# OR: directly assigned to project, OR assigned to one of the project's milestones
project_filter = or_(Note.project_id == project_id, Note.milestone_id.in_(milestone_ids))
else:
project_filter = Note.project_id == project_id
query = query.where(project_filter)
count_query = count_query.where(project_filter)
if milestone_id is not None:
query = query.where(Note.milestone_id == milestone_id)
count_query = count_query.where(Note.milestone_id == milestone_id)
if parent_id is not None:
query = query.where(Note.parent_id == parent_id)
count_query = count_query.where(Note.parent_id == parent_id)
if task_kind is not None:
query = query.where(Note.task_kind == task_kind)
count_query = count_query.where(Note.task_kind == task_kind)
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
if exclude_paused_projects:
from scribe.models.project import Project
paused_ids = (
select(Project.id)
.where(Project.user_id == user_id, Project.status == "paused")
.scalar_subquery()
)
paused_filter = or_(Note.project_id.is_(None), Note.project_id.not_in(paused_ids))
query = query.where(paused_filter)
count_query = count_query.where(paused_filter)
if semantic_order is not None:
# A query is a relevance claim — see the docstring.
query = query.order_by(semantic_order)
else:
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
else:
query = query.order_by(sort_col.desc())
query = query.limit(limit).offset(offset)
total = await session.scalar(count_query) or 0
result = await session.execute(query)
notes = list(result.scalars().all())
return notes, total
async def get_note_by_title(user_id: int, title: str) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(
Note.user_id == user_id,
func.lower(Note.title) == func.lower(title.strip()),
Note.deleted_at.is_(None),
).limit(1)
)
return result.scalars().first()
async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
title = title.strip()
note = await get_note_by_title(user_id, title)
if note:
return note
return await create_note(user_id, title=title)
# Kinds a caller may MINT. Narrower than what the COLUMN holds: `plan` is a
# valid stored value — historical plan-tasks carry it and must stay writable —
# but plans became milestones in 0066, so no door hands out a new one. The
# CHECK whitelist and this policy answer different questions, which is why
# they are deliberately not the same list.
MINTABLE_KINDS = ("work", "issue", "spike")
def minted_kind(kind: str) -> str:
"""Validate a kind a caller is asking to WRITE, or raise saying why.
Lives here rather than in either door so both share one copy: the REST
route cannot import an MCP tool module, and a second spelling of this
list is how the two doors would come to disagree.
Raises rather than falling back to 'work'. A silently-corrected kind is
the defect this exists to end (#3129: the editor's Kind select reported
success and changed nothing), and a caller naming a kind we do not know
has a wrong idea that an error corrects and a default hides.
"""
if kind in MINTABLE_KINDS:
return kind
if kind == "plan":
raise ValueError(
"kind='plan' is retired — plans are milestones. Call "
"start_planning(project_id, title) to begin one. Existing "
"plan-tasks keep the value and stay editable."
)
raise ValueError(f"kind must be one of {MINTABLE_KINDS}, got {kind!r}")
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
return None
# Snapshot before changes for version creation
old_body = note.body
old_title = note.title
old_tags = list(note.tags or [])
for key, value in fields.items():
if not hasattr(note, key):
continue
if key == "status" and isinstance(value, str):
try:
value = TaskStatus(value).value
except ValueError:
raise ValueError(f"Invalid status: {value!r}. Must be one of: {[s.value for s in TaskStatus]}")
elif key == "priority" and isinstance(value, str):
try:
value = TaskPriority(value).value
except ValueError:
raise ValueError(f"Invalid priority: {value!r}. Must be one of: {[p.value for p in TaskPriority]}")
elif key == "task_kind" and isinstance(value, str):
# Same shape as status/priority above, and for the same
# reason: a kind the column will refuse should fail here with
# a readable message, not as a CheckViolationError from the
# database. Before this, `task_kind` reached setattr through
# the hasattr guard with no validation at all — but no door
# ever offered it, so a task's kind was write-once (#3129).
try:
value = TaskKind(value).value
except ValueError:
raise ValueError(
f"Invalid kind: {value!r}. Must be one of: "
f"{[k.value for k in TaskKind]}"
)
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields:
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from scribe.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
# Create a version snapshot when body actually changes
if "body" in fields and fields["body"] != old_body:
from scribe.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
embed_note(note)
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
return note
# A hard `delete_note(user_id, note_id)` lived here with ZERO callers, and was
# removed with #278 step 1. It is recorded rather than silently dropped because
# the danger was never that it ran — it is that it was findable by name. Someone
# wanting to delete a note greps `delete_note`, finds a function in the notes
# service with exactly the right signature, and permanently destroys a record
# every path downstream expects to be recoverable.
#
# The delete path is `trash_svc.delete`, which soft-deletes an entity AND its
# descendants under one batch_id so `restore(batch)` works. `purge_trash` owns
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
async with async_session() as session:
if q:
result = await session.execute(
text(
"SELECT DISTINCT tag FROM"
" (SELECT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id) t"
" WHERE tag ILIKE :q_pattern ORDER BY tag LIMIT 100"
).bindparams(user_id=user_id, q_pattern=f"%{q}%")
)
else:
result = await session.execute(
text(
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id"
" ORDER BY tag LIMIT 100"
).bindparams(user_id=user_id)
)
return [row[0] for row in result.fetchall()]
async def convert_note_to_task(user_id: int, note_id: int) -> Note:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
logger.warning("convert_note_to_task: note %d not found", note_id)
raise ValueError("Note not found")
note.status = TaskStatus.todo.value
note.priority = TaskPriority.none.value
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
logger.info("Converted note %d to task", note_id)
return note
async def convert_task_to_note(user_id: int, note_id: int) -> Note:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
logger.warning("convert_task_to_note: note %d not found", note_id)
raise ValueError("Note not found")
note.status = None
note.priority = None
note.due_date = None
# A plain note is not a task and must not recur — clear the rule and
# any armed spawn timestamp so the recurrence sweep never picks it up.
note.recurrence_rule = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
logger.info("Converted task %d to note", note_id)
return note
async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]:
"""Resolve a stored process by id or name.
note_type='process', non-trashed, scoped to what this user may READ — owned
plus shared (rule #78). Naming one is an explicit act, so a Process shared
with the caller resolves here even though it is deliberately absent from the
passive process list and the skill manifest (decision note 2094); without
this, a Process a search surfaced could not then be run — see #2093.
Precedence: numeric id → exact case-insensitive title → substring. Returns
(note, other_candidates); on a substring tie with no exact hit, `note` is the
most-recently-updated match and `other_candidates` lists the rest as
[{id, title}] so the caller can disambiguate. Returns (None, []) when nothing
matches.
"""
from scribe.services.access import readable_notes_clause
visible = readable_notes_clause(user_id)
async with async_session() as session:
base = select(Note).where(
visible,
Note.note_type == "process",
Note.deleted_at.is_(None),
)
s = str(name_or_id).strip()
if s.isdigit():
row = (await session.execute(base.where(Note.id == int(s)))).scalars().first()
if row is not None:
return row, []
exact = (await session.execute(
base.where(func.lower(Note.title) == s.lower()).order_by(Note.updated_at.desc())
)).scalars().first()
if exact is not None:
return exact, []
matches = (await session.execute(
base.where(Note.title.ilike(f"%{s}%")).order_by(Note.updated_at.desc())
)).scalars().all()
if not matches:
return None, []
return matches[0], [{"id": n.id, "title": n.title} for n in matches[1:]]
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
if not note_ids:
return {}
async with async_session() as session:
result = await session.execute(
select(Note).where(
Note.user_id == user_id, Note.id.in_(note_ids), Note.deleted_at.is_(None)
)
)
return {n.id: n for n in result.scalars().all()}
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
note = await get_note(user_id, note_id)
if note is None:
return []
title = note.title
if not title:
return []
async with async_session() as session:
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%[[{escaped}]]%"
pattern_alias = f"%[[{escaped}|%"
results = await session.execute(
select(Note.id, Note.title, Note.status).where(
Note.user_id == user_id,
Note.id != note_id,
Note.deleted_at.is_(None),
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
)
)
backlinks: list[dict] = []
for row in results.fetchall():
link_type = "task" if row[2] is not None else "note"
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
return backlinks
_WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
async def build_note_graph(
user_id: int,
project_id: int | None = None,
include_shared_tags: bool = False,
) -> dict:
notes, _ = await list_notes(user_id, project_id=project_id, limit=1000)
if not notes:
return {"nodes": [], "edges": []}
# Fetch project colours
project_ids = {n.project_id for n in notes if n.project_id is not None}
project_colors: dict[int, str] = {}
if project_ids:
from scribe.models.project import Project
async with async_session() as session:
result = await session.execute(
select(Project.id, Project.color).where(Project.id.in_(project_ids))
)
for pid, color in result.fetchall():
project_colors[pid] = color or "#888888"
# Build title lookup (lowercase → id)
title_map: dict[str, int] = {}
for n in notes:
if n.title:
title_map[n.title.lower()] = n.id
# Build nodes
nodes = []
for n in notes:
nodes.append({
"id": n.id,
"title": n.title or "(untitled)",
"type": "task" if n.is_task else "note",
"tags": n.tags or [],
"project_id": n.project_id,
"project_color": project_colors.get(n.project_id) if n.project_id else None,
})
# Build wikilink edges
edge_set: set[tuple[int, int]] = set()
edges = []
for n in notes:
if not n.body:
continue
for match in _WIKILINK_RE.findall(n.body):
target_id = title_map.get(match.strip().lower())
if target_id is not None and target_id != n.id:
pair = (n.id, target_id)
if pair not in edge_set:
edge_set.add(pair)
edges.append({"source": n.id, "target": target_id, "type": "wikilink"})
# Build tag nodes + note→tag edges
if include_shared_tags:
tag_to_ids: dict[str, list[int]] = {}
for n in notes:
for tag in (n.tags or []):
tag_to_ids.setdefault(tag, []).append(n.id)
for tag, note_ids in tag_to_ids.items():
tag_node_id = f"tag:{tag}"
nodes.append({
"id": tag_node_id,
"title": tag,
"type": "tag",
"tags": [],
"project_id": None,
"project_color": None,
})
for nid in note_ids:
edges.append({"source": nid, "target": tag_node_id, "type": "tag"})
return {"nodes": nodes, "edges": edges}
# ---------------------------------------------------------------------------
# Shared-access variant
# ---------------------------------------------------------------------------
async def get_note_for_user(
accessing_user_id: int, note_id: int
) -> tuple["Note", str] | None:
"""Returns (note, permission) if user has any access, else None."""
from scribe.services.access import get_note_permission
perm = await get_note_permission(accessing_user_id, note_id)
if perm is None:
return None
async with async_session() as session:
note = await session.get(Note, note_id)
return (note, perm) if note else None