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 +21 -0
- charmfix-0.1.0/PKG-INFO +103 -0
- charmfix-0.1.0/README.md +76 -0
- charmfix-0.1.0/pyproject.toml +86 -0
- charmfix-0.1.0/pyproject.toml.orig +66 -0
- charmfix-0.1.0/src/charmfix/__init__.py +10 -0
- charmfix-0.1.0/src/charmfix/cli.py +233 -0
- charmfix-0.1.0/src/charmfix/dates.py +54 -0
- charmfix-0.1.0/src/charmfix/discovery.py +85 -0
- charmfix-0.1.0/src/charmfix/exif_read.py +217 -0
- charmfix-0.1.0/src/charmfix/exif_write.py +148 -0
- charmfix-0.1.0/src/charmfix/jpeg.py +221 -0
- charmfix-0.1.0/src/charmfix/output.py +129 -0
- charmfix-0.1.0/src/charmfix/pipeline.py +152 -0
- charmfix-0.1.0/src/charmfix/repair.py +521 -0
- charmfix-0.1.0/src/charmfix/report.py +138 -0
- charmfix-0.1.0/src/charmfix/verify.py +60 -0
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.
|
charmfix-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
charmfix-0.1.0/README.md
ADDED
|
@@ -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
|