charmfix 0.1.0__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.
charmfix-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Václav Vinklát
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,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: charmfix
3
+ Version: 0.1.0
4
+ Summary: Repair broken or missing EXIF metadata in Kodak Charmera (Generalplus) JPEGs without touching image data
5
+ Keywords: exif,jpeg,kodak,charmera,photos,metadata
6
+ Author: Václav Vinklát
7
+ Author-email: Václav Vinklát <vin@email.cz>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Multimedia :: Graphics
20
+ Requires-Dist: piexif>=1.1.3
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://github.com/vinklat/charmfix
23
+ Project-URL: Source, https://github.com/vinklat/charmfix
24
+ Project-URL: Issues, https://github.com/vinklat/charmfix/issues
25
+ Project-URL: Documentation, https://github.com/vinklat/charmfix/blob/main/CLI.md
26
+ Description-Content-Type: text/markdown
27
+
28
+ # charmfix
29
+
30
+ Repair the EXIF metadata of Kodak Charmera photos so Apple Photos, and everything else, shows the
31
+ right date, size and camera. Only metadata changes: every byte of image data stays the same (→
32
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#invariant)), and charmfix checks this before it writes each file (→
33
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#verification)). Source files are never modified (→
34
+ [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#source-safety)). Repaired copies go to a separate folder.
35
+
36
+ ## What is wrong with Charmera files
37
+
38
+ The Charmera uses a Generalplus chip; its JPEGs carry a `GPEncoder` comment. Most files have **no
39
+ EXIF at all**, so Photos falls back to the import or copy date. Other firmware writes broken EXIF:
40
+ dates like `2026:03:03:12:16:29`, a 640×480 size on a 1440×1080 image, or a MakerNote that points
41
+ outside the EXIF block. charmfix fixes all of these; the exact rules are in
42
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md).
43
+
44
+ ## Install
45
+
46
+ Released versions are published to [PyPI](https://pypi.org/project/charmfix/). With
47
+ [uv](https://docs.astral.sh/uv/):
48
+
49
+ ```sh
50
+ uv tool install charmfix # puts `charmfix` on your PATH
51
+ uvx charmfix -h # or run it without installing
52
+ ```
53
+
54
+ `pipx install charmfix` works too. Before the first release, or to run unreleased changes, install
55
+ from a checkout of this repository (→ [DEVELOPMENT.md](https://github.com/vinklat/charmfix/blob/v0.1.0/DEVELOPMENT.md#quality-gates)):
56
+
57
+ ```sh
58
+ uv tool install .
59
+ uvx --from . charmfix -h
60
+ ```
61
+
62
+ Supported Python versions → [DEVELOPMENT.md](https://github.com/vinklat/charmfix/blob/v0.1.0/DEVELOPMENT.md#prerequisites-and-setup).
63
+
64
+ ## Usage
65
+
66
+ ```sh
67
+ charmfix /Volumes/CHARMERA # writes to ~/Pictures/Kodak Charmera Repaired
68
+ charmfix /Volumes/CHARMERA -o ~/Desktop/fixed --report ~/Desktop/charmfix-report.json
69
+ charmfix /Volumes/CHARMERA --dry-run -v # show what would change, write nothing
70
+ ```
71
+
72
+ Without `-o`, copies go to the default output folder → [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#options). All options, exit
73
+ codes and the report format are in [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md). Each file gets a status →
74
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#status). Running charmfix again on the same card skips files it has already
75
+ written → [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#output-files).
76
+
77
+ **Run it on the card itself.** When a file has no usable EXIF date (often no EXIF at all), the
78
+ capture date is the file's modification time, which the camera sets when it takes the photo
79
+ ([rule 1a](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#rules)). Use the mounted card, or a copy that kept the times (`cp -p`, Finder
80
+ copy). Downloaded or exported files usually carry the copy time instead.
81
+
82
+ **macOS blocks the card?** Follow the hint charmfix prints (→ [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#discovery)): allow the
83
+ app you run it from under System Settings → Privacy & Security → Files and Folders → Removable
84
+ Volumes (or Full Disk Access), then restart that app.
85
+
86
+ ## Apple Photos workflow
87
+
88
+ 1. Insert the card and run `charmfix /Volumes/<card> --report ~/charmfix-report.json`.
89
+ 2. Check the summary and look at any `partial` or `failed` lines.
90
+ 3. In Photos, choose **File → Import…** and pick the output folder.
91
+
92
+ ## Documentation
93
+
94
+ | Location | Audience | Authority |
95
+ | -- | -- | -- |
96
+ | [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md) | Users, developers and AI agents | Canonical contract |
97
+ | [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md), [DEVELOPMENT.md](https://github.com/vinklat/charmfix/blob/v0.1.0/DEVELOPMENT.md), [TESTING.md](https://github.com/vinklat/charmfix/blob/v0.1.0/TESTING.md) | Developers and AI agents | Canonical contracts |
98
+ | [AGENTS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/AGENTS.md), [CLAUDE.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLAUDE.md) (imports AGENTS.md) | AI agents | How to use the contracts |
99
+ | `README.md` | Users | Non-canonical user guide |
100
+
101
+ ## License
102
+
103
+ MIT → [LICENSE](https://github.com/vinklat/charmfix/blob/v0.1.0/LICENSE).
@@ -0,0 +1,76 @@
1
+ # charmfix
2
+
3
+ Repair the EXIF metadata of Kodak Charmera photos so Apple Photos, and everything else, shows the
4
+ right date, size and camera. Only metadata changes: every byte of image data stays the same (→
5
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#invariant)), and charmfix checks this before it writes each file (→
6
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#verification)). Source files are never modified (→
7
+ [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#source-safety)). Repaired copies go to a separate folder.
8
+
9
+ ## What is wrong with Charmera files
10
+
11
+ The Charmera uses a Generalplus chip; its JPEGs carry a `GPEncoder` comment. Most files have **no
12
+ EXIF at all**, so Photos falls back to the import or copy date. Other firmware writes broken EXIF:
13
+ dates like `2026:03:03:12:16:29`, a 640×480 size on a 1440×1080 image, or a MakerNote that points
14
+ outside the EXIF block. charmfix fixes all of these; the exact rules are in
15
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md).
16
+
17
+ ## Install
18
+
19
+ Released versions are published to [PyPI](https://pypi.org/project/charmfix/). With
20
+ [uv](https://docs.astral.sh/uv/):
21
+
22
+ ```sh
23
+ uv tool install charmfix # puts `charmfix` on your PATH
24
+ uvx charmfix -h # or run it without installing
25
+ ```
26
+
27
+ `pipx install charmfix` works too. Before the first release, or to run unreleased changes, install
28
+ from a checkout of this repository (→ [DEVELOPMENT.md](https://github.com/vinklat/charmfix/blob/v0.1.0/DEVELOPMENT.md#quality-gates)):
29
+
30
+ ```sh
31
+ uv tool install .
32
+ uvx --from . charmfix -h
33
+ ```
34
+
35
+ Supported Python versions → [DEVELOPMENT.md](https://github.com/vinklat/charmfix/blob/v0.1.0/DEVELOPMENT.md#prerequisites-and-setup).
36
+
37
+ ## Usage
38
+
39
+ ```sh
40
+ charmfix /Volumes/CHARMERA # writes to ~/Pictures/Kodak Charmera Repaired
41
+ charmfix /Volumes/CHARMERA -o ~/Desktop/fixed --report ~/Desktop/charmfix-report.json
42
+ charmfix /Volumes/CHARMERA --dry-run -v # show what would change, write nothing
43
+ ```
44
+
45
+ Without `-o`, copies go to the default output folder → [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#options). All options, exit
46
+ codes and the report format are in [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md). Each file gets a status →
47
+ [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#status). Running charmfix again on the same card skips files it has already
48
+ written → [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#output-files).
49
+
50
+ **Run it on the card itself.** When a file has no usable EXIF date (often no EXIF at all), the
51
+ capture date is the file's modification time, which the camera sets when it takes the photo
52
+ ([rule 1a](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md#rules)). Use the mounted card, or a copy that kept the times (`cp -p`, Finder
53
+ copy). Downloaded or exported files usually carry the copy time instead.
54
+
55
+ **macOS blocks the card?** Follow the hint charmfix prints (→ [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md#discovery)): allow the
56
+ app you run it from under System Settings → Privacy & Security → Files and Folders → Removable
57
+ Volumes (or Full Disk Access), then restart that app.
58
+
59
+ ## Apple Photos workflow
60
+
61
+ 1. Insert the card and run `charmfix /Volumes/<card> --report ~/charmfix-report.json`.
62
+ 2. Check the summary and look at any `partial` or `failed` lines.
63
+ 3. In Photos, choose **File → Import…** and pick the output folder.
64
+
65
+ ## Documentation
66
+
67
+ | Location | Audience | Authority |
68
+ | -- | -- | -- |
69
+ | [CLI.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLI.md) | Users, developers and AI agents | Canonical contract |
70
+ | [REPAIRS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/REPAIRS.md), [DEVELOPMENT.md](https://github.com/vinklat/charmfix/blob/v0.1.0/DEVELOPMENT.md), [TESTING.md](https://github.com/vinklat/charmfix/blob/v0.1.0/TESTING.md) | Developers and AI agents | Canonical contracts |
71
+ | [AGENTS.md](https://github.com/vinklat/charmfix/blob/v0.1.0/AGENTS.md), [CLAUDE.md](https://github.com/vinklat/charmfix/blob/v0.1.0/CLAUDE.md) (imports AGENTS.md) | AI agents | How to use the contracts |
72
+ | `README.md` | Users | Non-canonical user guide |
73
+
74
+ ## License
75
+
76
+ MIT → [LICENSE](https://github.com/vinklat/charmfix/blob/v0.1.0/LICENSE).
@@ -0,0 +1,86 @@
1
+ [project]
2
+ name = "charmfix"
3
+ version = "0.1.0"
4
+ description = "Repair broken or missing EXIF metadata in Kodak Charmera (Generalplus) JPEGs without touching image data"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = ["piexif>=1.1.3"]
10
+ keywords = [
11
+ "exif",
12
+ "jpeg",
13
+ "kodak",
14
+ "charmera",
15
+ "photos",
16
+ "metadata",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: End Users/Desktop",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Multimedia :: Graphics",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "Václav Vinklát"
33
+ email = "vin@email.cz"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/vinklat/charmfix"
37
+ Source = "https://github.com/vinklat/charmfix"
38
+ Issues = "https://github.com/vinklat/charmfix/issues"
39
+ Documentation = "https://github.com/vinklat/charmfix/blob/main/CLI.md"
40
+
41
+ [project.scripts]
42
+ charmfix = "charmfix.cli:main"
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.12.23,<0.14.0"]
46
+ build-backend = "uv_build"
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest>=8",
51
+ "pillow>=11",
52
+ "hypothesis>=6",
53
+ "ruff>=0.8",
54
+ "mypy>=1.13",
55
+ ]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ addopts = "-ra"
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py311"
64
+
65
+ [tool.ruff.lint]
66
+ select = [
67
+ "E",
68
+ "F",
69
+ "W",
70
+ "I",
71
+ "B",
72
+ "UP",
73
+ "SIM",
74
+ "RUF",
75
+ ]
76
+
77
+ [tool.mypy]
78
+ python_version = "3.11"
79
+ strict = true
80
+
81
+ [[tool.mypy.overrides]]
82
+ module = [
83
+ "piexif",
84
+ "piexif.*",
85
+ ]
86
+ ignore_missing_imports = true
@@ -0,0 +1,66 @@
1
+ [project]
2
+ name = "charmfix"
3
+ version = "0.1.0"
4
+ description = "Repair broken or missing EXIF metadata in Kodak Charmera (Generalplus) JPEGs without touching image data"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Václav Vinklát", email = "vin@email.cz" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ dependencies = ["piexif>=1.1.3"]
13
+ keywords = ["exif", "jpeg", "kodak", "charmera", "photos", "metadata"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Multimedia :: Graphics",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/vinklat/charmfix"
29
+ Source = "https://github.com/vinklat/charmfix"
30
+ Issues = "https://github.com/vinklat/charmfix/issues"
31
+ Documentation = "https://github.com/vinklat/charmfix/blob/main/CLI.md"
32
+
33
+ [project.scripts]
34
+ charmfix = "charmfix.cli:main"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.12.23,<0.14.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=8",
43
+ "pillow>=11",
44
+ "hypothesis>=6",
45
+ "ruff>=0.8",
46
+ "mypy>=1.13",
47
+ ]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ addopts = "-ra"
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py311"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.11"
62
+ strict = true
63
+
64
+ [[tool.mypy.overrides]]
65
+ module = ["piexif", "piexif.*"]
66
+ ignore_missing_imports = true
@@ -0,0 +1,10 @@
1
+ """charmfix: repair EXIF metadata of Kodak Charmera JPEGs without touching image data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("charmfix")
9
+ except PackageNotFoundError: # run from a source tree that was never installed
10
+ __version__ = "0+unknown"
@@ -0,0 +1,233 @@
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+
12
+ from . import __version__
13
+ from .discovery import unreadable_reason
14
+ from .pipeline import DiskFullError, Job, RunOptions, prepare, process_file
15
+ from .report import FileResult, Summary, build_report, write_report
16
+
17
+ DEFAULT_OUTPUT = Path.home() / "Pictures" / "Kodak Charmera Repaired"
18
+ MACOS_PERMISSION_HINT = (
19
+ "macOS is blocking access. Allow the app you run charmfix from (Terminal, iTerm, "
20
+ "VS Code, ...) under System Settings > Privacy & Security > Files and Folders > "
21
+ "Removable Volumes (or Full Disk Access), then restart that app and try again."
22
+ )
23
+ EXIT_OK, EXIT_FAILURES, EXIT_FATAL, EXIT_INTERRUPTED = 0, 1, 2, 130
24
+
25
+ log = logging.getLogger("charmfix")
26
+
27
+
28
+ class _Progress:
29
+ """A single status line rewritten on stderr, only when stderr is a terminal."""
30
+
31
+ def __init__(self, stream: TextIO, enabled: bool) -> None:
32
+ self.stream, self.enabled, self.active = stream, enabled, False
33
+
34
+ def update(self, text: str) -> None:
35
+ if self.enabled:
36
+ self.stream.write("\r\x1b[K" + text[:120])
37
+ self.stream.flush()
38
+ self.active = True
39
+
40
+ def clear(self) -> None:
41
+ if self.active:
42
+ self.stream.write("\r\x1b[K")
43
+ self.stream.flush()
44
+ self.active = False
45
+
46
+
47
+ class _ProgressAwareHandler(logging.StreamHandler): # type: ignore[type-arg]
48
+ def __init__(self, progress: _Progress) -> None:
49
+ super().__init__(sys.stderr)
50
+ self.progress = progress
51
+
52
+ def emit(self, record: logging.LogRecord) -> None:
53
+ self.progress.clear()
54
+ super().emit(record)
55
+
56
+
57
+ def _parser() -> argparse.ArgumentParser:
58
+ p = argparse.ArgumentParser(
59
+ prog="charmfix",
60
+ description="Write copies of Kodak Charmera JPEGs with repaired EXIF metadata. "
61
+ "Image data is never changed and source files are never modified.",
62
+ )
63
+ p.add_argument("input", type=Path, help="folder (e.g. the mounted SD card) or a single JPEG")
64
+ p.add_argument(
65
+ "-o",
66
+ "--output",
67
+ type=Path,
68
+ default=DEFAULT_OUTPUT,
69
+ help=f"output folder (default: {DEFAULT_OUTPUT})",
70
+ )
71
+ p.add_argument(
72
+ "--recursive",
73
+ action=argparse.BooleanOptionalAction,
74
+ default=True,
75
+ help="descend into subfolders (default: yes)",
76
+ )
77
+ p.add_argument("-n", "--dry-run", action="store_true", help="analyse only; write no images")
78
+ p.add_argument("--force", action="store_true", help="overwrite existing output files")
79
+ p.add_argument("-v", "--verbose", action="store_true", help="log details for every file")
80
+ p.add_argument("--report", type=Path, help="write a JSON report to this path")
81
+ p.add_argument(
82
+ "--no-preserve-structure",
83
+ dest="preserve_structure",
84
+ action="store_false",
85
+ help="put all outputs directly in the output folder",
86
+ )
87
+ p.add_argument(
88
+ "--no-assume-charmera",
89
+ dest="assume_charmera",
90
+ action="store_false",
91
+ help="do not set Make/Model to Kodak/Charmera on files with only a GPEncoder comment",
92
+ )
93
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
94
+ return p
95
+
96
+
97
+ def _same(a: Path, b: Path) -> bool:
98
+ """Same file or folder on disk, whatever the spelling (letter case, Unicode form).
99
+
100
+ A path that cannot be statted counts as different: nothing can be written there either.
101
+ """
102
+ if a == b:
103
+ return True
104
+ try:
105
+ return a.samefile(b)
106
+ except OSError:
107
+ return False
108
+
109
+
110
+ def _within(path: Path, root: Path) -> bool:
111
+ """``path`` is ``root`` or lies under it, judged by identity on disk."""
112
+ return any(_same(p, root) for p in (path, *path.parents))
113
+
114
+
115
+ def _refusal(
116
+ input_path: Path, is_folder: bool, output_root: Path, report_path: Path | None
117
+ ) -> str | None:
118
+ """Why these paths would put a source file at risk (CLI.md source safety), or None."""
119
+ if is_folder:
120
+ if _within(output_root, input_path) or _within(input_path, output_root):
121
+ return "input and output folders must not overlap"
122
+ if report_path and _within(report_path, input_path):
123
+ return "the report must not be written inside the input folder"
124
+ else:
125
+ if _same(output_root, input_path.parent):
126
+ return "the output folder must not be the input file's folder"
127
+ if report_path and _same(report_path, input_path):
128
+ return "the report must not replace the input file"
129
+ return None
130
+
131
+
132
+ def _log_result(r: FileResult) -> None:
133
+ if r.status == "failed":
134
+ log.error("FAILED %s: %s", r.rel_path, r.error)
135
+ elif r.status == "partial":
136
+ log.warning("partial %s: %s", r.rel_path, "; ".join(r.partial_reasons))
137
+ elif r.warnings:
138
+ log.warning("%-7s %s: %s", r.status, r.rel_path, "; ".join(r.warnings))
139
+ else:
140
+ log.debug("%-7s %s", r.status, r.rel_path)
141
+ if r.status not in ("skipped", "failed"):
142
+ log.debug(
143
+ " SOF %s, old sizes %s; date %r -> %s (source: %s); identity %s; MakerNote %s",
144
+ r.sof_size,
145
+ r.old_sizes,
146
+ r.raw_date,
147
+ r.date_written,
148
+ r.timestamp_source,
149
+ r.identity,
150
+ r.makernote,
151
+ )
152
+ for change in r.changes:
153
+ log.debug(" change: %s", change)
154
+ elif r.reason:
155
+ log.debug(" reason: %s", r.reason)
156
+
157
+
158
+ def main(argv: Sequence[str] | None = None) -> int:
159
+ args = _parser().parse_args(argv)
160
+ progress = _Progress(sys.stderr, enabled=sys.stderr.isatty() and not args.verbose)
161
+ handler = _ProgressAwareHandler(progress)
162
+ handler.setFormatter(logging.Formatter("%(message)s"))
163
+ log.handlers[:] = [handler]
164
+ log.setLevel(logging.DEBUG if args.verbose else logging.INFO)
165
+ log.propagate = False
166
+
167
+ input_path: Path = args.input.expanduser().resolve()
168
+ output_root: Path = args.output.expanduser().resolve()
169
+ if not input_path.exists():
170
+ log.error("input not found: %s", args.input)
171
+ return EXIT_FATAL
172
+ report_path = args.report.expanduser().resolve() if args.report else None
173
+ is_folder = input_path.is_dir() # anything else is one file, as in discovery
174
+ refusal = _refusal(input_path, is_folder, output_root, report_path)
175
+ if refusal:
176
+ log.error(refusal)
177
+ return EXIT_FATAL
178
+
179
+ opts = RunOptions(
180
+ output_root=output_root,
181
+ recursive=args.recursive,
182
+ preserve_structure=args.preserve_structure,
183
+ force=args.force,
184
+ dry_run=args.dry_run,
185
+ assume_charmera=args.assume_charmera,
186
+ )
187
+ input_root = input_path if is_folder else input_path.parent
188
+ reason = unreadable_reason(input_root)
189
+ if reason is not None:
190
+ log.error("cannot read %s: %s", input_root, reason)
191
+ if sys.platform == "darwin" and reason == "Operation not permitted":
192
+ log.error(MACOS_PERMISSION_HINT)
193
+ return EXIT_FATAL
194
+
195
+ summary = Summary()
196
+ results: list[FileResult] = []
197
+ exit_code = EXIT_OK
198
+ try:
199
+ jobs: list[Job] = prepare(input_path, opts) # walks the card: Ctrl-C may land here too
200
+ if not jobs:
201
+ log.warning("no .jpg/.jpeg files found in %s", input_path)
202
+ summary.discovered = len(jobs)
203
+ for i, job in enumerate(jobs, 1):
204
+ progress.update(f"[{i}/{len(jobs)}] {job.candidate.rel.as_posix()}")
205
+ r = process_file(job, opts)
206
+ results.append(r)
207
+ summary.add(r)
208
+ _log_result(r)
209
+ except DiskFullError as exc:
210
+ summary.stopped = str(exc)
211
+ log.error("stopping: %s", exc)
212
+ exit_code = EXIT_FATAL
213
+ except KeyboardInterrupt:
214
+ summary.stopped = "interrupted"
215
+ exit_code = EXIT_INTERRUPTED
216
+ finally:
217
+ progress.clear()
218
+
219
+ print(summary.render(args.dry_run))
220
+ if report_path:
221
+ try:
222
+ write_report(report_path, build_report(results, summary, opts.report_options()))
223
+ except OSError as exc:
224
+ log.error("cannot write the report: %s", exc.strerror or exc.__class__.__name__)
225
+ if exit_code != EXIT_INTERRUPTED:
226
+ exit_code = EXIT_FATAL
227
+ if exit_code == EXIT_OK and summary.failed:
228
+ exit_code = EXIT_FAILURES
229
+ return exit_code
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(main())
@@ -0,0 +1,54 @@
1
+ """EXIF date parsing and formatting. Dates are naive local wall-clock times."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timedelta
7
+
8
+ EXIF_FORMAT = "%Y:%m:%d %H:%M:%S"
9
+
10
+ # Accepts the standard "YYYY:MM:DD HH:MM:SS" and the broken variants seen in the
11
+ # wild, e.g. the Charmera's six-part "YYYY:MM:DD:HH:MM:SS", or "T"/"-" separators.
12
+ _DATE_RE = re.compile(r"^\s*(\d{4})[:\-](\d{2})[:\-](\d{2})[ :T\-](\d{2}):(\d{2}):(\d{2})\s*$")
13
+ _NORMAL_RE = re.compile(r"^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$")
14
+
15
+
16
+ def _text(raw: bytes | str) -> str:
17
+ if isinstance(raw, bytes):
18
+ raw = raw.decode("ascii", errors="replace")
19
+ return raw.rstrip("\x00")
20
+
21
+
22
+ def normalize(raw: bytes | str | None) -> datetime | None:
23
+ """Parse an EXIF-ish date string; None for blank, zero or impossible dates."""
24
+ if raw is None:
25
+ return None
26
+ m = _DATE_RE.match(_text(raw))
27
+ if not m:
28
+ return None
29
+ year, month, day, hour, minute, second = (int(g) for g in m.groups())
30
+ try:
31
+ return datetime(year, month, day, hour, minute, second)
32
+ except ValueError:
33
+ return None
34
+
35
+
36
+ def is_normal_form(raw: bytes | str | None) -> bool:
37
+ """True when the value is already a valid, standard EXIF date string."""
38
+ if raw is None:
39
+ return False
40
+ text = _text(raw)
41
+ return bool(_NORMAL_RE.match(text)) and normalize(text) is not None
42
+
43
+
44
+ def format_exif(dt: datetime) -> str:
45
+ return dt.strftime(EXIF_FORMAT)
46
+
47
+
48
+ def sanity_warning(dt: datetime, now: datetime) -> str | None:
49
+ """A warning for dates that parse but look implausible; the date is still used."""
50
+ if dt.year < 2000:
51
+ return f"capture date {format_exif(dt)} is before 2000 (camera clock not set?)"
52
+ if dt > now + timedelta(days=1):
53
+ return f"capture date {format_exif(dt)} is in the future"
54
+ return None