Skip to content

zettelkasten.dashboard.backend.routes.notes

zettelkasten.dashboard.backend.routes.notes

Note, reading-list, dataset, and note-authoring routes.

Split out of the former flat routes.py; behaviour is unchanged.

missing_definitions

missing_definitions(name: str) -> list[dict]

Terms the graph references but never defines, ranked by reference count.

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.get("/graphs/{name}/missing-definitions")
def missing_definitions(name: str) -> list[dict]:
    """Terms the graph references but never defines, ranked by reference count."""
    zg = _get_graph_structural(name)  # structural analysis of links/prerequisites
    return zg.missing_definitions()

graph_reading_list

graph_reading_list(name: str) -> list[dict]

Reading list for a single source: works its notes cite, with relevance.

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.get("/graphs/{name}/reading-list")
def graph_reading_list(name: str) -> list[dict]:
    """Reading list for a single source: works its notes cite, with relevance."""
    _get_graph_structural(name)  # 404s on an unknown/unsafe name (no embedding build)
    _, repo = _resolve_ref(name)
    return _collect_reading_list([name], repo=repo)

project_reading_list

project_reading_list(name: str) -> list[dict]

Reading list across a project: works cited by any source (plus _cross).

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.get("/projects/{name}/reading-list")
def project_reading_list(name: str) -> list[dict]:
    """Reading list across a project: works cited by any source (plus _cross)."""
    _, repo = _resolve_ref(name)
    project_data = _load_project_or_404(name)
    gdir = repo.zettel_dir if repo is not None else GRAPHS_DIR
    sources = list(project_data.get("sources", []))
    if (gdir / "_cross").is_dir():
        sources.append("_cross")
    # Federated graph names must be namespaced so _get_graph routes to the repo;
    # source_graph in the results then carries the namespace for note routing.
    graph_names = [federation.namespace_id(repo.id, s) for s in sources] if repo else sources
    return _collect_reading_list(graph_names, repo=repo)

get_note_snapshot

get_note_snapshot(name: str, note_id: str)

Serve an equation or figure note's committed snapshot PNG (visual ground-truth).

404s when the note is missing, carries no snapshot pointer, or the sidecar file is absent. The path is jailed to the graphs dir by :func:equation_snapshot.resolve_snapshot_path.

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.get("/graphs/{name}/notes/{note_id}/snapshot")
def get_note_snapshot(name: str, note_id: str):
    """Serve an ``equation`` or ``figure`` note's committed snapshot PNG (visual ground-truth).

    404s when the note is missing, carries no snapshot pointer, or the sidecar
    file is absent. The path is jailed to the graphs dir by
    :func:`equation_snapshot.resolve_snapshot_path`.
    """
    from fastapi.responses import FileResponse

    from zettelkasten.equation_snapshot import resolve_snapshot_path

    zg = _get_graph_structural(name)  # reads a single note; no embedding build
    note = zg.notes.get(note_id)
    if not note or not note.snapshot:
        raise HTTPException(status_code=404, detail="No snapshot for this note.")
    rel = note.snapshot.get("path") if isinstance(note.snapshot, dict) else None
    path = resolve_snapshot_path(rel) if rel else None
    if path is None:
        raise HTTPException(status_code=404, detail="Snapshot file not found.")
    return FileResponse(str(path), media_type="image/png")

get_dataset_values

get_dataset_values(name: str, note_id: str, offset: int = 0, limit: int = 200) -> dict

Resolve a dataset note and return a paginated rows payload.

Shape: {columns, rows, num_rows, offset, limit, backend, format, content_hash, schema, warning}. 404s when the note is missing or not a dataset; a resolution problem (missing file, unknown provider) is a 200 with an empty rows and a populated warning so the UI can show it inline.

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.get("/graphs/{name}/datasets/{note_id}/values")
def get_dataset_values(name: str, note_id: str, offset: int = 0, limit: int = 200) -> dict:
    """Resolve a ``dataset`` note and return a paginated rows payload.

    Shape: ``{columns, rows, num_rows, offset, limit, backend, format,
    content_hash, schema, warning}``. 404s when the note is missing or not a
    dataset; a resolution problem (missing file, unknown provider) is a 200 with
    an empty ``rows`` and a populated ``warning`` so the UI can show it inline.
    """
    from zettelkasten import datasets as datasets_mod

    zg = _get_graph_structural(name)  # resolves a dataset note; no embedding build
    note = zg.notes.get(note_id)
    if not note:
        raise HTTPException(status_code=404, detail=f"Note '{note_id}' not found.")
    if note.type != "dataset":
        raise HTTPException(
            status_code=404,
            detail=f"Note '{note_id}' is type '{note.type}', not 'dataset'.",
        )
    # Clamp limit to keep a single response bounded regardless of dataset size.
    safe_limit = max(1, min(int(limit or 200), 5000))
    return datasets_mod.read_dataset_values(
        note, zg.path, offset=max(0, int(offset or 0)), limit=safe_limit,
    )

update_note

update_note(name: str, note_id: str, req: UpdateNoteRequest) -> dict

Edit an existing note in place. Currently only the title is editable.

The note id is the stable identity (filename + link target), so renaming a note's title rewrites only the title frontmatter and leaves the id, file, and every reference to it untouched. Federated graphs are a read-only overlay → 403.

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.put("/graphs/{name}/notes/{note_id}")
def update_note(name: str, note_id: str, req: UpdateNoteRequest) -> dict:
    """Edit an existing note in place. Currently only the ``title`` is editable.

    The note id is the stable identity (filename + link target), so renaming a
    note's title rewrites only the title frontmatter and leaves the id, file, and
    every reference to it untouched. Federated graphs are a read-only overlay → 403.
    """
    # Federated graphs are a read-only overlay of another repo — never writable.
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated graphs are read-only.")

    zg = _get_graph_structural(name)  # title edit; ``save_note`` re-indexes embeddings only if warm
    note = zg.notes.get(note_id)
    if not note:
        raise HTTPException(status_code=404, detail=f"Note '{note_id}' not found.")

    if req.title is not None:
        new_title = req.title.strip()
        if not new_title:
            raise HTTPException(status_code=400, detail="Title cannot be empty.")
        note.title = new_title

    zg.save_note(note)
    return {"id": note.id, "title": note.title}

auto_note_route

auto_note_route(name: str, req: AutoNoteRequest) -> dict

Research term with a one-shot agent and add it as a note of type.

Powers the Inspect "Add" fields: the agent drafts a concise, grounded note (it never writes), and this route deterministically creates it in name (a single source graph, or _cross for project scope). A label already in the graph short-circuits to the existing note (existed: true) without invoking the agent. Federated graphs are read-only → 403.

Source code in zettelkasten/dashboard/backend/routes/notes.py
@router.post("/graphs/{name}/auto-note")
def auto_note_route(name: str, req: AutoNoteRequest) -> dict:
    """Research ``term`` with a one-shot agent and add it as a note of ``type``.

    Powers the Inspect "Add" fields: the agent drafts a concise, grounded note
    (it never writes), and this route deterministically creates it in ``name`` (a
    single source graph, or ``_cross`` for project scope). A label already in the
    graph short-circuits to the existing note (``existed: true``) without invoking
    the agent. Federated graphs are read-only → 403.
    """
    if req.type not in auto_note_mod.KINDS:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid type '{req.type}' (expected one of {', '.join(auto_note_mod.KINDS)}).",
        )

    # Federated graphs are a read-only overlay of another repo — never writable.
    if _resolve_ref(name)[1] is not None:
        raise HTTPException(status_code=403, detail="Federated graphs are read-only.")

    term = (req.term or "").strip()
    if not term:
        raise HTTPException(status_code=400, detail="A term is required.")

    # ``_cross`` (the project's cross-source hub) may not exist on disk yet, so
    # mint the folder before _get_graph, whose 404 guard assumes an existing dir.
    if name == "_cross":
        local_cross, _ = _resolve_ref(name)
        (GRAPHS_DIR / local_cross).mkdir(parents=True, exist_ok=True)

    zg = _get_graph_structural(name)  # draft+create; ``save_note`` re-indexes embeddings only if warm

    # Already in the graph here? Short-circuit to the existing note — no agent
    # run, no duplicate. Matches on title OR alias (find_by_title checks both).
    existing = zg.find_by_title(term)
    if existing is not None:
        return {"id": existing.id, "title": existing.title, "type": existing.type, "existed": True}

    try:
        drafted = auto_note_mod.draft_note(term, kind=req.type, context=req.context, graph=name)
    except ValueError as e:
        # Bad/undraftable input or unparseable agent reply — a client-visible 400.
        raise HTTPException(status_code=400, detail=f"Couldn't add '{term}': {e}")
    except (RuntimeError, FileNotFoundError, OSError) as e:
        # The agent / its LLM stack failed — a runtime/upstream failure (502),
        # mirroring the outline DRAFT route, so the dashboard shows the real cause.
        raise HTTPException(status_code=502, detail=f"Drafting agent failed: {e}")

    # The agent may have canonicalized the title (e.g. "sharpe ratio" -> "Sharpe
    # Ratio"); re-check so we don't mint a near-duplicate of an existing note.
    existing = zg.find_by_title(drafted["title"])
    if existing is not None:
        return {"id": existing.id, "title": existing.title, "type": existing.type, "existed": True}

    note_id = zg.generate_unique_id(drafted["title"])

    # Attach the agent's suggested connections — but only those that resolve to a
    # real note (the agent merely proposes; the route validates). A cross-graph
    # target carries its owning graph so the edge points at the right note; an
    # intra-graph target leaves ``graph`` empty. An unknown relation falls back to
    # the generic "related". This both adds genuine structure and keeps a fresh
    # _cross concept from landing as an orphan the project graph would hide.
    local_name = _resolve_ref(name)[0]
    resolved_links: list[Link] = []
    linked_out: list[dict] = []
    seen_targets: set[tuple[str, str]] = set()
    for sug in drafted.get("links", []):
        hit = _resolve_note_by_title(sug.get("title", ""), prefer=local_name)
        if hit is None:
            continue
        owner_graph, target_note = hit
        if target_note.id == note_id:
            continue  # never self-link
        key = (owner_graph, target_note.id)
        if key in seen_targets:
            continue
        seen_targets.add(key)
        relation = sug.get("relation", "related")
        if relation not in VALID_RELATIONS:
            relation = "related"
        cross = owner_graph != local_name
        resolved_links.append(Link(
            target=target_note.id,
            relation=relation,
            direction="outgoing",
            graph=owner_graph if cross else "",
        ))
        linked_out.append({
            "id": target_note.id,
            "title": target_note.title,
            "relation": relation,
            "graph": owner_graph,
        })

    note = Note(
        id=note_id,
        title=drafted["title"],
        type=req.type,
        source={},
        body=drafted["body"],
        tags=drafted["tags"],
        aliases=drafted["aliases"],
        links=resolved_links,
    )
    filepath = zg.save_note(note)
    return {
        "id": note_id,
        "title": drafted["title"],
        "type": req.type,
        "path": str(filepath),
        "existed": False,
        "linked": linked_out,
    }