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
This commit is contained in:
140
sorter/models.py
Normal file
140
sorter/models.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user