diff --git a/docs/architecture.md b/docs/architecture.md index b77c55e..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 @@ -120,6 +121,7 @@ rl doctor --fix - the archive is self-contained; - user edits only what the filesystem cannot infer; - normalization and doctor fixes build a plan before changing anything; +- `doctor` reports issues first and lists safe fixes in a separate block; - vocabularies stay plain text and grow automatically; - dev-facing diagnostics stay in English; only user-facing UI is localized; - English is the default UI language. 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/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/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/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/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/diagnostics.py b/src/roll/app/diagnostics/diagnostics.py index 244df40..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,12 +69,22 @@ def run_doctor(config: Config) -> DoctorReport: issues=issues, fixable=fixable, keyword_vocab_fixes=keyword_vocab_fixes, + keyword_vocab_normalize=keyword_vocab_normalize, missing_rolls=missing_rolls, missing_roll_count=missing_roll_count, unindexed_folders=unindexed_folders, ) for archive in config.archives: + if archive.exists() and not archive.is_dir(): + issues.append( + DoctorIssue( + DoctorText.ERROR, + f"{Doctor.ARCHIVE_NOT_DIRECTORY} {archive}", + archive, + ) + ) + continue archive_issues, archive_missing_rolls, archive_unindexed = _check_archive( archive ) @@ -86,12 +98,16 @@ def run_doctor(config: Config) -> DoctorReport: for rule in plan.rules ) keyword_vocab_fixes.extend(collect_keyword_vocab_fixes(archive)) - issues.extend(_check_keywords_vocab(archive)) + keyword_issues = _check_keywords_vocab(archive) + if keyword_issues: + keyword_vocab_normalize.append(archive) + issues.extend(keyword_issues) return DoctorReport( issues=issues, fixable=fixable, keyword_vocab_fixes=keyword_vocab_fixes, + keyword_vocab_normalize=keyword_vocab_normalize, missing_rolls=missing_rolls, missing_roll_count=missing_roll_count, unindexed_folders=unindexed_folders, diff --git a/src/roll/app/diagnostics/doctor_output.py b/src/roll/app/diagnostics/doctor_output.py index 2dbb297..bec9b7d 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 @@ -62,7 +63,7 @@ def render_doctor(fix: bool = False, verbose: bool = False) -> int: report = run_doctor(config) if not report.issues and not report.missing_rolls: - echo(Doctor.OK) + echo(str(Doctor.OK)) return 0 global_issues = [issue for issue in report.issues if issue.archive is None] @@ -143,25 +144,26 @@ def _render_fix_summaries( ) -> None: fixers: list[tuple[str, list[str], Callable[[list[Path], bool], None]]] = [] if report.fixable: - fixers.append( - (Msg.DOCTOR_CAN_FIX, report.fixable, _apply_normalization_fixes, None) - ) + fixers.append((Msg.DOCTOR_CAN_FIX, report.fixable, _apply_normalization_fixes)) if report.keyword_vocab_fixes: fixers.append( (Msg.DOCTOR_CAN_ADD, report.keyword_vocab_fixes, _apply_keyword_fixes) ) + if report.keyword_vocab_normalize: + fixers.append( + ( + Msg.DOCTOR_CAN_FIX, + [str(path) for path in report.keyword_vocab_normalize], + _apply_keyword_normalization, + ) + ) if fix and any( issue.message.startswith(str(Doctor.LANGUAGE_INVALID)) for issue in report.issues ): fixers.append( - ( - Msg.DOCTOR_CAN_FIX, - [str(Doctor.LANGUAGE_INVALID)], - _apply_language_fix, - None, - ) + (Msg.DOCTOR_CAN_FIX, [str(Doctor.LANGUAGE_INVALID)], _apply_language_fix) ) if fixers: @@ -196,7 +198,7 @@ def _apply_language_fix(archives: list[Path], verbose: bool) -> None: def _apply_normalization_fixes(archives: list[Path], verbose: bool) -> None: plans = [build_safe_rename_plan(archive) for archive in archives] apply_normalization_plans(plans) - echo(Msg.DOCTOR_FIXES_APPLIED) + echo("Fixes applied.") if verbose: for plan in plans: if plan.rules: @@ -212,7 +214,17 @@ def _apply_keyword_fixes(archives: list[Path], verbose: bool) -> None: if applied and verbose: echo_lines([""]) echo(f" {applied}") - echo(Msg.DOCTOR_KEYWORDS_APPLIED) + echo("Keywords fixes applied.") + + +def _apply_keyword_normalization(archives: list[Path], verbose: bool) -> None: + for archive in archives: + touched = normalize_keywords_in_archive(archive) + if verbose and touched: + echo_lines([""]) + for path in touched: + echo(f" {path}") + echo("Fixes applied.") def _append_group( @@ -245,7 +257,8 @@ def _echo_block(prefix: str, order: list[str], groups: dict[str, list[str]]) -> echo(f" {_group_title(title)} {len(items)}") else: echo(f" {items[0]}") - echo_lines([f" {item}" for item in items]) + if len(items) > 1: + echo_lines([f" {item}" for item in items]) def _group_title(title: str) -> str: diff --git a/src/roll/app/flows/stock.py b/src/roll/app/flows/stock.py index f22e97b..9c1c876 100644 --- a/src/roll/app/flows/stock.py +++ b/src/roll/app/flows/stock.py @@ -2,6 +2,7 @@ from datetime import date from pathlib import Path +import textwrap import typer from prompt_toolkit import prompt @@ -25,11 +26,8 @@ ) 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, - choice_prompt, -) +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 from roll.messages import Msg @@ -42,10 +40,10 @@ 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) + typer.echo(str(Msg.INVALID_QUANTITY)) raise typer.Exit(code=1) try: @@ -74,13 +72,15 @@ 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 = ( _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" @@ -105,19 +105,22 @@ 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) except Exception: _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") @@ -142,7 +145,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]) @@ -150,13 +153,28 @@ def list_stock() -> None: 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}") + + 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() except ValueError as exc: - typer.echo(Msg.INVALID_DATE) + typer.echo(str(Msg.INVALID_DATE)) raise typer.Exit(code=1) from exc @@ -173,7 +191,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 @@ -182,11 +202,11 @@ 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: - film = autocomplete_prompt("Film", workspace.dictionary("films")) + film = autocomplete_prompt(str(Msg.PROMPT_FILM), workspace.dictionary("films")) return StockItem(film=film, quantity=1) @@ -220,23 +240,35 @@ def _normalize_choice(value: str) -> str: return "".join(ch for ch in value.casefold() if ch.isalnum()) -def _loaded_rolls(archive: Path) -> list[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: + 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: - 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(Msg.NO_LOADED_ROLLS) + typer.echo(str(Msg.NO_LOADED_ROLLS)) raise typer.Exit(code=1) selected = _choose_roll(loaded_rolls) @@ -250,14 +282,160 @@ 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: + 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 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("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: 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/cli.py b/src/roll/cli.py index 44c7d41..6128630 100644 --- a/src/roll/cli.py +++ b/src/roll/cli.py @@ -25,12 +25,19 @@ ) from roll.app.archive.normalization import ( apply_normalization_plans, + apply_keyword_vocab_fixes, 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 @@ -86,7 +93,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) @@ -137,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) @@ -159,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("") @@ -219,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) @@ -268,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) @@ -278,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) @@ -294,23 +303,42 @@ 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) + 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: + 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) for archive in config.archives] + plans = [build_normalization_plan(archive)] total_rules, has_changes = render_normalization_plans(plans) if not has_changes: return @@ -340,6 +368,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(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 _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(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( + 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 4f23961..47663a3 100644 --- a/src/roll/helpers/guards.py +++ b/src/roll/helpers/guards.py @@ -29,4 +29,28 @@ 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] + + +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/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/src/roll/messages/cli.py b/src/roll/messages/cli.py index 168f87b..0c680a8 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( @@ -107,6 +109,53 @@ 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_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}", + "{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", @@ -132,6 +181,31 @@ 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?", + ) + 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/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/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 6e5a2f5..1efc1ae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,11 +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, require_current_archive from roll.messages import Msg @@ -133,6 +136,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) @@ -178,6 +202,37 @@ 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 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..a4fe385 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -3,6 +3,10 @@ import tempfile from pathlib import Path import unittest +from contextlib import redirect_stdout +from io import StringIO +from unittest.mock import patch +import os from roll.app.workspace.config import Config from roll.app.diagnostics.diagnostics import run_doctor @@ -10,12 +14,21 @@ 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.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 +from roll.helpers.output import echo_lines +from roll.messages import Normalize class NormalizationTests(unittest.TestCase): @@ -48,6 +61,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 +154,85 @@ 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_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_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) @@ -292,6 +402,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 diff --git a/tests/test_storage.py b/tests/test_storage.py index 86e72d5..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,6 +20,8 @@ save_stock, ) 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.workspace.workspace import workspace_for class StockStoreTests(unittest.TestCase): @@ -157,3 +160,124 @@ 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_rolls_include_all_rolls_with_metadata(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(_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" + ) + + 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")