Files
obsidian-sorter/sorter/models.py
sam 33ec9b5bc5 obsidian-sorter: automated note filing for an Obsidian vault
Classifies notes from an inbox folder and files them, with a human
approval step. Zero third-party dependencies.

- type mirrors folder (200 projects / 300 areas / 400 Personal Family)
- TypeSafe JEV for semantic classification (~$0.0001 per note)
- watcher writes a proposal INTO the note; the owner ticks a checkbox
- systemd user service on .27, 10s poll
- 15/15 functional tests
2026-09-26 06:53:24 +10:00

141 lines
4.7 KiB
Python

"""Data structures shared across the sorter."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# The taxonomy, simplified 2026-09-24.
#
# Sam's model: the TYPE of a note mirrors the FOLDER it lives in. Nothing is
# guessed from content — the destination decides the type.
#
# being worked on -> 200 projects/ -> project
# ongoing interest + docs-> 300 areas/ -> area
# family / non-work -> 400 Personal Family -> family
#
# Everything else that used to exist (subject, tool, container, machine,
# backup-system, website, ...) has been RETIRED. Do not reintroduce them.
# ---------------------------------------------------------------------------
# Type mirrors folder. These are the only valid types.
VALID_TYPES = {
"project", # 200 projects/
"area", # 300 areas/
"family", # 400 Personal Family/
"index", # 100 Table of Contents/
"daily", # 000 daily/
"archive", # 500 archive/
"clipping", # Clippings/ (browser extension drops)
"note", # unsorted placeholder, only ever in the inbox
}
# The three real filing destinations.
BUCKETS = ("project", "area", "family")
# Folder prefix -> type. Order matters: longest prefix wins.
FOLDER_BUCKETS: list[tuple[str, str]] = [
("200 projects", "project"),
("300 areas", "area"),
("400 Personal Family", "family"),
("100 Table of Contents", "index"),
("000 daily", "daily"),
("500 archive", "archive"),
("Clippings", "clipping"),
]
# The generic-template marker. Only valid while a note sits in the inbox.
PLACEHOLDER_TYPE = "note"
def type_from_folder(folder: str) -> str | None:
"""Return the type implied by a vault folder, or None if unknown."""
if not folder:
return None
for prefix, note_type in FOLDER_BUCKETS:
if folder == prefix or folder.startswith(prefix + "/"):
return note_type
return None
@dataclass
class Note:
"""A note discovered in the vault."""
path: str # absolute path
rel_path: str # path relative to vault root
filename: str # e.g. "Borg Backup.md"
stem: str # e.g. "Borg Backup"
title: str | None # frontmatter title, if present
note_type: str | None # frontmatter type
status: str | None # frontmatter status
tags: list[str] = field(default_factory=list)
areas: list[str] = field(default_factory=list)
projects: list[str] = field(default_factory=list)
fm: dict[str, Any] = field(default_factory=dict)
has_frontmatter: bool = False
body: str = ""
@property
def display_name(self) -> str:
return self.title or self.stem
@property
def folder(self) -> str:
"""Vault-relative folder, '' for root."""
parent = self.rel_path.rsplit("/", 1)
return parent[0] if len(parent) == 2 else ""
@property
def expected_type(self) -> str | None:
"""The type this note's folder says it should have."""
return type_from_folder(self.folder)
@property
def needs_classification(self) -> bool:
"""
True when the note has no trustworthy type.
A note is only 'sorted' when its type matches its folder.
"""
current = (self.note_type or "").strip().lower()
expected = self.expected_type
if expected is None:
return True # not in a known bucket (e.g. inbox)
return current != expected
@dataclass
class Proposal:
"""A classification recommendation — a plan, not an action."""
source_rel: str
note_type: str
target_folder: str
toc_section: str
template: str
confidence: float
method: str # "deterministic" | "jev" | "none" | "trusted"
reasons: list[str] = field(default_factory=list)
is_new_area: bool = False
warnings: list[str] = field(default_factory=list)
alternatives: list[tuple[str, float]] = field(default_factory=list)
proposed_tags: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"source": self.source_rel,
"type": self.note_type,
"target_folder": self.target_folder,
"toc_section": self.toc_section,
"template": self.template,
"confidence": round(self.confidence, 3),
"method": self.method,
"reasons": self.reasons,
"is_new_area": self.is_new_area,
"warnings": self.warnings,
"alternatives": self.alternatives,
"proposed_tags": self.proposed_tags,
}