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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to this project are documented in this file. The format is b

## [Unreleased]

### Added

- Reproducible title and tag generation with an optional local random seed.
- Cross-platform-safe custom prefixes for image rename previews, applies, JSON output, and undo
manifests.

## [0.1.0] - 2026-08-07

### Added
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ or normalize a folder of image names from an interactive menu or scriptable subc
| --- | --- | --- |
| Draft a title | `creator workflow` | `10 Creator Workflow Tips Every Beginner Should Know` |
| Pick three tags | `--count 3` | `automation`, `productivity`, `tutorial` |
| Preview image renames | `cover.jpg`, `thumbnail.png` | `image_1.jpg`, `image_2.png` — no files changed |
| Preview image renames | `--prefix campaign` | `campaign_1.jpg`, `campaign_2.png` — no files changed |

See the [demo transcript and reproduction steps](https://github.com/HoungDev/creator-toolkit-cli/blob/main/docs/demo.md).

## Features

- Generate title ideas from a keyword.
- Select unique tags from a curated list.
- Preview, apply, and undo deterministic JPEG and PNG filename changes.
- Preview, apply, and undo deterministic JPEG and PNG filename changes with a safe custom prefix.
- Use automation-friendly subcommands or an interactive menu.

## Requirements
Expand Down Expand Up @@ -76,7 +76,7 @@ Add `--json` to any scriptable subcommand for automation-friendly output:
```bash
creator-toolkit title "video editing" --seed 2026 --json
creator-toolkit tags --count 5 --seed 2026 --json
creator-toolkit rename ./images --dry-run --json
creator-toolkit rename ./images --prefix campaign --dry-run --json
```

See the [JSON output reference](https://github.com/HoungDev/creator-toolkit-cli/blob/main/docs/json-output.md)
Expand All @@ -85,28 +85,33 @@ for response schemas, exit codes, error handling, and automation examples.
Preview changes to `.jpg`, `.jpeg`, and `.png` files without touching the directory:

```bash
creator-toolkit rename ./images --dry-run
creator-toolkit rename ./images --prefix campaign --dry-run
```

Apply the displayed plan after an interactive confirmation:
This produces names such as `campaign_1.jpg`. Omit `--prefix` to retain the default `image_1.jpg`
format. Unsafe filename components and cross-platform reserved names are rejected before any file
or manifest changes.

Apply the displayed plan after an interactive confirmation, using the same prefix:

```bash
creator-toolkit rename ./images
creator-toolkit rename ./images --prefix campaign
```

For non-interactive automation, pass `--yes`. Every applied CLI rename writes a unique JSON
manifest in the image directory:

```bash
creator-toolkit rename ./images --yes
creator-toolkit rename ./images --prefix campaign --yes
creator-toolkit undo ./images/.creator-toolkit-renames-20260807T120000Z-a1b2c3d4.json --dry-run
creator-toolkit undo ./images/.creator-toolkit-renames-20260807T120000Z-a1b2c3d4.json
```

The rename command orders inputs by filename and produces names such as `image_1.jpg`. Operations
use a two-phase rename to avoid collisions and roll back when a filesystem step fails. Undo
manifests reject path traversal and refuse to overwrite files created after the rename. Other
files are left untouched, but a separate backup is still recommended for valuable assets.
The rename command orders inputs by filename and uses a two-phase rename to avoid collisions and
roll back when a filesystem step fails. Undo manifests store concrete operations, so undo does not
need the original prefix. They reject path traversal and refuse to overwrite files created after
the rename. Other files are left untouched, but a separate backup is still recommended for
valuable assets.

Run without a subcommand to use the interactive menu:

Expand Down
Binary file modified docs/assets/creator-toolkit-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ automation
productivity
tutorial

$ creator-toolkit rename demo-images --dry-run
$ creator-toolkit rename demo-images --prefix campaign --dry-run
Planned 2 image(s).
cover.jpg -> image_1.jpg
thumbnail.png -> image_2.png
cover.jpg -> campaign_1.jpg
thumbnail.png -> campaign_2.png
```

The final command is a preview. It does not change either sample filename.
8 changes: 6 additions & 2 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ The fields inside `result` depend on the command:
Each operation contains `source` and `destination` filenames. Rename statuses are `preview`,
`unchanged`, or `applied`; undo statuses are `preview` or `restored`.

The optional `rename --prefix` changes destination values only, so the JSON schema remains version
1. Applied manifests store those concrete destinations and can be undone without repeating the
prefix.

## Reproducible generation

Pass the same integer `--seed` to `title` or `tags` to reproduce the same result for the same
Expand Down Expand Up @@ -89,12 +93,12 @@ print(tags)
Inspect a rename plan from PowerShell without modifying files:

```powershell
$plan = creator-toolkit rename .\images --dry-run --json | ConvertFrom-Json
$plan = creator-toolkit rename .\images --prefix campaign --dry-run --json | ConvertFrom-Json
$plan.result.operations | Format-Table source, destination
```

Apply a previously reviewed rename plan in CI:

```bash
creator-toolkit rename ./images --yes --json > rename-result.json
creator-toolkit rename ./images --prefix campaign --yes --json > rename-result.json
```
6 changes: 4 additions & 2 deletions scripts/render_readme_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ def _capture_scenes() -> list[tuple[str, list[Line]]]:

title = _run_cli(["title", "creator workflow", "--seed", str(SEED)], cwd=workspace)
tags = _run_cli(["tags", "--count", "3", "--seed", str(SEED)], cwd=workspace)
rename = _run_cli(["rename", "demo-images", "--dry-run"], cwd=workspace)
rename = _run_cli(
["rename", "demo-images", "--prefix", "campaign", "--dry-run"], cwd=workspace
)

return [
(
Expand Down Expand Up @@ -108,7 +110,7 @@ def _capture_scenes() -> list[tuple[str, list[Line]]]:
("muted", " cover.jpg"),
("muted", " thumbnail.png"),
("blank", ""),
("prompt", "$ creator-toolkit rename demo-images --dry-run"),
("prompt", "$ creator-toolkit rename demo-images --prefix campaign --dry-run"),
*(("output", line) for line in rename),
("blank", ""),
("success", "Preview only - no files changed."),
Expand Down
15 changes: 13 additions & 2 deletions src/creator_toolkit/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from typing import Any

from creator_toolkit.rename_images import (
DEFAULT_RENAME_PREFIX,
InvalidPrefixError,
ManifestError,
RenameOperation,
generate_manifest_path,
Expand Down Expand Up @@ -45,6 +47,11 @@ def build_parser() -> argparse.ArgumentParser:
rename_parser.add_argument("folder")
rename_parser.add_argument("--dry-run", action="store_true", help="preview without renaming")
rename_parser.add_argument("--yes", action="store_true", help="apply without confirmation")
rename_parser.add_argument(
"--prefix",
default=DEFAULT_RENAME_PREFIX,
help=f"destination filename prefix (default: {DEFAULT_RENAME_PREFIX})",
)
rename_parser.add_argument("--manifest", type=Path, help="custom recovery manifest path")
rename_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")

Expand Down Expand Up @@ -118,13 +125,14 @@ def run_rename(
assume_yes: bool,
manifest_path: str | Path | None,
json_output: bool,
prefix: str = DEFAULT_RENAME_PREFIX,
) -> int:
"""Preview and optionally apply an image rename plan."""
try:
if json_output and not (dry_run or assume_yes):
raise UsageError("JSON rename requires --dry-run or --yes.")

operations = plan_image_renames(folder)
operations = plan_image_renames(folder, prefix=prefix)
if not json_output:
print_operations("Planned", operations)
if dry_run:
Expand Down Expand Up @@ -158,7 +166,7 @@ def run_rename(
manifest = (
Path(manifest_path) if manifest_path is not None else generate_manifest_path(folder)
)
applied = rename_images(folder, manifest_path=manifest)
applied = rename_images(folder, manifest_path=manifest, prefix=prefix)
if json_output:
_emit_success(
"rename",
Expand All @@ -179,6 +187,7 @@ def run_rename(
FileNotFoundError,
NotADirectoryError,
FileExistsError,
InvalidPrefixError,
ManifestError,
OSError,
RuntimeError,
Expand Down Expand Up @@ -254,6 +263,7 @@ def interactive_menu() -> int:
assume_yes=False,
manifest_path=None,
json_output=False,
prefix=DEFAULT_RENAME_PREFIX,
)
else:
print("Invalid option")
Expand Down Expand Up @@ -287,6 +297,7 @@ def main(argv: Sequence[str] | None = None) -> int:
assume_yes=args.yes,
manifest_path=args.manifest,
json_output=args.json,
prefix=args.prefix,
)
elif args.command == "undo":
return run_undo(
Expand Down
79 changes: 73 additions & 6 deletions src/creator_toolkit/rename_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@

SUPPORTED_EXTENSIONS = {".jpeg", ".jpg", ".png"}
MANIFEST_VERSION = 1
DEFAULT_RENAME_PREFIX = "image"
MAX_FILENAME_COMPONENT_LENGTH = 255

_WINDOWS_INVALID_CHARACTERS = frozenset('<>:"/\\|?*')
_WINDOWS_RESERVED_NAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
}

RenameOperation = tuple[Path, Path]

Expand All @@ -14,6 +32,50 @@ class ManifestError(ValueError):
"""Raised when a rename manifest is invalid or unsafe."""


class InvalidPrefixError(ValueError):
"""Raised when a rename prefix is unsafe as a filename component."""


def _component_lengths(value: str) -> tuple[int, int]:
try:
return len(value.encode("utf-8")), len(value.encode("utf-16-le")) // 2
except UnicodeEncodeError as error:
raise InvalidPrefixError("Prefix contains unsupported Unicode characters.") from error


def _validate_component_length(value: str, *, generated: bool = False) -> None:
utf8_length, utf16_length = _component_lengths(value)
if max(utf8_length, utf16_length) <= MAX_FILENAME_COMPONENT_LENGTH:
return

subject = "Generated filename" if generated else "Prefix"
raise InvalidPrefixError(
f"{subject} exceeds the cross-platform filename limit of "
f"{MAX_FILENAME_COMPONENT_LENGTH} characters or bytes."
)


def validate_rename_prefix(prefix: str) -> str:
"""Return ``prefix`` when it is safe on Windows, macOS, and Linux."""
if not isinstance(prefix, str) or not prefix or not prefix.strip():
raise InvalidPrefixError("Prefix must not be empty.")
if prefix != prefix.strip():
raise InvalidPrefixError("Prefix must not start or end with whitespace.")
if prefix in {".", ".."}:
raise InvalidPrefixError("Prefix must be a single filename component.")
if prefix.endswith("."):
raise InvalidPrefixError("Prefix must not end with a period.")
if any(character in _WINDOWS_INVALID_CHARACTERS or ord(character) < 32 for character in prefix):
raise InvalidPrefixError("Prefix contains characters that are unsafe in filenames.")

windows_stem = prefix.split(".", maxsplit=1)[0].upper()
if windows_stem in _WINDOWS_RESERVED_NAMES:
raise InvalidPrefixError("Prefix is a reserved filename on Windows.")

_validate_component_length(prefix)
return prefix


def _validate_directory(folder: str | Path) -> Path:
path = Path(folder)
if not path.exists():
Expand All @@ -23,8 +85,11 @@ def _validate_directory(folder: str | Path) -> Path:
return path


def plan_image_renames(folder: str | Path) -> list[RenameOperation]:
def plan_image_renames(
folder: str | Path, *, prefix: str = DEFAULT_RENAME_PREFIX
) -> list[RenameOperation]:
"""Return deterministic image rename operations without changing files."""
prefix = validate_rename_prefix(prefix)
path = _validate_directory(folder)
images = sorted(
(
Expand All @@ -34,10 +99,11 @@ def plan_image_renames(folder: str | Path) -> list[RenameOperation]:
),
key=lambda item: item.name.casefold(),
)
operations = [
(image, path / f"image_{index}{image.suffix.lower()}")
for index, image in enumerate(images, start=1)
]
operations: list[RenameOperation] = []
for index, image in enumerate(images, start=1):
destination_name = f"{prefix}_{index}{image.suffix.lower()}"
_validate_component_length(destination_name, generated=True)
operations.append((image, path / destination_name))
return [(source, destination) for source, destination in operations if source != destination]


Expand Down Expand Up @@ -154,10 +220,11 @@ def rename_images(
*,
dry_run: bool = False,
manifest_path: str | Path | None = None,
prefix: str = DEFAULT_RENAME_PREFIX,
) -> list[RenameOperation]:
"""Plan or apply deterministic image renames with optional recovery metadata."""
path = _validate_directory(folder)
operations = plan_image_renames(path)
operations = plan_image_renames(path, prefix=prefix)
if dry_run or not operations:
return operations

Expand Down
5 changes: 4 additions & 1 deletion tests/test_cli_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ def test_installed_cli_rename_preview_is_non_mutating(tmp_path):
image = tmp_path / "photo.jpg"
image.write_bytes(b"photo")

result = run_cli("rename", str(tmp_path), "--dry-run", "--json")
result = run_cli("rename", str(tmp_path), "--prefix", "campaign", "--dry-run", "--json")

assert result.returncode == 0
assert result.stderr == ""
payload = json.loads(result.stdout)
assert payload["status"] == "preview"
assert payload["result"]["operations"] == [
{"source": "photo.jpg", "destination": "campaign_1.jpg"}
]
assert image.exists()
7 changes: 5 additions & 2 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,17 @@ def test_installed_cli_rename_preview_does_not_change_files(tmp_path):
image = tmp_path / "photo.jpg"
image.write_bytes(b"photo")

result = run_cli("rename", str(tmp_path), "--dry-run", "--json")
result = run_cli("rename", str(tmp_path), "--prefix", "campaign", "--dry-run", "--json")

assert_success(result)
assert result.stderr == ""
payload = json.loads(result.stdout)
assert payload["status"] == "preview"
assert payload["result"]["operations"] == [
{"source": "photo.jpg", "destination": "campaign_1.jpg"}
]
assert image.read_bytes() == b"photo"
assert not (tmp_path / "image_1.jpg").exists()
assert not (tmp_path / "campaign_1.jpg").exists()


def test_installed_cli_failure_preserves_exit_code_and_diagnostics(tmp_path):
Expand Down
Loading