Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ archives = ["/path/to/archive"]
| `rl features add` / `rl tags add` | global config, workspace, vocabulary, roll | roll, vocabulary | no | roll editing |
| `rl search` / `rl scan` / `rl status` / `rl stats` / `rl vocab` | global config, workspace, roll, vocabulary | no | no | read-only |
| `rl doctor` | global config, workspace, stock, roll, vocabulary | no | yes with `--fix` | integrity |
| `rl normalize` | workspace, roll, vocabulary | roll, vocabulary | yes | normalization |
| `rl normalize` | current archive workspace, roll, vocabulary | roll, vocabulary | yes | normalization |
| `rl normalize --photos` | photo folders in current archive workspace | archive folders | yes | photo import |
| `rl batch process` | global config, workspace, roll | roll | no | batch update |

## Lifecycle
Expand Down Expand Up @@ -120,6 +121,7 @@ rl doctor --fix
- the archive is self-contained;
- user edits only what the filesystem cannot infer;
- normalization and doctor fixes build a plan before changing anything;
- `doctor` reports issues first and lists safe fixes in a separate block;
- vocabularies stay plain text and grow automatically;
- dev-facing diagnostics stay in English; only user-facing UI is localized;
- English is the default UI language.
Expand Down
2 changes: 2 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ rl search kir balcony # half a year later — found it
| Fill in a roll | `rl features add`, `rl tags add` |
| Find / inspect | `rl search`, `rl scan`, `rl status`, `rl stats [-v]`, `rl vocab` |
| Integrity | `rl doctor`, `rl doctor --fix`, `rl normalize --tags` |
| Photo import | `rl normalize --photos` |
| Batch | `rl batch process` |

## Out of Scope
Expand All @@ -32,6 +33,7 @@ sync between machines · cloud · web UI · migrating old formats · image proce
The CLI defaults to English in the global config and `rl config lang` changes it.
`rl --version` prints the current version. If a newer git tag is available in the current checkout, it also prints a short update hint and points to `rl update`.
`rl update` upgrades the installed package from the GitHub repo with `pip`.
`rl normalize --photos` works in the current archive workspace and can turn a raw photo folder into an archive month based on the dominant photo date.

## Rule

Expand Down
2 changes: 1 addition & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Brings folder names to a consistent shape: builds a plan, asks for confirmation,

## Doctor

Integrity check: global config, workspaces, stock, roll metadata, vocabularies, suspicious and unindexed folders. Diagnostics stay in English. `--fix` applies safe fixes; `-v` shows the full list of fixes.
Integrity check: global config, workspaces, stock, roll metadata, vocabularies, suspicious and unindexed folders. Diagnostics stay in English. `--fix` applies safe fixes and prints them in a separate block; `-v` shows the full list of fixes.

---

Expand Down
2 changes: 1 addition & 1 deletion src/roll/app/archive/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def process_archives(archives: list[Path]) -> int:
loaded_rolls.append(roll.folder)

if not loaded_rolls:
typer.echo(Msg.BATCH_NO_LOADED)
typer.echo(str(Msg.BATCH_NO_LOADED))
return 0

typer.echo(f"{Msg.BATCH_WILL_PROCESS} {len(loaded_rolls)}")
Expand Down
34 changes: 30 additions & 4 deletions src/roll/app/archive/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
save_roll_metadata,
)
from roll.app.workspace.workspace import workspace_for
from roll.app.archive.photo_dates import guess_archive_month
from roll.messages import Normalize


Expand Down Expand Up @@ -100,14 +101,37 @@ def build_safe_rename_plan(archive: Path) -> NormalizationPlan:
continue
target = roll_dir.with_name(target_name)
if target.exists():
conflicts.append(f"Target already exists: {target}")
conflicts.append(f"{Normalize.TARGET_ALREADY_EXISTS} {target}")
continue
rules.append(RenameRule(folder=roll_dir, target=target))

conflicts.extend(_detect_conflicts(rules))
return NormalizationPlan(archive=archive, rules=rules, conflicts=conflicts)


def build_photo_normalization_plan(archive: Path) -> NormalizationPlan:
rules: list[RenameRule] = []
conflicts: list[str] = []

for folder in sorted(
(path for path in archive.iterdir() if path.is_dir() and path.name != ".roll"),
key=lambda path: path.name.casefold(),
):
guess = guess_archive_month(folder)
if guess is None:
continue

target = archive / f"{guess.year:04d}" / f"{guess.month:02d}-01"
if target.exists():
conflicts.append(f"{Normalize.TARGET_ALREADY_EXISTS} {target}")
continue

rules.append(RenameRule(folder=folder, target=target))

conflicts.extend(_detect_conflicts(rules))
return NormalizationPlan(archive=archive, rules=rules, conflicts=conflicts)


def print_normalization_plan(plan: NormalizationPlan) -> list[str]:
lines = [Normalize.HEADER]
if not plan.rules:
Expand Down Expand Up @@ -145,6 +169,8 @@ def apply_normalization_plans(plans: list[NormalizationPlan]) -> None:
):
os.replace(source, temp_path)
renamed_to_temp.append((source, temp_path, target))
for _, _, target in all_rules:
target.parent.mkdir(parents=True, exist_ok=True)
for _, temp_path, target in all_rules:
os.replace(temp_path, target)
except Exception:
Expand Down Expand Up @@ -250,11 +276,11 @@ def _detect_conflicts(rules: list[RenameRule]) -> list[str]:

for rule in rules:
if rule.target.exists() and rule.target != rule.folder:
conflicts.append(f"Target already exists: {rule.target}")
conflicts.append(f"{Normalize.TARGET_ALREADY_EXISTS} {rule.target}")
if rule.target in sources and rule.target != rule.folder:
conflicts.append(f"Target collides with source: {rule.target}")
conflicts.append(f"{Normalize.TARGET_COLLIDES_WITH_SOURCE} {rule.target}")
if rule.target in targets and targets[rule.target] != rule.folder:
conflicts.append(f"Duplicate target: {rule.target}")
conflicts.append(f"{Normalize.DUPLICATE_TARGET} {rule.target}")
targets[rule.target] = rule.folder

return conflicts
Expand Down
61 changes: 61 additions & 0 deletions src/roll/app/archive/photo_dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

from collections import Counter
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path


@dataclass(frozen=True)
class PhotoDateGuess:
year: int
month: int
confidence: int

@property
def month_key(self) -> str:
return f"{self.year:04d}-{self.month:02d}"


def guess_archive_month(folder: Path) -> PhotoDateGuess | None:
counts = _month_counts(folder)
if not counts:
return None

month_key, confidence = counts.most_common(1)[0]
year, month = month_key.split("-", 1)
return PhotoDateGuess(year=int(year), month=int(month), confidence=confidence)


def guess_archive_year(folder: Path) -> int | None:
counts = _month_counts(folder)
if not counts:
return None

year_counter = Counter(
month_key.split("-", 1)[0] for month_key in counts.elements()
)
year, _ = year_counter.most_common(1)[0]
return int(year)


def _photo_files(folder: Path) -> list[Path]:
if not folder.exists():
return []

return [
path
for path in folder.rglob("*")
if path.is_file()
and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".heic", ".tif", ".tiff"}
]


def _month_counts(folder: Path) -> Counter[str]:
timestamps = [photo.stat().st_mtime for photo in _photo_files(folder)]
return Counter(_month_key(timestamp) for timestamp in timestamps)


def _month_key(timestamp: float) -> str:
dt = datetime.fromtimestamp(timestamp)
return f"{dt.year:04d}-{dt.month:02d}"
4 changes: 2 additions & 2 deletions src/roll/app/archive/search_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ def render_search_results(results) -> None:
)

if roll.features:
echo(f"{Msg.SEARCH_FEATURES} {', '.join(roll.features)}")
echo(f"{str(Msg.SEARCH_FEATURES)} {', '.join(roll.features)}")

if roll.keywords:
echo(f"{Msg.SEARCH_TAGS} {', '.join(roll.keywords)}")
echo(f"{str(Msg.SEARCH_TAGS)} {', '.join(roll.keywords)}")

echo_lines([f"{Msg.SEARCH_FOLDER} {roll.folder}", ""])
2 changes: 1 addition & 1 deletion src/roll/app/archive/stats_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def render_stats_report(
if not report.roll_count:
from typer import echo

echo(Msg.NO_STATS_DATA)
echo(str(Msg.NO_STATS_DATA))
return

from typer import echo
Expand Down
18 changes: 17 additions & 1 deletion src/roll/app/diagnostics/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class DoctorReport:
issues: list[DoctorIssue]
fixable: list[str]
keyword_vocab_fixes: list[str]
keyword_vocab_normalize: list[Path]
missing_rolls: list[Path]
missing_roll_count: int
unindexed_folders: list[Path]
Expand All @@ -50,6 +51,7 @@ def run_doctor(config: Config) -> DoctorReport:
issues: list[DoctorIssue] = []
fixable: list[str] = []
keyword_vocab_fixes: list[str] = []
keyword_vocab_normalize: list[Path] = []
missing_rolls: list[Path] = []
unindexed_folders: list[Path] = []
missing_roll_count = 0
Expand All @@ -67,12 +69,22 @@ def run_doctor(config: Config) -> DoctorReport:
issues=issues,
fixable=fixable,
keyword_vocab_fixes=keyword_vocab_fixes,
keyword_vocab_normalize=keyword_vocab_normalize,
missing_rolls=missing_rolls,
missing_roll_count=missing_roll_count,
unindexed_folders=unindexed_folders,
)

for archive in config.archives:
if archive.exists() and not archive.is_dir():
issues.append(
DoctorIssue(
DoctorText.ERROR,
f"{Doctor.ARCHIVE_NOT_DIRECTORY} {archive}",
archive,
)
)
continue
archive_issues, archive_missing_rolls, archive_unindexed = _check_archive(
archive
)
Expand All @@ -86,12 +98,16 @@ def run_doctor(config: Config) -> DoctorReport:
for rule in plan.rules
)
keyword_vocab_fixes.extend(collect_keyword_vocab_fixes(archive))
issues.extend(_check_keywords_vocab(archive))
keyword_issues = _check_keywords_vocab(archive)
if keyword_issues:
keyword_vocab_normalize.append(archive)
issues.extend(keyword_issues)

return DoctorReport(
issues=issues,
fixable=fixable,
keyword_vocab_fixes=keyword_vocab_fixes,
keyword_vocab_normalize=keyword_vocab_normalize,
missing_rolls=missing_rolls,
missing_roll_count=missing_roll_count,
unindexed_folders=unindexed_folders,
Expand Down
39 changes: 26 additions & 13 deletions src/roll/app/diagnostics/doctor_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
apply_normalization_plans,
build_safe_rename_plan,
collect_keyword_vocab_fixes,
normalize_keywords_in_archive,
print_normalization_plan,
)
from roll.helpers.formatting import highlight_cli_names
Expand Down Expand Up @@ -62,7 +63,7 @@ def render_doctor(fix: bool = False, verbose: bool = False) -> int:
report = run_doctor(config)

if not report.issues and not report.missing_rolls:
echo(Doctor.OK)
echo(str(Doctor.OK))
return 0

global_issues = [issue for issue in report.issues if issue.archive is None]
Expand Down Expand Up @@ -143,25 +144,26 @@ def _render_fix_summaries(
) -> None:
fixers: list[tuple[str, list[str], Callable[[list[Path], bool], None]]] = []
if report.fixable:
fixers.append(
(Msg.DOCTOR_CAN_FIX, report.fixable, _apply_normalization_fixes, None)
)
fixers.append((Msg.DOCTOR_CAN_FIX, report.fixable, _apply_normalization_fixes))
if report.keyword_vocab_fixes:
fixers.append(
(Msg.DOCTOR_CAN_ADD, report.keyword_vocab_fixes, _apply_keyword_fixes)
)
if report.keyword_vocab_normalize:
fixers.append(
(
Msg.DOCTOR_CAN_FIX,
[str(path) for path in report.keyword_vocab_normalize],
_apply_keyword_normalization,
)
)

if fix and any(
issue.message.startswith(str(Doctor.LANGUAGE_INVALID))
for issue in report.issues
):
fixers.append(
(
Msg.DOCTOR_CAN_FIX,
[str(Doctor.LANGUAGE_INVALID)],
_apply_language_fix,
None,
)
(Msg.DOCTOR_CAN_FIX, [str(Doctor.LANGUAGE_INVALID)], _apply_language_fix)
)

if fixers:
Expand Down Expand Up @@ -196,7 +198,7 @@ def _apply_language_fix(archives: list[Path], verbose: bool) -> None:
def _apply_normalization_fixes(archives: list[Path], verbose: bool) -> None:
plans = [build_safe_rename_plan(archive) for archive in archives]
apply_normalization_plans(plans)
echo(Msg.DOCTOR_FIXES_APPLIED)
echo("Fixes applied.")
if verbose:
for plan in plans:
if plan.rules:
Expand All @@ -212,7 +214,17 @@ def _apply_keyword_fixes(archives: list[Path], verbose: bool) -> None:
if applied and verbose:
echo_lines([""])
echo(f" {applied}")
echo(Msg.DOCTOR_KEYWORDS_APPLIED)
echo("Keywords fixes applied.")


def _apply_keyword_normalization(archives: list[Path], verbose: bool) -> None:
for archive in archives:
touched = normalize_keywords_in_archive(archive)
if verbose and touched:
echo_lines([""])
for path in touched:
echo(f" {path}")
echo("Fixes applied.")


def _append_group(
Expand Down Expand Up @@ -245,7 +257,8 @@ def _echo_block(prefix: str, order: list[str], groups: dict[str, list[str]]) ->
echo(f" {_group_title(title)} {len(items)}")
else:
echo(f" {items[0]}")
echo_lines([f" {item}" for item in items])
if len(items) > 1:
echo_lines([f" {item}" for item in items])


def _group_title(title: str) -> str:
Expand Down
Loading
Loading