Files
photoanalyzer/photo_pipeline/services/naming.py

187 lines
7.5 KiB
Python

"""Album naming policy: render candidate album names from evidence fields and
validate them for supported filesystems (US03-02). Pure logic — it never touches
the disk; it only proposes and inspects strings.
The policy is template-driven. A template is an ordered tuple of evidence field
names; rendering drops fields that are missing/empty and joins the rest with the
separator, and the first template in the chain that yields a non-empty result wins.
This gives the concept's graceful fallbacks (§7)::
date place event → "2019-07 — Rome — Summer Holiday"
date event → "2019 — Summer Holiday"
place event → "Rome — Summer Holiday"
event → "Summer Holiday"
``date`` combines year and (optional) month into ``YYYY`` / ``YYYY-MM`` (album_naming.md:
year leads, ISO format). Naming conventions follow the concept and album_naming.md:
UTF-8 kept (umlauts survive), no underscores/double spaces, a single ``—`` qualifier
separator.
Validation is non-destructive: it sanitizes a proposed string, reports every change
and blocking problem as a structured issue, and offers filesystem-safe suggestions
(including collision-disambiguated variants) — but only the caller ever renames.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
# Characters no supported filesystem tolerates in a path component, plus the path
# separators themselves (a name must never introduce a new path level) and control
# characters. Replaced with a space during sanitisation.
FORBIDDEN = set('/\\:*?"<>|') | {chr(c) for c in range(0x20)}
_FORBIDDEN_RE = re.compile("[" + re.escape("".join(sorted(FORBIDDEN))) + "]")
_WHITESPACE_RE = re.compile(r"\s+")
# Case-insensitive reserved basenames on Windows; rejected so a name stays portable.
RESERVED = (
{"CON", "PRN", "AUX", "NUL"}
| {f"COM{n}" for n in range(1, 10)}
| {f"LPT{n}" for n in range(1, 10)}
)
DEFAULT_SEPARATOR = ""
DEFAULT_MAX_LENGTH = 120
DEFAULT_TEMPLATES = (
("date", "place", "event"),
("date", "event"),
("place", "event"),
("date", "place"),
("event",),
("place",),
("date",),
)
ALLOWED_FIELDS = ("date", "year", "month", "place", "event")
@dataclass(frozen=True)
class NamingPolicy:
separator: str = DEFAULT_SEPARATOR
max_length: int = DEFAULT_MAX_LENGTH
templates: tuple[tuple[str, ...], ...] = DEFAULT_TEMPLATES
empty_fallback: str = "(untitled)"
allowed_fields: tuple[str, ...] = ALLOWED_FIELDS
@dataclass
class ValidationResult:
name: str # the sanitised name ("" when nothing survives)
valid: bool
issues: list[dict] = field(default_factory=list)
suggestions: list[str] = field(default_factory=list)
def date_field(year: int | None, month: int | None) -> str | None:
"""``YYYY-MM`` when a month is known, ``YYYY`` with only a year, else ``None``."""
if year is None:
return None
return f"{year}-{month:02d}" if month else str(year)
def build_fields(evidence: dict) -> dict:
"""Project an US03-01 folder-evidence dict onto the naming fields. Deterministic:
``place`` is the single most common location, ``event`` is the folder's own name.
The richer AI ``event`` naming is US03-03; this keeps a usable default."""
locations = evidence.get("locations") or []
place = locations[0]["value"] if locations else None
return {
"date": date_field(evidence.get("dominant_year"), evidence.get("month")),
"year": evidence.get("dominant_year"),
"month": evidence.get("month"),
"place": place,
"event": evidence.get("folder_name") or None,
}
def render_name(fields: dict, policy: NamingPolicy = NamingPolicy()) -> str:
"""Render the first template whose available fields produce a non-empty name."""
for template in policy.templates:
pieces = [str(fields[name]).strip() for name in template if _present(fields.get(name))]
if pieces:
return policy.separator.join(pieces)
return ""
def _present(value) -> bool:
return value is not None and str(value).strip() != ""
def sanitize(name: str, policy: NamingPolicy = NamingPolicy()) -> tuple[str, list[dict]]:
"""Return a filesystem-safe name plus the list of changes made, each a structured
issue. Never raises and never returns a name containing a path separator, control
character, ``..`` traversal, or leading/trailing space/dot."""
issues: list[dict] = []
normalized = unicodedata.normalize("NFC", name)
if normalized != name:
issues.append(_issue("normalized_unicode", "applied Unicode NFC normalization"))
replaced = _FORBIDDEN_RE.sub(" ", normalized)
if replaced != normalized:
issues.append(_issue("removed_forbidden_characters", "replaced forbidden characters"))
# Strip spaces and dots together from the ends in one pass: doing them
# separately isn't idempotent ("x. ." would leave a trailing "x."). Interior
# dots (e.g. "St. Louis") are preserved; leading dots (hidden files) are not.
collapsed = _WHITESPACE_RE.sub(" ", replaced).strip(" .")
if collapsed != replaced:
issues.append(_issue("normalized_whitespace", "collapsed whitespace and trimmed"))
if len(collapsed) > policy.max_length:
collapsed = collapsed[: policy.max_length].strip(" .")
issues.append(_issue("truncated", f"truncated to {policy.max_length} characters"))
return collapsed, issues
def validate(
name: str,
*,
existing: set[str] | None = None,
policy: NamingPolicy = NamingPolicy(),
) -> ValidationResult:
"""Sanitise and inspect a proposed name. Reports every change and blocking problem
and offers safe suggestions; mutates nothing. ``existing`` is the set of names
already taken on the target (compared case-insensitively)."""
taken = {e.casefold() for e in (existing or set())}
sanitized, issues = sanitize(name, policy)
suggestions: list[str] = []
valid = True
if not sanitized:
valid = False
issues.append(_issue("empty", "name is empty after sanitization"))
suggestions.append(policy.empty_fallback)
return ValidationResult(name="", valid=False, issues=issues, suggestions=suggestions)
if sanitized.upper() in RESERVED:
valid = False
issues.append(_issue("reserved_name", f"{sanitized!r} is a reserved filesystem name"))
suggestions.append(_disambiguate(f"{sanitized} (album)", taken, policy))
if sanitized.casefold() in taken:
valid = False
issues.append(_issue("collision", f"{sanitized!r} already exists on the target"))
suggestions.append(_disambiguate(sanitized, taken, policy))
return ValidationResult(name=sanitized, valid=valid, issues=issues, suggestions=suggestions)
def _disambiguate(name: str, taken: set[str], policy: NamingPolicy) -> str:
"""Append ``(2)``, ``(3)`` … until the name is free (case-insensitively), bounded
by the length limit."""
if name.casefold() not in taken and name.upper() not in RESERVED:
return name
for suffix in range(2, 1000):
candidate = f"{name} ({suffix})"
if len(candidate) > policy.max_length:
candidate = f"{name[: policy.max_length - len(f' ({suffix})')].strip()} ({suffix})"
if candidate.casefold() not in taken and candidate.upper() not in RESERVED:
return candidate
return name # pathological: 998 collisions — caller still sees the collision issue
def _issue(code: str, message: str) -> dict:
return {"code": code, "message": message}