tidybatch 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TidyBatch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidybatch
3
+ Version: 1.1.1
4
+ Summary: Bulk rename files safely: dry-run preview, collision-proof two-phase renames, one-command undo.
5
+ Author: TidyBatch
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tidybatch/tidybatch
8
+ Project-URL: Issues, https://github.com/tidybatch/tidybatch/issues
9
+ Project-URL: TidyBatch Pro, https://whop.com/tidybatch/bulk-file-renamer/
10
+ Keywords: rename,bulk-rename,batch-rename,files,cli,undo,dry-run
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Filesystems
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # TidyBatch
23
+
24
+ **Bulk rename files without the fear.** Preview every change, apply it, and undo it with one command.
25
+
26
+ ![TidyBatch demo: preview, apply, undo](https://raw.githubusercontent.com/tidybatch/tidybatch/main/docs/demo.gif)
27
+
28
+ [![CI](https://github.com/tidybatch/tidybatch/actions/workflows/ci.yml/badge.svg)](https://github.com/tidybatch/tidybatch/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/tidybatch)](https://pypi.org/project/tidybatch/)
30
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)
31
+ ![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen)
32
+ ![License: MIT](https://img.shields.io/badge/license-MIT-lightgrey)
33
+
34
+ Most bulk-rename scripts have a silent bug: renumber `1.txt 2.txt 3.txt` → `2.txt 3.txt 4.txt` and the first `mv` overwrites the original `2.txt`. TidyBatch is built around not doing that:
35
+
36
+ - **Dry run by default.** Nothing changes until you add `--apply`.
37
+ - **One-command undo.** Every applied run is journaled; `tidybatch undo <folder>` puts every file back.
38
+ - **Never overwrites.** Two-phase renames make chains and swaps safe; clashes get `_1`, `_2`, …
39
+ - **Case-insensitive collision checks**, because Windows and macOS treat `Photo.JPG` and `photo.jpg` as the same file.
40
+ - **One file, standard library only.** No dependencies: you can read all of it before you trust it with your photos.
41
+
42
+ New to bulk renaming? Read the tested guides at **[tidybatch.github.io/guides](https://tidybatch.github.io/guides/)**: how to rename safely on any OS, how to undo a bulk rename, and how to add dates to file names.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pipx install tidybatch
48
+ # or: uv tool install tidybatch
49
+ # or: pip install tidybatch
50
+ # or just download tidybatch.py and run: python3 tidybatch.py --help
51
+ ```
52
+
53
+ Try it on a folder without installing anything permanent (it's a preview, so nothing changes):
54
+
55
+ ```bash
56
+ uvx tidybatch rename ~/Downloads --slug --lower
57
+ ```
58
+
59
+ ## Recipes
60
+
61
+ ```bash
62
+ # Clean names: no accents, spaces or symbols, all lowercase
63
+ tidybatch rename ~/Downloads --slug --lower
64
+
65
+ # Number photos in date order: holiday_001.jpg, holiday_002.jpg, …
66
+ tidybatch rename ./photos -g "*.jpg" --sort date -t "holiday_{n:03}{ext}"
67
+
68
+ # Put the file date in front: 2024-08-12_invoice.pdf
69
+ tidybatch rename ./invoices -t "{date:%Y-%m-%d}_{stem}{ext}"
70
+
71
+ # Name photos by when they were TAKEN (EXIF): 2023-07-14_182205.jpg
72
+ tidybatch rename ./camera -t "{taken:%Y-%m-%d_%H%M%S}{ext}"
73
+
74
+ # Regex with groups: IMG_2024_0001.jpg -> 2024-photo-0001.jpg
75
+ tidybatch rename . --find "IMG_(\d{4})_(\d+)" --replace "\1-photo-\2"
76
+
77
+ # Happy with the preview? Run it for real, then change your mind
78
+ tidybatch rename ~/Downloads --slug --lower --apply
79
+ tidybatch undo ~/Downloads
80
+ ```
81
+
82
+ **Template fields:** `{stem}` `{ext}` `{n}` (counter, e.g. `{n:03}`) `{date}` (modification date, e.g. `{date:%Y%m%d}`) `{taken}` (photo **date taken** from EXIF, falling back to `{date}`) `{today}` `{parent}` `{size_kb}`
83
+
84
+ **Options:** `-r` sub-folders · `-g "*.jpg"` filter · `--hidden` include hidden files · `--sort name|date|taken|size` · `--start N` · `--apply`
85
+
86
+ **Photo dates:** `{taken}` reads EXIF with the standard library only, with no Pillow or exiftool needed. Every CI run checks it against ExifTool on ExifTool's own sample images (JPEG, HEIC, TIFF, DNG, CR2, CR3, NEF, RW2, RAF: same date for every file, [dev/verify_exif.py](https://github.com/tidybatch/tidybatch/blob/main/dev/verify_exif.py)) and feeds it 20,000 corrupted files, which must never crash or hang it ([dev/fuzz_exif.py](https://github.com/tidybatch/tidybatch/blob/main/dev/fuzz_exif.py)). Dates stored only in XMP/IPTC aren't read; those files fall back to the modification date.
87
+
88
+ ## How it stays safe
89
+
90
+ 1. **Plan:** compute every new name without touching the disk, then resolve collisions. A destination is free only if nobody else claimed it *and* whatever is there now is itself moving away.
91
+ 2. **Phase 1:** every file moves to a temporary name in the same folder.
92
+ 3. **Phase 2:** every temporary file moves to its final name, refusing to overwrite anything that appeared in the meantime.
93
+ 4. **Journal:** `<folder>/.tidybatch/journal-*.json` records each completed move, even if the run is interrupted, so `undo` can replay it backwards.
94
+
95
+ ## How it's made
96
+
97
+ Most of the code was written with an AI coding agent. The behaviour that protects your files (two-phase moves, collision checks, undo) is pinned down by the test suite, which runs on Linux, macOS and Windows on every push.
98
+
99
+ ## TidyBatch Pro
100
+
101
+ This repository is **TidyBatch Core** (rename + undo), free and MIT-licensed. **[TidyBatch Pro](https://whop.com/tidybatch/bulk-file-renamer/?utm_source=github&utm_campaign=tidybatch)** adds:
102
+
103
+ - `organize`: sort a folder into Images / Documents / Videos…, by year/month (modified **or photo date taken**), or by extension
104
+ - `dedupe`: find exact duplicates by content (SHA-256), keep the oldest, move the rest aside (never deleted)
105
+ - ready-to-run apps for **Windows and Linux** (no Python needed), plus free 1.x updates
106
+
107
+ Buying Pro also funds the free version. Thank you!
108
+
109
+ ## Contributing
110
+
111
+ Bug reports with a reproducible folder layout are gold. Run the tests with:
112
+
113
+ ```bash
114
+ python -m unittest discover -s tests
115
+
116
+ # photo-date checks (needs perl and an ExifTool checkout)
117
+ git clone --depth 1 https://github.com/exiftool/exiftool /tmp/exiftool
118
+ python dev/verify_exif.py /tmp/exiftool
119
+ python dev/fuzz_exif.py /tmp/exiftool
120
+ ```
121
+
122
+ ## License
123
+
124
+ [MIT](https://github.com/tidybatch/tidybatch/blob/main/LICENSE)
@@ -0,0 +1,103 @@
1
+ # TidyBatch
2
+
3
+ **Bulk rename files without the fear.** Preview every change, apply it, and undo it with one command.
4
+
5
+ ![TidyBatch demo: preview, apply, undo](https://raw.githubusercontent.com/tidybatch/tidybatch/main/docs/demo.gif)
6
+
7
+ [![CI](https://github.com/tidybatch/tidybatch/actions/workflows/ci.yml/badge.svg)](https://github.com/tidybatch/tidybatch/actions/workflows/ci.yml)
8
+ [![PyPI](https://img.shields.io/pypi/v/tidybatch)](https://pypi.org/project/tidybatch/)
9
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)
10
+ ![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen)
11
+ ![License: MIT](https://img.shields.io/badge/license-MIT-lightgrey)
12
+
13
+ Most bulk-rename scripts have a silent bug: renumber `1.txt 2.txt 3.txt` → `2.txt 3.txt 4.txt` and the first `mv` overwrites the original `2.txt`. TidyBatch is built around not doing that:
14
+
15
+ - **Dry run by default.** Nothing changes until you add `--apply`.
16
+ - **One-command undo.** Every applied run is journaled; `tidybatch undo <folder>` puts every file back.
17
+ - **Never overwrites.** Two-phase renames make chains and swaps safe; clashes get `_1`, `_2`, …
18
+ - **Case-insensitive collision checks**, because Windows and macOS treat `Photo.JPG` and `photo.jpg` as the same file.
19
+ - **One file, standard library only.** No dependencies: you can read all of it before you trust it with your photos.
20
+
21
+ New to bulk renaming? Read the tested guides at **[tidybatch.github.io/guides](https://tidybatch.github.io/guides/)**: how to rename safely on any OS, how to undo a bulk rename, and how to add dates to file names.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pipx install tidybatch
27
+ # or: uv tool install tidybatch
28
+ # or: pip install tidybatch
29
+ # or just download tidybatch.py and run: python3 tidybatch.py --help
30
+ ```
31
+
32
+ Try it on a folder without installing anything permanent (it's a preview, so nothing changes):
33
+
34
+ ```bash
35
+ uvx tidybatch rename ~/Downloads --slug --lower
36
+ ```
37
+
38
+ ## Recipes
39
+
40
+ ```bash
41
+ # Clean names: no accents, spaces or symbols, all lowercase
42
+ tidybatch rename ~/Downloads --slug --lower
43
+
44
+ # Number photos in date order: holiday_001.jpg, holiday_002.jpg, …
45
+ tidybatch rename ./photos -g "*.jpg" --sort date -t "holiday_{n:03}{ext}"
46
+
47
+ # Put the file date in front: 2024-08-12_invoice.pdf
48
+ tidybatch rename ./invoices -t "{date:%Y-%m-%d}_{stem}{ext}"
49
+
50
+ # Name photos by when they were TAKEN (EXIF): 2023-07-14_182205.jpg
51
+ tidybatch rename ./camera -t "{taken:%Y-%m-%d_%H%M%S}{ext}"
52
+
53
+ # Regex with groups: IMG_2024_0001.jpg -> 2024-photo-0001.jpg
54
+ tidybatch rename . --find "IMG_(\d{4})_(\d+)" --replace "\1-photo-\2"
55
+
56
+ # Happy with the preview? Run it for real, then change your mind
57
+ tidybatch rename ~/Downloads --slug --lower --apply
58
+ tidybatch undo ~/Downloads
59
+ ```
60
+
61
+ **Template fields:** `{stem}` `{ext}` `{n}` (counter, e.g. `{n:03}`) `{date}` (modification date, e.g. `{date:%Y%m%d}`) `{taken}` (photo **date taken** from EXIF, falling back to `{date}`) `{today}` `{parent}` `{size_kb}`
62
+
63
+ **Options:** `-r` sub-folders · `-g "*.jpg"` filter · `--hidden` include hidden files · `--sort name|date|taken|size` · `--start N` · `--apply`
64
+
65
+ **Photo dates:** `{taken}` reads EXIF with the standard library only, with no Pillow or exiftool needed. Every CI run checks it against ExifTool on ExifTool's own sample images (JPEG, HEIC, TIFF, DNG, CR2, CR3, NEF, RW2, RAF: same date for every file, [dev/verify_exif.py](https://github.com/tidybatch/tidybatch/blob/main/dev/verify_exif.py)) and feeds it 20,000 corrupted files, which must never crash or hang it ([dev/fuzz_exif.py](https://github.com/tidybatch/tidybatch/blob/main/dev/fuzz_exif.py)). Dates stored only in XMP/IPTC aren't read; those files fall back to the modification date.
66
+
67
+ ## How it stays safe
68
+
69
+ 1. **Plan:** compute every new name without touching the disk, then resolve collisions. A destination is free only if nobody else claimed it *and* whatever is there now is itself moving away.
70
+ 2. **Phase 1:** every file moves to a temporary name in the same folder.
71
+ 3. **Phase 2:** every temporary file moves to its final name, refusing to overwrite anything that appeared in the meantime.
72
+ 4. **Journal:** `<folder>/.tidybatch/journal-*.json` records each completed move, even if the run is interrupted, so `undo` can replay it backwards.
73
+
74
+ ## How it's made
75
+
76
+ Most of the code was written with an AI coding agent. The behaviour that protects your files (two-phase moves, collision checks, undo) is pinned down by the test suite, which runs on Linux, macOS and Windows on every push.
77
+
78
+ ## TidyBatch Pro
79
+
80
+ This repository is **TidyBatch Core** (rename + undo), free and MIT-licensed. **[TidyBatch Pro](https://whop.com/tidybatch/bulk-file-renamer/?utm_source=github&utm_campaign=tidybatch)** adds:
81
+
82
+ - `organize`: sort a folder into Images / Documents / Videos…, by year/month (modified **or photo date taken**), or by extension
83
+ - `dedupe`: find exact duplicates by content (SHA-256), keep the oldest, move the rest aside (never deleted)
84
+ - ready-to-run apps for **Windows and Linux** (no Python needed), plus free 1.x updates
85
+
86
+ Buying Pro also funds the free version. Thank you!
87
+
88
+ ## Contributing
89
+
90
+ Bug reports with a reproducible folder layout are gold. Run the tests with:
91
+
92
+ ```bash
93
+ python -m unittest discover -s tests
94
+
95
+ # photo-date checks (needs perl and an ExifTool checkout)
96
+ git clone --depth 1 https://github.com/exiftool/exiftool /tmp/exiftool
97
+ python dev/verify_exif.py /tmp/exiftool
98
+ python dev/fuzz_exif.py /tmp/exiftool
99
+ ```
100
+
101
+ ## License
102
+
103
+ [MIT](https://github.com/tidybatch/tidybatch/blob/main/LICENSE)
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tidybatch"
7
+ dynamic = ["version"]
8
+ description = "Bulk rename files safely: dry-run preview, collision-proof two-phase renames, one-command undo."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "TidyBatch" }]
13
+ keywords = ["rename", "bulk-rename", "batch-rename", "files", "cli", "undo", "dry-run"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: System :: Filesystems",
20
+ "Topic :: Utilities",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/tidybatch/tidybatch"
25
+ Issues = "https://github.com/tidybatch/tidybatch/issues"
26
+ "TidyBatch Pro" = "https://whop.com/tidybatch/bulk-file-renamer/"
27
+
28
+ [project.scripts]
29
+ tidybatch = "tidybatch:main"
30
+
31
+ [tool.setuptools]
32
+ py-modules = ["tidybatch"]
33
+
34
+ [tool.setuptools.dynamic]
35
+ version = { attr = "tidybatch.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,175 @@
1
+ """Run with: python -m unittest discover -s tests (from the product folder)."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import unittest
10
+ from contextlib import redirect_stderr, redirect_stdout
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
14
+ import tidybatch # noqa: E402
15
+
16
+
17
+ def run(*argv: str) -> int:
18
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
19
+ return tidybatch.main(list(argv))
20
+
21
+
22
+ def jpeg_with_exif_date(when: str) -> bytes:
23
+ """Smallest JPEG-ish file whose APP1 segment carries EXIF DateTimeOriginal."""
24
+ import struct
25
+ tiff = b"II*\x00" + struct.pack("<I", 8)
26
+ tiff += struct.pack("<H", 1) + struct.pack("<HHII", 0x8769, 4, 1, 26) + struct.pack("<I", 0)
27
+ tiff += struct.pack("<H", 1) + struct.pack("<HHII", 0x9003, 2, 20, 44) + struct.pack("<I", 0)
28
+ tiff += when.encode("ascii") + b"\x00"
29
+ payload = b"Exif\x00\x00" + tiff
30
+ return b"\xff\xd8" + b"\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + b"\xff\xd9"
31
+
32
+
33
+ class TidyBatchTest(unittest.TestCase):
34
+ def setUp(self):
35
+ self._tmp = tempfile.TemporaryDirectory()
36
+ self.root = Path(self._tmp.name)
37
+
38
+ def tearDown(self):
39
+ self._tmp.cleanup()
40
+
41
+ def make(self, rel: str, content: str = "x", age: float = 0) -> Path:
42
+ p = self.root / rel
43
+ p.parent.mkdir(parents=True, exist_ok=True)
44
+ p.write_text(content)
45
+ if age:
46
+ t = time.time() - age
47
+ os.utime(p, (t, t))
48
+ return p
49
+
50
+ def names(self, folder: Path | None = None) -> set[str]:
51
+ folder = folder or self.root
52
+ return {str(p.relative_to(folder)) for p in folder.rglob("*")
53
+ if p.is_file() and tidybatch.JOURNAL_DIR not in p.parts}
54
+
55
+ def make_photo(self, rel: str, taken: str, age: float = 0) -> Path:
56
+ p = self.root / rel
57
+ p.write_bytes(jpeg_with_exif_date(taken))
58
+ if age:
59
+ t = time.time() - age
60
+ os.utime(p, (t, t))
61
+ return p
62
+
63
+ def test_taken_date_reads_exif(self):
64
+ p = self.make_photo("a.jpg", "2021:05:06 07:08:09")
65
+ self.assertEqual(str(tidybatch.taken_date(p)), "2021-05-06 07:08:09")
66
+
67
+ def test_taken_template_uses_exif_not_mtime(self):
68
+ self.make_photo("IMG_1.jpg", "2019:12:31 23:59:59")
69
+ run("rename", str(self.root), "-t", "{taken:%Y-%m-%d_%H%M%S}{ext}", "--apply")
70
+ self.assertEqual(self.names(), {"2019-12-31_235959.jpg"})
71
+
72
+ def test_taken_falls_back_to_mtime(self):
73
+ self.make("notes.txt")
74
+ p = self.root / "notes.txt"
75
+ t = time.mktime((2020, 2, 3, 12, 0, 0, 0, 0, -1)); os.utime(p, (t, t))
76
+ run("rename", str(self.root), "-t", "{taken:%Y-%m-%d}_{stem}{ext}", "--apply")
77
+ self.assertEqual(self.names(), {"2020-02-03_notes.txt"})
78
+
79
+ def test_garbage_file_has_no_taken_date(self):
80
+ p = self.root / "broken.jpg"
81
+ p.write_bytes(b"\xff\xd8\xff\xe1\x00")
82
+ self.assertIsNone(tidybatch.taken_date(p))
83
+
84
+ def test_sort_taken_orders_by_photo_date(self):
85
+ self.make_photo("b.jpg", "2020:01:01 10:00:00")
86
+ self.make_photo("a.jpg", "2021:01:01 10:00:00")
87
+ run("rename", str(self.root), "--sort", "taken", "-t", "{n}{ext}", "--apply")
88
+ self.assertEqual(tidybatch.taken_date(self.root / "1.jpg").year, 2020)
89
+
90
+ def test_dry_run_changes_nothing(self):
91
+ self.make("My File.TXT")
92
+ self.assertEqual(run("rename", str(self.root), "--slug", "--lower"), 0)
93
+ self.assertEqual(self.names(), {"My File.TXT"})
94
+
95
+ def test_slug_and_undo_round_trip(self):
96
+ self.make("Relatório Final (v2).PDF")
97
+ self.make("Foto Férias 01.JPG")
98
+ before = self.names()
99
+ run("rename", str(self.root), "--slug", "--lower", "--apply")
100
+ self.assertEqual(self.names(), {"relatorio_final_v2.pdf", "foto_ferias_01.jpg"})
101
+ run("undo", str(self.root))
102
+ self.assertEqual(self.names(), before)
103
+
104
+ def test_numbering_chain_does_not_clobber(self):
105
+ # 1->2, 2->3, 3->4: a naive renamer overwrites file 2 with file 1.
106
+ for i in (1, 2, 3):
107
+ self.make(f"{i}.txt", content=f"content-{i}")
108
+ run("rename", str(self.root), "-t", "{n}{ext}", "--start", "2", "--apply")
109
+ self.assertEqual(self.names(), {"2.txt", "3.txt", "4.txt"})
110
+ self.assertEqual((self.root / "4.txt").read_text(), "content-3")
111
+ self.assertEqual((self.root / "2.txt").read_text(), "content-1")
112
+
113
+ def test_collisions_get_suffix(self):
114
+ self.make("a b.txt", "1")
115
+ self.make("a_b.txt", "2") # already the slug of the first one
116
+ run("rename", str(self.root), "--slug", "--apply")
117
+ self.assertEqual(self.names(), {"a_b.txt", "a_b_1.txt"})
118
+ self.assertEqual((self.root / "a_b.txt").read_text(), "2")
119
+
120
+ def test_find_replace_with_groups(self):
121
+ self.make("IMG_2024_0001.jpg")
122
+ run("rename", str(self.root), "--find", r"IMG_(\d{4})_(\d+)", "--replace", r"\1-photo-\2", "--apply")
123
+ self.assertEqual(self.names(), {"2024-photo-0001.jpg"})
124
+
125
+ def test_natural_sort_numbering(self):
126
+ for name in ("img10.png", "img2.png", "img1.png"):
127
+ self.make(name, content=name)
128
+ run("rename", str(self.root), "-t", "{n:02}{ext}", "--apply")
129
+ self.assertEqual((self.root / "02.png").read_text(), "img2.png")
130
+ self.assertEqual((self.root / "03.png").read_text(), "img10.png")
131
+
132
+ def test_summary_wording_matches_mode(self):
133
+ self.make("A B.txt")
134
+ out = io.StringIO()
135
+ with redirect_stdout(out):
136
+ tidybatch.main(["rename", str(self.root), "--slug"])
137
+ self.assertIn("1 file(s) would change.", out.getvalue())
138
+ out = io.StringIO()
139
+ with redirect_stdout(out):
140
+ tidybatch.main(["rename", str(self.root), "--slug", "--apply"])
141
+ self.assertIn("1 file(s) will change.", out.getvalue())
142
+
143
+ def test_unicode_normalization_counts_as_collision(self):
144
+ # 'é' precomposed (NFC) vs 'e' + combining accent (NFD): one file on macOS.
145
+ a, b = self.make("a.txt"), self.make("b.txt")
146
+ moves = tidybatch.resolve_collisions([
147
+ tidybatch.Move(a, self.root / "caf\u00e9.txt"),
148
+ tidybatch.Move(b, self.root / "cafe\u0301.txt"),
149
+ ])
150
+ self.assertEqual(moves[1].dst.name, "cafe\u0301_1.txt")
151
+
152
+ def test_pro_commands_point_to_pro_without_touching_files(self):
153
+ self.make("a b.txt")
154
+ for argv in (["organize", str(self.root), "--apply"], ["dedupe", str(self.root)]):
155
+ out, err = io.StringIO(), io.StringIO()
156
+ with redirect_stdout(out), redirect_stderr(err):
157
+ code = tidybatch.main(argv)
158
+ # A script must not mistake "not in Core" for success.
159
+ self.assertEqual(code, 2)
160
+ self.assertIn("TidyBatch Pro", err.getvalue())
161
+ self.assertEqual(out.getvalue(), "")
162
+ self.assertEqual(self.names(), {"a b.txt"})
163
+
164
+ def test_bad_template_is_a_clean_error(self):
165
+ self.make("a.txt")
166
+ self.assertEqual(run("rename", str(self.root), "-t", "{nope}"), 2)
167
+
168
+ def test_template_cannot_create_paths(self):
169
+ self.make("a.txt")
170
+ self.assertEqual(run("rename", str(self.root), "-t", "../{stem}{ext}", "--apply"), 2)
171
+ self.assertEqual(self.names(), {"a.txt"})
172
+
173
+
174
+ if __name__ == "__main__":
175
+ unittest.main()
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidybatch
3
+ Version: 1.1.1
4
+ Summary: Bulk rename files safely: dry-run preview, collision-proof two-phase renames, one-command undo.
5
+ Author: TidyBatch
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tidybatch/tidybatch
8
+ Project-URL: Issues, https://github.com/tidybatch/tidybatch/issues
9
+ Project-URL: TidyBatch Pro, https://whop.com/tidybatch/bulk-file-renamer/
10
+ Keywords: rename,bulk-rename,batch-rename,files,cli,undo,dry-run
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Filesystems
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # TidyBatch
23
+
24
+ **Bulk rename files without the fear.** Preview every change, apply it, and undo it with one command.
25
+
26
+ ![TidyBatch demo: preview, apply, undo](https://raw.githubusercontent.com/tidybatch/tidybatch/main/docs/demo.gif)
27
+
28
+ [![CI](https://github.com/tidybatch/tidybatch/actions/workflows/ci.yml/badge.svg)](https://github.com/tidybatch/tidybatch/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/tidybatch)](https://pypi.org/project/tidybatch/)
30
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)
31
+ ![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen)
32
+ ![License: MIT](https://img.shields.io/badge/license-MIT-lightgrey)
33
+
34
+ Most bulk-rename scripts have a silent bug: renumber `1.txt 2.txt 3.txt` → `2.txt 3.txt 4.txt` and the first `mv` overwrites the original `2.txt`. TidyBatch is built around not doing that:
35
+
36
+ - **Dry run by default.** Nothing changes until you add `--apply`.
37
+ - **One-command undo.** Every applied run is journaled; `tidybatch undo <folder>` puts every file back.
38
+ - **Never overwrites.** Two-phase renames make chains and swaps safe; clashes get `_1`, `_2`, …
39
+ - **Case-insensitive collision checks**, because Windows and macOS treat `Photo.JPG` and `photo.jpg` as the same file.
40
+ - **One file, standard library only.** No dependencies: you can read all of it before you trust it with your photos.
41
+
42
+ New to bulk renaming? Read the tested guides at **[tidybatch.github.io/guides](https://tidybatch.github.io/guides/)**: how to rename safely on any OS, how to undo a bulk rename, and how to add dates to file names.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pipx install tidybatch
48
+ # or: uv tool install tidybatch
49
+ # or: pip install tidybatch
50
+ # or just download tidybatch.py and run: python3 tidybatch.py --help
51
+ ```
52
+
53
+ Try it on a folder without installing anything permanent (it's a preview, so nothing changes):
54
+
55
+ ```bash
56
+ uvx tidybatch rename ~/Downloads --slug --lower
57
+ ```
58
+
59
+ ## Recipes
60
+
61
+ ```bash
62
+ # Clean names: no accents, spaces or symbols, all lowercase
63
+ tidybatch rename ~/Downloads --slug --lower
64
+
65
+ # Number photos in date order: holiday_001.jpg, holiday_002.jpg, …
66
+ tidybatch rename ./photos -g "*.jpg" --sort date -t "holiday_{n:03}{ext}"
67
+
68
+ # Put the file date in front: 2024-08-12_invoice.pdf
69
+ tidybatch rename ./invoices -t "{date:%Y-%m-%d}_{stem}{ext}"
70
+
71
+ # Name photos by when they were TAKEN (EXIF): 2023-07-14_182205.jpg
72
+ tidybatch rename ./camera -t "{taken:%Y-%m-%d_%H%M%S}{ext}"
73
+
74
+ # Regex with groups: IMG_2024_0001.jpg -> 2024-photo-0001.jpg
75
+ tidybatch rename . --find "IMG_(\d{4})_(\d+)" --replace "\1-photo-\2"
76
+
77
+ # Happy with the preview? Run it for real, then change your mind
78
+ tidybatch rename ~/Downloads --slug --lower --apply
79
+ tidybatch undo ~/Downloads
80
+ ```
81
+
82
+ **Template fields:** `{stem}` `{ext}` `{n}` (counter, e.g. `{n:03}`) `{date}` (modification date, e.g. `{date:%Y%m%d}`) `{taken}` (photo **date taken** from EXIF, falling back to `{date}`) `{today}` `{parent}` `{size_kb}`
83
+
84
+ **Options:** `-r` sub-folders · `-g "*.jpg"` filter · `--hidden` include hidden files · `--sort name|date|taken|size` · `--start N` · `--apply`
85
+
86
+ **Photo dates:** `{taken}` reads EXIF with the standard library only, with no Pillow or exiftool needed. Every CI run checks it against ExifTool on ExifTool's own sample images (JPEG, HEIC, TIFF, DNG, CR2, CR3, NEF, RW2, RAF: same date for every file, [dev/verify_exif.py](https://github.com/tidybatch/tidybatch/blob/main/dev/verify_exif.py)) and feeds it 20,000 corrupted files, which must never crash or hang it ([dev/fuzz_exif.py](https://github.com/tidybatch/tidybatch/blob/main/dev/fuzz_exif.py)). Dates stored only in XMP/IPTC aren't read; those files fall back to the modification date.
87
+
88
+ ## How it stays safe
89
+
90
+ 1. **Plan:** compute every new name without touching the disk, then resolve collisions. A destination is free only if nobody else claimed it *and* whatever is there now is itself moving away.
91
+ 2. **Phase 1:** every file moves to a temporary name in the same folder.
92
+ 3. **Phase 2:** every temporary file moves to its final name, refusing to overwrite anything that appeared in the meantime.
93
+ 4. **Journal:** `<folder>/.tidybatch/journal-*.json` records each completed move, even if the run is interrupted, so `undo` can replay it backwards.
94
+
95
+ ## How it's made
96
+
97
+ Most of the code was written with an AI coding agent. The behaviour that protects your files (two-phase moves, collision checks, undo) is pinned down by the test suite, which runs on Linux, macOS and Windows on every push.
98
+
99
+ ## TidyBatch Pro
100
+
101
+ This repository is **TidyBatch Core** (rename + undo), free and MIT-licensed. **[TidyBatch Pro](https://whop.com/tidybatch/bulk-file-renamer/?utm_source=github&utm_campaign=tidybatch)** adds:
102
+
103
+ - `organize`: sort a folder into Images / Documents / Videos…, by year/month (modified **or photo date taken**), or by extension
104
+ - `dedupe`: find exact duplicates by content (SHA-256), keep the oldest, move the rest aside (never deleted)
105
+ - ready-to-run apps for **Windows and Linux** (no Python needed), plus free 1.x updates
106
+
107
+ Buying Pro also funds the free version. Thank you!
108
+
109
+ ## Contributing
110
+
111
+ Bug reports with a reproducible folder layout are gold. Run the tests with:
112
+
113
+ ```bash
114
+ python -m unittest discover -s tests
115
+
116
+ # photo-date checks (needs perl and an ExifTool checkout)
117
+ git clone --depth 1 https://github.com/exiftool/exiftool /tmp/exiftool
118
+ python dev/verify_exif.py /tmp/exiftool
119
+ python dev/fuzz_exif.py /tmp/exiftool
120
+ ```
121
+
122
+ ## License
123
+
124
+ [MIT](https://github.com/tidybatch/tidybatch/blob/main/LICENSE)
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tidybatch.py
5
+ tests/test_tidybatch.py
6
+ tidybatch.egg-info/PKG-INFO
7
+ tidybatch.egg-info/SOURCES.txt
8
+ tidybatch.egg-info/dependency_links.txt
9
+ tidybatch.egg-info/entry_points.txt
10
+ tidybatch.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tidybatch = tidybatch:main
@@ -0,0 +1 @@
1
+ tidybatch
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TidyBatch Core - bulk rename files safely, with preview and one-command undo.
4
+
5
+ Safety model:
6
+ * Every command is a DRY RUN unless you pass --apply.
7
+ * Every applied run writes a journal to <folder>/.tidybatch/,
8
+ so `tidybatch undo <folder>` puts every file back where it was.
9
+ * Nothing is ever deleted or overwritten.
10
+ * Name collisions are resolved with a numeric suffix, never by overwriting.
11
+
12
+ Standard library only (Python 3.9+). MIT licensed.
13
+
14
+ `organize` and `dedupe` are part of TidyBatch Pro: https://whop.com/tidybatch/bulk-file-renamer/
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ import struct
24
+ import sys
25
+ import unicodedata
26
+ from dataclasses import dataclass
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Iterable, Iterator
30
+
31
+ __version__ = "1.1.1"
32
+
33
+ JOURNAL_DIR = ".tidybatch"
34
+ TMP_PREFIX = ".tidybatch-tmp-"
35
+ SKIP_DIRS = {JOURNAL_DIR, "_duplicates", ".git", "__pycache__"}
36
+
37
+ class TidyError(Exception):
38
+ """A user-facing error: printed without a traceback."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Move:
43
+ src: Path
44
+ dst: Path
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # File discovery
49
+ # --------------------------------------------------------------------------- #
50
+
51
+ def iter_files(root: Path, recursive: bool, glob: str | None, include_hidden: bool) -> Iterator[Path]:
52
+ """Yield regular files under root, skipping TidyBatch's own folders."""
53
+ if recursive:
54
+ for dirpath, dirnames, filenames in os.walk(root):
55
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and (include_hidden or not d.startswith("."))]
56
+ for name in filenames:
57
+ p = Path(dirpath) / name
58
+ if _wanted(p, glob, include_hidden):
59
+ yield p
60
+ else:
61
+ for p in root.iterdir():
62
+ if p.is_file() and _wanted(p, glob, include_hidden):
63
+ yield p
64
+
65
+
66
+ def _wanted(p: Path, glob: str | None, include_hidden: bool) -> bool:
67
+ if p.name.startswith(TMP_PREFIX) or p.is_symlink():
68
+ return False
69
+ if not include_hidden and p.name.startswith("."):
70
+ return False
71
+ return glob is None or p.match(glob)
72
+
73
+
74
+ def natural_key(p: Path) -> list:
75
+ """Sort 'img2' before 'img10', the way humans expect."""
76
+ return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", p.name.casefold())]
77
+
78
+
79
+ def sort_files(files: Iterable[Path], by: str) -> list[Path]:
80
+ if by == "taken":
81
+ return sorted(files, key=lambda p: (taken_or_mtime(p), natural_key(p)))
82
+ if by == "date":
83
+ return sorted(files, key=lambda p: (p.stat().st_mtime, natural_key(p)))
84
+ if by == "size":
85
+ return sorted(files, key=lambda p: (p.stat().st_size, natural_key(p)))
86
+ return sorted(files, key=natural_key)
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # Photo dates (EXIF "date taken"), standard library only.
91
+ # JPEG, TIFF, TIFF-based RAW (DNG, CR2, NEF, ARW, ORF, RW2), Fuji RAF, Canon CR3
92
+ # and HEIC/HEIF/AVIF. Checked against ExifTool's sample images; see dev/.
93
+ # --------------------------------------------------------------------------- #
94
+
95
+ _TIFF_MAGIC = {42, 0x4F52, 0x5352, 0x55} # TIFF, Olympus ORF (x2), Panasonic RW2
96
+ _DATE_TAGS = (0x9003, 0x9004, 0x0132) # DateTimeOriginal, DateTimeDigitized, DateTime
97
+
98
+
99
+ def taken_date(path: Path) -> datetime | None:
100
+ """EXIF DateTimeOriginal, else DateTimeDigitized, else IFD0 DateTime; None if absent."""
101
+ try:
102
+ with path.open("rb") as f:
103
+ head = f.read(16)
104
+ if head[:2] == b"\xff\xd8":
105
+ base = _jpeg_tiff_offset(f, 0)
106
+ elif head[:4] in (b"II*\x00", b"MM\x00*", b"IIRO", b"IIRS", b"IIU\x00"):
107
+ base = 0
108
+ elif head.startswith(b"FUJIFILMCCD-RAW"):
109
+ f.seek(84)
110
+ jpeg_at = struct.unpack(">I", f.read(4))[0]
111
+ base = _jpeg_tiff_offset(f, jpeg_at)
112
+ elif head[4:12] == b"ftypcrx ":
113
+ return _cr3_date(f)
114
+ elif head[4:8] == b"ftyp":
115
+ base = _isobmff_tiff_offset(f)
116
+ else:
117
+ return None
118
+ return _tiff_date(f, base) if base is not None else None
119
+ except Exception: # metadata is best-effort: a broken file must never stop a rename
120
+ return None
121
+
122
+
123
+ def _cr3_date(f) -> datetime | None:
124
+ """Canon CR3: EXIF lives in 'CMT1' (IFD0) and 'CMT2' (Exif IFD) boxes, each a TIFF."""
125
+ f.seek(0)
126
+ head = f.read(1 << 18)
127
+ for box in (b"CMT2", b"CMT1"):
128
+ i = head.find(box)
129
+ if i >= 4:
130
+ d = _tiff_date(f, i + 4, exif_ifd_at_root=(box == b"CMT2"))
131
+ if d:
132
+ return d
133
+ return None
134
+
135
+
136
+ def _jpeg_tiff_offset(f, start: int) -> int | None:
137
+ """Offset of the TIFF header inside a JPEG's 'Exif' APP1 segment."""
138
+ pos = start + 2
139
+ for _ in range(1000):
140
+ f.seek(pos)
141
+ marker = f.read(2)
142
+ if len(marker) < 2 or marker[0] != 0xFF:
143
+ return None
144
+ m = marker[1]
145
+ if m == 0xFF: # fill byte
146
+ pos += 1
147
+ continue
148
+ if m in (0xD9, 0xDA): # end of image / start of scan: no EXIF before pixels
149
+ return None
150
+ if 0xD0 <= m <= 0xD7 or m == 0x01:
151
+ pos += 2
152
+ continue
153
+ seglen = struct.unpack(">H", f.read(2))[0]
154
+ if m == 0xE1 and f.read(6) == b"Exif\x00\x00":
155
+ return pos + 10
156
+ pos += 2 + seglen
157
+ return None
158
+
159
+
160
+ def _boxes(f, start: int, end: int):
161
+ pos = start
162
+ while pos + 8 <= end:
163
+ f.seek(pos)
164
+ size, btype = struct.unpack(">I4s", f.read(8))
165
+ hdr = 8
166
+ if size == 1:
167
+ size = struct.unpack(">Q", f.read(8))[0]
168
+ hdr = 16
169
+ elif size == 0:
170
+ size = end - pos
171
+ if size < hdr:
172
+ return
173
+ yield btype, pos, size, hdr
174
+ pos += size
175
+
176
+
177
+ def _isobmff_tiff_offset(f) -> int | None:
178
+ """HEIC/HEIF/AVIF: locate the 'Exif' item via meta/iinf + meta/iloc."""
179
+ f.seek(0, 2)
180
+ file_end = f.tell()
181
+ meta = next(((p + h + 4, p + s) for t, p, s, h in _boxes(f, 0, file_end) if t == b"meta"), None)
182
+ if not meta:
183
+ return None
184
+ exif_id, iloc = None, None
185
+ for btype, p, s, h in _boxes(f, *meta):
186
+ if btype == b"iinf":
187
+ exif_id = _iinf_exif_id(f, p + h, p + s)
188
+ elif btype == b"iloc":
189
+ iloc = (p + h, p + s)
190
+ if exif_id is None or iloc is None:
191
+ return None
192
+ loc = _iloc_offset(f, iloc[0], exif_id)
193
+ if loc is None:
194
+ return None
195
+ f.seek(loc)
196
+ tiff = loc + 4 + struct.unpack(">I", f.read(4))[0]
197
+ f.seek(tiff)
198
+ if f.read(2) not in (b"II", b"MM"): # some writers point at "Exif\0\0"
199
+ f.seek(loc)
200
+ blob = f.read(32)
201
+ i = blob.find(b"Exif\x00\x00")
202
+ tiff = loc + i + 6 if i >= 0 else None
203
+ return tiff
204
+
205
+
206
+ def _iinf_exif_id(f, start: int, end: int) -> int | None:
207
+ f.seek(start)
208
+ version = f.read(4)[0]
209
+ f.read(2 if version == 0 else 4) # entry_count
210
+ for btype, p, s, h in _boxes(f, f.tell(), end):
211
+ if btype != b"infe":
212
+ continue
213
+ f.seek(p + h)
214
+ v = f.read(4)[0]
215
+ if v < 2:
216
+ continue
217
+ item_id = struct.unpack(">H" if v == 2 else ">I", f.read(2 if v == 2 else 4))[0]
218
+ f.read(2) # protection index
219
+ if f.read(4) == b"Exif":
220
+ return item_id
221
+ return None
222
+
223
+
224
+ def _uint(f, size: int) -> int:
225
+ return int.from_bytes(f.read(size), "big") if size else 0
226
+
227
+
228
+ def _iloc_offset(f, start: int, wanted: int) -> int | None:
229
+ f.seek(start)
230
+ version = f.read(4)[0]
231
+ b1, b2 = f.read(2)
232
+ off_size, len_size, base_size = b1 >> 4, b1 & 15, b2 >> 4
233
+ idx_size = b2 & 15 if version in (1, 2) else 0
234
+ count = _uint(f, 2 if version < 2 else 4)
235
+ if count > 4096:
236
+ return None
237
+ for _ in range(count):
238
+ item_id = _uint(f, 2 if version < 2 else 4)
239
+ method = _uint(f, 2) & 15 if version in (1, 2) else 0
240
+ f.read(2) # data reference index
241
+ base = _uint(f, base_size)
242
+ extents = _uint(f, 2)
243
+ if extents > 4096:
244
+ return None
245
+ first = None
246
+ for _ in range(extents):
247
+ _uint(f, idx_size)
248
+ off = _uint(f, off_size)
249
+ _uint(f, len_size)
250
+ if first is None:
251
+ first = off
252
+ if item_id == wanted:
253
+ return base + (first or 0) if method == 0 else None
254
+ return None
255
+
256
+
257
+ def _tiff_date(f, base: int, exif_ifd_at_root: bool = False) -> datetime | None:
258
+ f.seek(base)
259
+ hdr = f.read(8)
260
+ if hdr[:2] == b"II":
261
+ e = "<"
262
+ elif hdr[:2] == b"MM":
263
+ e = ">"
264
+ else:
265
+ return None
266
+ magic, ifd0 = struct.unpack(e + "HI", hdr[2:8])
267
+ if magic not in _TIFF_MAGIC:
268
+ return None
269
+ found: dict[int, tuple] = {}
270
+ exif_ifd = None
271
+ for tag, typ, count, raw in _ifd(f, base, ifd0, e):
272
+ if exif_ifd_at_root and tag in (0x9003, 0x9004):
273
+ found[tag] = (typ, count, raw)
274
+ elif tag == 0x8769:
275
+ exif_ifd = struct.unpack(e + "I", raw)[0]
276
+ elif tag == 0x0132:
277
+ found[tag] = (typ, count, raw)
278
+ if exif_ifd:
279
+ for tag, typ, count, raw in _ifd(f, base, exif_ifd, e):
280
+ if tag in (0x9003, 0x9004):
281
+ found[tag] = (typ, count, raw)
282
+ for tag in _DATE_TAGS:
283
+ if tag in found:
284
+ d = _ascii_date(f, base, e, *found[tag])
285
+ if d:
286
+ return d
287
+ return None
288
+
289
+
290
+ def _ifd(f, base: int, offset: int, e: str):
291
+ f.seek(base + offset)
292
+ n = struct.unpack(e + "H", f.read(2))[0]
293
+ if n > 1000:
294
+ return
295
+ data = f.read(12 * n)
296
+ for i in range(n):
297
+ entry = data[12 * i:12 * i + 12]
298
+ if len(entry) < 12:
299
+ return
300
+ tag, typ, count = struct.unpack(e + "HHI", entry[:8])
301
+ yield tag, typ, count, entry[8:12]
302
+
303
+
304
+ def _ascii_date(f, base: int, e: str, typ: int, count: int, raw: bytes) -> datetime | None:
305
+ if typ != 2 or count < 19:
306
+ return None
307
+ f.seek(base + struct.unpack(e + "I", raw)[0])
308
+ text = f.read(min(count, 32)).split(b"\x00")[0].decode("ascii", "ignore").strip()
309
+ for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y:%m:%d %H:%M"):
310
+ try:
311
+ return datetime.strptime(text[:19] if "S" in fmt else text[:16], fmt)
312
+ except ValueError:
313
+ continue
314
+ return None
315
+
316
+
317
+ def taken_or_mtime(path: Path) -> datetime:
318
+ """Photo 'date taken' when the file has one, otherwise its modification time."""
319
+ return taken_date(path) or datetime.fromtimestamp(path.stat().st_mtime)
320
+
321
+
322
+ # --------------------------------------------------------------------------- #
323
+ # Naming helpers
324
+ # --------------------------------------------------------------------------- #
325
+
326
+ _INVALID_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
327
+
328
+
329
+ def slugify(text: str, lower: bool = True, sep: str = "_") -> str:
330
+ """'Relatório Final (v2)' -> 'relatorio_final_v2'. Safe on every OS."""
331
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
332
+ text = re.sub(r"[^\w.-]+", sep, text)
333
+ text = re.sub(rf"{re.escape(sep)}{{2,}}", sep, text).strip(sep + ".")
334
+ return text.lower() if lower else text
335
+
336
+
337
+ def render_name(template: str, path: Path, index: int, stem: str | None = None) -> str:
338
+ stat = path.stat()
339
+ fields = {
340
+ "stem": path.stem if stem is None else stem,
341
+ "ext": path.suffix,
342
+ "n": index,
343
+ "date": datetime.fromtimestamp(stat.st_mtime),
344
+ # Reading EXIF costs a file read, so only do it when the template asks.
345
+ "taken": taken_or_mtime(path) if "{taken" in template else None,
346
+ "today": datetime.now(),
347
+ "parent": path.parent.name,
348
+ "size_kb": stat.st_size // 1024,
349
+ }
350
+ try:
351
+ name = template.format(**fields)
352
+ except KeyError as e:
353
+ raise TidyError(f"Unknown template field {e}. Available: {', '.join('{' + k + '}' for k in fields)}")
354
+ except (ValueError, IndexError) as e:
355
+ raise TidyError(f"Invalid template '{template}': {e}")
356
+ if "/" in name or "\\" in name:
357
+ raise TidyError("Template must produce a file name, not a path (no '/' or '\\').")
358
+ return name
359
+
360
+
361
+ def _key(p: Path) -> str:
362
+ # Case- and normalization-insensitive on purpose: Windows and macOS file
363
+ # systems treat 'A.txt'/'a.txt' (and composed/decomposed 'é') as the same
364
+ # file, so we must too.
365
+ return unicodedata.normalize("NFC", str(p)).casefold()
366
+
367
+
368
+ def resolve_collisions(moves: list[Move]) -> list[Move]:
369
+ """
370
+ Make every destination unique. A destination is free if nobody else
371
+ already claimed it in this plan and either nothing is there on disk, or
372
+ whatever is there is itself being moved away by this plan.
373
+ """
374
+ active = [m for m in moves if m.src != m.dst]
375
+ leaving = {_key(m.src) for m in active}
376
+ # Files that keep their name still occupy it.
377
+ claimed = {_key(m.src) for m in moves if m.src == m.dst}
378
+ result = []
379
+ for m in active:
380
+ dst, i = m.dst, 1
381
+ # A case-only rename (Photo.JPG -> photo.jpg) passes: its own src is "leaving".
382
+ while _key(dst) in claimed or (dst.exists() and _key(dst) not in leaving):
383
+ dst = m.dst.with_name(f"{m.dst.stem}_{i}{m.dst.suffix}")
384
+ i += 1
385
+ claimed.add(_key(dst))
386
+ result.append(Move(m.src, dst))
387
+ return result
388
+
389
+
390
+ # --------------------------------------------------------------------------- #
391
+ # Planners: they only compute moves, never touch the disk
392
+ # --------------------------------------------------------------------------- #
393
+
394
+ def plan_rename(files: list[Path], template: str, find: str | None, replace: str,
395
+ slug: bool, lower: bool, start: int) -> list[Move]:
396
+ pattern = None
397
+ if find:
398
+ try:
399
+ pattern = re.compile(find)
400
+ except re.error as e:
401
+ raise TidyError(f"Invalid --find regex: {e}")
402
+
403
+ moves = []
404
+ for index, path in enumerate(files, start=start):
405
+ stem = pattern.sub(replace, path.stem) if pattern else path.stem
406
+ name = render_name(template, path, index, stem)
407
+ if slug or lower:
408
+ p = Path(name)
409
+ stem = slugify(p.stem, lower=lower) if slug else p.stem.lower()
410
+ name = (stem or "file") + (p.suffix.lower() if lower else p.suffix)
411
+ name = _INVALID_CHARS.sub("_", name).strip() or f"file{path.suffix}"
412
+ moves.append(Move(path, path.with_name(name)))
413
+ return resolve_collisions(moves)
414
+
415
+
416
+ # --------------------------------------------------------------------------- #
417
+ # Execution + journal
418
+ # --------------------------------------------------------------------------- #
419
+
420
+ def apply_moves(moves: list[Move], root: Path) -> Path:
421
+ """
422
+ Two-phase move: every source first goes to a temporary name, then to its
423
+ final name. This makes chains and swaps safe (a->b while b->c, or a<->b).
424
+ The journal is written even if something fails halfway.
425
+ """
426
+ journal_dir = root / JOURNAL_DIR
427
+ journal_dir.mkdir(exist_ok=True)
428
+ journal = journal_dir / f"journal-{datetime.now():%Y%m%d-%H%M%S-%f}.json"
429
+
430
+ staged: list[tuple[Move, Path]] = []
431
+ done: list[Move] = []
432
+ try:
433
+ for i, m in enumerate(moves):
434
+ tmp = m.src.with_name(f"{TMP_PREFIX}{os.getpid()}-{i}{m.src.suffix}")
435
+ os.rename(m.src, tmp)
436
+ staged.append((m, tmp))
437
+ for m, tmp in staged:
438
+ if m.dst.exists():
439
+ raise TidyError(f"Refusing to overwrite '{m.dst}' (it appeared during the run).")
440
+ m.dst.parent.mkdir(parents=True, exist_ok=True)
441
+ shutil.move(str(tmp), str(m.dst))
442
+ done.append(m)
443
+ except BaseException:
444
+ finished = {id(m) for m in done}
445
+ for m, tmp in staged:
446
+ if id(m) not in finished and tmp.exists():
447
+ os.rename(tmp, m.src)
448
+ raise
449
+ finally:
450
+ if done:
451
+ payload = {
452
+ "version": __version__,
453
+ "root": str(root.resolve()),
454
+ "created": datetime.now().isoformat(timespec="seconds"),
455
+ "moves": [{"src": str(m.src.resolve()), "dst": str(m.dst.resolve())} for m in done],
456
+ }
457
+ journal.write_text(json.dumps(payload, indent=2), encoding="utf-8")
458
+ return journal
459
+
460
+
461
+ def undo(root: Path, journal_path: Path | None) -> int:
462
+ journal_dir = root / JOURNAL_DIR
463
+ if journal_path is None:
464
+ journals = sorted(journal_dir.glob("journal-*.json")) if journal_dir.is_dir() else []
465
+ if not journals:
466
+ raise TidyError(f"No journal found in '{journal_dir}'. Nothing to undo.")
467
+ journal_path = journals[-1]
468
+
469
+ data = json.loads(journal_path.read_text(encoding="utf-8"))
470
+ journal_root = Path(data["root"])
471
+ restored = skipped = 0
472
+ for entry in reversed(data["moves"]):
473
+ src, dst = Path(entry["src"]), Path(entry["dst"])
474
+ if not dst.exists():
475
+ print(f" skip (missing now): {dst}")
476
+ skipped += 1
477
+ continue
478
+ if src.exists() and _key(src) != _key(dst):
479
+ print(f" skip (original name taken): {src}")
480
+ skipped += 1
481
+ continue
482
+ src.parent.mkdir(parents=True, exist_ok=True)
483
+ shutil.move(str(dst), str(src))
484
+ _remove_empty_parents(dst.parent, stop_at=journal_root)
485
+ restored += 1
486
+
487
+ journal_path.rename(journal_path.with_suffix(".undone"))
488
+ print(f"Restored {restored} file(s), skipped {skipped}. Journal: {journal_path.name}")
489
+ return 0 if skipped == 0 else 1
490
+
491
+
492
+ def _remove_empty_parents(folder: Path, stop_at: Path) -> None:
493
+ folder, stop_at = folder.resolve(), stop_at.resolve()
494
+ while folder != stop_at and stop_at in folder.parents:
495
+ try:
496
+ folder.rmdir() # only succeeds when empty
497
+ except OSError:
498
+ return
499
+ folder = folder.parent
500
+
501
+
502
+ # --------------------------------------------------------------------------- #
503
+ # CLI
504
+ # --------------------------------------------------------------------------- #
505
+
506
+ def show_plan(moves: list[Move], root: Path, applying: bool = False) -> None:
507
+ def rel(p: Path) -> str:
508
+ try:
509
+ return str(p.relative_to(root))
510
+ except ValueError:
511
+ return str(p)
512
+
513
+ if not moves:
514
+ print("Nothing to do: every file already matches.")
515
+ return
516
+ width = min(max(len(rel(m.src)) for m in moves), 60)
517
+ for m in moves:
518
+ print(f" {rel(m.src):<{width}} -> {rel(m.dst)}")
519
+ print(f"\n{len(moves)} file(s) {'will change' if applying else 'would change'}.")
520
+
521
+
522
+ def build_parser() -> argparse.ArgumentParser:
523
+ common = argparse.ArgumentParser(add_help=False)
524
+ common.add_argument("folder", type=Path, help="folder to work on")
525
+ common.add_argument("-r", "--recursive", action="store_true", help="include sub-folders")
526
+ common.add_argument("-g", "--glob", help="only files matching this pattern, e.g. '*.jpg'")
527
+ common.add_argument("--hidden", action="store_true", help="include hidden files")
528
+ common.add_argument("--apply", action="store_true", help="actually do it (default is a dry run)")
529
+
530
+ parser = argparse.ArgumentParser(
531
+ prog="tidybatch",
532
+ description="Bulk rename files safely. Dry run by default; every change can be undone.",
533
+ epilog="Need to sort folders or remove duplicates? TidyBatch Pro adds `organize` and `dedupe`: https://whop.com/tidybatch/bulk-file-renamer/",
534
+ )
535
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
536
+ sub = parser.add_subparsers(dest="command", required=True)
537
+
538
+ r = sub.add_parser("rename", parents=[common], help="rename files from a template",
539
+ description="Template fields: {stem} {ext} {n} {date} {taken} {today} {parent} {size_kb}. "
540
+ "{date} is the modification time; {taken} is the photo's EXIF date taken "
541
+ "(falls back to {date}). Formats work: {n:03}, {taken:%Y-%m-%d}.")
542
+ r.add_argument("-t", "--template", default="{stem}{ext}", help="new name template (default: {stem}{ext})")
543
+ r.add_argument("--find", help="regex to search in the original name (without extension)")
544
+ r.add_argument("--replace", default="", help="replacement for --find (supports \\1 groups)")
545
+ r.add_argument("--slug", action="store_true", help="remove accents, spaces and symbols")
546
+ r.add_argument("--lower", action="store_true", help="lowercase the whole name")
547
+ r.add_argument("--sort", choices=["name", "date", "taken", "size"], default="name", help="order used for {n}")
548
+ r.add_argument("--start", type=int, default=1, help="first value of {n} (default: 1)")
549
+
550
+ u = sub.add_parser("undo", help="revert the last applied run in a folder")
551
+ u.add_argument("folder", type=Path)
552
+ u.add_argument("--journal", type=Path, help="a specific journal file to revert")
553
+
554
+ for name, what in (("organize", "sort files into folders"), ("dedupe", "move exact duplicates aside")):
555
+ pro = sub.add_parser(name, help=f"[Pro] {what}")
556
+ pro.add_argument("rest", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
557
+ return parser
558
+
559
+
560
+ def main(argv: list[str] | None = None) -> int:
561
+ # Windows consoles/redirects may use a legacy code page: never crash on a file name.
562
+ for stream in (sys.stdout, sys.stderr):
563
+ if hasattr(stream, "reconfigure"):
564
+ stream.reconfigure(errors="replace")
565
+ args = build_parser().parse_args(argv)
566
+ if args.command in ("organize", "dedupe"):
567
+ # Non-zero so a script calling it never mistakes "not available" for success.
568
+ print(f"`{args.command}` is part of TidyBatch Pro (one-time purchase, includes ready-to-run apps):\n"
569
+ f" https://whop.com/tidybatch/bulk-file-renamer/\n"
570
+ "TidyBatch Core (this version) does `rename` and `undo`.", file=sys.stderr)
571
+ return 2
572
+ root: Path = args.folder.expanduser()
573
+ if not root.is_dir():
574
+ print(f"error: '{root}' is not a folder", file=sys.stderr)
575
+ return 2
576
+
577
+ try:
578
+ if args.command == "undo":
579
+ return undo(root, args.journal)
580
+
581
+ files = sort_files(iter_files(root, args.recursive, args.glob, args.hidden),
582
+ getattr(args, "sort", "name"))
583
+ moves = plan_rename(files, args.template, args.find,
584
+ args.replace, args.slug, args.lower, args.start)
585
+
586
+ show_plan(moves, root, applying=args.apply)
587
+ if not moves:
588
+ return 0
589
+ if not args.apply:
590
+ print("Dry run: nothing was changed. Add --apply to do it.")
591
+ return 0
592
+ journal = apply_moves(moves, root)
593
+ print(f"Done. To revert: tidybatch undo \"{root}\" (journal: {journal.name})")
594
+ return 0
595
+ except TidyError as e:
596
+ print(f"error: {e}", file=sys.stderr)
597
+ return 2
598
+ except KeyboardInterrupt:
599
+ print("\nInterrupted. Files already moved are listed in the journal; run `undo` to revert.", file=sys.stderr)
600
+ return 130
601
+
602
+
603
+ if __name__ == "__main__":
604
+ sys.exit(main())