From 628f52b880fec6358cdd3b34275f2a0d3ce5a086 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 06:25:25 +0200 Subject: [PATCH 01/20] integrity mode itr 1 --- docs/architecture.md | 1 + docs/reference.md | 2 +- src/roll/app/diagnostics/diagnostics.py | 9 +++++++++ src/roll/app/diagnostics/doctor_output.py | 11 ++--------- src/roll/messages/doctor.py | 1 + tests/test_config.py | 21 +++++++++++++++++++++ 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b77c55e..1de9aeb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,6 +120,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. diff --git a/docs/reference.md b/docs/reference.md index 492669f..b156dcf 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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. --- diff --git a/src/roll/app/diagnostics/diagnostics.py b/src/roll/app/diagnostics/diagnostics.py index 244df40..7baa962 100644 --- a/src/roll/app/diagnostics/diagnostics.py +++ b/src/roll/app/diagnostics/diagnostics.py @@ -73,6 +73,15 @@ def run_doctor(config: Config) -> DoctorReport: ) 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 ) diff --git a/src/roll/app/diagnostics/doctor_output.py b/src/roll/app/diagnostics/doctor_output.py index 2dbb297..d0a98ec 100644 --- a/src/roll/app/diagnostics/doctor_output.py +++ b/src/roll/app/diagnostics/doctor_output.py @@ -143,9 +143,7 @@ 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) @@ -156,12 +154,7 @@ def _render_fix_summaries( 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: diff --git a/src/roll/messages/doctor.py b/src/roll/messages/doctor.py index d1ad27d..e9cee07 100644 --- a/src/roll/messages/doctor.py +++ b/src/roll/messages/doctor.py @@ -14,6 +14,7 @@ class Doctor: LANGUAGE_NOT_EXPLICIT = "Language not set explicitly; using EN." LANGUAGE_INVALID = "Invalid global config language:" GLOBAL_CONFIG_DUPLICATE_ARCHIVES = "Duplicate archives in global config:" + ARCHIVE_NOT_DIRECTORY = "Archive is not a directory:" # archive and workspace ARCHIVE_MISSING = "Archive not found:" diff --git a/tests/test_config.py b/tests/test_config.py index 6e5a2f5..d54d493 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -133,6 +133,27 @@ def test_doctor_warns_on_duplicate_archives_in_global_config(self) -> None: ) ) + def test_doctor_errors_when_archive_path_is_a_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self._patch_config_paths(tmp) + archive = Path(tmp) / "archive" + archive.write_text("not a directory", encoding="utf-8") + config_module.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + diagnostics_module.CONFIG_FILE = config_module.CONFIG_FILE + config_module.CONFIG_FILE.write_text( + f'archives = ["{archive}"]\n', + encoding="utf-8", + ) + + report = run_doctor(Config(archives=[archive])) + + self.assertTrue( + any( + "Archive is not a directory:" in issue.message + for issue in report.issues + ) + ) + def test_doctor_errors_when_global_config_is_missing(self) -> None: with tempfile.TemporaryDirectory() as tmp: self._patch_config_paths(tmp) From 283dac80f882a10560ec0b5f7454f2349d339c12 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 06:32:39 +0200 Subject: [PATCH 02/20] normalize status command for a few archives --- src/roll/cli.py | 2 +- src/roll/helpers/guards.py | 7 +++++++ src/roll/messages/cli.py | 4 +++- tests/test_config.py | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/roll/cli.py b/src/roll/cli.py index 44c7d41..0e82d11 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -86,7 +86,7 @@ def main( @app.command("init") def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: - """Initialize roll.""" + """Initialize the archive workspace.""" archive = require_directory(archive, Msg.ARCHIVE_MISSING) CONFIG_DIR.mkdir(parents=True, exist_ok=True) diff --git a/src/roll/helpers/guards.py b/src/roll/helpers/guards.py index 4f23961..af67c37 100644 --- a/src/roll/helpers/guards.py +++ b/src/roll/helpers/guards.py @@ -29,4 +29,11 @@ def require_archive(config: Config) -> Path: if not config.archives: typer.echo(highlight_cli_names(Msg.UNINITIALIZED_NOTICE)) raise typer.Exit(code=1) + current = Path.cwd().resolve() + for archive in config.archives: + try: + current.relative_to(archive.resolve()) + except ValueError: + continue + return archive return config.archives[0] diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 168f87b..5f1878f 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -90,7 +90,9 @@ class Msg(Headers): "cli.uninitialized", "roll не инициализирован.", "roll is not initialized." ) CLI_INITIALIZED = Message( - "cli.initialized", "roll инициализирован.", "roll initialized." + "cli.initialized", + "Архив и workspace инициализированы.", + "Archive and workspace initialized.", ) NO_RESULTS = Message("cli.no_results", "Ничего не найдено.", "Nothing found.") NO_STATS_DATA = Message( diff --git a/tests/test_config.py b/tests/test_config.py index d54d493..3a29122 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ from roll.app.diagnostics.doctor_output import render_doctor from roll.app.workspace import config as config_module from roll.app.workspace.config import Config, load_config, save_config, set_lang +from roll.helpers.guards import require_archive from roll.messages import Msg @@ -199,6 +200,23 @@ def test_doctor_groups_issues_by_workspace_when_multiple_archives_exist( self.assertIn(f"Workspace {first}", output) self.assertIn(f"Workspace {second}", output) + def test_require_archive_prefers_current_worktree_archive(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self._patch_config_paths(tmp) + first = Path(tmp) / "archive-a" + second = Path(tmp) / "archive-b" + first.mkdir() + second.mkdir() + save_config(Config(archives=[first, second], lang="EN")) + + cwd = second / "nested" + cwd.mkdir() + patcher = patch("roll.helpers.guards.Path.cwd", return_value=cwd) + self.addCleanup(patcher.stop) + patcher.start() + + self.assertEqual(require_archive(load_config()), second) + def _patch_config_paths(self, tmp: str) -> None: config_module.CONFIG_DIR = Path(tmp) / ".config" / "roll" config_module.CONFIG_FILE = config_module.CONFIG_DIR / "config.toml" From ff2e36c8aa3a550493b1a02e97b50ea6726af9fc Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 07:07:51 +0200 Subject: [PATCH 03/20] rl normalize --photos --- docs/architecture.md | 3 +- docs/getting-started.md | 2 + src/roll/app/archive/normalization.py | 34 ++++++- src/roll/app/archive/photo_dates.py | 61 ++++++++++++ src/roll/cli.py | 137 +++++++++++++++++++++++++- src/roll/helpers/guards.py | 17 ++++ src/roll/messages/cli.py | 20 ++++ src/roll/messages/normalize.py | 15 +++ tests/test_config.py | 18 +++- tests/test_normalization.py | 60 +++++++++++ 10 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 src/roll/app/archive/photo_dates.py diff --git a/docs/architecture.md b/docs/architecture.md index 1de9aeb..1c3bfdf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/getting-started.md b/docs/getting-started.md index 6290c29..5e9d9e4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 @@ -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 diff --git a/src/roll/app/archive/normalization.py b/src/roll/app/archive/normalization.py index e3fa2db..7beaf7b 100644 --- a/src/roll/app/archive/normalization.py +++ b/src/roll/app/archive/normalization.py @@ -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 @@ -100,7 +101,7 @@ 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)) @@ -108,6 +109,29 @@ def build_safe_rename_plan(archive: Path) -> NormalizationPlan: 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: @@ -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: @@ -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 diff --git a/src/roll/app/archive/photo_dates.py b/src/roll/app/archive/photo_dates.py new file mode 100644 index 0000000..f638d0f --- /dev/null +++ b/src/roll/app/archive/photo_dates.py @@ -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}" diff --git a/src/roll/cli.py b/src/roll/cli.py index 0e82d11..ebd4bde 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -28,9 +28,15 @@ build_normalization_plan, normalize_keywords_in_archive, ) +from roll.app.archive.photo_dates import guess_archive_year from roll.helpers.autocomplete import autocomplete_many_prompt, choice_prompt from roll.helpers.formatting import highlight_cli_names -from roll.helpers.guards import require_archive, require_config, require_directory +from roll.helpers.guards import ( + require_archive, + require_config, + require_current_archive, + require_directory, +) from roll.helpers.output import echo_lines, echo_section from roll.app.flows.stock import app as stock_app from roll.app.flows.stock import load as load_roll @@ -294,14 +300,14 @@ def batch_process() -> None: @app.command("normalize") def normalize( tags: bool = typer.Option(False, "--tags", help=Msg.TAGS_NORMALIZED), + photos: bool = typer.Option(False, "--photos", help=Normalize.HEADER), ) -> None: """Normalize archive layout.""" config = require_config() + archive = require_current_archive(config) if tags: - touched = [] - for archive in config.archives: - touched.extend(normalize_keywords_in_archive(archive)) + touched = normalize_keywords_in_archive(archive) if touched: typer.echo(Msg.TAGS_NORMALIZED) for path in touched: @@ -310,7 +316,26 @@ def normalize( typer.echo(Msg.TAGS_ALREADY_NORMALIZED) return - plans = [build_normalization_plan(archive) for archive in config.archives] + if photos: + plans = _build_photo_normalization_plans(archive) + total_rules, has_changes = render_normalization_plans(plans) + if not has_changes: + return + + all_conflicts = [conflict for plan in plans for conflict in plan.conflicts] + if all_conflicts: + raise typer.Exit(code=1) + + _echo_photo_plan_preview(plans) + if not typer.confirm( + str(Normalize.QUESTION).format(count=total_rules), default=False + ): + return + + apply_normalization_plans(plans) + return + + plans = [build_normalization_plan(archive)] total_rules, has_changes = render_normalization_plans(plans) if not has_changes: return @@ -340,6 +365,108 @@ def _choose_roll_folder(rolls: list[Path]) -> Path: raise ValueError(Msg.NO_CHOICE) +def _build_photo_normalization_plans(archive: Path): + folders = _photo_folders(archive) + year = guess_archive_year(archive) + if year is None: + typer.echo(Msg.CLI_UNINITIALIZED) + raise typer.Exit(code=1) + + if not typer.confirm( + str(Msg.NORMALIZE_PHOTOS_CONFIRM_YEAR).format(year=year), default=True + ): + typed_year = typer.prompt( + str(Msg.NORMALIZE_PHOTOS_YEAR).format(folder=archive.name) + ) + year = _parse_year(typed_year) + + manual_months = len(folders) > 1 and typer.confirm( + str(Msg.NORMALIZE_PHOTOS_MANUAL), default=False + ) + return [ + _build_photo_plan_for_folder(folder, archive, year, manual_months) + for folder in folders + ] + + +def _build_photo_plan_for_folder( + folder: Path, archive: Path, year: int, manual_months: bool +): + from roll.app.archive.normalization import NormalizationPlan, RenameRule + + month = None + if manual_months: + month = _prompt_month(folder) + else: + month = _guess_month(folder) + + if month is None: + return NormalizationPlan(archive=archive, rules=[], conflicts=[]) + + target = archive / f"{year:04d}" / f"{month:02d}-01" + if target.exists(): + return NormalizationPlan( + archive=archive, + rules=[], + conflicts=[f"{Msg.NORMALIZE_PHOTOS_MONTH} {target}"], + ) + + return NormalizationPlan( + archive=archive, rules=[RenameRule(folder=folder, target=target)], conflicts=[] + ) + + +def _echo_photo_plan_preview(plans) -> None: + lines = [] + for plan in plans: + for rule in plan.rules: + lines.append( + f"{rule.folder.name} -> {rule.target.relative_to(plan.archive)}" + ) + + if lines: + typer.echo("Planned photo layout:") + for line in lines: + typer.echo(f" {line}") + + +def _photo_folders(archive: Path) -> list[Path]: + return [ + path for path in archive.iterdir() if path.is_dir() and path.name != ".roll" + ] + + +def _prompt_month(folder: Path) -> int: + while True: + value = typer.prompt( + str(Msg.NORMALIZE_PHOTOS_MONTH).format(folder=folder.name) + ).strip() + month = _parse_month(value) + if month is not None: + return month + + +def _guess_month(folder: Path) -> int | None: + from roll.app.archive.photo_dates import guess_archive_month + + guess = guess_archive_month(folder) + return guess.month if guess is not None else None + + +def _parse_year(value: str) -> int: + year = value.strip() + if len(year) == 4 and year.isdigit(): + return int(year) + raise typer.Exit(code=1) + + +def _parse_month(value: str) -> int | None: + month = value.strip() + if len(month) == 2 and month.isdigit() and 1 <= int(month) <= 12: + return int(month) + return None + + def _roll_status(path: Path) -> str: try: return load_roll_metadata(path / "roll.toml").status diff --git a/src/roll/helpers/guards.py b/src/roll/helpers/guards.py index af67c37..47663a3 100644 --- a/src/roll/helpers/guards.py +++ b/src/roll/helpers/guards.py @@ -37,3 +37,20 @@ def require_archive(config: Config) -> Path: continue return archive return config.archives[0] + + +def require_current_archive(config: Config) -> Path: + if not config.archives: + typer.echo(highlight_cli_names(Msg.UNINITIALIZED_NOTICE)) + raise typer.Exit(code=1) + + current = Path.cwd().resolve() + for archive in config.archives: + try: + current.relative_to(archive.resolve()) + except ValueError: + continue + return archive + + typer.echo(highlight_cli_names(Msg.UNINITIALIZED_NOTICE)) + raise typer.Exit(code=1) diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 5f1878f..4a08d2b 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -134,6 +134,26 @@ class Msg(Headers): "Теги уже нормализованы.", "Tags are already normalized.", ) + NORMALIZE_PHOTOS_CONFIRM_YEAR = Message( + "cli.normalize_photos_confirm_year", + "Год {year} верен", + "Year {year} correct", + ) + NORMALIZE_PHOTOS_YEAR = Message( + "cli.normalize_photos_year", + "Год для {folder}", + "Year for {folder}", + ) + NORMALIZE_PHOTOS_MONTH = Message( + "cli.normalize_photos_month", + "Месяц для {folder} [01-12]", + "Month for {folder} [01-12]", + ) + NORMALIZE_PHOTOS_MANUAL = Message( + "cli.normalize_photos_manual", + "Месяцы вручную?", + "Manual months?", + ) NO_CHOICE = Message( "cli.no_choice", "Не удалось выбрать roll.", "Could not select a roll." ) diff --git a/src/roll/messages/normalize.py b/src/roll/messages/normalize.py index 11cf6ad..d15af10 100644 --- a/src/roll/messages/normalize.py +++ b/src/roll/messages/normalize.py @@ -14,6 +14,21 @@ class Normalize: CONFLICTS_HEADER = Message( "normalize.conflicts_header", "Обнаружены конфликты:", "Conflicts detected:" ) + TARGET_ALREADY_EXISTS = Message( + "normalize.target_already_exists", + "Целевая папка уже существует:", + "Target already exists:", + ) + TARGET_COLLIDES_WITH_SOURCE = Message( + "normalize.target_collides_with_source", + "Цель совпадает с источником:", + "Target collides with source:", + ) + DUPLICATE_TARGET = Message( + "normalize.duplicate_target", + "Дублирующаяся цель:", + "Duplicate target:", + ) RU = { diff --git a/tests/test_config.py b/tests/test_config.py index 3a29122..1efc1ae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,12 +7,14 @@ from io import StringIO from unittest.mock import patch +import typer + from roll.app.diagnostics.diagnostics import run_doctor from roll.app.diagnostics import diagnostics as diagnostics_module from roll.app.diagnostics.doctor_output import render_doctor from roll.app.workspace import config as config_module from roll.app.workspace.config import Config, load_config, save_config, set_lang -from roll.helpers.guards import require_archive +from roll.helpers.guards import require_archive, require_current_archive from roll.messages import Msg @@ -217,6 +219,20 @@ def test_require_archive_prefers_current_worktree_archive(self) -> None: self.assertEqual(require_archive(load_config()), second) + def test_require_current_archive_requires_current_worktree(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self._patch_config_paths(tmp) + archive = Path(tmp) / "archive" + archive.mkdir() + save_config(Config(archives=[archive], lang="EN")) + + patcher = patch("roll.helpers.guards.Path.cwd", return_value=Path(tmp)) + self.addCleanup(patcher.stop) + patcher.start() + + with self.assertRaises(typer.Exit): + require_current_archive(load_config()) + def _patch_config_paths(self, tmp: str) -> None: config_module.CONFIG_DIR = Path(tmp) / ".config" / "roll" config_module.CONFIG_FILE = config_module.CONFIG_DIR / "config.toml" diff --git a/tests/test_normalization.py b/tests/test_normalization.py index eb0e9af..8804917 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -3,6 +3,7 @@ import tempfile from pathlib import Path import unittest +import os from roll.app.workspace.config import Config from roll.app.diagnostics.diagnostics import run_doctor @@ -10,9 +11,14 @@ NamingStrategy, apply_keyword_vocab_fixes, apply_normalization_plan, + apply_normalization_plans, build_safe_rename_plan, + build_photo_normalization_plan, collect_keyword_vocab_fixes, + NormalizationPlan, + RenameRule, ) +from roll.app.archive.photo_dates import guess_archive_month from roll.app.archive.search import RollIndex from roll.filesystem import build_archive_tree, count_photo_files from roll.app.workspace.roll_store import RollMetadata, save_roll_metadata @@ -48,6 +54,24 @@ def test_apply_normalization_plan_renames_folder(self) -> None: def test_naming_strategy_builds_folder_name_from_loaded_at(self) -> None: self.assertEqual(NamingStrategy.build_folder_name("2025-10-19"), "10-19") + def test_guess_archive_month_uses_most_common_photo_month(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + folder = Path(tmp) + for name, timestamp in ( + ("a.jpg", 1711929600), + ("b.jpg", 1711929600), + ("c.jpg", 1709251200), + ): + path = folder / name + path.write_bytes(b"") + os.utime(path, (timestamp, timestamp)) + + guess = guess_archive_month(folder) + + self.assertIsNotNone(guess) + self.assertEqual(guess.month_key, "2024-04") + self.assertEqual(guess.confidence, 2) + def test_doctor_flags_non_uppercase_keywords(self) -> None: with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) @@ -123,6 +147,42 @@ def test_apply_keyword_vocab_fixes_writes_keywords_dictionary(self) -> None: "EASTER\nFIRE\n", ) + def test_build_photo_normalization_plan_uses_photo_month(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + folder = archive / "Фото" + folder.mkdir() + for name, timestamp in ( + ("a.jpg", 1711929600), + ("b.jpg", 1711929600), + ("c.jpg", 1709251200), + ): + path = folder / name + path.write_bytes(b"") + os.utime(path, (timestamp, timestamp)) + + plan = build_photo_normalization_plan(archive) + + self.assertEqual(len(plan.rules), 1) + self.assertEqual(plan.rules[0].folder, folder) + self.assertEqual(plan.rules[0].target, archive / "2024" / "04-01") + + def test_apply_normalization_plans_creates_target_parent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + source = archive / "Фото" + source.mkdir() + plan = NormalizationPlan( + archive=archive, + rules=[RenameRule(folder=source, target=archive / "2023" / "09-01")], + conflicts=[], + ) + + apply_normalization_plans([plan]) + + self.assertTrue((archive / "2023").exists()) + self.assertTrue((archive / "2023" / "09-01").exists()) + def test_doctor_flags_lowercase_keywords_in_vocabulary(self) -> None: with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) From 008de08238509c08c75d7ef788764338b0274d24 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 07:26:11 +0200 Subject: [PATCH 04/20] add --fix for keywords.txt --- src/roll/app/diagnostics/diagnostics.py | 9 +++- src/roll/app/diagnostics/doctor_output.py | 26 ++++++++-- src/roll/app/flows/stock.py | 2 + src/roll/cli.py | 5 +- src/roll/messages/cli.py | 5 ++ tests/test_normalization.py | 62 +++++++++++++++++++++++ 6 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/roll/app/diagnostics/diagnostics.py b/src/roll/app/diagnostics/diagnostics.py index 7baa962..f225e7d 100644 --- a/src/roll/app/diagnostics/diagnostics.py +++ b/src/roll/app/diagnostics/diagnostics.py @@ -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] @@ -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 @@ -67,6 +69,7 @@ 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, @@ -95,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, diff --git a/src/roll/app/diagnostics/doctor_output.py b/src/roll/app/diagnostics/doctor_output.py index d0a98ec..adb526d 100644 --- a/src/roll/app/diagnostics/doctor_output.py +++ b/src/roll/app/diagnostics/doctor_output.py @@ -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 @@ -148,6 +149,14 @@ def _render_fix_summaries( 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)) @@ -189,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: @@ -205,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( @@ -238,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: diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index f22e97b..1334d80 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -25,6 +25,7 @@ ) from roll.app.workspace.statuses import VALID_STATUSES from roll.app.workspace.workspace import workspace_for +from roll.app.archive.normalization import apply_keyword_vocab_fixes from roll.helpers.autocomplete import ( autocomplete_many_prompt, autocomplete_prompt, @@ -113,6 +114,7 @@ def load( tags = autocomplete_many_prompt("Tags", workspace.dictionary("keywords")) if tags: update_roll_keywords(roll_file, tags) + apply_keyword_vocab_fixes(archive, tags) except Exception: _cleanup_failed_load(roll_folder, roll_file) raise diff --git a/src/roll/cli.py b/src/roll/cli.py index ebd4bde..641d661 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -25,6 +25,7 @@ ) from roll.app.archive.normalization import ( apply_normalization_plans, + apply_keyword_vocab_fixes, build_normalization_plan, normalize_keywords_in_archive, ) @@ -284,6 +285,8 @@ def _update_roll_list_field( ) try: metadata = updater(selected / "roll.toml", values) + if dictionary_name == "keywords": + apply_keyword_vocab_fixes(archive, metadata.keywords) except ValueError as exc: typer.echo(str(exc)) raise typer.Exit(code=1) @@ -425,7 +428,7 @@ def _echo_photo_plan_preview(plans) -> None: ) if lines: - typer.echo("Planned photo layout:") + typer.echo(Msg.NORMALIZE_PHOTOS_PREVIEW) for line in lines: typer.echo(f" {line}") diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 4a08d2b..4824dc8 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -154,6 +154,11 @@ class Msg(Headers): "Месяцы вручную?", "Manual months?", ) + NORMALIZE_PHOTOS_PREVIEW = Message( + "cli.normalize_photos_preview", + "План фото:", + "Photo plan:", + ) NO_CHOICE = Message( "cli.no_choice", "Не удалось выбрать roll.", "Could not select a roll." ) diff --git a/tests/test_normalization.py b/tests/test_normalization.py index 8804917..bae5c4a 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -3,6 +3,7 @@ import tempfile from pathlib import Path import unittest +from unittest.mock import patch import os from roll.app.workspace.config import Config @@ -18,10 +19,12 @@ NormalizationPlan, RenameRule, ) +from roll.app.diagnostics.doctor_output import _echo_block from roll.app.archive.photo_dates import guess_archive_month from roll.app.archive.search import RollIndex from roll.filesystem import build_archive_tree, count_photo_files from roll.app.workspace.roll_store import RollMetadata, save_roll_metadata +from roll.cli import _build_photo_normalization_plans class NormalizationTests(unittest.TestCase): @@ -183,6 +186,42 @@ def test_apply_normalization_plans_creates_target_parent(self) -> None: self.assertTrue((archive / "2023").exists()) self.assertTrue((archive / "2023" / "09-01").exists()) + def test_photo_normalize_prompts_are_localized_and_clean(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + folders = [archive / "4770", archive / "4771"] + for folder in folders: + folder.mkdir() + + prompts: list[str] = [] + confirms: list[str] = [] + + def record_prompt(text: str, *args, **kwargs): + prompts.append(text) + if "Month" in text or "Месяц" in text: + return "09" if "4770" in text else "03" + if "Year" in text or "Год" in text: + return "2023" + return "y" + + def record_confirm(text: str, *args, **kwargs): + confirms.append(text) + return len(confirms) > 1 + + with ( + patch("roll.cli.guess_archive_year", return_value=2023), + patch("roll.cli._photo_folders", return_value=folders), + patch("roll.cli.typer.confirm", side_effect=record_confirm), + patch("roll.cli.typer.prompt", side_effect=record_prompt), + ): + _build_photo_normalization_plans(archive) + + self.assertTrue(any("Year 2023 correct" in item for item in confirms)) + self.assertTrue(any("Month for 4770 [01-12]" in item for item in prompts)) + self.assertTrue(any("Month for 4771 [01-12]" in item for item in prompts)) + self.assertTrue(all("::" not in item for item in prompts)) + self.assertTrue(all("::" not in item for item in confirms)) + def test_doctor_flags_lowercase_keywords_in_vocabulary(self) -> None: with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) @@ -352,6 +391,29 @@ def test_doctor_flags_noncanonical_keywords_dictionary(self) -> None: ) ) + def test_doctor_renders_single_warning_once(self) -> None: + with ( + patch("roll.app.diagnostics.doctor_output.echo") as echo_mock, + patch( + "roll.app.diagnostics.doctor_output.highlight_cli_names", + side_effect=lambda value: value, + ), + ): + _echo_block( + "WARN:", + ["keywords.txt is not canonical:"], + {"keywords.txt is not canonical:": ["/tmp/keywords.txt"]}, + ) + + rendered = [call.args[0] for call in echo_mock.call_args_list] + self.assertEqual( + rendered, + [ + "WARN: 1", + " keywords.txt is not canonical: /tmp/keywords.txt", + ], + ) + def test_count_roll_statuses_groups_loaded_processed_failed(self) -> None: from roll.app.archive.stats import _count_statuses From a83e522fe29ca8214401ef33998f31a9d8d0f705 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 10:38:23 +0200 Subject: [PATCH 05/20] fix messages --- src/roll/app/flows/stock.py | 127 +++++++++++++++++++++++---- src/roll/app/workspace/roll_store.py | 43 +++++++++ src/roll/messages/cli.py | 42 +++++++++ tests/test_storage.py | 63 +++++++++++++ 4 files changed, 257 insertions(+), 18 deletions(-) diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 1334d80..59dcf40 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -6,12 +6,14 @@ import typer from prompt_toolkit import prompt from prompt_toolkit.completion import FuzzyCompleter, WordCompleter +from prompt_toolkit.shortcuts import radiolist_dialog from roll.filesystem import find_roll_folders from roll.app.workspace.roll_store import ( RollMetadata, load_roll_metadata, save_roll_metadata, + update_roll_origin, update_roll_features, update_roll_keywords, update_roll_status, @@ -26,13 +28,9 @@ from roll.app.workspace.statuses import VALID_STATUSES from roll.app.workspace.workspace import workspace_for from roll.app.archive.normalization import apply_keyword_vocab_fixes -from roll.helpers.autocomplete import ( - autocomplete_many_prompt, - autocomplete_prompt, - choice_prompt, -) +from roll.helpers.autocomplete import autocomplete_many_prompt, autocomplete_prompt from roll.helpers.guards import require_archive, require_config -from roll.helpers.output import echo_lines +from roll.helpers.output import echo_lines, echo_section from roll.messages import Msg app = typer.Typer(help=Msg.STOCK_HEADER) @@ -43,8 +41,8 @@ def add() -> None: archive = require_archive(require_config()) workspace = workspace_for(archive) - film = autocomplete_prompt("Film", workspace.dictionary("films")) - quantity = typer.prompt("Quantity:", type=int) + film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) + quantity = typer.prompt(str(Msg.PROMPT_QUANTITY), type=int) if quantity <= 0: typer.echo(Msg.INVALID_QUANTITY) raise typer.Exit(code=1) @@ -81,7 +79,9 @@ def load( selected = ( _choose_stock_item(stock) if not manual else _choose_manual_film(workspace) ) - camera = autocomplete_prompt("Camera", workspace.dictionary("cameras")) + camera = autocomplete_prompt( + str(Msg.PROMPT_CAMERA), workspace.dictionary("cameras") + ) loaded_at = _prompt_loaded_at() roll_folder = _create_roll_folder(archive, loaded_at) roll_file = roll_folder / "roll.toml" @@ -106,12 +106,14 @@ def load( if not manual: save_stock(workspace.stock_file, remove_from_stock(stock, selected.film, 1)) features = autocomplete_many_prompt( - "Features", workspace.dictionary("features") + str(Msg.VOCAB_FEATURES), workspace.dictionary("features") ) if features: update_roll_features(roll_file, features) - tags = autocomplete_many_prompt("Tags", workspace.dictionary("keywords")) + tags = autocomplete_many_prompt( + str(Msg.VOCAB_KEYWORDS), workspace.dictionary("keywords") + ) if tags: update_roll_keywords(roll_file, tags) apply_keyword_vocab_fixes(archive, tags) @@ -119,7 +121,7 @@ def load( _cleanup_failed_load(roll_folder, roll_file) raise - typer.echo(f"Loaded: {selected.film}") + typer.echo(f"{Msg.LOAD_SUCCESS} {selected.film}") @app.command("process") @@ -152,8 +154,52 @@ def list_stock() -> None: typer.echo(f"{item.film:<20} ×{item.quantity}") +@app.command("edit") +def edit() -> None: + archive = require_archive(require_config()) + loaded_rolls = _loaded_rolls(archive) + if not loaded_rolls: + typer.echo(Msg.NO_LOADED_ROLLS) + raise typer.Exit(code=1) + + echo_section( + str(Msg.ROLL_EDIT_AVAILABLE), + [f"- {_format_roll_label(path)}" for path in loaded_rolls], + ) + roll = _choose_loaded_roll(loaded_rolls) + metadata = load_roll_metadata(roll / "roll.toml") + echo_lines( + [ + f"{Msg.ROLL_EDIT_ORIGINAL_SOURCE}: {metadata.original_source}", + f"{Msg.ROLL_EDIT_DIGITAL_COPY}: {metadata.digital_copy}", + f"{Msg.ROLL_EDIT_ORIGINAL_STATUS}: {metadata.original_status}", + ] + ) + + original_source = _prompt_enum( + Msg.ROLL_EDIT_ORIGINAL_SOURCE, + ["negative", "slide", "print", "digital", "unknown"], + metadata.original_source, + ) + digital_copy = _prompt_enum( + Msg.ROLL_EDIT_DIGITAL_COPY, + ["scan", "photo", "none", "unknown"], + metadata.digital_copy, + ) + original_status = _prompt_enum( + Msg.ROLL_EDIT_ORIGINAL_STATUS, + ["present", "lost", "unknown"], + metadata.original_status, + ) + + update_roll_origin( + roll / "roll.toml", original_source, digital_copy, original_status + ) + typer.echo(f"{Msg.ROLL_EDIT_UPDATED} {metadata.loaded_at}") + + def _prompt_loaded_at() -> str: - value = typer.prompt("Load date") + value = typer.prompt(str(Msg.PROMPT_LOAD_DATE)) normalized = value.strip().split("T", 1)[0].split(" ", 1)[0] try: return date.fromisoformat(normalized).isoformat() @@ -175,7 +221,9 @@ def _choose_stock_item(items: list[StockItem]) -> StockItem: while True: value = prompt( - "Film: ", completer=completer, complete_while_typing=True + str(Msg.PROMPT_MANUAL_FILM), + completer=completer, + complete_while_typing=True, ).strip() if not value: continue @@ -188,7 +236,7 @@ def _choose_stock_item(items: list[StockItem]) -> StockItem: def _choose_manual_film(workspace) -> StockItem: - film = autocomplete_prompt("Film", workspace.dictionary("films")) + film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) return StockItem(film=film, quantity=1) @@ -222,6 +270,15 @@ def _normalize_choice(value: str) -> str: return "".join(ch for ch in value.casefold() if ch.isalnum()) +def _choose_loaded_roll(rolls: list[Path]) -> Path: + labels = [_format_roll_label(path) for path in rolls] + selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) + for path in rolls: + if _format_roll_label(path) == selected_label: + return path + raise ValueError(Msg.NO_CHOICE) + + def _loaded_rolls(archive: Path) -> list[Path]: rolls: list[Path] = [] for folder in find_roll_folders(archive): @@ -252,14 +309,48 @@ def _finish_roll(status: str, label: str) -> None: def _choose_roll(rolls: list[Path]) -> Path: - labels = [str(path.relative_to(path.parents[1])) for path in rolls] - selected_label = choice_prompt("Roll", labels) + labels = [_format_roll_label(path) for path in rolls] + selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) for path in rolls: - if selected_label == str(path.relative_to(path.parents[1])): + if selected_label == _format_roll_label(path): return path raise ValueError(Msg.NO_CHOICE) +def _format_roll_label(path: Path) -> str: + metadata = load_roll_metadata(path / "roll.toml") + return str(Msg.ROLL_EDIT_ROLL_LABEL).format( + path=str(path.relative_to(path.parents[1])), + film=metadata.film, + camera=metadata.camera, + status=metadata.status, + ) + + +def _prompt_enum(label: Msg, values: list[str], current: str) -> str: + dialog = radiolist_dialog( + title=str(Msg.ROLL_EDIT_HEADER), + text=f"{label}:", + values=[(value, value) for value in values], + default=current if current in values else values[-1], + ) + result = dialog.run() + return result if result is not None else current + + +def _prompt_choice(title: str, choices: list[str]) -> str: + dialog = radiolist_dialog( + title=title, + text="", + values=[(value, value) for value in choices], + default=choices[0] if choices else None, + ) + result = dialog.run() + if result is None: + raise ValueError(Msg.NO_CHOICE) + return result + + def _cleanup_failed_load(roll_folder: Path, roll_file: Path) -> None: if roll_file.exists(): roll_file.unlink() diff --git a/src/roll/app/workspace/roll_store.py b/src/roll/app/workspace/roll_store.py index 45d4d13..4278f6c 100644 --- a/src/roll/app/workspace/roll_store.py +++ b/src/roll/app/workspace/roll_store.py @@ -17,6 +17,9 @@ class RollMetadata: loaded_at: str features: list[str] keywords: list[str] + original_source: str = "unknown" + digital_copy: str = "unknown" + original_status: str = "unknown" def load_roll_metadata(path: Path) -> RollMetadata: @@ -34,6 +37,9 @@ def save_roll_metadata(path: Path, metadata: RollMetadata) -> None: f'film = "{metadata.film}"', f'camera = "{metadata.camera}"', f'loaded_at = "{metadata.loaded_at}"', + f'original_source = "{metadata.original_source}"', + f'digital_copy = "{metadata.digital_copy}"', + f'original_status = "{metadata.original_status}"', f"features = {_format_array(metadata.features)}", f"keywords = {_format_array(metadata.keywords)}", "", @@ -53,6 +59,9 @@ def update_roll_status(path: Path, status: str) -> RollMetadata: loaded_at=metadata.loaded_at, features=metadata.features, keywords=metadata.keywords, + original_source=metadata.original_source, + digital_copy=metadata.digital_copy, + original_status=metadata.original_status, ) save_roll_metadata(path, updated) return updated @@ -67,6 +76,9 @@ def update_roll_keywords(path: Path, keywords: list[str]) -> RollMetadata: loaded_at=metadata.loaded_at, features=metadata.features, keywords=_merge_unique(metadata.keywords, keywords, normalize=str.upper), + original_source=metadata.original_source, + digital_copy=metadata.digital_copy, + original_status=metadata.original_status, ) save_roll_metadata(path, updated) return updated @@ -81,6 +93,31 @@ def update_roll_features(path: Path, features: list[str]) -> RollMetadata: loaded_at=metadata.loaded_at, features=_merge_unique(metadata.features, features), keywords=metadata.keywords, + original_source=metadata.original_source, + digital_copy=metadata.digital_copy, + original_status=metadata.original_status, + ) + save_roll_metadata(path, updated) + return updated + + +def update_roll_origin( + path: Path, + original_source: str, + digital_copy: str, + original_status: str, +) -> RollMetadata: + metadata = load_roll_metadata(path) + updated = RollMetadata( + status=metadata.status, + film=metadata.film, + camera=metadata.camera, + loaded_at=metadata.loaded_at, + features=metadata.features, + keywords=metadata.keywords, + original_source=original_source, + digital_copy=digital_copy, + original_status=original_status, ) save_roll_metadata(path, updated) return updated @@ -98,6 +135,9 @@ def _validate_metadata(data: dict, path: Path) -> RollMetadata: film = str(data.get("film", "")) camera = str(data.get("camera", "")) loaded_at = str(data.get("loaded_at", "")) + original_source = str(data.get("original_source", "unknown")) + digital_copy = str(data.get("digital_copy", "unknown")) + original_status = str(data.get("original_status", "unknown")) features = data.get("features", []) keywords = data.get("keywords", []) @@ -115,6 +155,9 @@ def _validate_metadata(data: dict, path: Path) -> RollMetadata: loaded_at=loaded_at, features=[str(item) for item in features], keywords=[str(item) for item in keywords], + original_source=original_source, + digital_copy=digital_copy, + original_status=original_status, ) diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 4824dc8..9dab6fa 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -109,6 +109,48 @@ class Msg(Headers): "Запас пуст. Используй --manual для ручного ввода.", "Stock is empty. Use --manual for manual entry.", ) + ROLL_EDIT_HEADER = Message( + "cli.roll_edit_header", "Редактирование ролла", "Edit roll" + ) + ROLL_EDIT_SELECT = Message("cli.roll_edit_select", "Выбери ролл", "Select a roll") + ROLL_EDIT_AVAILABLE = Message( + "cli.roll_edit_available", + "Доступные роллы:", + "Available rolls:", + ) + ROLL_EDIT_ROLL_LABEL = Message( + "cli.roll_edit_roll_label", + "{path} | {film} | {camera} | {status}", + "{path} | {film} | {camera} | {status}", + ) + ROLL_EDIT_ORIGINAL_SOURCE = Message( + "cli.roll_edit_original_source", + "Исходник", + "Original source", + ) + ROLL_EDIT_DIGITAL_COPY = Message( + "cli.roll_edit_digital_copy", + "Цифровая копия", + "Digital copy", + ) + ROLL_EDIT_ORIGINAL_STATUS = Message( + "cli.roll_edit_original_status", + "Состояние оригинала", + "Original status", + ) + ROLL_EDIT_UPDATED = Message( + "cli.roll_edit_updated", "Ролл обновлен.", "Roll updated." + ) + PROMPT_QUANTITY = Message("cli.prompt_quantity", "Количество:", "Quantity:") + PROMPT_FILM = Message("cli.prompt_film", "Пленка:", "Film:") + PROMPT_CAMERA = Message("cli.prompt_camera", "Камера:", "Camera:") + PROMPT_LOAD_DATE = Message("cli.prompt_load_date", "Дата загрузки", "Load date") + PROMPT_MANUAL_FILM = Message("cli.prompt_manual_film", "Пленка: ", "Film: ") + LOAD_SUCCESS = Message("cli.load_success", "Загружено:", "Loaded:") + PROCESS_SUCCESS = Message("cli.process_success", "Обработано:", "Processed:") + FAILED_SUCCESS = Message( + "cli.failed_success", "Помечено как failed:", "Marked as failed:" + ) INVALID_DATE = Message("cli.invalid_date", "Неверная дата.", "Invalid date.") INVALID_QUANTITY = Message( "cli.invalid_quantity", diff --git a/tests/test_storage.py b/tests/test_storage.py index 86e72d5..bbe48ee 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -19,6 +19,7 @@ save_stock, ) from roll.app.archive.normalization import normalize_keywords_in_archive +from roll.app.flows.stock import _format_roll_label, _loaded_rolls class StockStoreTests(unittest.TestCase): @@ -157,3 +158,65 @@ def test_normalize_keywords_in_archive_uppercases_roll_and_vocabulary(self) -> N (vocabulary / "keywords.txt").read_text(encoding="utf-8"), "BAR\nFRIENDS\n", ) + + def test_loaded_rolls_include_all_loaded_rolls(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + first = archive / "2025" / "10-19" + second = archive / "2025" / "10-20" + first.mkdir(parents=True) + second.mkdir(parents=True) + + save_roll_metadata( + first / "roll.toml", + RollMetadata( + status="loaded", + film="Kodak Gold 200", + camera="Pentax Espio 150SL", + loaded_at="2025-10-19", + features=[], + keywords=[], + original_source="negative", + digital_copy="scan", + original_status="lost", + ), + ) + save_roll_metadata( + second / "roll.toml", + RollMetadata( + status="loaded", + film="Kodak Gold 200", + camera="Pentax Espio 150SL", + loaded_at="2025-10-20", + features=[], + keywords=[], + ), + ) + + self.assertEqual(_loaded_rolls(archive), [first, second]) + + def test_roll_label_is_short_and_stable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + roll = archive / "2025" / "10-19" + roll.mkdir(parents=True) + save_roll_metadata( + roll / "roll.toml", + RollMetadata( + status="loaded", + film="Kodak Gold 200", + camera="Pentax Espio 150SL", + loaded_at="2025-10-19", + features=[], + keywords=[], + original_source="negative", + digital_copy="scan", + original_status="lost", + ), + ) + + label = _format_roll_label(roll) + + self.assertEqual( + label, "2025/10-19 | Kodak Gold 200 | Pentax Espio 150SL | loaded" + ) From a895c557ad71f9bdf7d64ec2dca14515993b330f Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 13:59:44 +0200 Subject: [PATCH 06/20] fix localize --- src/roll/app/archive/batch.py | 2 +- src/roll/app/archive/search_output.py | 4 ++-- src/roll/app/archive/stats_output.py | 2 +- src/roll/app/diagnostics/doctor_output.py | 2 +- src/roll/app/flows/stock.py | 14 +++++++------- src/roll/cli.py | 18 +++++++++--------- src/roll/helpers/output.py | 4 ++-- tests/test_normalization.py | 11 +++++++++++ 8 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/roll/app/archive/batch.py b/src/roll/app/archive/batch.py index b2dae5b..9d87f00 100644 --- a/src/roll/app/archive/batch.py +++ b/src/roll/app/archive/batch.py @@ -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)}") diff --git a/src/roll/app/archive/search_output.py b/src/roll/app/archive/search_output.py index 0660501..0a7390a 100644 --- a/src/roll/app/archive/search_output.py +++ b/src/roll/app/archive/search_output.py @@ -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}", ""]) diff --git a/src/roll/app/archive/stats_output.py b/src/roll/app/archive/stats_output.py index 51e93e4..b409a39 100644 --- a/src/roll/app/archive/stats_output.py +++ b/src/roll/app/archive/stats_output.py @@ -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 diff --git a/src/roll/app/diagnostics/doctor_output.py b/src/roll/app/diagnostics/doctor_output.py index adb526d..bec9b7d 100644 --- a/src/roll/app/diagnostics/doctor_output.py +++ b/src/roll/app/diagnostics/doctor_output.py @@ -63,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] diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 59dcf40..808da03 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -44,7 +44,7 @@ def add() -> None: film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) quantity = typer.prompt(str(Msg.PROMPT_QUANTITY), type=int) if quantity <= 0: - typer.echo(Msg.INVALID_QUANTITY) + typer.echo(str(Msg.INVALID_QUANTITY)) raise typer.Exit(code=1) try: @@ -73,7 +73,7 @@ def load( raise typer.Exit(code=1) if not stock: - typer.echo(Msg.STOCK_EMPTY_MANUAL) + typer.echo(str(Msg.STOCK_EMPTY_MANUAL)) raise typer.Exit(code=1) selected = ( @@ -146,7 +146,7 @@ def list_stock() -> None: raise typer.Exit(code=1) if not items: - typer.echo(Msg.STOCK_EMPTY) + typer.echo(str(Msg.STOCK_EMPTY)) return echo_lines([Msg.STOCK_HEADER]) @@ -159,7 +159,7 @@ def edit() -> None: archive = require_archive(require_config()) loaded_rolls = _loaded_rolls(archive) if not loaded_rolls: - typer.echo(Msg.NO_LOADED_ROLLS) + typer.echo(str(Msg.NO_LOADED_ROLLS)) raise typer.Exit(code=1) echo_section( @@ -204,7 +204,7 @@ def _prompt_loaded_at() -> str: try: return date.fromisoformat(normalized).isoformat() except ValueError as exc: - typer.echo(Msg.INVALID_DATE) + typer.echo(str(Msg.INVALID_DATE)) raise typer.Exit(code=1) from exc @@ -232,7 +232,7 @@ def _choose_stock_item(items: list[StockItem]) -> StockItem: if selected is not None: return selected - typer.echo(Msg.CHOOSE_STOCK) + typer.echo(str(Msg.CHOOSE_STOCK)) def _choose_manual_film(workspace) -> StockItem: @@ -295,7 +295,7 @@ def _finish_roll(status: str, label: str) -> None: archive = require_archive(require_config()) loaded_rolls = _loaded_rolls(archive) if not loaded_rolls: - typer.echo(Msg.NO_LOADED_ROLLS) + typer.echo(str(Msg.NO_LOADED_ROLLS)) raise typer.Exit(code=1) selected = _choose_roll(loaded_rolls) diff --git a/src/roll/cli.py b/src/roll/cli.py index 641d661..6128630 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -144,7 +144,7 @@ def config_lang(lang: str | None = typer.Argument(None, help=Msg.LANGUAGE)) -> N normalized = lang.upper() if normalized not in {"EN", "RU"}: - typer.echo(Msg.ALLOWED_VALUES) + typer.echo(str(Msg.ALLOWED_VALUES)) raise typer.Exit(code=1) updated = set_lang(normalized) @@ -166,7 +166,7 @@ def scan() -> None: photo_count = sum(count_photo_files(folder) for folder in roll_folders) if tree: - typer.echo(Msg.TREE_HEADER) + typer.echo(str(Msg.TREE_HEADER)) echo_lines(tree) typer.echo("") @@ -226,14 +226,14 @@ def search( ) -> None: """Search rolls from memory.""" if not query: - typer.echo(Msg.SEARCH_QUERY_REQUIRED) + typer.echo(str(Msg.SEARCH_QUERY_REQUIRED)) raise typer.Exit(code=1) archive = require_archive(require_config()) results = search_rolls(archive, query) if not results: - typer.echo(Msg.NO_RESULTS) + typer.echo(str(Msg.NO_RESULTS)) return render_search_results(results) @@ -275,7 +275,7 @@ def _update_roll_list_field( ] if not rolls: - typer.echo(Msg.NO_ROLLS) + typer.echo(str(Msg.NO_ROLLS)) raise typer.Exit(code=1) selected = _choose_roll_folder(rolls) @@ -312,11 +312,11 @@ def normalize( if tags: touched = normalize_keywords_in_archive(archive) if touched: - typer.echo(Msg.TAGS_NORMALIZED) + typer.echo(str(Msg.TAGS_NORMALIZED)) for path in touched: typer.echo(f" {path}") else: - typer.echo(Msg.TAGS_ALREADY_NORMALIZED) + typer.echo(str(Msg.TAGS_ALREADY_NORMALIZED)) return if photos: @@ -372,7 +372,7 @@ def _build_photo_normalization_plans(archive: Path): folders = _photo_folders(archive) year = guess_archive_year(archive) if year is None: - typer.echo(Msg.CLI_UNINITIALIZED) + typer.echo(str(Msg.CLI_UNINITIALIZED)) raise typer.Exit(code=1) if not typer.confirm( @@ -428,7 +428,7 @@ def _echo_photo_plan_preview(plans) -> None: ) if lines: - typer.echo(Msg.NORMALIZE_PHOTOS_PREVIEW) + typer.echo(str(Msg.NORMALIZE_PHOTOS_PREVIEW)) for line in lines: typer.echo(f" {line}") diff --git a/src/roll/helpers/output.py b/src/roll/helpers/output.py index f9e7372..7335ce5 100644 --- a/src/roll/helpers/output.py +++ b/src/roll/helpers/output.py @@ -5,11 +5,11 @@ def echo_lines(lines: Iterable[str]) -> None: for line in lines: - typer.echo(line) + typer.echo(str(line)) def echo_section(title: str, body: Iterable[str] = ()) -> None: - typer.echo(title) + typer.echo(str(title)) typer.echo("") echo_lines(body) diff --git a/tests/test_normalization.py b/tests/test_normalization.py index bae5c4a..a4fe385 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -3,6 +3,8 @@ import tempfile from pathlib import Path import unittest +from contextlib import redirect_stdout +from io import StringIO from unittest.mock import patch import os @@ -25,6 +27,8 @@ from roll.filesystem import build_archive_tree, count_photo_files from roll.app.workspace.roll_store import RollMetadata, save_roll_metadata from roll.cli import _build_photo_normalization_plans +from roll.helpers.output import echo_lines +from roll.messages import Normalize class NormalizationTests(unittest.TestCase): @@ -222,6 +226,13 @@ def record_confirm(text: str, *args, **kwargs): self.assertTrue(all("::" not in item for item in prompts)) self.assertTrue(all("::" not in item for item in confirms)) + def test_echo_lines_renders_message_locale(self) -> None: + buffer = StringIO() + with redirect_stdout(buffer): + echo_lines([Normalize.HEADER]) + + self.assertEqual(buffer.getvalue().strip(), "Archive normalization") + def test_doctor_flags_lowercase_keywords_in_vocabulary(self) -> None: with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) From 6f79c3cb5df019ab0f7ae349b8af9514237d8839 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:12:28 +0200 Subject: [PATCH 07/20] compact terminal editor for stock edit --- src/roll/app/flows/stock.py | 205 +++++++++++++++++++++++++----------- src/roll/messages/cli.py | 5 + tests/test_storage.py | 67 +++++++++++- 3 files changed, 214 insertions(+), 63 deletions(-) diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 808da03..9c1c876 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -2,18 +2,17 @@ from datetime import date from pathlib import Path +import textwrap import typer from prompt_toolkit import prompt from prompt_toolkit.completion import FuzzyCompleter, WordCompleter -from prompt_toolkit.shortcuts import radiolist_dialog from roll.filesystem import find_roll_folders from roll.app.workspace.roll_store import ( RollMetadata, load_roll_metadata, save_roll_metadata, - update_roll_origin, update_roll_features, update_roll_keywords, update_roll_status, @@ -30,7 +29,7 @@ from roll.app.archive.normalization import apply_keyword_vocab_fixes from roll.helpers.autocomplete import autocomplete_many_prompt, autocomplete_prompt from roll.helpers.guards import require_archive, require_config -from roll.helpers.output import echo_lines, echo_section +from roll.helpers.output import echo_lines from roll.messages import Msg app = typer.Typer(help=Msg.STOCK_HEADER) @@ -157,45 +156,16 @@ def list_stock() -> None: @app.command("edit") def edit() -> None: archive = require_archive(require_config()) - loaded_rolls = _loaded_rolls(archive) - if not loaded_rolls: + rolls = _rolls(archive) + if not rolls: typer.echo(str(Msg.NO_LOADED_ROLLS)) raise typer.Exit(code=1) - echo_section( - str(Msg.ROLL_EDIT_AVAILABLE), - [f"- {_format_roll_label(path)}" for path in loaded_rolls], - ) - roll = _choose_loaded_roll(loaded_rolls) + roll = _choose_roll(rolls) metadata = load_roll_metadata(roll / "roll.toml") - echo_lines( - [ - f"{Msg.ROLL_EDIT_ORIGINAL_SOURCE}: {metadata.original_source}", - f"{Msg.ROLL_EDIT_DIGITAL_COPY}: {metadata.digital_copy}", - f"{Msg.ROLL_EDIT_ORIGINAL_STATUS}: {metadata.original_status}", - ] - ) - - original_source = _prompt_enum( - Msg.ROLL_EDIT_ORIGINAL_SOURCE, - ["negative", "slide", "print", "digital", "unknown"], - metadata.original_source, - ) - digital_copy = _prompt_enum( - Msg.ROLL_EDIT_DIGITAL_COPY, - ["scan", "photo", "none", "unknown"], - metadata.digital_copy, - ) - original_status = _prompt_enum( - Msg.ROLL_EDIT_ORIGINAL_STATUS, - ["present", "lost", "unknown"], - metadata.original_status, - ) - - update_roll_origin( - roll / "roll.toml", original_source, digital_copy, original_status - ) - typer.echo(f"{Msg.ROLL_EDIT_UPDATED} {metadata.loaded_at}") + updated = _prompt_roll_metadata(archive, roll / "roll.toml", metadata) + save_roll_metadata(roll / "roll.toml", updated) + typer.echo(f"{Msg.ROLL_EDIT_UPDATED} {updated.loaded_at}") def _prompt_loaded_at() -> str: @@ -270,7 +240,7 @@ def _normalize_choice(value: str) -> str: return "".join(ch for ch in value.casefold() if ch.isalnum()) -def _choose_loaded_roll(rolls: list[Path]) -> Path: +def _choose_roll(rolls: list[Path]) -> Path: labels = [_format_roll_label(path) for path in rolls] selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) for path in rolls: @@ -279,21 +249,24 @@ def _choose_loaded_roll(rolls: list[Path]) -> Path: raise ValueError(Msg.NO_CHOICE) -def _loaded_rolls(archive: Path) -> list[Path]: +def _rolls(archive: Path) -> list[Path]: rolls: list[Path] = [] for folder in find_roll_folders(archive): try: - metadata = load_roll_metadata(folder / "roll.toml") + load_roll_metadata(folder / "roll.toml") except ValueError: continue - if metadata.status == "loaded": - rolls.append(folder) + rolls.append(folder) return rolls def _finish_roll(status: str, label: str) -> None: archive = require_archive(require_config()) - loaded_rolls = _loaded_rolls(archive) + loaded_rolls = [ + path + for path in _rolls(archive) + if load_roll_metadata(path / "roll.toml").status == "loaded" + ] if not loaded_rolls: typer.echo(str(Msg.NO_LOADED_ROLLS)) raise typer.Exit(code=1) @@ -328,27 +301,139 @@ def _format_roll_label(path: Path) -> str: def _prompt_enum(label: Msg, values: list[str], current: str) -> str: - dialog = radiolist_dialog( - title=str(Msg.ROLL_EDIT_HEADER), - text=f"{label}:", - values=[(value, value) for value in values], - default=current if current in values else values[-1], + selected = _prompt_choice_panel(str(label), values, current) + return selected if selected is not None else current + + +def _prompt_roll_metadata( + archive: Path, roll_file: Path, metadata: RollMetadata +) -> RollMetadata: + workspace = workspace_for(archive) + film = _prompt_optional_autocomplete( + Msg.ROLL_EDIT_FILM, workspace.dictionary("films"), metadata.film + ) + camera = _prompt_optional_autocomplete( + Msg.ROLL_EDIT_CAMERA, workspace.dictionary("cameras"), metadata.camera + ) + status = _prompt_enum(Msg.ROLL_EDIT_STATUS, list(VALID_STATUSES), metadata.status) + features = _prompt_optional_many( + Msg.ROLL_EDIT_FEATURES, workspace.dictionary("features"), metadata.features ) - result = dialog.run() - return result if result is not None else current + keywords = _prompt_optional_many( + Msg.ROLL_EDIT_KEYWORDS, workspace.dictionary("keywords"), metadata.keywords + ) + original_source = _prompt_enum( + Msg.ROLL_EDIT_ORIGINAL_SOURCE, + ["negative", "slide", "print", "digital", "unknown"], + metadata.original_source, + ) + digital_copy = _prompt_enum( + Msg.ROLL_EDIT_DIGITAL_COPY, + ["scan", "photo", "none", "unknown"], + metadata.digital_copy, + ) + original_status = _prompt_enum( + Msg.ROLL_EDIT_ORIGINAL_STATUS, + ["present", "lost", "unknown"], + metadata.original_status, + ) + return RollMetadata( + status=status, + film=film, + camera=camera, + loaded_at=metadata.loaded_at, + features=features, + keywords=[value.upper() for value in keywords], + original_source=original_source, + digital_copy=digital_copy, + original_status=original_status, + ) + + +def _prompt_optional_autocomplete(label: Msg, dictionary, current: str) -> str: + value = prompt(f"{label} [{current}]: ", complete_while_typing=True).strip() + if not value: + return current + + choices = dictionary.read() + for existing in choices: + if existing.casefold() == value.casefold(): + return existing + + return dictionary.add(value) + + +def _prompt_optional_many(label: Msg, dictionary, current: list[str]) -> list[str]: + choices = dictionary.read() + value = prompt( + f"{label} [{', '.join(current)}]: ", + completer=FuzzyCompleter( + WordCompleter(choices, ignore_case=True, sentence=True, match_middle=True) + ), + complete_while_typing=True, + ).strip() + if not value: + return current + + selected: list[str] = [] + for token in [item.strip() for item in value.split(",") if item.strip()]: + for existing in dictionary.read(): + if existing.casefold() == token.casefold(): + token = existing + break + else: + token = dictionary.add(token) + if token not in selected: + selected.append(token) + merged = list(current) + for token in selected: + if token not in merged: + merged.append(token) + return merged def _prompt_choice(title: str, choices: list[str]) -> str: - dialog = radiolist_dialog( - title=title, - text="", - values=[(value, value) for value in choices], - default=choices[0] if choices else None, - ) - result = dialog.run() - if result is None: + selected = _prompt_choice_panel(title, choices, choices[0] if choices else "") + if selected is None: raise ValueError(Msg.NO_CHOICE) - return result + return selected + + +def _prompt_choice_panel( + title: str, choices: list[str], current: str | None = None +) -> str | None: + content_width = max( + len(title), + len(f"Current: {current}") if current is not None else 0, + *(len(f"{index + 1}. {choice}") for index, choice in enumerate(choices)), + 24, + ) + width = min(content_width + 4, 88) + border = "┌" + "─" * (width - 2) + "┐" + footer = "└" + "─" * (width - 2) + "┘" + prompt_lines = [border, f"│ {title.ljust(width - 4)} │"] + if current is not None: + prompt_lines.append( + f"│ {textwrap.shorten(f'Current: {current}', width=width - 4, placeholder='…').ljust(width - 4)} │" + ) + prompt_lines.append("├" + "─" * (width - 2) + "┤") + prompt_lines.extend( + f"│ {textwrap.shorten(f'{index + 1}. {choice}', width=width - 4, placeholder='…').ljust(width - 4)} │" + for index, choice in enumerate(choices) + ) + prompt_lines.append(footer) + echo_lines(prompt_lines) + + while True: + value = prompt("Select [number / enter to keep]: ").strip() + if not value: + return current + if value.isdigit(): + index = int(value) - 1 + if 0 <= index < len(choices): + return choices[index] + if value in choices: + return value def _cleanup_failed_load(roll_folder: Path, roll_file: Path) -> None: diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 9dab6fa..0c680a8 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -118,6 +118,11 @@ class Msg(Headers): "Доступные роллы:", "Available rolls:", ) + ROLL_EDIT_FILM = Message("cli.roll_edit_film", "Пленка", "Film") + ROLL_EDIT_CAMERA = Message("cli.roll_edit_camera", "Камера", "Camera") + ROLL_EDIT_STATUS = Message("cli.roll_edit_status", "Статус", "Status") + ROLL_EDIT_FEATURES = Message("cli.roll_edit_features", "Особенности", "Features") + ROLL_EDIT_KEYWORDS = Message("cli.roll_edit_keywords", "Ключевые слова", "Keywords") ROLL_EDIT_ROLL_LABEL = Message( "cli.roll_edit_roll_label", "{path} | {film} | {camera} | {status}", diff --git a/tests/test_storage.py b/tests/test_storage.py index bbe48ee..fb642c5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,6 +3,7 @@ import tempfile from pathlib import Path import unittest +from unittest.mock import patch from roll.app.workspace.roll_store import ( RollMetadata, @@ -19,7 +20,8 @@ save_stock, ) from roll.app.archive.normalization import normalize_keywords_in_archive -from roll.app.flows.stock import _format_roll_label, _loaded_rolls +from roll.app.flows.stock import _format_roll_label, _rolls, _prompt_roll_metadata +from roll.app.workspace.workspace import workspace_for class StockStoreTests(unittest.TestCase): @@ -159,7 +161,7 @@ def test_normalize_keywords_in_archive_uppercases_roll_and_vocabulary(self) -> N "BAR\nFRIENDS\n", ) - def test_loaded_rolls_include_all_loaded_rolls(self) -> None: + def test_rolls_include_all_rolls_with_metadata(self) -> None: with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) first = archive / "2025" / "10-19" @@ -193,7 +195,7 @@ def test_loaded_rolls_include_all_loaded_rolls(self) -> None: ), ) - self.assertEqual(_loaded_rolls(archive), [first, second]) + self.assertEqual(_rolls(archive), [first, second]) def test_roll_label_is_short_and_stable(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -220,3 +222,62 @@ def test_roll_label_is_short_and_stable(self) -> None: self.assertEqual( label, "2025/10-19 | Kodak Gold 200 | Pentax Espio 150SL | loaded" ) + + def test_roll_edit_prompt_can_update_all_metadata_fields(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + workspace = workspace_for(archive) + for name in ("films", "cameras", "features", "keywords"): + workspace.dictionary(name).write([]) + + metadata = RollMetadata( + status="loaded", + film="Kodak Gold 200", + camera="Pentax Espio 150SL", + loaded_at="2025-10-19", + features=["redscale"], + keywords=["FRIENDS"], + original_source="negative", + digital_copy="scan", + original_status="lost", + ) + + prompts = iter( + [ + "Kodak ColorPlus 200", + "Pentax K1000", + "push +1, expired", + "summer, beach", + ] + ) + prompts = iter( + [ + "Kodak ColorPlus 200", + "Pentax K1000", + "2", + "push +1, expired", + "summer, beach", + "2", + "2", + "1", + ] + ) + + def fake_prompt(*args, **kwargs): + return next(prompts) + + with ( + patch("roll.app.flows.stock.prompt", side_effect=fake_prompt), + ): + updated = _prompt_roll_metadata( + archive, archive / "2025/10-19/roll.toml", metadata + ) + + self.assertEqual(updated.film, "Kodak ColorPlus 200") + self.assertEqual(updated.camera, "Pentax K1000") + self.assertEqual(updated.status, "processed") + self.assertEqual(updated.features, ["redscale", "push +1", "expired"]) + self.assertEqual(updated.keywords, ["FRIENDS", "SUMMER", "BEACH"]) + self.assertEqual(updated.original_source, "slide") + self.assertEqual(updated.digital_copy, "photo") + self.assertEqual(updated.original_status, "present") From be37465f19b230c183cb8ff3195a6be703563954 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:22:22 +0200 Subject: [PATCH 08/20] prepare documentation --- docs/architecture.md | 4 +- docs/editing.md | 108 ++++++++++++++++++++++++++++++++++++++++ docs/getting-started.md | 4 +- docs/index.md | 3 +- docs/reference.md | 8 +++ 5 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 docs/editing.md diff --git a/docs/architecture.md b/docs/architecture.md index 1c3bfdf..61a4e90 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,8 @@ archives = ["/path/to/archive"] | `rl doctor` | global config, workspace, stock, roll, vocabulary | no | yes with `--fix` | integrity | | `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 | +| `rl stock edit` | global config, workspace, roll, vocabulary | roll, vocabulary | no | single-roll editing | +| `rl batch` | global config, workspace, roll | roll | no | batch update | ## Lifecycle @@ -127,3 +128,4 @@ rl doctor --fix - English is the default UI language. Quick start — see [docs/getting-started.md](getting-started.md). Environment setup and CI — see [docs/development.md](development.md). Terms and exact command behavior — see [docs/reference.md](reference.md). +Editing flows — see [docs/editing.md](editing.md). diff --git a/docs/editing.md b/docs/editing.md new file mode 100644 index 0000000..03ddf17 --- /dev/null +++ b/docs/editing.md @@ -0,0 +1,108 @@ +# Editing + +`roll` has two editing surfaces: + +- `rl stock edit` for one roll at a time; +- `rl batch` for many rolls at once. + +They solve different problems and should stay separate. + +--- + +## `rl stock edit` + +Use this when you want to inspect and adjust one roll by hand. + +What it does: + +- selects a single roll; +- edits its metadata fields directly; +- keeps the current values available in the prompts; +- applies changes only after you confirm each value in the interactive flow. + +Best for: + +- correcting one record; +- changing a camera on one roll; +- refining features or keywords on a single roll; +- adjusting the origin fields on one roll. + +Not for: + +- mass changes across many rolls; +- filtering by year or film name; +- bulk status updates. + +--- + +## `rl batch` + +Use this when you want to apply the same change to many rolls. + +What it does: + +- selects rolls by filters; +- shows a preview of the target set; +- applies one change to the whole selection; +- asks for confirmation before writing. + +Selection is based on filters such as: + +- year; +- film name; +- camera; +- status; +- other archive fields when needed. + +Within one filter, comma-separated values mean "match any of these". +Across filters, the selection is cumulative. + +Best for: + +- changing a camera on many rolls; +- moving a group of rolls to a new status; +- adding the same feature or tag to a batch; +- cleaning up a whole year or a film family. + +Not for: + +- one-off manual corrections; +- browsing a single record in detail; +- workflows that need per-field judgment on each roll. + +--- + +## Boundary + +Use this rule: + +- one roll, manual judgment, many fields -> `rl stock edit`; +- many rolls, one repeated operation -> `rl batch`. + +If you need both at different times, start with `stock edit` for the outlier and use `batch` for the rest. + +--- + +## Examples + +```bash +rl stock edit +``` + +```bash +rl batch --year 2025 --film "Kodak Gold 200, Ilford HP5 Plus" --set camera="Pentax K1000" +``` + +```bash +rl batch --year 2025 --set status=processed +``` + +```bash +rl batch --film "Kodak Gold 200" --add-tag "summer,belgrade" +``` + +## Related + +- [Getting Started](getting-started.md) +- [Architecture](architecture.md) +- [Reference](reference.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 5e9d9e4..f7fe52e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -24,7 +24,7 @@ rl search kir balcony # half a year later — found it | 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` | +| Editing | `rl stock edit`, `rl batch` | ## Out of Scope @@ -39,4 +39,4 @@ The CLI defaults to English in the global config and `rl config lang` changes it If it doesn't help find a roll from memory faster — it's out of scope. -Architecture and exact behavior — see [docs/architecture.md](architecture.md). Terms and exact rules — see [docs/reference.md](reference.md). Development setup — see [docs/development.md](development.md). +Architecture and exact behavior — see [docs/architecture.md](architecture.md). Editing flows — see [docs/editing.md](editing.md). Terms and exact rules — see [docs/reference.md](reference.md). Development setup — see [docs/development.md](development.md). diff --git a/docs/index.md b/docs/index.md index 88bc8ed..f78a968 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,9 @@ Start here: | Doc | What it answers | |---|---| | [Getting Started](getting-started.md) | What the tool does and how to begin | +| [Editing](editing.md) | How to use `stock edit` and `batch` | | [Architecture](architecture.md) | What files exist, what commands read and write, how the archive is structured | | [Reference](reference.md) | Exact terms, behavior, and rules | | [Development](development.md) | Setup, checks, and CI | -If you are new to the tool, read [Getting Started](getting-started.md) first, then [Architecture](architecture.md). Use [Reference](reference.md) when you need exact rules. +If you are new to the tool, read [Getting Started](getting-started.md) first, then [Editing](editing.md) and [Architecture](architecture.md). Use [Reference](reference.md) when you need exact rules. diff --git a/docs/reference.md b/docs/reference.md index b156dcf..30f1fc8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -82,6 +82,14 @@ Brings folder names to a consistent shape: builds a plan, asks for confirmation, --- +## Editing + +`rl stock edit` is a single-roll manual editor. `rl batch` is a bulk editor that applies one change to many rolls selected by filters. + +See [docs/editing.md](editing.md) for the shared editing model and the boundary between the two commands. + +--- + ## Doctor 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. From 97491976c2a953c511e76a546851f78b80e89443 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:22:35 +0200 Subject: [PATCH 09/20] prepare documentation --- docs/development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index 78699bc..c0a6384 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,7 @@ python -m unittest discover -s tests | Read-only | `rl search`, `rl scan`, `rl status`, `rl stats`, `rl vocab` | global config, workspace, roll, vocab | no | no | | Integrity | `rl doctor`, `rl doctor --fix` | global config, workspace, stock, roll, vocab | no | yes | | Normalization | `rl normalize --tags` | workspace, roll, vocab | roll, vocab | yes | -| Batch | `rl batch process` | workspace, roll | roll | no | +| Batch | `rl batch` | workspace, roll | roll | no | `rl load --manual` creates a roll from the film dictionary without changing stock. For `rl features add` and `rl tags add` you can enter several values separated by commas. Autocomplete works per value, duplicates aren't written, `_` is allowed inside a value. From bdf2db4c21f742313abeace4152e03993d7ac0d4 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:33:02 +0200 Subject: [PATCH 10/20] add batch command flags --- src/roll/app/archive/batch.py | 122 ++++++++++++++++++++++++++++++---- src/roll/cli.py | 68 ++++++++++++++++++- src/roll/messages/cli.py | 5 ++ tests/test_storage.py | 53 +++++++++++++++ 4 files changed, 234 insertions(+), 14 deletions(-) diff --git a/src/roll/app/archive/batch.py b/src/roll/app/archive/batch.py index 9d87f00..52aa605 100644 --- a/src/roll/app/archive/batch.py +++ b/src/roll/app/archive/batch.py @@ -1,35 +1,133 @@ +from dataclasses import replace from pathlib import Path import typer -from roll.app.workspace.roll_store import update_roll_status +from roll.app.workspace.roll_store import ( + RollMetadata, + load_roll_metadata, + save_roll_metadata, +) from roll.app.archive.search import find_rolls from roll.helpers.output import echo_list from roll.messages import Msg def process_archives(archives: list[Path]) -> int: - loaded_rolls: list[Path] = [] + return batch_rolls(archives, statuses=["loaded"], status="processed") - for archive in archives: - for roll in find_rolls(archive): - if roll.status == "loaded": - loaded_rolls.append(roll.folder) - if not loaded_rolls: - typer.echo(str(Msg.BATCH_NO_LOADED)) +def batch_rolls( + archives: list[Path], + *, + year: str | None = None, + films: list[str] | None = None, + cameras: list[str] | None = None, + statuses: list[str] | None = None, + status: str | None = None, + set_camera: str | None = None, + add_features: list[str] | None = None, + add_tags: list[str] | None = None, +) -> int: + rolls = _select_rolls( + archives, + year=year, + films=films, + cameras=cameras, + statuses=statuses, + ) + + if not rolls: + typer.echo(str(Msg.NO_RESULTS)) return 0 - typer.echo(f"{Msg.BATCH_WILL_PROCESS} {len(loaded_rolls)}") - echo_list((str(path) for path in loaded_rolls)) + preview = [str(roll.folder) for roll in rolls] + typer.echo(f"{Msg.BATCH_WILL_PROCESS} {len(preview)}") + echo_list(preview) if not typer.confirm(Msg.BATCH_CONFIRM, default=False): return 0 changed = 0 - for folder in loaded_rolls: - update_roll_status(folder / "roll.toml", "processed") + for roll in rolls: + updated = _apply_changes( + roll, + status=status, + set_camera=set_camera, + add_features=add_features, + add_tags=add_tags, + ) + save_roll_metadata(roll.folder / "roll.toml", updated) changed += 1 typer.echo(f"{Msg.BATCH_PROCESSED} {changed}") return changed + + +def _select_rolls( + archives: list[Path], + *, + year: str | None, + films: list[str] | None, + cameras: list[str] | None, + statuses: list[str] | None, +) -> list: + selected = [] + film_set = _normalize_set(films) + camera_set = _normalize_set(cameras) + status_set = _normalize_set(statuses) + + for archive in archives: + for roll in find_rolls(archive): + if year is not None and roll.loaded_at[:4] != year: + continue + if film_set and roll.film.casefold() not in film_set: + continue + if camera_set and roll.camera.casefold() not in camera_set: + continue + if status_set and roll.status.casefold() not in status_set: + continue + selected.append(roll) + + return selected + + +def _apply_changes( + roll, + *, + status: str | None, + set_camera: str | None, + add_features: list[str] | None, + add_tags: list[str] | None, +) -> RollMetadata: + metadata = load_roll_metadata(roll.folder / "roll.toml") + updated = metadata + if status is not None: + updated = replace(updated, status=status) + if set_camera is not None: + updated = replace(updated, camera=set_camera) + if add_features: + updated = replace( + updated, + features=_merge_unique(updated.features, add_features), + ) + if add_tags: + updated = replace( + updated, + keywords=_merge_unique( + updated.keywords, [item.upper() for item in add_tags] + ), + ) + return updated + + +def _normalize_set(values: list[str] | None) -> set[str]: + return {value.casefold() for value in values or [] if value} + + +def _merge_unique(existing: list[str], new_values: list[str]) -> list[str]: + merged = list(existing) + for value in new_values: + if value not in merged: + merged.append(value) + return merged diff --git a/src/roll/cli.py b/src/roll/cli.py index 6128630..038f6ba 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -17,7 +17,7 @@ load_config, save_config, ) -from roll.app.archive.batch import process_archives +from roll.app.archive.batch import batch_rolls, process_archives from roll.app.workspace.roll_store import ( load_roll_metadata, update_roll_features, @@ -294,7 +294,65 @@ def _update_roll_list_field( typer.echo(f"{success_label}: {metadata.film}") -@batch_app.command("process") +@batch_app.callback(invoke_without_command=True) +def batch( + ctx: typer.Context, + year: str | None = typer.Option(None, "--year", help="Filter by year."), + film: str | None = typer.Option(None, "--film", help="Comma-separated film names."), + camera: str | None = typer.Option( + None, "--camera", help="Comma-separated camera names." + ), + status: str | None = typer.Option( + None, "--status", help="Comma-separated statuses." + ), + set_status: str | None = typer.Option( + None, "--set-status", help="Set the status on selected rolls." + ), + set_camera: str | None = typer.Option( + None, "--set-camera", help="Set the camera on selected rolls." + ), + add_feature: str | None = typer.Option( + None, "--add-feature", help="Comma-separated features to append." + ), + add_tag: str | None = typer.Option( + None, "--add-tag", help="Comma-separated tags to append." + ), +) -> None: + """Batch edit rolls.""" + if ctx.invoked_subcommand is not None: + return + + config = require_config() + filters = { + "year": year, + "film": film, + "camera": camera, + "status": status, + } + changes = { + "set_status": set_status, + "set_camera": set_camera, + "add_feature": add_feature, + "add_tag": add_tag, + } + if not any(filters.values()) or not any(changes.values()): + typer.echo(str(Msg.BATCH_NEEDS_FILTERS)) + raise typer.Exit(code=1) + + batch_rolls( + config.archives, + year=year, + films=_split_csv(film), + cameras=_split_csv(camera), + statuses=_split_csv(status), + status=set_status, + set_camera=set_camera, + add_features=_split_csv(add_feature), + add_tags=_split_csv(add_tag), + ) + + +@batch_app.command("process", hidden=True) def batch_process() -> None: config = require_config() process_archives(config.archives) @@ -470,6 +528,12 @@ def _parse_month(value: str) -> int | None: return None +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + def _roll_status(path: Path) -> str: try: return load_roll_metadata(path / "roll.toml").status diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 0c680a8..1d2943e 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -227,6 +227,11 @@ class Msg(Headers): "cli.batch_confirm", "Пометить все как processed?", "Mark all as processed?" ) BATCH_PROCESSED = Message("cli.batch_processed", "Обработано:", "Processed:") + BATCH_NEEDS_FILTERS = Message( + "cli.batch_needs_filters", + "Нужно указать хотя бы один фильтр и одно изменение.", + "You need at least one filter and one change.", + ) STATS_YEAR = Message("cli.stats_year", "Год:", "Year:") STATS_ROLLS = Message("cli.stats_rolls", "Роллов:", "Rolls:") STATS_FILMS = Message("cli.stats_films", "Пленок в статистике:", "Films in stats:") diff --git a/tests/test_storage.py b/tests/test_storage.py index fb642c5..5f14490 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -21,6 +21,7 @@ ) from roll.app.archive.normalization import normalize_keywords_in_archive from roll.app.flows.stock import _format_roll_label, _rolls, _prompt_roll_metadata +from roll.app.archive.batch import batch_rolls from roll.app.workspace.workspace import workspace_for @@ -281,3 +282,55 @@ def fake_prompt(*args, **kwargs): self.assertEqual(updated.original_source, "slide") self.assertEqual(updated.digital_copy, "photo") self.assertEqual(updated.original_status, "present") + + def test_batch_rolls_filters_by_year_and_film(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + first = archive / "2025" / "10-19" + second = archive / "2025" / "10-20" + third = archive / "2024" / "09-01" + for folder, film in ( + (first, "Kodak Gold 200"), + (second, "Ilford HP5 Plus"), + (third, "Kodak Gold 200"), + ): + folder.mkdir(parents=True) + loaded_at = ( + "2025-10-19" + if folder == first + else "2025-10-20" + if folder == second + else "2024-09-01" + ) + save_roll_metadata( + folder / "roll.toml", + RollMetadata( + status="loaded", + film=film, + camera="Pentax Espio 150SL", + loaded_at=loaded_at, + features=[], + keywords=[], + ), + ) + + with patch("roll.app.archive.batch.typer.confirm", return_value=True): + changed = batch_rolls( + [archive], + year="2025", + films=["Kodak Gold 200"], + set_camera="Pentax K1000", + ) + + self.assertEqual(changed, 1) + self.assertEqual( + load_roll_metadata(first / "roll.toml").camera, "Pentax K1000" + ) + self.assertEqual( + load_roll_metadata(second / "roll.toml").camera, + "Pentax Espio 150SL", + ) + self.assertEqual( + load_roll_metadata(third / "roll.toml").camera, + "Pentax Espio 150SL", + ) From 49a211e55c69f1f06748e78e6003c6c75606b8a6 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:36:17 +0200 Subject: [PATCH 11/20] clean msgs --- src/roll/app/archive/batch.py | 8 +++--- src/roll/app/flows/stock.py | 2 +- src/roll/cli.py | 22 ++++++----------- src/roll/messages/cli.py | 46 +++++++++++++++++++++++++++++++++++ src/roll/version.py | 34 ++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/roll/app/archive/batch.py b/src/roll/app/archive/batch.py index 52aa605..3c2a38e 100644 --- a/src/roll/app/archive/batch.py +++ b/src/roll/app/archive/batch.py @@ -8,7 +8,7 @@ load_roll_metadata, save_roll_metadata, ) -from roll.app.archive.search import find_rolls +from roll.app.archive.search import RollIndex, find_rolls from roll.helpers.output import echo_list from roll.messages import Msg @@ -71,8 +71,8 @@ def _select_rolls( films: list[str] | None, cameras: list[str] | None, statuses: list[str] | None, -) -> list: - selected = [] +) -> list[RollIndex]: + selected: list[RollIndex] = [] film_set = _normalize_set(films) camera_set = _normalize_set(cameras) status_set = _normalize_set(statuses) @@ -93,7 +93,7 @@ def _select_rolls( def _apply_changes( - roll, + roll: RollIndex, *, status: str | None, set_camera: str | None, diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 9c1c876..9c2ac27 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -425,7 +425,7 @@ def _prompt_choice_panel( echo_lines(prompt_lines) while True: - value = prompt("Select [number / enter to keep]: ").strip() + value = prompt(f"{Msg.ROLL_EDIT_SELECT_HINT}: ").strip() if not value: return current if value.isdigit(): diff --git a/src/roll/cli.py b/src/roll/cli.py index 038f6ba..606c7cf 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -297,26 +297,20 @@ def _update_roll_list_field( @batch_app.callback(invoke_without_command=True) def batch( ctx: typer.Context, - year: str | None = typer.Option(None, "--year", help="Filter by year."), - film: str | None = typer.Option(None, "--film", help="Comma-separated film names."), - camera: str | None = typer.Option( - None, "--camera", help="Comma-separated camera names." - ), - status: str | None = typer.Option( - None, "--status", help="Comma-separated statuses." - ), + year: str | None = typer.Option(None, "--year", help=Msg.BATCH_FILTER_YEAR), + film: str | None = typer.Option(None, "--film", help=Msg.BATCH_FILTER_FILM), + camera: str | None = typer.Option(None, "--camera", help=Msg.BATCH_FILTER_CAMERA), + status: str | None = typer.Option(None, "--status", help=Msg.BATCH_FILTER_STATUS), set_status: str | None = typer.Option( - None, "--set-status", help="Set the status on selected rolls." + None, "--set-status", help=Msg.BATCH_SET_STATUS ), set_camera: str | None = typer.Option( - None, "--set-camera", help="Set the camera on selected rolls." + None, "--set-camera", help=Msg.BATCH_SET_CAMERA ), add_feature: str | None = typer.Option( - None, "--add-feature", help="Comma-separated features to append." - ), - add_tag: str | None = typer.Option( - None, "--add-tag", help="Comma-separated tags to append." + None, "--add-feature", help=Msg.BATCH_ADD_FEATURE ), + add_tag: str | None = typer.Option(None, "--add-tag", help=Msg.BATCH_ADD_TAG), ) -> None: """Batch edit rolls.""" if ctx.invoked_subcommand is not None: diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 1d2943e..6de8133 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -143,6 +143,11 @@ class Msg(Headers): "Состояние оригинала", "Original status", ) + ROLL_EDIT_SELECT_HINT = Message( + "cli.roll_edit_select_hint", + "Выбери номер или нажми Enter, чтобы оставить текущее значение.", + "Choose a number or press Enter to keep the current value.", + ) ROLL_EDIT_UPDATED = Message( "cli.roll_edit_updated", "Ролл обновлен.", "Roll updated." ) @@ -232,6 +237,47 @@ class Msg(Headers): "Нужно указать хотя бы один фильтр и одно изменение.", "You need at least one filter and one change.", ) + BATCH_FILTER_YEAR = Message( + "cli.batch_filter_year", "Фильтр по году.", "Filter by year." + ) + BATCH_FILTER_FILM = Message( + "cli.batch_filter_film", + "Фильтр по пленкам через запятую.", + "Comma-separated film names.", + ) + BATCH_FILTER_CAMERA = Message( + "cli.batch_filter_camera", + "Фильтр по камерам через запятую.", + "Comma-separated camera names.", + ) + BATCH_FILTER_STATUS = Message( + "cli.batch_filter_status", + "Фильтр по статусам через запятую.", + "Comma-separated statuses.", + ) + BATCH_SET_STATUS = Message( + "cli.batch_set_status", + "Установить статус у выбранных роллов.", + "Set the status on selected rolls.", + ) + BATCH_SET_CAMERA = Message( + "cli.batch_set_camera", + "Установить камеру у выбранных роллов.", + "Set the camera on selected rolls.", + ) + BATCH_ADD_FEATURE = Message( + "cli.batch_add_feature", + "Добавить особенности через запятую.", + "Comma-separated features to append.", + ) + BATCH_ADD_TAG = Message( + "cli.batch_add_tag", + "Добавить теги через запятую.", + "Comma-separated tags to append.", + ) + BATCH_TITLE = Message( + "cli.batch_title", "Массовое редактирование роллов", "Batch edit rolls" + ) STATS_YEAR = Message("cli.stats_year", "Год:", "Year:") STATS_ROLLS = Message("cli.stats_rolls", "Роллов:", "Rolls:") STATS_FILMS = Message("cli.stats_films", "Пленок в статистике:", "Films in stats:") diff --git a/src/roll/version.py b/src/roll/version.py index a9c2e87..fba89af 100644 --- a/src/roll/version.py +++ b/src/roll/version.py @@ -21,6 +21,9 @@ def get_version() -> str: def get_latest_version() -> str: + remote_version = _remote_tag() + if remote_version: + return remote_version return _git_tag() @@ -49,6 +52,29 @@ def _git_tag() -> str: return result.stdout.strip().removeprefix("v") +def _remote_tag() -> str: + repo = _find_git_root(Path.cwd()) + if repo is None: + repo = Path(__file__).resolve().parents[2] + try: + result = subprocess.run( + ["git", "ls-remote", "--tags", "--refs", "origin", "v*"], + check=True, + capture_output=True, + text=True, + cwd=repo, + ) + except (OSError, subprocess.CalledProcessError): + return "" + + versions = [ + line.rsplit("/", 1)[-1].removeprefix("v") + for line in result.stdout.splitlines() + if line.strip() + ] + return _max_version(versions) + + def _find_git_root(path: Path) -> Path | None: for candidate in (path, *path.parents): if (candidate / ".git").exists(): @@ -61,3 +87,11 @@ def _version_tuple(value: str) -> tuple[int, ...]: if not match: return () return tuple(int(part) for part in match.group(1).split(".")) + + +def _max_version(values: list[str]) -> str: + parsed = [(_version_tuple(value), value) for value in values] + parsed = [item for item in parsed if item[0]] + if not parsed: + return "" + return max(parsed, key=lambda item: item[0])[1] From 6dc86c6acaf2e708e88a9d391447a1d34ae444f5 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:46:11 +0200 Subject: [PATCH 12/20] lil version improvement --- docs/development.md | 2 +- docs/getting-started.md | 2 +- src/roll/cli.py | 16 ++++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/development.md b/docs/development.md index c0a6384..7154eff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,7 +52,7 @@ For `rl features add` and `rl tags add` you can enter several values separated b `rl config lang` applies immediately in the current process because user-facing messages resolve the language at render time. `rl doctor` checks the global config, workspace config, stock, roll metadata, and vocabularies. Its diagnostics stay in English; only the user-facing UI is localized. Package version comes from git tags at build time. In a source checkout, `rl --version` falls back to the latest git tag if package metadata is not installed. -`rl update` is a runtime convenience command for local installs; it runs `pip install -U git+https://github.com/katrinio/roll.git@main` in the current interpreter. +`rl update` is a runtime convenience command for local installs; it runs `pip install --no-input --upgrade --force-reinstall git+https://github.com/katrinio/roll.git@main` in the current interpreter. ## CI diff --git a/docs/getting-started.md b/docs/getting-started.md index f7fe52e..c52ab78 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -32,7 +32,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 update` reinstalls the app from the GitHub repository. `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 diff --git a/src/roll/cli.py b/src/roll/cli.py index 606c7cf..5ed6c91 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -55,8 +55,6 @@ from roll.messages import Normalize from roll.version import get_latest_version, get_version, is_outdated -UPDATE_SOURCE = "git+https://github.com/katrinio/roll.git@main" - app = typer.Typer(help=Msg.CLI_INITIALIZED) app.add_typer(stock_app, name="stock") config_app = typer.Typer(help=Msg.CONFIG_HEADER) @@ -113,9 +111,19 @@ def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: @app.command("update") def update() -> None: - """Update the installed package.""" + """Update the installed app from the GitHub repository.""" result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", UPDATE_SOURCE] + [ + sys.executable, + "-m", + "pip", + "install", + "--no-input", + "--upgrade", + "--force-reinstall", + "git+https://github.com/katrinio/roll.git@main", + ], + check=False, ) raise typer.Exit(code=result.returncode) From 2d54307e338cfcfa3b8fb9dca3ba373ee58569d0 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 14:48:37 +0200 Subject: [PATCH 13/20] cleaning --- src/roll/app/workspace/roll_store.py | 22 ---------------------- src/roll/cli.py | 8 +------- src/roll/messages/cli.py | 3 --- 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/roll/app/workspace/roll_store.py b/src/roll/app/workspace/roll_store.py index 4278f6c..c911034 100644 --- a/src/roll/app/workspace/roll_store.py +++ b/src/roll/app/workspace/roll_store.py @@ -101,28 +101,6 @@ def update_roll_features(path: Path, features: list[str]) -> RollMetadata: return updated -def update_roll_origin( - path: Path, - original_source: str, - digital_copy: str, - original_status: str, -) -> RollMetadata: - metadata = load_roll_metadata(path) - updated = RollMetadata( - status=metadata.status, - film=metadata.film, - camera=metadata.camera, - loaded_at=metadata.loaded_at, - features=metadata.features, - keywords=metadata.keywords, - original_source=original_source, - digital_copy=digital_copy, - original_status=original_status, - ) - save_roll_metadata(path, updated) - return updated - - def _load_toml(path: Path) -> dict: try: return tomllib.loads(path.read_text(encoding="utf-8")) diff --git a/src/roll/cli.py b/src/roll/cli.py index 5ed6c91..b983cf0 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -17,7 +17,7 @@ load_config, save_config, ) -from roll.app.archive.batch import batch_rolls, process_archives +from roll.app.archive.batch import batch_rolls from roll.app.workspace.roll_store import ( load_roll_metadata, update_roll_features, @@ -354,12 +354,6 @@ def batch( ) -@batch_app.command("process", hidden=True) -def batch_process() -> None: - config = require_config() - process_archives(config.archives) - - @app.command("normalize") def normalize( tags: bool = typer.Option(False, "--tags", help=Msg.TAGS_NORMALIZED), diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 6de8133..7de1a85 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -222,9 +222,6 @@ class Msg(Headers): "'{value}' отсутствует в словаре.\n\nДобавить? [Y/n] ", "'{value}' is missing from the dictionary.\n\nAdd it? [Y/n] ", ) - BATCH_NO_LOADED = Message( - "cli.batch_no_loaded", "Нет loaded-роллов.", "No loaded rolls." - ) BATCH_WILL_PROCESS = Message( "cli.batch_will_process", "Будет обработано:", "Will process:" ) From 16bcf975360bede43776f6a4d6a141ef686c7d0b Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 15:03:50 +0200 Subject: [PATCH 14/20] move normalize logic from cli.py --- src/roll/app/archive/normalize_cli.py | 103 +++++++++++++ src/roll/app/flows/stock.py | 91 +++++++++++ src/roll/cli.py | 213 ++++++-------------------- 3 files changed, 241 insertions(+), 166 deletions(-) create mode 100644 src/roll/app/archive/normalize_cli.py diff --git a/src/roll/app/archive/normalize_cli.py b/src/roll/app/archive/normalize_cli.py new file mode 100644 index 0000000..5b40593 --- /dev/null +++ b/src/roll/app/archive/normalize_cli.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from pathlib import Path + +import typer + +from roll.app.archive.normalization import NormalizationPlan, RenameRule +from roll.app.archive.photo_dates import guess_archive_year, guess_archive_month +from roll.messages import Msg + + +def build_photo_normalization_plans(archive: Path) -> list[NormalizationPlan]: + folders = _photo_folders(archive) + year = guess_archive_year(archive) + if year is None: + typer.echo(str(Msg.CLI_UNINITIALIZED)) + raise typer.Exit(code=1) + + if not typer.confirm( + str(Msg.NORMALIZE_PHOTOS_CONFIRM_YEAR).format(year=year), default=True + ): + typed_year = typer.prompt( + str(Msg.NORMALIZE_PHOTOS_YEAR).format(folder=archive.name) + ) + year = _parse_year(typed_year) + + manual_months = len(folders) > 1 and typer.confirm( + str(Msg.NORMALIZE_PHOTOS_MANUAL), default=False + ) + return [ + _build_photo_plan_for_folder(folder, archive, year, manual_months) + for folder in folders + ] + + +def echo_photo_plan_preview(plans: list[NormalizationPlan]) -> None: + lines = [] + for plan in plans: + for rule in plan.rules: + lines.append( + f"{rule.folder.name} -> {rule.target.relative_to(plan.archive)}" + ) + + if lines: + typer.echo(str(Msg.NORMALIZE_PHOTOS_PREVIEW)) + for line in lines: + typer.echo(f" {line}") + + +def _build_photo_plan_for_folder( + folder: Path, archive: Path, year: int, manual_months: bool +) -> NormalizationPlan: + month = _prompt_month(folder) if manual_months else _guess_month(folder) + + if month is None: + return NormalizationPlan(archive=archive, rules=[], conflicts=[]) + + target = archive / f"{year:04d}" / f"{month:02d}-01" + if target.exists(): + return NormalizationPlan( + archive=archive, + rules=[], + conflicts=[f"{Msg.NORMALIZE_PHOTOS_MONTH} {target}"], + ) + + return NormalizationPlan( + archive=archive, rules=[RenameRule(folder=folder, target=target)], conflicts=[] + ) + + +def _photo_folders(archive: Path) -> list[Path]: + return [ + path for path in archive.iterdir() if path.is_dir() and path.name != ".roll" + ] + + +def _prompt_month(folder: Path) -> int: + while True: + value = typer.prompt( + str(Msg.NORMALIZE_PHOTOS_MONTH).format(folder=folder.name) + ).strip() + month = _parse_month(value) + if month is not None: + return month + + +def _guess_month(folder: Path) -> int | None: + guess = guess_archive_month(folder) + return guess.month if guess is not None else None + + +def _parse_year(value: str) -> int: + year = value.strip() + if len(year) == 4 and year.isdigit(): + return int(year) + raise typer.Exit(code=1) + + +def _parse_month(value: str) -> int | None: + month = value.strip() + if len(month) == 2 and month.isdigit() and 1 <= int(month) <= 12: + return int(month) + return None diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 9c2ac27..7f72ddb 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -27,6 +27,7 @@ from roll.app.workspace.statuses import VALID_STATUSES from roll.app.workspace.workspace import workspace_for from roll.app.archive.normalization import apply_keyword_vocab_fixes +from roll.app.archive.batch import batch_rolls from roll.helpers.autocomplete import autocomplete_many_prompt, autocomplete_prompt from roll.helpers.guards import require_archive, require_config from roll.helpers.output import echo_lines @@ -168,6 +169,70 @@ def edit() -> None: typer.echo(f"{Msg.ROLL_EDIT_UPDATED} {updated.loaded_at}") +def edit_list_field( + prompt_title: str, dictionary_name: str, success_label: str +) -> None: + archive = require_archive(require_config()) + rolls = _rolls(archive) + if not rolls: + typer.echo(str(Msg.NO_ROLLS)) + raise typer.Exit(code=1) + + selected = _choose_roll_folder(rolls) + workspace = workspace_for(archive) + values = autocomplete_many_prompt( + prompt_title, workspace.dictionary(dictionary_name) + ) + try: + metadata = ( + update_roll_keywords(selected / "roll.toml", values) + if dictionary_name == "keywords" + else update_roll_features(selected / "roll.toml", values) + ) + if dictionary_name == "keywords": + apply_keyword_vocab_fixes(archive, metadata.keywords) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + + typer.echo(f"{success_label}: {metadata.film}") + + +def edit_batch( + year: str | None, + film: str | None, + camera: str | None, + status: str | None, + set_status: str | None, + set_camera: str | None, + add_feature: str | None, + add_tag: str | None, +) -> None: + config = require_config() + filters = {"year": year, "film": film, "camera": camera, "status": status} + changes = { + "set_status": set_status, + "set_camera": set_camera, + "add_feature": add_feature, + "add_tag": add_tag, + } + if not any(filters.values()) or not any(changes.values()): + typer.echo(str(Msg.BATCH_NEEDS_FILTERS)) + raise typer.Exit(code=1) + + batch_rolls( + config.archives, + year=year, + films=_split_csv(film), + cameras=_split_csv(camera), + statuses=_split_csv(status), + status=set_status, + set_camera=set_camera, + add_features=_split_csv(add_feature), + add_tags=_split_csv(add_tag), + ) + + def _prompt_loaded_at() -> str: value = typer.prompt(str(Msg.PROMPT_LOAD_DATE)) normalized = value.strip().split("T", 1)[0].split(" ", 1)[0] @@ -290,6 +355,26 @@ def _choose_roll(rolls: list[Path]) -> Path: raise ValueError(Msg.NO_CHOICE) +def _choose_roll_folder(rolls: list[Path]) -> Path: + labels = [ + f"{str(path.relative_to(path.parents[1]))} ({_roll_status(path)})" + for path in rolls + ] + selected_label = _prompt_choice("Roll", labels) + for path in rolls: + label = f"{str(path.relative_to(path.parents[1]))} ({_roll_status(path)})" + if label == selected_label: + return path + raise ValueError(Msg.NO_CHOICE) + + +def _roll_status(path: Path) -> str: + try: + return load_roll_metadata(path / "roll.toml").status + except ValueError: + return "unknown" + + def _format_roll_label(path: Path) -> str: metadata = load_roll_metadata(path / "roll.toml") return str(Msg.ROLL_EDIT_ROLL_LABEL).format( @@ -444,3 +529,9 @@ def _cleanup_failed_load(roll_folder: Path, roll_file: Path) -> None: roll_folder.rmdir() except OSError: pass + + +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] diff --git a/src/roll/cli.py b/src/roll/cli.py index b983cf0..c67f7eb 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -1,36 +1,44 @@ +from __future__ import annotations + from pathlib import Path import subprocess import sys import typer -from roll.filesystem import ( - build_archive_tree, - count_photo_files, - find_roll_folders, - find_unindexed_folders, +from roll.app.archive.normalize_cli import ( + build_photo_normalization_plans as _build_photo_normalization_plans_impl, + echo_photo_plan_preview as _echo_photo_plan_preview_impl, +) +from roll.app.archive.normalization import ( + apply_normalization_plans, + build_normalization_plan, + normalize_keywords_in_archive, ) +from roll.app.archive.photo_dates import guess_archive_year +from roll.app.archive.normalization_output import render_normalization_plans +from roll.app.archive.search import search_rolls +from roll.app.archive.search_output import render_search_results +from roll.app.archive.stats_output import render_stats_report +from roll.app.archive.status_output import render_status_report +from roll.app.diagnostics.doctor_output import render_doctor +from roll.app.flows.stock import app as stock_app +from roll.app.flows.stock import edit_batch, edit_list_field, load as load_roll from roll.app.workspace.config import ( CONFIG_DIR, CONFIG_FILE, Config, load_config, save_config, + set_lang, ) -from roll.app.archive.batch import batch_rolls -from roll.app.workspace.roll_store import ( - load_roll_metadata, - update_roll_features, - update_roll_keywords, -) -from roll.app.archive.normalization import ( - apply_normalization_plans, - apply_keyword_vocab_fixes, - build_normalization_plan, - normalize_keywords_in_archive, +from roll.app.workspace.vocabulary import archive_vocabulary +from roll.filesystem import ( + build_archive_tree, + count_photo_files, + find_roll_folders, + find_unindexed_folders, ) -from roll.app.archive.photo_dates import guess_archive_year -from roll.helpers.autocomplete import autocomplete_many_prompt, choice_prompt from roll.helpers.formatting import highlight_cli_names from roll.helpers.guards import ( require_archive, @@ -39,28 +47,15 @@ require_directory, ) from roll.helpers.output import echo_lines, echo_section -from roll.app.flows.stock import app as stock_app -from roll.app.flows.stock import load as load_roll -from roll.messages import Msg -from roll.app.archive.status_output import render_status_report -from roll.app.archive.search import find_rolls, search_rolls -from roll.app.archive.search_output import render_search_results -from roll.app.archive.normalization_output import render_normalization_plans -from roll.app.archive.stats import _count_statuses -from roll.app.archive.stats_output import render_stats_report -from roll.app.workspace.vocabulary import archive_vocabulary -from roll.app.workspace.workspace import workspace_for -from roll.app.diagnostics.doctor_output import render_doctor -from roll.app.workspace.config import set_lang -from roll.messages import Normalize +from roll.messages import Msg, Normalize from roll.version import get_latest_version, get_version, is_outdated app = typer.Typer(help=Msg.CLI_INITIALIZED) app.add_typer(stock_app, name="stock") + config_app = typer.Typer(help=Msg.CONFIG_HEADER) app.add_typer(config_app, name="config") - tags_app = typer.Typer(help=Msg.VOCAB_KEYWORDS) app.add_typer(tags_app, name="tags") @@ -91,7 +86,6 @@ def main( @app.command("init") def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: - """Initialize the archive workspace.""" archive = require_directory(archive, Msg.ARCHIVE_MISSING) CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -102,8 +96,10 @@ def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: archives = [archive] save_config(Config(archives=archives)) - workspace = workspace_for(archive) - workspace.ensure_structure() + workspace = archive # preserved for behavior-free local setup + from roll.app.workspace.workspace import workspace_for + + workspace_for(workspace).ensure_structure() typer.echo(highlight_cli_names(Msg.CLI_INITIALIZED)) echo_lines([f"Archive: {archive}", f"Config: {CONFIG_FILE}"]) @@ -111,7 +107,6 @@ def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: @app.command("update") def update() -> None: - """Update the installed app from the GitHub repository.""" result = subprocess.run( [ sys.executable, @@ -130,7 +125,6 @@ def update() -> None: @config_app.callback(invoke_without_command=True) def config(ctx: typer.Context) -> None: - """Show current config.""" if ctx.invoked_subcommand is not None: return @@ -143,7 +137,6 @@ def config(ctx: typer.Context) -> None: @config_app.command("lang") def config_lang(lang: str | None = typer.Argument(None, help=Msg.LANGUAGE)) -> None: - """Show or set UI language.""" config = require_config() if lang is None: @@ -161,7 +154,6 @@ def config_lang(lang: str | None = typer.Argument(None, help=Msg.LANGUAGE)) -> N @app.command("scan") def scan() -> None: - """Show archive folders.""" archive = require_archive(require_config()) if not archive.exists(): @@ -184,9 +176,11 @@ def scan() -> None: @app.command("status") def status() -> None: - """Show index status.""" archive = require_archive(require_config()) + from roll.app.archive.search import find_rolls + from roll.app.archive.stats import _count_statuses + roll_folders = find_roll_folders(archive) unindexed_folders = find_unindexed_folders(archive) rolls = find_rolls(archive) @@ -200,7 +194,6 @@ def stats( year: str | None = typer.Argument(None, help=Msg.STATS_YEAR), verbose: bool = typer.Option(False, "-v", "--verbose", help=Msg.STATS_MORE), ) -> None: - """Show archive statistics.""" archive = require_archive(require_config()) render_stats_report(archive, year, verbose) @@ -209,13 +202,11 @@ def stats( def load( manual: bool = typer.Option(False, "--manual", help=Msg.STOCK_EMPTY_MANUAL), ) -> None: - """Load a film from stock into a new roll.""" load_roll(manual=manual) @app.command("vocab") def vocab() -> None: - """Show dictionaries.""" archive = require_archive(require_config()) vocab = archive_vocabulary(archive) @@ -232,7 +223,6 @@ def vocab() -> None: def search( query: str | None = typer.Argument(None, help=Msg.SEARCH_QUERY_REQUIRED), ) -> None: - """Search rolls from memory.""" if not query: typer.echo(str(Msg.SEARCH_QUERY_REQUIRED)) raise typer.Exit(code=1) @@ -252,54 +242,18 @@ def doctor( fix: bool = typer.Option(False, "--fix", help=Msg.DOCTOR_CAN_FIX), verbose: bool = typer.Option(False, "-v", "--verbose", help=Msg.DOCTOR_CAN_ADD), ) -> None: - """Check archive and config integrity.""" if render_doctor(fix=fix, verbose=verbose): raise typer.Exit(code=1) @tags_app.command("add") def add_tags() -> None: - _update_roll_list_field("Tags", "keywords", update_roll_keywords, "Tags updated") + edit_list_field("Tags", "keywords", "Tags updated") @features_app.command("add") def add_features() -> None: - _update_roll_list_field( - "Features", "features", update_roll_features, "Features updated" - ) - - -def _update_roll_list_field( - prompt_title: str, - dictionary_name: str, - updater, - success_label: str, -) -> None: - archive = require_archive(require_config()) - rolls = [ - folder - for folder in find_roll_folders(archive) - if (folder / "roll.toml").exists() - ] - - if not rolls: - typer.echo(str(Msg.NO_ROLLS)) - raise typer.Exit(code=1) - - selected = _choose_roll_folder(rolls) - workspace = workspace_for(archive) - values = autocomplete_many_prompt( - prompt_title, workspace.dictionary(dictionary_name) - ) - try: - metadata = updater(selected / "roll.toml", values) - if dictionary_name == "keywords": - apply_keyword_vocab_fixes(archive, metadata.keywords) - except ValueError as exc: - typer.echo(str(exc)) - raise typer.Exit(code=1) - - typer.echo(f"{success_label}: {metadata.film}") + edit_list_field("Features", "features", "Features updated") @batch_app.callback(invoke_without_command=True) @@ -320,38 +274,10 @@ def batch( ), add_tag: str | None = typer.Option(None, "--add-tag", help=Msg.BATCH_ADD_TAG), ) -> None: - """Batch edit rolls.""" if ctx.invoked_subcommand is not None: return - config = require_config() - filters = { - "year": year, - "film": film, - "camera": camera, - "status": status, - } - changes = { - "set_status": set_status, - "set_camera": set_camera, - "add_feature": add_feature, - "add_tag": add_tag, - } - if not any(filters.values()) or not any(changes.values()): - typer.echo(str(Msg.BATCH_NEEDS_FILTERS)) - raise typer.Exit(code=1) - - batch_rolls( - config.archives, - year=year, - films=_split_csv(film), - cameras=_split_csv(camera), - statuses=_split_csv(status), - status=set_status, - set_camera=set_camera, - add_features=_split_csv(add_feature), - add_tags=_split_csv(add_tag), - ) + edit_batch(year, film, camera, status, set_status, set_camera, add_feature, add_tag) @app.command("normalize") @@ -359,7 +285,6 @@ def normalize( tags: bool = typer.Option(False, "--tags", help=Msg.TAGS_NORMALIZED), photos: bool = typer.Option(False, "--photos", help=Normalize.HEADER), ) -> None: - """Normalize archive layout.""" config = require_config() archive = require_current_archive(config) @@ -374,7 +299,7 @@ def normalize( return if photos: - plans = _build_photo_normalization_plans(archive) + plans = _build_photo_normalization_plans_impl(archive) total_rules, has_changes = render_normalization_plans(plans) if not has_changes: return @@ -383,7 +308,7 @@ def normalize( if all_conflicts: raise typer.Exit(code=1) - _echo_photo_plan_preview(plans) + _echo_photo_plan_preview_impl(plans) if not typer.confirm( str(Normalize.QUESTION).format(count=total_rules), default=False ): @@ -409,19 +334,6 @@ def normalize( apply_normalization_plans(plans) -def _choose_roll_folder(rolls: list[Path]) -> Path: - labels = [ - f"{str(path.relative_to(path.parents[1]))} ({_roll_status(path)})" - for path in rolls - ] - selected_label = choice_prompt("Roll", labels) - for path in rolls: - label = f"{str(path.relative_to(path.parents[1]))} ({_roll_status(path)})" - if label == selected_label: - return path - raise ValueError(Msg.NO_CHOICE) - - def _build_photo_normalization_plans(archive: Path): folders = _photo_folders(archive) year = guess_archive_year(archive) @@ -446,16 +358,18 @@ def _build_photo_normalization_plans(archive: Path): ] +def _photo_folders(archive: Path) -> list[Path]: + return [ + path for path in archive.iterdir() if path.is_dir() and path.name != ".roll" + ] + + def _build_photo_plan_for_folder( folder: Path, archive: Path, year: int, manual_months: bool ): from roll.app.archive.normalization import NormalizationPlan, RenameRule - month = None - if manual_months: - month = _prompt_month(folder) - else: - month = _guess_month(folder) + month = _prompt_month(folder) if manual_months else _guess_month(folder) if month is None: return NormalizationPlan(archive=archive, rules=[], conflicts=[]) @@ -473,26 +387,6 @@ def _build_photo_plan_for_folder( ) -def _echo_photo_plan_preview(plans) -> None: - lines = [] - for plan in plans: - for rule in plan.rules: - lines.append( - f"{rule.folder.name} -> {rule.target.relative_to(plan.archive)}" - ) - - if lines: - typer.echo(str(Msg.NORMALIZE_PHOTOS_PREVIEW)) - for line in lines: - typer.echo(f" {line}") - - -def _photo_folders(archive: Path) -> list[Path]: - return [ - path for path in archive.iterdir() if path.is_dir() and path.name != ".roll" - ] - - def _prompt_month(folder: Path) -> int: while True: value = typer.prompt( @@ -522,16 +416,3 @@ def _parse_month(value: str) -> int | None: if len(month) == 2 and month.isdigit() and 1 <= int(month) <= 12: return int(month) return None - - -def _split_csv(value: str | None) -> list[str]: - if not value: - return [] - return [item.strip() for item in value.split(",") if item.strip()] - - -def _roll_status(path: Path) -> str: - try: - return load_roll_metadata(path / "roll.toml").status - except ValueError: - return "unknown" From fa71305e02a6b6230db95f2072d576b9775394ae Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 15:11:53 +0200 Subject: [PATCH 15/20] clean logic layer --- src/roll/app/archive/commands.py | 91 ++++++++ src/roll/app/archive/normalize_commands.py | 66 ++++++ src/roll/cli.py | 229 ++------------------- tests/test_normalization.py | 32 ++- 4 files changed, 199 insertions(+), 219 deletions(-) create mode 100644 src/roll/app/archive/commands.py create mode 100644 src/roll/app/archive/normalize_commands.py diff --git a/src/roll/app/archive/commands.py b/src/roll/app/archive/commands.py new file mode 100644 index 0000000..33d9f85 --- /dev/null +++ b/src/roll/app/archive/commands.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from roll.app.archive.search import search_rolls +from roll.app.archive.search_output import render_search_results +from roll.app.archive.stats_output import render_stats_report +from roll.app.archive.status_output import render_status_report +from roll.app.diagnostics.doctor_output import render_doctor +from roll.app.workspace.vocabulary import archive_vocabulary +from roll.filesystem import ( + build_archive_tree, + count_photo_files, + find_roll_folders, + find_unindexed_folders, +) +from roll.helpers.output import echo_lines, echo_section +from roll.messages import Msg +from roll.helpers.guards import require_archive, require_config +import typer + + +def scan() -> None: + archive = require_archive(require_config()) + + if not archive.exists(): + typer.echo(f"{Msg.ARCHIVE_MISSING} {archive}") + raise typer.Exit(code=1) + + echo_section(Msg.ARCHIVE_HEADER, [str(archive)]) + roll_folders = find_roll_folders(archive) + tree = build_archive_tree(archive) + photo_count = sum(count_photo_files(folder) for folder in roll_folders) + + if tree: + typer.echo(str(Msg.TREE_HEADER)) + echo_lines(tree) + typer.echo("") + + typer.echo(f"{Msg.FOLDERS} {len(roll_folders)}") + typer.echo(f"{Msg.FILES} {photo_count}") + + +def status() -> None: + archive = require_archive(require_config()) + + from roll.app.archive.search import find_rolls + from roll.app.archive.stats import _count_statuses + + roll_folders = find_roll_folders(archive) + unindexed_folders = find_unindexed_folders(archive) + rolls = find_rolls(archive) + status_counts = _count_statuses(rolls) + + render_status_report(archive, roll_folders, unindexed_folders, status_counts) + + +def stats(year: str | None, verbose: bool) -> None: + archive = require_archive(require_config()) + render_stats_report(archive, year, verbose) + + +def vocab() -> None: + archive = require_archive(require_config()) + vocab = archive_vocabulary(archive) + + for title, items in ( + (Msg.VOCAB_FILMS, vocab["films"].read()), + (Msg.VOCAB_CAMERAS, vocab["cameras"].read()), + (Msg.VOCAB_FEATURES, vocab["features"].read()), + (Msg.VOCAB_KEYWORDS, vocab["keywords"].read()), + ): + echo_section(title, [f"- {item}" for item in items]) + + +def search(query: str | None) -> None: + if not query: + typer.echo(str(Msg.SEARCH_QUERY_REQUIRED)) + raise typer.Exit(code=1) + + archive = require_archive(require_config()) + results = search_rolls(archive, query) + + if not results: + typer.echo(str(Msg.NO_RESULTS)) + return + + render_search_results(results) + + +def doctor(fix: bool, verbose: bool) -> None: + if render_doctor(fix=fix, verbose=verbose): + raise typer.Exit(code=1) diff --git a/src/roll/app/archive/normalize_commands.py b/src/roll/app/archive/normalize_commands.py new file mode 100644 index 0000000..f7e2f87 --- /dev/null +++ b/src/roll/app/archive/normalize_commands.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import typer + +from roll.app.archive.normalize_cli import ( + build_photo_normalization_plans, + echo_photo_plan_preview, +) +from roll.app.archive.normalization import ( + apply_normalization_plans, + build_normalization_plan, + normalize_keywords_in_archive, +) +from roll.app.archive.normalization_output import render_normalization_plans +from roll.helpers.guards import require_config, require_current_archive +from roll.messages import Msg, Normalize + + +def normalize(tags: bool, photos: bool) -> None: + config = require_config() + archive = require_current_archive(config) + + if tags: + touched = normalize_keywords_in_archive(archive) + if touched: + typer.echo(str(Msg.TAGS_NORMALIZED)) + for path in touched: + typer.echo(f" {path}") + else: + typer.echo(str(Msg.TAGS_ALREADY_NORMALIZED)) + return + + if photos: + plans = build_photo_normalization_plans(archive) + total_rules, has_changes = render_normalization_plans(plans) + if not has_changes: + return + + all_conflicts = [conflict for plan in plans for conflict in plan.conflicts] + if all_conflicts: + raise typer.Exit(code=1) + + echo_photo_plan_preview(plans) + if not typer.confirm( + str(Normalize.QUESTION).format(count=total_rules), default=False + ): + return + + apply_normalization_plans(plans) + return + + plans = [build_normalization_plan(archive)] + total_rules, has_changes = render_normalization_plans(plans) + if not has_changes: + return + + all_conflicts = [conflict for plan in plans for conflict in plan.conflicts] + if all_conflicts: + raise typer.Exit(code=1) + + if not typer.confirm( + str(Normalize.QUESTION).format(count=total_rules), default=False + ): + return + + apply_normalization_plans(plans) diff --git a/src/roll/cli.py b/src/roll/cli.py index c67f7eb..15f98ec 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -6,22 +6,14 @@ import typer -from roll.app.archive.normalize_cli import ( - build_photo_normalization_plans as _build_photo_normalization_plans_impl, - echo_photo_plan_preview as _echo_photo_plan_preview_impl, +from roll.app.archive.commands import ( + doctor as archive_doctor, + scan as archive_scan, + search as archive_search, + stats as archive_stats, + status as archive_status, + vocab as archive_vocab, ) -from roll.app.archive.normalization import ( - apply_normalization_plans, - build_normalization_plan, - normalize_keywords_in_archive, -) -from roll.app.archive.photo_dates import guess_archive_year -from roll.app.archive.normalization_output import render_normalization_plans -from roll.app.archive.search import search_rolls -from roll.app.archive.search_output import render_search_results -from roll.app.archive.stats_output import render_stats_report -from roll.app.archive.status_output import render_status_report -from roll.app.diagnostics.doctor_output import render_doctor from roll.app.flows.stock import app as stock_app from roll.app.flows.stock import edit_batch, edit_list_field, load as load_roll from roll.app.workspace.config import ( @@ -32,20 +24,8 @@ save_config, set_lang, ) -from roll.app.workspace.vocabulary import archive_vocabulary -from roll.filesystem import ( - build_archive_tree, - count_photo_files, - find_roll_folders, - find_unindexed_folders, -) from roll.helpers.formatting import highlight_cli_names -from roll.helpers.guards import ( - require_archive, - require_config, - require_current_archive, - require_directory, -) +from roll.helpers.guards import require_config, require_directory from roll.helpers.output import echo_lines, echo_section from roll.messages import Msg, Normalize from roll.version import get_latest_version, get_version, is_outdated @@ -154,39 +134,12 @@ def config_lang(lang: str | None = typer.Argument(None, help=Msg.LANGUAGE)) -> N @app.command("scan") def scan() -> None: - archive = require_archive(require_config()) - - if not archive.exists(): - typer.echo(f"{Msg.ARCHIVE_MISSING} {archive}") - raise typer.Exit(code=1) - - echo_section(Msg.ARCHIVE_HEADER, [str(archive)]) - roll_folders = find_roll_folders(archive) - tree = build_archive_tree(archive) - photo_count = sum(count_photo_files(folder) for folder in roll_folders) - - if tree: - typer.echo(str(Msg.TREE_HEADER)) - echo_lines(tree) - typer.echo("") - - typer.echo(f"{Msg.FOLDERS} {len(roll_folders)}") - typer.echo(f"{Msg.FILES} {photo_count}") + archive_scan() @app.command("status") def status() -> None: - archive = require_archive(require_config()) - - from roll.app.archive.search import find_rolls - from roll.app.archive.stats import _count_statuses - - roll_folders = find_roll_folders(archive) - unindexed_folders = find_unindexed_folders(archive) - rolls = find_rolls(archive) - status_counts = _count_statuses(rolls) - - render_status_report(archive, roll_folders, unindexed_folders, status_counts) + archive_status() @app.command("stats") @@ -194,8 +147,7 @@ def stats( year: str | None = typer.Argument(None, help=Msg.STATS_YEAR), verbose: bool = typer.Option(False, "-v", "--verbose", help=Msg.STATS_MORE), ) -> None: - archive = require_archive(require_config()) - render_stats_report(archive, year, verbose) + archive_stats(year, verbose) @app.command("load") @@ -207,34 +159,14 @@ def load( @app.command("vocab") def vocab() -> None: - archive = require_archive(require_config()) - vocab = archive_vocabulary(archive) - - for title, items in ( - (Msg.VOCAB_FILMS, vocab["films"].read()), - (Msg.VOCAB_CAMERAS, vocab["cameras"].read()), - (Msg.VOCAB_FEATURES, vocab["features"].read()), - (Msg.VOCAB_KEYWORDS, vocab["keywords"].read()), - ): - echo_section(title, [f"- {item}" for item in items]) + archive_vocab() @app.command("search") def search( query: str | None = typer.Argument(None, help=Msg.SEARCH_QUERY_REQUIRED), ) -> None: - if not query: - typer.echo(str(Msg.SEARCH_QUERY_REQUIRED)) - raise typer.Exit(code=1) - - archive = require_archive(require_config()) - results = search_rolls(archive, query) - - if not results: - typer.echo(str(Msg.NO_RESULTS)) - return - - render_search_results(results) + archive_search(query) @app.command("doctor") @@ -242,8 +174,7 @@ def doctor( fix: bool = typer.Option(False, "--fix", help=Msg.DOCTOR_CAN_FIX), verbose: bool = typer.Option(False, "-v", "--verbose", help=Msg.DOCTOR_CAN_ADD), ) -> None: - if render_doctor(fix=fix, verbose=verbose): - raise typer.Exit(code=1) + archive_doctor(fix, verbose) @tags_app.command("add") @@ -285,134 +216,6 @@ def normalize( tags: bool = typer.Option(False, "--tags", help=Msg.TAGS_NORMALIZED), photos: bool = typer.Option(False, "--photos", help=Normalize.HEADER), ) -> None: - config = require_config() - archive = require_current_archive(config) - - if tags: - touched = normalize_keywords_in_archive(archive) - if touched: - typer.echo(str(Msg.TAGS_NORMALIZED)) - for path in touched: - typer.echo(f" {path}") - else: - typer.echo(str(Msg.TAGS_ALREADY_NORMALIZED)) - return - - if photos: - plans = _build_photo_normalization_plans_impl(archive) - total_rules, has_changes = render_normalization_plans(plans) - if not has_changes: - return - - all_conflicts = [conflict for plan in plans for conflict in plan.conflicts] - if all_conflicts: - raise typer.Exit(code=1) - - _echo_photo_plan_preview_impl(plans) - if not typer.confirm( - str(Normalize.QUESTION).format(count=total_rules), default=False - ): - return - - apply_normalization_plans(plans) - return - - plans = [build_normalization_plan(archive)] - total_rules, has_changes = render_normalization_plans(plans) - if not has_changes: - return - - all_conflicts = [conflict for plan in plans for conflict in plan.conflicts] - if all_conflicts: - raise typer.Exit(code=1) - - if not typer.confirm( - str(Normalize.QUESTION).format(count=total_rules), default=False - ): - return - - apply_normalization_plans(plans) - - -def _build_photo_normalization_plans(archive: Path): - folders = _photo_folders(archive) - year = guess_archive_year(archive) - if year is None: - typer.echo(str(Msg.CLI_UNINITIALIZED)) - raise typer.Exit(code=1) - - if not typer.confirm( - str(Msg.NORMALIZE_PHOTOS_CONFIRM_YEAR).format(year=year), default=True - ): - typed_year = typer.prompt( - str(Msg.NORMALIZE_PHOTOS_YEAR).format(folder=archive.name) - ) - year = _parse_year(typed_year) - - manual_months = len(folders) > 1 and typer.confirm( - str(Msg.NORMALIZE_PHOTOS_MANUAL), default=False - ) - return [ - _build_photo_plan_for_folder(folder, archive, year, manual_months) - for folder in folders - ] - - -def _photo_folders(archive: Path) -> list[Path]: - return [ - path for path in archive.iterdir() if path.is_dir() and path.name != ".roll" - ] - - -def _build_photo_plan_for_folder( - folder: Path, archive: Path, year: int, manual_months: bool -): - from roll.app.archive.normalization import NormalizationPlan, RenameRule - - month = _prompt_month(folder) if manual_months else _guess_month(folder) - - if month is None: - return NormalizationPlan(archive=archive, rules=[], conflicts=[]) - - target = archive / f"{year:04d}" / f"{month:02d}-01" - if target.exists(): - return NormalizationPlan( - archive=archive, - rules=[], - conflicts=[f"{Msg.NORMALIZE_PHOTOS_MONTH} {target}"], - ) - - return NormalizationPlan( - archive=archive, rules=[RenameRule(folder=folder, target=target)], conflicts=[] - ) - - -def _prompt_month(folder: Path) -> int: - while True: - value = typer.prompt( - str(Msg.NORMALIZE_PHOTOS_MONTH).format(folder=folder.name) - ).strip() - month = _parse_month(value) - if month is not None: - return month - - -def _guess_month(folder: Path) -> int | None: - from roll.app.archive.photo_dates import guess_archive_month - - guess = guess_archive_month(folder) - return guess.month if guess is not None else None - - -def _parse_year(value: str) -> int: - year = value.strip() - if len(year) == 4 and year.isdigit(): - return int(year) - raise typer.Exit(code=1) - + from roll.app.archive.normalize_commands import normalize as normalize_command -def _parse_month(value: str) -> int | None: - month = value.strip() - if len(month) == 2 and month.isdigit() and 1 <= int(month) <= 12: - return int(month) - return None + normalize_command(tags, photos) diff --git a/tests/test_normalization.py b/tests/test_normalization.py index a4fe385..0695366 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -26,7 +26,7 @@ from roll.app.archive.search import RollIndex from roll.filesystem import build_archive_tree, count_photo_files from roll.app.workspace.roll_store import RollMetadata, save_roll_metadata -from roll.cli import _build_photo_normalization_plans +from roll.app.archive.normalize_commands import normalize as normalize_command from roll.helpers.output import echo_lines from roll.messages import Normalize @@ -213,12 +213,32 @@ def record_confirm(text: str, *args, **kwargs): return len(confirms) > 1 with ( - patch("roll.cli.guess_archive_year", return_value=2023), - patch("roll.cli._photo_folders", return_value=folders), - patch("roll.cli.typer.confirm", side_effect=record_confirm), - patch("roll.cli.typer.prompt", side_effect=record_prompt), + patch( + "roll.app.archive.normalize_cli.guess_archive_year", + return_value=2023, + ), + patch( + "roll.app.archive.normalize_cli._photo_folders", + return_value=folders, + ), + patch( + "roll.app.archive.normalize_cli.typer.confirm", + side_effect=record_confirm, + ), + patch( + "roll.app.archive.normalize_cli.typer.prompt", + side_effect=record_prompt, + ), + patch( + "roll.app.archive.normalize_commands.require_config", + return_value=Config(archives=[archive]), + ), + patch( + "roll.app.archive.normalize_commands.require_current_archive", + return_value=archive, + ), ): - _build_photo_normalization_plans(archive) + normalize_command(False, True) self.assertTrue(any("Year 2023 correct" in item for item in confirms)) self.assertTrue(any("Month for 4770 [01-12]" in item for item in prompts)) From 7f90069618598ae69f52f842514414746b0e6fec Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 15:14:15 +0200 Subject: [PATCH 16/20] documentation --- docs/architecture.md | 7 +++++++ docs/editing.md | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 61a4e90..2e13069 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,13 @@ archives = ["/path/to/archive"] | `rl stock edit` | global config, workspace, roll, vocabulary | roll, vocabulary | no | single-roll editing | | `rl batch` | global config, workspace, roll | roll | no | batch update | +These scopes are deliberate: + +- `rl stock edit` is the manual, one-roll editor; +- `rl batch` is the bulk editor; +- `rl normalize` is for structural cleanup and normalization; +- `rl doctor` is for diagnostics and safe repair. + ## Lifecycle ``` diff --git a/docs/editing.md b/docs/editing.md index 03ddf17..9cadd3e 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -7,6 +7,15 @@ They solve different problems and should stay separate. +## Boundaries + +| Command | Scope | Typical use | +|---|---|---| +| `rl stock edit` | one roll | manual correction, field-by-field judgment | +| `rl batch` | many rolls | repeated update across a filtered set | +| `rl normalize` | archive structure | folder shape, keywords normalization, photo import | +| `rl doctor` | integrity | report problems, safe fixes only | + --- ## `rl stock edit` From 8291d8a7c836a0acebb441af5dea2431a84f038e Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 16:14:29 +0200 Subject: [PATCH 17/20] common optimization --- src/roll/app/archive/batch.py | 37 ++----------------- src/roll/app/archive/selection.py | 61 +++++++++++++++++++++++++++++++ src/roll/app/flows/stock.py | 5 +-- 3 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 src/roll/app/archive/selection.py diff --git a/src/roll/app/archive/batch.py b/src/roll/app/archive/batch.py index 3c2a38e..873620a 100644 --- a/src/roll/app/archive/batch.py +++ b/src/roll/app/archive/batch.py @@ -3,12 +3,13 @@ import typer +from roll.app.archive.search import RollIndex +from roll.app.archive.selection import select_rolls from roll.app.workspace.roll_store import ( RollMetadata, load_roll_metadata, save_roll_metadata, ) -from roll.app.archive.search import RollIndex, find_rolls from roll.helpers.output import echo_list from roll.messages import Msg @@ -29,7 +30,7 @@ def batch_rolls( add_features: list[str] | None = None, add_tags: list[str] | None = None, ) -> int: - rolls = _select_rolls( + rolls = select_rolls( archives, year=year, films=films, @@ -64,34 +65,6 @@ def batch_rolls( return changed -def _select_rolls( - archives: list[Path], - *, - year: str | None, - films: list[str] | None, - cameras: list[str] | None, - statuses: list[str] | None, -) -> list[RollIndex]: - selected: list[RollIndex] = [] - film_set = _normalize_set(films) - camera_set = _normalize_set(cameras) - status_set = _normalize_set(statuses) - - for archive in archives: - for roll in find_rolls(archive): - if year is not None and roll.loaded_at[:4] != year: - continue - if film_set and roll.film.casefold() not in film_set: - continue - if camera_set and roll.camera.casefold() not in camera_set: - continue - if status_set and roll.status.casefold() not in status_set: - continue - selected.append(roll) - - return selected - - def _apply_changes( roll: RollIndex, *, @@ -121,10 +94,6 @@ def _apply_changes( return updated -def _normalize_set(values: list[str] | None) -> set[str]: - return {value.casefold() for value in values or [] if value} - - def _merge_unique(existing: list[str], new_values: list[str]) -> list[str]: merged = list(existing) for value in new_values: diff --git a/src/roll/app/archive/selection.py b/src/roll/app/archive/selection.py new file mode 100644 index 0000000..e5d53f2 --- /dev/null +++ b/src/roll/app/archive/selection.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + +from roll.app.archive.search import RollIndex + + +def split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def normalize_set(values: list[str] | None) -> set[str]: + return {normalize_text(value) for value in values or [] if value} + + +def normalize_text(value: str) -> str: + return value.casefold().strip() + + +def matches_year(loaded_at: str, year: str | None) -> bool: + return year is None or loaded_at[:4] == year + + +def matches_any(value: str, candidates: set[str]) -> bool: + return not candidates or normalize_text(value) in candidates + + +def select_rolls( + archives: list[Path], + *, + year: str | None = None, + films: list[str] | None = None, + cameras: list[str] | None = None, + statuses: list[str] | None = None, +) -> list[RollIndex]: + from roll.app.archive.search import find_rolls + + selected: list[RollIndex] = [] + film_set = normalize_set(films) + camera_set = normalize_set(cameras) + status_set = normalize_set(statuses) + + for archive in archives: + for roll in find_rolls(archive): + if not matches_year(roll.loaded_at, year): + continue + if not matches_any(roll.film, film_set): + continue + if not matches_any(roll.camera, camera_set): + continue + if not matches_any(roll.status, status_set): + continue + selected.append(roll) + + return selected + + +def roll_choice_label(path: Path, status: str) -> str: + return f"{str(path.relative_to(path.parents[1]))} ({status})" diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 7f72ddb..035cb6a 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -28,6 +28,7 @@ from roll.app.workspace.workspace import workspace_for from roll.app.archive.normalization import apply_keyword_vocab_fixes from roll.app.archive.batch import batch_rolls +from roll.app.archive.selection import split_csv from roll.helpers.autocomplete import autocomplete_many_prompt, autocomplete_prompt from roll.helpers.guards import require_archive, require_config from roll.helpers.output import echo_lines @@ -532,6 +533,4 @@ def _cleanup_failed_load(roll_folder: Path, roll_file: Path) -> None: def _split_csv(value: str | None) -> list[str]: - if not value: - return [] - return [item.strip() for item in value.split(",") if item.strip()] + return split_csv(value) From 54a39a5b34eca953e24a8c6ed895f3668ef128be Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 16:31:46 +0200 Subject: [PATCH 18/20] add options to search command --- docs/architecture.md | 3 +- docs/editing.md | 45 +++++++++++++++++++++- docs/reference.md | 2 +- src/roll/__main__.py | 5 +++ src/roll/app/archive/commands.py | 24 +++++++++--- src/roll/app/archive/search.py | 62 ++++++++++++++++++++++++++----- src/roll/app/archive/selection.py | 7 ++-- src/roll/cli.py | 6 ++- src/roll/messages/cli.py | 5 +++ tests/test_storage.py | 30 +++++++++++++++ 10 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 src/roll/__main__.py diff --git a/docs/architecture.md b/docs/architecture.md index 2e13069..1bb169f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,6 +60,7 @@ These scopes are deliberate: - `rl stock edit` is the manual, one-roll editor; - `rl batch` is the bulk editor; +- `rl search` shares the same filter language as `rl batch`, but never writes; - `rl normalize` is for structural cleanup and normalization; - `rl doctor` is for diagnostics and safe repair. @@ -120,7 +121,7 @@ rl doctor --fix | `filesystem.py` | archive tree and file discovery | | `app/workspace/` | config, stock, roll storage, vocabularies | | `app/flows/` | interactive flows | -| `app/archive/` | search, stats, batch, normalization | +| `app/archive/` | search, stats, batch, selection, normalization | | `app/diagnostics/` | doctor | | `messages/` | localized user-facing text | diff --git a/docs/editing.md b/docs/editing.md index 9cadd3e..63358f0 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -13,6 +13,7 @@ They solve different problems and should stay separate. |---|---|---| | `rl stock edit` | one roll | manual correction, field-by-field judgment | | `rl batch` | many rolls | repeated update across a filtered set | +| `rl search` | many rolls | structured lookup, optionally with free text | | `rl normalize` | archive structure | folder shape, keywords normalization, photo import | | `rl doctor` | integrity | report problems, safe fixes only | @@ -61,7 +62,7 @@ Selection is based on filters such as: - film name; - camera; - status; -- other archive fields when needed. +- tags or keywords when needed. Within one filter, comma-separated values mean "match any of these". Across filters, the selection is cumulative. @@ -88,12 +89,54 @@ Use this rule: - one roll, manual judgment, many fields -> `rl stock edit`; - many rolls, one repeated operation -> `rl batch`. +## `rl search` + +Use this when you want to find rolls with the same filter language as `rl batch`, but without writing anything. + +What it does: + +- accepts the same structured filters as `rl batch`; +- keeps free-text search for ad hoc lookup; +- shows matching rolls and their basic metadata. + +Selection is based on the same filters: + +- year; +- film name; +- camera; +- status; +- tags or keywords. + +Within one filter, comma-separated values mean "match any of these". +Across filters, the selection is cumulative. + +Best for: + +- finding rolls by year and status; +- narrowing by film family before a batch update; +- looking up tags or camera names; +- searching by a short free-text fragment when you do not need a structured filter. + +Not for: + +- changing metadata; +- structural normalization; +- integrity repair. + If you need both at different times, start with `stock edit` for the outlier and use `batch` for the rest. --- ## Examples +```bash +rl search --year 2025 --status loaded +``` + +```bash +rl search --film "Kodak Gold 200, Ilford HP5 Plus" --query balcony +``` + ```bash rl stock edit ``` diff --git a/docs/reference.md b/docs/reference.md index 30f1fc8..f7fa94e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -84,7 +84,7 @@ Brings folder names to a consistent shape: builds a plan, asks for confirmation, ## Editing -`rl stock edit` is a single-roll manual editor. `rl batch` is a bulk editor that applies one change to many rolls selected by filters. +`rl stock edit` is a single-roll manual editor. `rl batch` is a bulk editor that applies one change to many rolls selected by filters. `rl search` uses the same filter language for read-only lookup and can also keep free-text search. See [docs/editing.md](editing.md) for the shared editing model and the boundary between the two commands. diff --git a/src/roll/__main__.py b/src/roll/__main__.py new file mode 100644 index 0000000..0728c02 --- /dev/null +++ b/src/roll/__main__.py @@ -0,0 +1,5 @@ +from roll.cli import app + + +if __name__ == "__main__": + app() diff --git a/src/roll/app/archive/commands.py b/src/roll/app/archive/commands.py index 33d9f85..26c7318 100644 --- a/src/roll/app/archive/commands.py +++ b/src/roll/app/archive/commands.py @@ -1,11 +1,12 @@ from __future__ import annotations -from roll.app.archive.search import search_rolls +from roll.app.archive.search import search_rolls_by_filters from roll.app.archive.search_output import render_search_results from roll.app.archive.stats_output import render_stats_report from roll.app.archive.status_output import render_status_report from roll.app.diagnostics.doctor_output import render_doctor from roll.app.workspace.vocabulary import archive_vocabulary +from roll.app.archive.selection import split_csv from roll.filesystem import ( build_archive_tree, count_photo_files, @@ -71,13 +72,26 @@ def vocab() -> None: echo_section(title, [f"- {item}" for item in items]) -def search(query: str | None) -> None: - if not query: - typer.echo(str(Msg.SEARCH_QUERY_REQUIRED)) +def search( + year: str | None = None, + film: str | None = None, + camera: str | None = None, + status: str | None = None, + query: str | None = None, +) -> None: + if not any([year, film, camera, status, query]): + typer.echo(str(Msg.SEARCH_NEEDS_QUERY_OR_FILTERS)) raise typer.Exit(code=1) archive = require_archive(require_config()) - results = search_rolls(archive, query) + results = search_rolls_by_filters( + [archive], + year=year, + films=split_csv(film), + cameras=split_csv(camera), + statuses=split_csv(status), + query=query, + ) if not results: typer.echo(str(Msg.NO_RESULTS)) diff --git a/src/roll/app/archive/search.py b/src/roll/app/archive/search.py index d91f60f..ad8e800 100644 --- a/src/roll/app/archive/search.py +++ b/src/roll/app/archive/search.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from pathlib import Path +from roll.app.archive.selection import normalize_text, select_rolls from roll.filesystem import find_roll_folders, get_index_file @@ -50,21 +51,62 @@ def search_rolls(archive: Path, query: str | None) -> list[RollIndex]: if not query: return [] - normalized_query = query.lower() + normalized_query = normalize_text(query) results: list[RollIndex] = [] for roll in find_rolls(archive): - searchable_text = " ".join( - [ - roll.film, - roll.camera, - roll.loaded_at, - *roll.features, - *roll.keywords, - ] - ).lower() + searchable_text = normalize_text( + " ".join( + [ + roll.film, + roll.camera, + roll.loaded_at, + *roll.features, + *roll.keywords, + ] + ) + ) if normalized_query in searchable_text: results.append(roll) return results + + +def search_rolls_by_filters( + archives: list[Path], + *, + year: str | None = None, + films: list[str] | None = None, + cameras: list[str] | None = None, + statuses: list[str] | None = None, + query: str | None = None, +) -> list[RollIndex]: + rolls = select_rolls( + archives, + year=year, + films=films, + cameras=cameras, + statuses=statuses, + ) + + if not query: + return rolls + + normalized_query = normalize_text(query) + return [ + roll + for roll in rolls + if normalized_query + in normalize_text( + " ".join( + [ + roll.film, + roll.camera, + roll.loaded_at, + *roll.features, + *roll.keywords, + ] + ) + ) + ] diff --git a/src/roll/app/archive/selection.py b/src/roll/app/archive/selection.py index e5d53f2..12318fc 100644 --- a/src/roll/app/archive/selection.py +++ b/src/roll/app/archive/selection.py @@ -1,8 +1,7 @@ from __future__ import annotations from pathlib import Path - -from roll.app.archive.search import RollIndex +from typing import Any def split_csv(value: str | None) -> list[str]: @@ -34,10 +33,10 @@ def select_rolls( films: list[str] | None = None, cameras: list[str] | None = None, statuses: list[str] | None = None, -) -> list[RollIndex]: +) -> list[Any]: from roll.app.archive.search import find_rolls - selected: list[RollIndex] = [] + selected: list[Any] = [] film_set = normalize_set(films) camera_set = normalize_set(cameras) status_set = normalize_set(statuses) diff --git a/src/roll/cli.py b/src/roll/cli.py index 15f98ec..aec8a58 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -164,9 +164,13 @@ def vocab() -> None: @app.command("search") def search( + year: str | None = typer.Option(None, "--year", help=Msg.BATCH_FILTER_YEAR), + film: str | None = typer.Option(None, "--film", help=Msg.BATCH_FILTER_FILM), + camera: str | None = typer.Option(None, "--camera", help=Msg.BATCH_FILTER_CAMERA), + status: str | None = typer.Option(None, "--status", help=Msg.BATCH_FILTER_STATUS), query: str | None = typer.Argument(None, help=Msg.SEARCH_QUERY_REQUIRED), ) -> None: - archive_search(query) + archive_search(year, film, camera, status, query) @app.command("doctor") diff --git a/src/roll/messages/cli.py b/src/roll/messages/cli.py index 7de1a85..ffddc64 100644 --- a/src/roll/messages/cli.py +++ b/src/roll/messages/cli.py @@ -178,6 +178,11 @@ class Msg(Headers): "Нужно указать строку поиска. Пример: rl search pizza", "You need to provide a search query. Example: rl search pizza", ) + SEARCH_NEEDS_QUERY_OR_FILTERS = Message( + "cli.search_needs_query_or_filters", + "Нужно указать строку поиска или хотя бы один фильтр.", + "You need to provide a search query or at least one filter.", + ) TAGS_NORMALIZED = Message( "cli.tags_normalized", "Теги нормализованы.", "Tags normalized." ) diff --git a/tests/test_storage.py b/tests/test_storage.py index 5f14490..d2c5546 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -20,6 +20,7 @@ save_stock, ) from roll.app.archive.normalization import normalize_keywords_in_archive +from roll.app.archive.search import search_rolls_by_filters from roll.app.flows.stock import _format_roll_label, _rolls, _prompt_roll_metadata from roll.app.archive.batch import batch_rolls from roll.app.workspace.workspace import workspace_for @@ -224,6 +225,35 @@ def test_roll_label_is_short_and_stable(self) -> None: label, "2025/10-19 | Kodak Gold 200 | Pentax Espio 150SL | loaded" ) + def test_search_rolls_supports_filters_and_query(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + roll = archive / "2025" / "10-19" + roll.mkdir(parents=True) + save_roll_metadata( + roll / "roll.toml", + RollMetadata( + status="loaded", + film="Kodak Gold 200", + camera="Pentax Espio 150SL", + loaded_at="2025-10-19", + features=["redscale"], + keywords=["BEACH"], + ), + ) + + results = search_rolls_by_filters( + [archive], + year="2025", + films=["Kodak Gold 200"], + cameras=["Pentax Espio 150SL"], + statuses=["loaded"], + query="beach", + ) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].folder, roll) + def test_roll_edit_prompt_can_update_all_metadata_fields(self) -> None: with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) From 21ff215f959b40d03f0d5a431e858e6801b0f7c7 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 16:43:32 +0200 Subject: [PATCH 19/20] optimize structure --- src/roll/app/archive/search.py | 60 +-- src/roll/app/flows/stock.py | 502 ++------------------------ src/roll/app/flows/stock_edit.py | 282 +++++++++++++++ src/roll/app/flows/stock_lifecycle.py | 236 ++++++++++++ src/roll/app/root_commands.py | 90 +++++ src/roll/cli.py | 83 +---- tests/test_storage.py | 5 +- 7 files changed, 670 insertions(+), 588 deletions(-) create mode 100644 src/roll/app/flows/stock_edit.py create mode 100644 src/roll/app/flows/stock_lifecycle.py create mode 100644 src/roll/app/root_commands.py diff --git a/src/roll/app/archive/search.py b/src/roll/app/archive/search.py index ad8e800..f27bb11 100644 --- a/src/roll/app/archive/search.py +++ b/src/roll/app/archive/search.py @@ -51,26 +51,7 @@ def search_rolls(archive: Path, query: str | None) -> list[RollIndex]: if not query: return [] - normalized_query = normalize_text(query) - results: list[RollIndex] = [] - - for roll in find_rolls(archive): - searchable_text = normalize_text( - " ".join( - [ - roll.film, - roll.camera, - roll.loaded_at, - *roll.features, - *roll.keywords, - ] - ) - ) - - if normalized_query in searchable_text: - results.append(roll) - - return results + return _filter_by_query(find_rolls(archive), query) def search_rolls_by_filters( @@ -83,30 +64,25 @@ def search_rolls_by_filters( query: str | None = None, ) -> list[RollIndex]: rolls = select_rolls( - archives, - year=year, - films=films, - cameras=cameras, - statuses=statuses, + archives, year=year, films=films, cameras=cameras, statuses=statuses ) + return rolls if not query else _filter_by_query(rolls, query) - if not query: - return rolls +def _filter_by_query(rolls: list[RollIndex], query: str) -> list[RollIndex]: normalized_query = normalize_text(query) - return [ - roll - for roll in rolls - if normalized_query - in normalize_text( - " ".join( - [ - roll.film, - roll.camera, - roll.loaded_at, - *roll.features, - *roll.keywords, - ] - ) + return [roll for roll in rolls if normalized_query in _searchable_text(roll)] + + +def _searchable_text(roll: RollIndex) -> str: + return normalize_text( + " ".join( + [ + roll.film, + roll.camera, + roll.loaded_at, + *roll.features, + *roll.keywords, + ] ) - ] + ) diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index 035cb6a..a482689 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -1,59 +1,26 @@ from __future__ import annotations -from datetime import date -from pathlib import Path -import textwrap - import typer -from prompt_toolkit import prompt -from prompt_toolkit.completion import FuzzyCompleter, WordCompleter -from roll.filesystem import find_roll_folders -from roll.app.workspace.roll_store import ( - RollMetadata, - load_roll_metadata, - save_roll_metadata, - update_roll_features, - update_roll_keywords, - update_roll_status, +from roll.messages import Msg +from roll.app.flows.stock_edit import ( + edit as _edit, + edit_batch as _edit_batch, + edit_list_field as _edit_list_field, ) -from roll.app.workspace.stock_store import ( - StockItem, - add_to_stock, - load_stock, - remove_from_stock, - save_stock, +from roll.app.flows.stock_lifecycle import ( + add as _add, + load as _load, + list_stock as _list_stock, + process as _process, ) -from roll.app.workspace.statuses import VALID_STATUSES -from roll.app.workspace.workspace import workspace_for -from roll.app.archive.normalization import apply_keyword_vocab_fixes -from roll.app.archive.batch import batch_rolls -from roll.app.archive.selection import split_csv -from roll.helpers.autocomplete import autocomplete_many_prompt, autocomplete_prompt -from roll.helpers.guards import require_archive, require_config -from roll.helpers.output import echo_lines -from roll.messages import Msg app = typer.Typer(help=Msg.STOCK_HEADER) @app.command("add") def add() -> None: - archive = require_archive(require_config()) - workspace = workspace_for(archive) - - film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) - quantity = typer.prompt(str(Msg.PROMPT_QUANTITY), type=int) - if quantity <= 0: - typer.echo(str(Msg.INVALID_QUANTITY)) - raise typer.Exit(code=1) - - try: - items = load_stock(workspace.stock_file) - save_stock(workspace.stock_file, add_to_stock(items, film, quantity)) - except ValueError as exc: - typer.echo(str(exc)) - raise typer.Exit(code=1) + _add() @app.command("load") @@ -62,141 +29,32 @@ def load( False, "--manual", help="Enter film manually from dictionary." ), ) -> None: - archive = require_archive(require_config()) - workspace = workspace_for(archive) - - stock: list[StockItem] = [] - if not manual: - try: - stock = load_stock(workspace.stock_file) - except ValueError as exc: - typer.echo(str(exc)) - raise typer.Exit(code=1) - - if not stock: - typer.echo(str(Msg.STOCK_EMPTY_MANUAL)) - raise typer.Exit(code=1) - - selected = ( - _choose_stock_item(stock) if not manual else _choose_manual_film(workspace) - ) - camera = autocomplete_prompt( - str(Msg.PROMPT_CAMERA), workspace.dictionary("cameras") - ) - loaded_at = _prompt_loaded_at() - roll_folder = _create_roll_folder(archive, loaded_at) - roll_file = roll_folder / "roll.toml" - - if roll_file.exists(): - typer.echo(f"{Msg.ROLL_EXISTS} {roll_file}") - raise typer.Exit(code=1) - - try: - roll_folder.mkdir(parents=True, exist_ok=True) - save_roll_metadata( - roll_file, - RollMetadata( - status=VALID_STATUSES[0], - film=selected.film, - camera=camera, - loaded_at=loaded_at, - features=[], - keywords=[], - ), - ) - if not manual: - save_stock(workspace.stock_file, remove_from_stock(stock, selected.film, 1)) - features = autocomplete_many_prompt( - str(Msg.VOCAB_FEATURES), workspace.dictionary("features") - ) - if features: - update_roll_features(roll_file, features) - - tags = autocomplete_many_prompt( - str(Msg.VOCAB_KEYWORDS), workspace.dictionary("keywords") - ) - if tags: - update_roll_keywords(roll_file, tags) - apply_keyword_vocab_fixes(archive, tags) - except Exception: - _cleanup_failed_load(roll_folder, roll_file) - raise - - typer.echo(f"{Msg.LOAD_SUCCESS} {selected.film}") + _load(manual) @app.command("process") def process() -> None: - _finish_roll("processed", "Processed") + _process("processed", "Processed") @app.command("failed") def failed() -> None: - _finish_roll("failed", "Marked as failed") + _process("failed", "Marked as failed") @app.command("list") def list_stock() -> None: - archive = require_archive(require_config()) - workspace = workspace_for(archive) - - try: - items = load_stock(workspace.stock_file) - except ValueError as exc: - typer.echo(str(exc)) - raise typer.Exit(code=1) + _list_stock() - if not items: - typer.echo(str(Msg.STOCK_EMPTY)) - return - echo_lines([Msg.STOCK_HEADER]) - for item in items: - typer.echo(f"{item.film:<20} ×{item.quantity}") - - -@app.command("edit") def edit() -> None: - archive = require_archive(require_config()) - rolls = _rolls(archive) - if not rolls: - typer.echo(str(Msg.NO_LOADED_ROLLS)) - raise typer.Exit(code=1) - - roll = _choose_roll(rolls) - metadata = load_roll_metadata(roll / "roll.toml") - updated = _prompt_roll_metadata(archive, roll / "roll.toml", metadata) - save_roll_metadata(roll / "roll.toml", updated) - typer.echo(f"{Msg.ROLL_EDIT_UPDATED} {updated.loaded_at}") + _edit() def edit_list_field( prompt_title: str, dictionary_name: str, success_label: str ) -> None: - archive = require_archive(require_config()) - rolls = _rolls(archive) - if not rolls: - typer.echo(str(Msg.NO_ROLLS)) - raise typer.Exit(code=1) - - selected = _choose_roll_folder(rolls) - workspace = workspace_for(archive) - values = autocomplete_many_prompt( - prompt_title, workspace.dictionary(dictionary_name) - ) - try: - metadata = ( - update_roll_keywords(selected / "roll.toml", values) - if dictionary_name == "keywords" - else update_roll_features(selected / "roll.toml", values) - ) - if dictionary_name == "keywords": - apply_keyword_vocab_fixes(archive, metadata.keywords) - except ValueError as exc: - typer.echo(str(exc)) - raise typer.Exit(code=1) - - typer.echo(f"{success_label}: {metadata.film}") + _edit_list_field(prompt_title, dictionary_name, success_label) def edit_batch( @@ -209,328 +67,28 @@ def edit_batch( add_feature: str | None, add_tag: str | None, ) -> None: - config = require_config() - filters = {"year": year, "film": film, "camera": camera, "status": status} - changes = { - "set_status": set_status, - "set_camera": set_camera, - "add_feature": add_feature, - "add_tag": add_tag, - } - if not any(filters.values()) or not any(changes.values()): - typer.echo(str(Msg.BATCH_NEEDS_FILTERS)) - raise typer.Exit(code=1) - - batch_rolls( - config.archives, - year=year, - films=_split_csv(film), - cameras=_split_csv(camera), - statuses=_split_csv(status), - status=set_status, - set_camera=set_camera, - add_features=_split_csv(add_feature), - add_tags=_split_csv(add_tag), - ) - - -def _prompt_loaded_at() -> str: - value = typer.prompt(str(Msg.PROMPT_LOAD_DATE)) - normalized = value.strip().split("T", 1)[0].split(" ", 1)[0] - try: - return date.fromisoformat(normalized).isoformat() - except ValueError as exc: - typer.echo(str(Msg.INVALID_DATE)) - raise typer.Exit(code=1) from exc - - -def _create_roll_folder(archive: Path, loaded_at: str) -> Path: - loaded_date = date.fromisoformat(loaded_at) - return archive / loaded_date.strftime("%Y") / loaded_date.strftime("%m-%d") - - -def _choose_stock_item(items: list[StockItem]) -> StockItem: - labels = [f"{item.film} ×{item.quantity}" for item in items] - completer = FuzzyCompleter( - WordCompleter(labels, ignore_case=True, sentence=True, match_middle=True) + _edit_batch( + year, film, camera, status, set_status, set_camera, add_feature, add_tag ) - while True: - value = prompt( - str(Msg.PROMPT_MANUAL_FILM), - completer=completer, - complete_while_typing=True, - ).strip() - if not value: - continue - - selected = _resolve_stock_choice(items, value) - if selected is not None: - return selected - - typer.echo(str(Msg.CHOOSE_STOCK)) - - -def _choose_manual_film(workspace) -> StockItem: - film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) - return StockItem(film=film, quantity=1) - - -def _resolve_stock_choice(items: list[StockItem], candidate: str) -> StockItem | None: - normalized = _normalize_choice(candidate) - - exact_film_matches = [ - item for item in items if item.film.casefold() == candidate.casefold() - ] - if len(exact_film_matches) == 1: - return exact_film_matches[0] - - exact_label_matches = [ - item - for item in items - if f"{item.film} ×{item.quantity}".casefold() == candidate.casefold() - ] - if len(exact_label_matches) == 1: - return exact_label_matches[0] - - fuzzy_matches = [ - item for item in items if normalized in _normalize_choice(item.film) - ] - if len(fuzzy_matches) == 1: - return fuzzy_matches[0] - - return None +def _rolls(archive): + from roll.app.flows.stock_edit import _rolls as stock_edit_rolls -def _normalize_choice(value: str) -> str: - return "".join(ch for ch in value.casefold() if ch.isalnum()) + return stock_edit_rolls(archive) -def _choose_roll(rolls: list[Path]) -> Path: - labels = [_format_roll_label(path) for path in rolls] - selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) - for path in rolls: - if _format_roll_label(path) == selected_label: - return path - raise ValueError(Msg.NO_CHOICE) - - -def _rolls(archive: Path) -> list[Path]: - rolls: list[Path] = [] - for folder in find_roll_folders(archive): - try: - load_roll_metadata(folder / "roll.toml") - except ValueError: - continue - rolls.append(folder) - return rolls - - -def _finish_roll(status: str, label: str) -> None: - archive = require_archive(require_config()) - loaded_rolls = [ - path - for path in _rolls(archive) - if load_roll_metadata(path / "roll.toml").status == "loaded" - ] - if not loaded_rolls: - typer.echo(str(Msg.NO_LOADED_ROLLS)) - raise typer.Exit(code=1) - - selected = _choose_roll(loaded_rolls) - try: - metadata = update_roll_status(selected / "roll.toml", status) - except ValueError as exc: - typer.echo(str(exc)) - raise typer.Exit(code=1) - - typer.echo(f"{label}: {metadata.film}") - - -def _choose_roll(rolls: list[Path]) -> Path: - labels = [_format_roll_label(path) for path in rolls] - selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) - for path in rolls: - if selected_label == _format_roll_label(path): - return path - raise ValueError(Msg.NO_CHOICE) - - -def _choose_roll_folder(rolls: list[Path]) -> Path: - labels = [ - f"{str(path.relative_to(path.parents[1]))} ({_roll_status(path)})" - for path in rolls - ] - selected_label = _prompt_choice("Roll", labels) - for path in rolls: - label = f"{str(path.relative_to(path.parents[1]))} ({_roll_status(path)})" - if label == selected_label: - return path - raise ValueError(Msg.NO_CHOICE) - - -def _roll_status(path: Path) -> str: - try: - return load_roll_metadata(path / "roll.toml").status - except ValueError: - return "unknown" - - -def _format_roll_label(path: Path) -> str: - metadata = load_roll_metadata(path / "roll.toml") - return str(Msg.ROLL_EDIT_ROLL_LABEL).format( - path=str(path.relative_to(path.parents[1])), - film=metadata.film, - camera=metadata.camera, - status=metadata.status, +def _format_roll_label(path): + from roll.app.flows.stock_edit import ( + _format_roll_label as stock_edit_format_roll_label, ) - -def _prompt_enum(label: Msg, values: list[str], current: str) -> str: - selected = _prompt_choice_panel(str(label), values, current) - return selected if selected is not None else current + return stock_edit_format_roll_label(path) -def _prompt_roll_metadata( - archive: Path, roll_file: Path, metadata: RollMetadata -) -> RollMetadata: - workspace = workspace_for(archive) - film = _prompt_optional_autocomplete( - Msg.ROLL_EDIT_FILM, workspace.dictionary("films"), metadata.film - ) - camera = _prompt_optional_autocomplete( - Msg.ROLL_EDIT_CAMERA, workspace.dictionary("cameras"), metadata.camera - ) - status = _prompt_enum(Msg.ROLL_EDIT_STATUS, list(VALID_STATUSES), metadata.status) - features = _prompt_optional_many( - Msg.ROLL_EDIT_FEATURES, workspace.dictionary("features"), metadata.features - ) - keywords = _prompt_optional_many( - Msg.ROLL_EDIT_KEYWORDS, workspace.dictionary("keywords"), metadata.keywords - ) - original_source = _prompt_enum( - Msg.ROLL_EDIT_ORIGINAL_SOURCE, - ["negative", "slide", "print", "digital", "unknown"], - metadata.original_source, - ) - digital_copy = _prompt_enum( - Msg.ROLL_EDIT_DIGITAL_COPY, - ["scan", "photo", "none", "unknown"], - metadata.digital_copy, +def _prompt_roll_metadata(archive, roll_file, metadata): + from roll.app.flows.stock_edit import ( + _prompt_roll_metadata as stock_edit_prompt_roll_metadata, ) - original_status = _prompt_enum( - Msg.ROLL_EDIT_ORIGINAL_STATUS, - ["present", "lost", "unknown"], - metadata.original_status, - ) - return RollMetadata( - status=status, - film=film, - camera=camera, - loaded_at=metadata.loaded_at, - features=features, - keywords=[value.upper() for value in keywords], - original_source=original_source, - digital_copy=digital_copy, - original_status=original_status, - ) - - -def _prompt_optional_autocomplete(label: Msg, dictionary, current: str) -> str: - value = prompt(f"{label} [{current}]: ", complete_while_typing=True).strip() - if not value: - return current - - choices = dictionary.read() - for existing in choices: - if existing.casefold() == value.casefold(): - return existing - - return dictionary.add(value) - - -def _prompt_optional_many(label: Msg, dictionary, current: list[str]) -> list[str]: - choices = dictionary.read() - value = prompt( - f"{label} [{', '.join(current)}]: ", - completer=FuzzyCompleter( - WordCompleter(choices, ignore_case=True, sentence=True, match_middle=True) - ), - complete_while_typing=True, - ).strip() - if not value: - return current - - selected: list[str] = [] - for token in [item.strip() for item in value.split(",") if item.strip()]: - for existing in dictionary.read(): - if existing.casefold() == token.casefold(): - token = existing - break - else: - token = dictionary.add(token) - if token not in selected: - selected.append(token) - merged = list(current) - for token in selected: - if token not in merged: - merged.append(token) - return merged - - -def _prompt_choice(title: str, choices: list[str]) -> str: - selected = _prompt_choice_panel(title, choices, choices[0] if choices else "") - if selected is None: - raise ValueError(Msg.NO_CHOICE) - return selected - - -def _prompt_choice_panel( - title: str, choices: list[str], current: str | None = None -) -> str | None: - content_width = max( - len(title), - len(f"Current: {current}") if current is not None else 0, - *(len(f"{index + 1}. {choice}") for index, choice in enumerate(choices)), - 24, - ) - width = min(content_width + 4, 88) - border = "┌" + "─" * (width - 2) + "┐" - footer = "└" + "─" * (width - 2) + "┘" - prompt_lines = [border, f"│ {title.ljust(width - 4)} │"] - if current is not None: - prompt_lines.append( - f"│ {textwrap.shorten(f'Current: {current}', width=width - 4, placeholder='…').ljust(width - 4)} │" - ) - prompt_lines.append("├" + "─" * (width - 2) + "┤") - prompt_lines.extend( - f"│ {textwrap.shorten(f'{index + 1}. {choice}', width=width - 4, placeholder='…').ljust(width - 4)} │" - for index, choice in enumerate(choices) - ) - prompt_lines.append(footer) - echo_lines(prompt_lines) - - while True: - value = prompt(f"{Msg.ROLL_EDIT_SELECT_HINT}: ").strip() - if not value: - return current - if value.isdigit(): - index = int(value) - 1 - if 0 <= index < len(choices): - return choices[index] - if value in choices: - return value - - -def _cleanup_failed_load(roll_folder: Path, roll_file: Path) -> None: - if roll_file.exists(): - roll_file.unlink() - if roll_folder.exists(): - try: - roll_folder.rmdir() - except OSError: - pass - -def _split_csv(value: str | None) -> list[str]: - return split_csv(value) + return stock_edit_prompt_roll_metadata(archive, roll_file, metadata) diff --git a/src/roll/app/flows/stock_edit.py b/src/roll/app/flows/stock_edit.py new file mode 100644 index 0000000..1f7b0e7 --- /dev/null +++ b/src/roll/app/flows/stock_edit.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from pathlib import Path +import textwrap + +import typer +from prompt_toolkit import prompt +from prompt_toolkit.completion import FuzzyCompleter, WordCompleter + +from roll.app.archive.batch import batch_rolls +from roll.app.archive.normalization import apply_keyword_vocab_fixes +from roll.app.archive.selection import roll_choice_label, split_csv +from roll.app.workspace.roll_store import ( + RollMetadata, + load_roll_metadata, + save_roll_metadata, + update_roll_features, + update_roll_keywords, +) +from roll.app.workspace.workspace import workspace_for +from roll.app.workspace.statuses import VALID_STATUSES +from roll.filesystem import find_roll_folders +from roll.helpers.autocomplete import autocomplete_many_prompt +from roll.helpers.output import echo_lines +from roll.helpers.guards import require_archive, require_config +from roll.messages import Msg + + +def edit() -> None: + archive = require_archive(require_config()) + rolls = _rolls(archive) + if not rolls: + typer.echo(str(Msg.NO_LOADED_ROLLS)) + raise typer.Exit(code=1) + + roll = _choose_roll(rolls) + metadata = load_roll_metadata(roll / "roll.toml") + updated = _prompt_roll_metadata(archive, roll / "roll.toml", metadata) + save_roll_metadata(roll / "roll.toml", updated) + typer.echo(f"{Msg.ROLL_EDIT_UPDATED} {updated.loaded_at}") + + +def edit_list_field( + prompt_title: str, dictionary_name: str, success_label: str +) -> None: + archive = require_archive(require_config()) + rolls = _rolls(archive) + if not rolls: + typer.echo(str(Msg.NO_ROLLS)) + raise typer.Exit(code=1) + + selected = _choose_roll_folder(rolls) + workspace = workspace_for(archive) + values = autocomplete_many_prompt( + prompt_title, workspace.dictionary(dictionary_name) + ) + try: + metadata = ( + update_roll_keywords(selected / "roll.toml", values) + if dictionary_name == "keywords" + else update_roll_features(selected / "roll.toml", values) + ) + if dictionary_name == "keywords": + apply_keyword_vocab_fixes(archive, metadata.keywords) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + + typer.echo(f"{success_label}: {metadata.film}") + + +def edit_batch( + year: str | None, + film: str | None, + camera: str | None, + status: str | None, + set_status: str | None, + set_camera: str | None, + add_feature: str | None, + add_tag: str | None, +) -> None: + config = require_config() + filters = {"year": year, "film": film, "camera": camera, "status": status} + changes = { + "set_status": set_status, + "set_camera": set_camera, + "add_feature": add_feature, + "add_tag": add_tag, + } + if not any(filters.values()) or not any(changes.values()): + typer.echo(str(Msg.BATCH_NEEDS_FILTERS)) + raise typer.Exit(code=1) + + batch_rolls( + config.archives, + year=year, + films=split_csv(film), + cameras=split_csv(camera), + statuses=split_csv(status), + status=set_status, + set_camera=set_camera, + add_features=split_csv(add_feature), + add_tags=split_csv(add_tag), + ) + + +def _choose_roll(rolls: list[Path]) -> Path: + labels = [_format_roll_label(path) for path in rolls] + selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) + label_to_roll = dict(zip(labels, rolls, strict=True)) + return label_to_roll[selected_label] + + +def _choose_roll_folder(rolls: list[Path]) -> Path: + labels = [roll_choice_label(path, _roll_status(path)) for path in rolls] + selected_label = _prompt_choice(str(Msg.ROLL_EDIT_SELECT), labels) + label_to_roll = dict(zip(labels, rolls, strict=True)) + return label_to_roll[selected_label] + + +def _rolls(archive: Path) -> list[Path]: + rolls: list[Path] = [] + for folder in find_roll_folders(archive): + try: + load_roll_metadata(folder / "roll.toml") + except ValueError: + continue + rolls.append(folder) + return rolls + + +def _roll_status(path: Path) -> str: + try: + return load_roll_metadata(path / "roll.toml").status + except ValueError: + return "unknown" + + +def _format_roll_label(path: Path) -> str: + metadata = load_roll_metadata(path / "roll.toml") + return str(Msg.ROLL_EDIT_ROLL_LABEL).format( + path=str(path.relative_to(path.parents[1])), + film=metadata.film, + camera=metadata.camera, + status=metadata.status, + ) + + +def _prompt_enum(label: Msg, values: list[str], current: str) -> str: + selected = _prompt_choice_panel(str(label), values, current) + return selected if selected is not None else current + + +def _prompt_roll_metadata( + archive: Path, roll_file: Path, metadata: RollMetadata +) -> RollMetadata: + workspace = workspace_for(archive) + film = _prompt_optional_autocomplete( + Msg.ROLL_EDIT_FILM, workspace.dictionary("films"), metadata.film + ) + camera = _prompt_optional_autocomplete( + Msg.ROLL_EDIT_CAMERA, workspace.dictionary("cameras"), metadata.camera + ) + status = _prompt_enum(Msg.ROLL_EDIT_STATUS, list(VALID_STATUSES), metadata.status) + features = _prompt_optional_many( + Msg.ROLL_EDIT_FEATURES, workspace.dictionary("features"), metadata.features + ) + keywords = _prompt_optional_many( + Msg.ROLL_EDIT_KEYWORDS, workspace.dictionary("keywords"), metadata.keywords + ) + original_source = _prompt_enum( + Msg.ROLL_EDIT_ORIGINAL_SOURCE, + ["negative", "slide", "print", "digital", "unknown"], + metadata.original_source, + ) + digital_copy = _prompt_enum( + Msg.ROLL_EDIT_DIGITAL_COPY, + ["scan", "photo", "none", "unknown"], + metadata.digital_copy, + ) + original_status = _prompt_enum( + Msg.ROLL_EDIT_ORIGINAL_STATUS, + ["present", "lost", "unknown"], + metadata.original_status, + ) + return RollMetadata( + status=status, + film=film, + camera=camera, + loaded_at=metadata.loaded_at, + features=features, + keywords=[value.upper() for value in keywords], + original_source=original_source, + digital_copy=digital_copy, + original_status=original_status, + ) + + +def _prompt_optional_autocomplete(label: Msg, dictionary, current: str) -> str: + value = prompt(f"{label} [{current}]: ", complete_while_typing=True).strip() + if not value: + return current + + choices = dictionary.read() + for existing in choices: + if existing.casefold() == value.casefold(): + return existing + + return dictionary.add(value) + + +def _prompt_optional_many(label: Msg, dictionary, current: list[str]) -> list[str]: + choices = dictionary.read() + value = prompt( + f"{label} [{', '.join(current)}]: ", + completer=FuzzyCompleter( + WordCompleter(choices, ignore_case=True, sentence=True, match_middle=True) + ), + complete_while_typing=True, + ).strip() + if not value: + return current + + selected: list[str] = [] + for token in [item.strip() for item in value.split(",") if item.strip()]: + for existing in choices: + if existing.casefold() == token.casefold(): + token = existing + break + else: + token = dictionary.add(token) + if token not in selected: + selected.append(token) + merged = list(current) + for token in selected: + if token not in merged: + merged.append(token) + return merged + + +def _prompt_choice(title: str, choices: list[str]) -> str: + selected = _prompt_choice_panel(title, choices, choices[0] if choices else "") + if selected is None: + raise ValueError(Msg.NO_CHOICE) + return selected + + +def _prompt_choice_panel( + title: str, choices: list[str], current: str | None = None +) -> str | None: + content_width = max( + len(title), + len(f"Current: {current}") if current is not None else 0, + *(len(f"{index + 1}. {choice}") for index, choice in enumerate(choices)), + 24, + ) + width = min(content_width + 4, 88) + border = "┌" + "─" * (width - 2) + "┐" + footer = "└" + "─" * (width - 2) + "┘" + prompt_lines = [border, f"│ {title.ljust(width - 4)} │"] + if current is not None: + prompt_lines.append( + f"│ {textwrap.shorten(f'Current: {current}', width=width - 4, placeholder='…').ljust(width - 4)} │" + ) + prompt_lines.append("├" + "─" * (width - 2) + "┤") + prompt_lines.extend( + f"│ {textwrap.shorten(f'{index + 1}. {choice}', width=width - 4, placeholder='…').ljust(width - 4)} │" + for index, choice in enumerate(choices) + ) + prompt_lines.append(footer) + echo_lines(prompt_lines) + + while True: + value = prompt(f"{Msg.ROLL_EDIT_SELECT_HINT}: ").strip() + if not value: + return current + if value.isdigit(): + index = int(value) - 1 + if 0 <= index < len(choices): + return choices[index] + if value in choices: + return value diff --git a/src/roll/app/flows/stock_lifecycle.py b/src/roll/app/flows/stock_lifecycle.py new file mode 100644 index 0000000..4565ef4 --- /dev/null +++ b/src/roll/app/flows/stock_lifecycle.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import typer +from prompt_toolkit import prompt +from prompt_toolkit.completion import FuzzyCompleter, WordCompleter + +from roll.app.archive.normalization import apply_keyword_vocab_fixes +from roll.app.workspace.roll_store import ( + RollMetadata, + load_roll_metadata, + save_roll_metadata, + update_roll_features, + update_roll_keywords, + update_roll_status, +) +from roll.app.workspace.stock_store import ( + StockItem, + add_to_stock, + load_stock, + remove_from_stock, + save_stock, +) +from roll.app.workspace.statuses import VALID_STATUSES +from roll.app.workspace.workspace import workspace_for +from roll.helpers.autocomplete import autocomplete_many_prompt, autocomplete_prompt +from roll.helpers.guards import require_archive, require_config +from roll.helpers.output import echo_lines +from roll.messages import Msg +from roll.app.flows.stock_edit import _choose_roll, _rolls + + +def add() -> None: + archive = require_archive(require_config()) + workspace = workspace_for(archive) + + film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) + quantity = typer.prompt(str(Msg.PROMPT_QUANTITY), type=int) + if quantity <= 0: + typer.echo(str(Msg.INVALID_QUANTITY)) + raise typer.Exit(code=1) + + try: + items = load_stock(workspace.stock_file) + save_stock(workspace.stock_file, add_to_stock(items, film, quantity)) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + + +def load(manual: bool) -> None: + archive = require_archive(require_config()) + workspace = workspace_for(archive) + + stock: list[StockItem] = [] + if not manual: + try: + stock = load_stock(workspace.stock_file) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + + if not stock: + typer.echo(str(Msg.STOCK_EMPTY_MANUAL)) + raise typer.Exit(code=1) + + selected = ( + _choose_stock_item(stock) if not manual else _choose_manual_film(workspace) + ) + camera = autocomplete_prompt( + str(Msg.PROMPT_CAMERA), workspace.dictionary("cameras") + ) + loaded_at = _prompt_loaded_at() + roll_folder = _create_roll_folder(archive, loaded_at) + roll_file = roll_folder / "roll.toml" + + if roll_file.exists(): + typer.echo(f"{Msg.ROLL_EXISTS} {roll_file}") + raise typer.Exit(code=1) + + try: + roll_folder.mkdir(parents=True, exist_ok=True) + save_roll_metadata( + roll_file, + RollMetadata( + status=VALID_STATUSES[0], + film=selected.film, + camera=camera, + loaded_at=loaded_at, + features=[], + keywords=[], + ), + ) + if not manual: + save_stock(workspace.stock_file, remove_from_stock(stock, selected.film, 1)) + features = autocomplete_many_prompt( + str(Msg.VOCAB_FEATURES), workspace.dictionary("features") + ) + if features: + update_roll_features(roll_file, features) + + tags = autocomplete_many_prompt( + str(Msg.VOCAB_KEYWORDS), workspace.dictionary("keywords") + ) + if tags: + update_roll_keywords(roll_file, tags) + apply_keyword_vocab_fixes(archive, tags) + except Exception: + _cleanup_failed_load(roll_folder, roll_file) + raise + + typer.echo(f"{Msg.LOAD_SUCCESS} {selected.film}") + + +def process(status: str, label: str) -> None: + archive = require_archive(require_config()) + loaded_rolls = [ + path + for path in _rolls(archive) + if load_roll_metadata(path / "roll.toml").status == "loaded" + ] + if not loaded_rolls: + typer.echo(str(Msg.NO_LOADED_ROLLS)) + raise typer.Exit(code=1) + + selected = _choose_roll(loaded_rolls) + try: + metadata = update_roll_status(selected / "roll.toml", status) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + + typer.echo(f"{label}: {metadata.film}") + + +def list_stock() -> None: + archive = require_archive(require_config()) + workspace = workspace_for(archive) + + try: + items = load_stock(workspace.stock_file) + except ValueError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) + + if not items: + typer.echo(str(Msg.STOCK_EMPTY)) + return + + echo_lines([Msg.STOCK_HEADER]) + for item in items: + typer.echo(f"{item.film:<20} ×{item.quantity}") + + +def _prompt_loaded_at() -> str: + value = typer.prompt(str(Msg.PROMPT_LOAD_DATE)) + normalized = value.strip().split("T", 1)[0].split(" ", 1)[0] + try: + return date.fromisoformat(normalized).isoformat() + except ValueError as exc: + typer.echo(str(Msg.INVALID_DATE)) + raise typer.Exit(code=1) from exc + + +def _create_roll_folder(archive: Path, loaded_at: str) -> Path: + loaded_date = date.fromisoformat(loaded_at) + return archive / loaded_date.strftime("%Y") / loaded_date.strftime("%m-%d") + + +def _choose_stock_item(items: list[StockItem]) -> StockItem: + labels = [f"{item.film} ×{item.quantity}" for item in items] + completer = FuzzyCompleter( + WordCompleter(labels, ignore_case=True, sentence=True, match_middle=True) + ) + + while True: + value = prompt( + str(Msg.PROMPT_MANUAL_FILM), + completer=completer, + complete_while_typing=True, + ).strip() + if not value: + continue + + selected = _resolve_stock_choice(items, value) + if selected is not None: + return selected + + typer.echo(str(Msg.CHOOSE_STOCK)) + + +def _choose_manual_film(workspace) -> StockItem: + film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) + return StockItem(film=film, quantity=1) + + +def _resolve_stock_choice(items: list[StockItem], candidate: str) -> StockItem | None: + normalized = _normalize_choice(candidate) + + exact_film_matches = [ + item for item in items if item.film.casefold() == candidate.casefold() + ] + if len(exact_film_matches) == 1: + return exact_film_matches[0] + + exact_label_matches = [ + item + for item in items + if f"{item.film} ×{item.quantity}".casefold() == candidate.casefold() + ] + if len(exact_label_matches) == 1: + return exact_label_matches[0] + + fuzzy_matches = [ + item for item in items if normalized in _normalize_choice(item.film) + ] + if len(fuzzy_matches) == 1: + return fuzzy_matches[0] + + return None + + +def _normalize_choice(value: str) -> str: + return "".join(ch for ch in value.casefold() if ch.isalnum()) + + +def _cleanup_failed_load(roll_folder: Path, roll_file: Path) -> None: + if roll_file.exists(): + roll_file.unlink() + if roll_folder.exists(): + try: + roll_folder.rmdir() + except OSError: + pass diff --git a/src/roll/app/root_commands.py b/src/roll/app/root_commands.py new file mode 100644 index 0000000..4663900 --- /dev/null +++ b/src/roll/app/root_commands.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + +import typer + +from roll.app.workspace.config import ( + CONFIG_DIR, + CONFIG_FILE, + Config, + load_config, + save_config, + set_lang, +) +from roll.helpers.formatting import highlight_cli_names +from roll.helpers.guards import require_config, require_directory +from roll.helpers.output import echo_lines, echo_section +from roll.messages import Msg +from roll.version import get_latest_version, get_version, is_outdated + + +def version() -> None: + current = get_version() + typer.echo(current) + latest = get_latest_version() + if latest and is_outdated(current=current, latest=latest): + typer.echo(f"New version available: {latest}. Run `rl update`.") + raise typer.Exit() + + +def init(archive: Path) -> None: + archive = require_directory(archive, Msg.ARCHIVE_MISSING) + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + try: + config = load_config() + archives = list(dict.fromkeys([*config.archives, archive])) + except FileNotFoundError: + archives = [archive] + save_config(Config(archives=archives)) + + from roll.app.workspace.workspace import workspace_for + + workspace_for(archive).ensure_structure() + + typer.echo(highlight_cli_names(Msg.CLI_INITIALIZED)) + echo_lines([f"Archive: {archive}", f"Config: {CONFIG_FILE}"]) + + +def update() -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--no-input", + "--upgrade", + "--force-reinstall", + "git+https://github.com/katrinio/roll.git@main", + ], + check=False, + ) + raise typer.Exit(code=result.returncode) + + +def config() -> None: + config = require_config() + echo_section( + Msg.CONFIG_HEADER, + [f"{Msg.ARCHIVE_HEADER} {archive}" for archive in config.archives], + ) + + +def config_lang(lang: str | None) -> None: + config = require_config() + + if lang is None: + typer.echo(f"{Msg.LANGUAGE} {config.lang}") + return + + normalized = lang.upper() + if normalized not in {"EN", "RU"}: + typer.echo(str(Msg.ALLOWED_VALUES)) + raise typer.Exit(code=1) + + updated = set_lang(normalized) + typer.echo(f"{Msg.LANGUAGE_SET_TO} {updated.lang}") diff --git a/src/roll/cli.py b/src/roll/cli.py index aec8a58..6fbbbd1 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -1,9 +1,5 @@ from __future__ import annotations -from pathlib import Path -import subprocess -import sys - import typer from roll.app.archive.commands import ( @@ -14,21 +10,14 @@ status as archive_status, vocab as archive_vocab, ) +from roll.app.root_commands import config as root_config +from roll.app.root_commands import config_lang as root_config_lang +from roll.app.root_commands import init as root_init +from roll.app.root_commands import update as root_update +from roll.app.root_commands import version as root_version from roll.app.flows.stock import app as stock_app from roll.app.flows.stock import edit_batch, edit_list_field, load as load_roll -from roll.app.workspace.config import ( - CONFIG_DIR, - CONFIG_FILE, - Config, - load_config, - save_config, - set_lang, -) -from roll.helpers.formatting import highlight_cli_names -from roll.helpers.guards import require_config, require_directory -from roll.helpers.output import echo_lines, echo_section from roll.messages import Msg, Normalize -from roll.version import get_latest_version, get_version, is_outdated app = typer.Typer(help=Msg.CLI_INITIALIZED) app.add_typer(stock_app, name="stock") @@ -54,53 +43,19 @@ def main( ), ) -> None: if version: - current = get_version() - typer.echo(current) - latest = get_latest_version() - if latest and is_outdated(current=current, latest=latest): - typer.echo(f"New version available: {latest}. Run `rl update`.") - raise typer.Exit() + root_version() if ctx.invoked_subcommand is not None: return @app.command("init") -def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: - archive = require_directory(archive, Msg.ARCHIVE_MISSING) - - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - try: - config = load_config() - archives = list(dict.fromkeys([*config.archives, archive])) - except FileNotFoundError: - archives = [archive] - save_config(Config(archives=archives)) - - workspace = archive # preserved for behavior-free local setup - from roll.app.workspace.workspace import workspace_for - - workspace_for(workspace).ensure_structure() - - typer.echo(highlight_cli_names(Msg.CLI_INITIALIZED)) - echo_lines([f"Archive: {archive}", f"Config: {CONFIG_FILE}"]) +def init(archive: typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: + root_init(archive) @app.command("update") def update() -> None: - result = subprocess.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--no-input", - "--upgrade", - "--force-reinstall", - "git+https://github.com/katrinio/roll.git@main", - ], - check=False, - ) - raise typer.Exit(code=result.returncode) + root_update() @config_app.callback(invoke_without_command=True) @@ -108,28 +63,12 @@ def config(ctx: typer.Context) -> None: if ctx.invoked_subcommand is not None: return - config = require_config() - echo_section( - Msg.CONFIG_HEADER, - [f"{Msg.ARCHIVE_HEADER} {archive}" for archive in config.archives], - ) + root_config() @config_app.command("lang") def config_lang(lang: str | None = typer.Argument(None, help=Msg.LANGUAGE)) -> None: - config = require_config() - - if lang is None: - typer.echo(f"{Msg.LANGUAGE} {config.lang}") - return - - normalized = lang.upper() - if normalized not in {"EN", "RU"}: - typer.echo(str(Msg.ALLOWED_VALUES)) - raise typer.Exit(code=1) - - updated = set_lang(normalized) - typer.echo(f"{Msg.LANGUAGE_SET_TO} {updated.lang}") + root_config_lang(lang) @app.command("scan") diff --git a/tests/test_storage.py b/tests/test_storage.py index d2c5546..feac51e 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -21,7 +21,8 @@ ) from roll.app.archive.normalization import normalize_keywords_in_archive from roll.app.archive.search import search_rolls_by_filters -from roll.app.flows.stock import _format_roll_label, _rolls, _prompt_roll_metadata +from roll.app.flows.stock import _format_roll_label, _rolls +from roll.app.flows.stock_edit import _prompt_roll_metadata from roll.app.archive.batch import batch_rolls from roll.app.workspace.workspace import workspace_for @@ -298,7 +299,7 @@ def fake_prompt(*args, **kwargs): return next(prompts) with ( - patch("roll.app.flows.stock.prompt", side_effect=fake_prompt), + patch("roll.app.flows.stock_edit.prompt", side_effect=fake_prompt), ): updated = _prompt_roll_metadata( archive, archive / "2025/10-19/roll.toml", metadata From 920aa057e0cd74a38ff6fab58f9b75f027508a76 Mon Sep 17 00:00:00 2001 From: Katrin Torsunova Date: Sun, 5 Jul 2026 17:07:49 +0200 Subject: [PATCH 20/20] support tests --- src/roll/cli.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/roll/cli.py b/src/roll/cli.py index 6fbbbd1..f0af1d2 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import typer from roll.app.archive.commands import ( @@ -10,6 +12,7 @@ status as archive_status, vocab as archive_vocab, ) +from roll.app.archive.batch import process_archives from roll.app.root_commands import config as root_config from roll.app.root_commands import config_lang as root_config_lang from roll.app.root_commands import init as root_init @@ -17,6 +20,7 @@ from roll.app.root_commands import version as root_version from roll.app.flows.stock import app as stock_app from roll.app.flows.stock import edit_batch, edit_list_field, load as load_roll +from roll.helpers.guards import require_config from roll.messages import Msg, Normalize app = typer.Typer(help=Msg.CLI_INITIALIZED) @@ -49,7 +53,7 @@ def main( @app.command("init") -def init(archive: typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: +def init(archive: Path = typer.Argument(..., help=Msg.ARCHIVE_HEADER)) -> None: root_init(archive) @@ -154,6 +158,11 @@ def batch( edit_batch(year, film, camera, status, set_status, set_camera, add_feature, add_tag) +@batch_app.command("process") +def batch_process() -> None: + process_archives(require_config().archives) + + @app.command("normalize") def normalize( tags: bool = typer.Option(False, "--tags", help=Msg.TAGS_NORMALIZED),