chronocatalog-desktop 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.
Files changed (43) hide show
  1. chronocatalog_desktop-0.1.0/.github/workflows/ci.yml +61 -0
  2. chronocatalog_desktop-0.1.0/.github/workflows/release.yml +139 -0
  3. chronocatalog_desktop-0.1.0/.gitignore +11 -0
  4. chronocatalog_desktop-0.1.0/CHANGELOG.md +67 -0
  5. chronocatalog_desktop-0.1.0/CONTRIBUTING.md +42 -0
  6. chronocatalog_desktop-0.1.0/ChronoCatalog.spec +35 -0
  7. chronocatalog_desktop-0.1.0/LICENSE +21 -0
  8. chronocatalog_desktop-0.1.0/PKG-INFO +102 -0
  9. chronocatalog_desktop-0.1.0/README.md +66 -0
  10. chronocatalog_desktop-0.1.0/assets/make_icon.py +144 -0
  11. chronocatalog_desktop-0.1.0/demo/make_demo.py +111 -0
  12. chronocatalog_desktop-0.1.0/pyproject.toml +87 -0
  13. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/__init__.py +3 -0
  14. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/__main__.py +8 -0
  15. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/app.py +259 -0
  16. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/base.py +305 -0
  17. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/buckets.py +88 -0
  18. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/history.py +176 -0
  19. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/import_view.py +346 -0
  20. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/organize.py +229 -0
  21. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/overview.py +59 -0
  22. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/rename.py +312 -0
  23. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/resources/icon.png +0 -0
  24. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/settings.py +330 -0
  25. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/theme.py +156 -0
  26. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/verify.py +181 -0
  27. chronocatalog_desktop-0.1.0/src/chronocatalog_desktop/worker.py +88 -0
  28. chronocatalog_desktop-0.1.0/tests/__init__.py +0 -0
  29. chronocatalog_desktop-0.1.0/tests/conftest.py +27 -0
  30. chronocatalog_desktop-0.1.0/tests/support.py +82 -0
  31. chronocatalog_desktop-0.1.0/tests/test_app.py +84 -0
  32. chronocatalog_desktop-0.1.0/tests/test_cli_panel.py +69 -0
  33. chronocatalog_desktop-0.1.0/tests/test_history_view.py +97 -0
  34. chronocatalog_desktop-0.1.0/tests/test_import_view.py +125 -0
  35. chronocatalog_desktop-0.1.0/tests/test_organize_view.py +118 -0
  36. chronocatalog_desktop-0.1.0/tests/test_rename_dam.py +115 -0
  37. chronocatalog_desktop-0.1.0/tests/test_rename_view.py +72 -0
  38. chronocatalog_desktop-0.1.0/tests/test_scale.py +97 -0
  39. chronocatalog_desktop-0.1.0/tests/test_settings_view.py +102 -0
  40. chronocatalog_desktop-0.1.0/tests/test_theme.py +18 -0
  41. chronocatalog_desktop-0.1.0/tests/test_verify_view.py +60 -0
  42. chronocatalog_desktop-0.1.0/tests/test_worker.py +69 -0
  43. chronocatalog_desktop-0.1.0/uv.lock +566 -0
@@ -0,0 +1,61 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ env:
16
+ QT_QPA_PLATFORM: offscreen
17
+
18
+ jobs:
19
+ lint:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v7.0.0
23
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
24
+ with:
25
+ python-version: "3.13"
26
+ - name: Install package
27
+ run: python -m pip install -e ".[dev]"
28
+ - name: Ruff lint
29
+ run: ruff check .
30
+ - name: Ruff format
31
+ run: ruff format --check .
32
+ - name: Mypy
33
+ run: mypy
34
+
35
+ test:
36
+ strategy:
37
+ fail-fast: false
38
+ matrix:
39
+ os: [ubuntu-latest, macos-latest]
40
+ python-version: ["3.11", "3.13"]
41
+ runs-on: ${{ matrix.os }}
42
+ steps:
43
+ - uses: actions/checkout@v7.0.0
44
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
45
+ with:
46
+ python-version: ${{ matrix.python-version }}
47
+ - name: Install Qt runtime libraries (Linux)
48
+ if: runner.os == 'Linux'
49
+ run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0
50
+ - name: Install ExifTool (Linux)
51
+ if: runner.os == 'Linux'
52
+ run: sudo apt-get install -y libimage-exiftool-perl
53
+ - name: Install ExifTool (macOS)
54
+ if: runner.os == 'macOS'
55
+ run: brew install exiftool
56
+ - name: Show ExifTool version
57
+ run: exiftool -ver
58
+ - name: Install package
59
+ run: python -m pip install -e ".[dev]"
60
+ - name: Run tests
61
+ run: pytest --cov=chronocatalog_desktop --cov-report=term-missing --cov-fail-under=85
@@ -0,0 +1,139 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ env:
11
+ QT_QPA_PLATFORM: offscreen
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
18
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
19
+ with:
20
+ python-version: "3.13"
21
+ - name: Install Qt runtime libraries
22
+ run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0
23
+ - name: Install ExifTool
24
+ run: sudo apt-get install -y libimage-exiftool-perl
25
+ - name: Run checks
26
+ run: |
27
+ python -m pip install -e ".[dev]"
28
+ ruff check .
29
+ ruff format --check .
30
+ mypy
31
+ pytest -q
32
+ - name: Tag must match the package version
33
+ run: |
34
+ version=$(python -c "import chronocatalog_desktop; print(chronocatalog_desktop.__version__)")
35
+ test "v$version" = "$GITHUB_REF_NAME" || {
36
+ echo "tag $GITHUB_REF_NAME does not match version $version" >&2
37
+ exit 1
38
+ }
39
+ - name: Build distributions
40
+ run: |
41
+ python -m pip install build twine
42
+ python -m build
43
+ twine check dist/*
44
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+
49
+ publish:
50
+ needs: build
51
+ runs-on: ubuntu-latest
52
+ environment: pypi
53
+ permissions:
54
+ # PyPI Trusted Publishing: configure this repository as a trusted
55
+ # publisher on pypi.org (project: chronocatalog-desktop, workflow:
56
+ # release.yml, environment: pypi). No stored tokens.
57
+ id-token: write
58
+ steps:
59
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
60
+ with:
61
+ name: dist
62
+ path: dist/
63
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
64
+
65
+ github-release:
66
+ needs: build
67
+ runs-on: ubuntu-latest
68
+ permissions:
69
+ contents: write
70
+ steps:
71
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
72
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
73
+ with:
74
+ name: dist
75
+ path: dist/
76
+ - name: Extract this version's changelog section
77
+ run: |
78
+ awk -v version="${GITHUB_REF_NAME#v}" '
79
+ $0 ~ "^## \\[" version "\\]" { found = 1; next }
80
+ found && /^## \[/ { exit }
81
+ found { print }
82
+ ' CHANGELOG.md > release-notes.md
83
+ test -s release-notes.md || {
84
+ echo "no changelog section for $GITHUB_REF_NAME" >&2
85
+ exit 1
86
+ }
87
+ - name: Create GitHub release
88
+ env:
89
+ GH_TOKEN: ${{ github.token }}
90
+ run: |
91
+ gh release create "$GITHUB_REF_NAME" dist/* \
92
+ --repo "$GITHUB_REPOSITORY" \
93
+ --title "$GITHUB_REF_NAME" \
94
+ --notes-file release-notes.md
95
+
96
+ macos-app:
97
+ # Unsigned ad-hoc build: macOS blocks the first launch until the user
98
+ # approves it under System Settings > Privacy & Security > Open Anyway.
99
+ # Signing and notarization land when there is a Developer ID to sign with.
100
+ needs: github-release
101
+ runs-on: macos-15 # arm64
102
+ permissions:
103
+ contents: write
104
+ steps:
105
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
106
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
107
+ with:
108
+ python-version: "3.13"
109
+ - name: Build the app bundle
110
+ run: |
111
+ python -m pip install -e . pyinstaller
112
+ python assets/make_icon.py
113
+ pyinstaller --noconfirm ChronoCatalog.spec
114
+ - name: App must start
115
+ env:
116
+ QT_QPA_PLATFORM: offscreen
117
+ run: |
118
+ dist/ChronoCatalog.app/Contents/MacOS/ChronoCatalog &
119
+ sleep 8
120
+ kill -0 $! || { echo "bundled app exited early" >&2; exit 1; }
121
+ kill $!
122
+ - name: Package the disk image
123
+ run: |
124
+ brew install create-dmg
125
+ create-dmg \
126
+ --volname ChronoCatalog \
127
+ --window-size 540 380 \
128
+ --icon-size 128 \
129
+ --icon ChronoCatalog.app 140 180 \
130
+ --app-drop-link 400 180 \
131
+ "dist/ChronoCatalog-$GITHUB_REF_NAME-arm64.dmg" \
132
+ dist/ChronoCatalog.app
133
+ - name: Attach to the release
134
+ env:
135
+ GH_TOKEN: ${{ github.token }}
136
+ run: |
137
+ gh release upload "$GITHUB_REF_NAME" \
138
+ "dist/ChronoCatalog-$GITHUB_REF_NAME-arm64.dmg" \
139
+ --repo "$GITHUB_REPOSITORY"
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .coverage
5
+ dist/
6
+ demo/archive/
7
+ demo/card/
8
+ demo/demo.toml
9
+ build/
10
+ assets/icon.icns
11
+ assets/icon.iconset/
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-10
9
+
10
+ ### Fixed
11
+
12
+ - Files excluded by import ignore patterns are counted in the Organize
13
+ summary, and a folder where every file is excluded says so explicitly
14
+ in Organize and Import instead of rendering as empty.
15
+
16
+ ### Changed
17
+
18
+ - Groups replace families throughout, matching chronocatalog 0.2
19
+ vocabulary; requires chronocatalog >= 0.2.
20
+ - Depends on PySide6-Essentials instead of the full PySide6
21
+ metapackage — the app uses no Addons module, and the packaged
22
+ bundle is roughly half the size.
23
+
24
+ ### Added
25
+
26
+ - App icon: an aperture iris with clock hands in the opening, drawn
27
+ from geometry at every size (small sizes are redrawn bolder, not
28
+ downscaled).
29
+ - macOS app bundle: releases attach an Apple Silicon .dmg built with
30
+ PyInstaller; the bundle is ad-hoc signed until there is a Developer
31
+ ID, and the README documents the one-time Open Anyway step.
32
+ - Dark theme: graphite palette with a safelight-amber accent.
33
+ - Background worker: library calls run off the UI thread with
34
+ throttled progress events and a cooperative stop flag.
35
+ - Application shell: archive-centric window with a sidebar of views
36
+ and an Overview of the archive and its trees.
37
+ - Verify view: findings in library order colored by library severity,
38
+ plain-language explanations, structured date-mismatch details, live
39
+ progress and Stop.
40
+ - History view: every run against the archive with its originating
41
+ command, timestamp and status; Undo for applied runs, Resume for
42
+ interrupted ones.
43
+ - Rename view: the plan as old → new with the changed span
44
+ highlighted, groups kept whole, apply with confirm and Stop.
45
+ - Terminal transparency: a quiet >_ toggle reveals each action's
46
+ exact CLI command with Copy, and confirmation dialogs carry the
47
+ command under Show Details.
48
+ - Demo archive generator for a safe tour of every view.
49
+ - Import view: card to archive with live progress, problem list and
50
+ the safe-to-format verdict — green only when the library itself
51
+ issues it.
52
+ - Organize view: report-only triage of messy folders with a hand-off
53
+ to Import for confirmed batches.
54
+ - DAM hand-off in the Rename view: masters the DAM must rename itself
55
+ are listed with their tokens and the in-DAM checklist; writing tokens
56
+ is its own confirmed action, verified by reading each token back.
57
+ - Hardening for huge archives: capped rendering with exact counts
58
+ everywhere, accurate post-apply import summaries (failed groups are
59
+ never reported as copied), History capped and guarded against
60
+ double-clicks.
61
+ - Settings view: edit the archive configuration with validation before
62
+ every save and hand-written comments preserved; the naming pattern is
63
+ shown read-only. First-run flow can create a new archive config.
64
+ - Design pass: one clean surface per card (no label striping), sidebar
65
+ wordmark, empty states on every action view, tinted verdict banner,
66
+ severity stripes, status pills, calm undo, styled form controls, and
67
+ per-view status messages that can no longer go stale.
@@ -0,0 +1,42 @@
1
+ # Contributing
2
+
3
+ ## Development setup
4
+
5
+ ```console
6
+ $ python3 -m venv .venv
7
+ $ .venv/bin/pip install -e ".[dev]"
8
+ ```
9
+
10
+ To develop against a local `chronocatalog` checkout (changing library
11
+ and app in lockstep), install it editable first:
12
+
13
+ ```console
14
+ $ .venv/bin/pip install -e ../chronocatalog -e ".[dev]"
15
+ ```
16
+
17
+ [ExifTool](https://exiftool.org/) must be on `PATH`; integration tests
18
+ skip without it, but CI always runs them. Tests run Qt headless
19
+ (`QT_QPA_PLATFORM=offscreen`); no display is needed.
20
+
21
+ ## Checks
22
+
23
+ Everything CI runs, locally:
24
+
25
+ ```console
26
+ $ ruff format --check .
27
+ $ ruff check .
28
+ $ mypy
29
+ $ pytest --cov=chronocatalog_desktop
30
+ ```
31
+
32
+ All four must pass; coverage is gated at 85%.
33
+
34
+ ## Expectations
35
+
36
+ - Every change ships with tests and any needed documentation in the same
37
+ commit.
38
+ - The app adds no behavior of its own: every screen renders the
39
+ library's plans and reports, and every mutation goes through the same
40
+ validated, journaled engine as the CLI. Treat a change that would
41
+ bypass that as a design discussion, not a patch.
42
+ - Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`).
@@ -0,0 +1,35 @@
1
+ # PyInstaller build for the macOS app bundle.
2
+ # Build with: pyinstaller --noconfirm ChronoCatalog.spec
3
+
4
+ a = Analysis(
5
+ ["src/chronocatalog_desktop/__main__.py"],
6
+ datas=[("src/chronocatalog_desktop/resources", "chronocatalog_desktop/resources")],
7
+ )
8
+ pyz = PYZ(a.pure)
9
+
10
+ exe = EXE(
11
+ pyz,
12
+ a.scripts,
13
+ exclude_binaries=True,
14
+ name="ChronoCatalog",
15
+ console=False,
16
+ )
17
+
18
+ coll = COLLECT(
19
+ exe,
20
+ a.binaries,
21
+ a.datas,
22
+ name="ChronoCatalog",
23
+ )
24
+
25
+ app = BUNDLE(
26
+ coll,
27
+ name="ChronoCatalog.app",
28
+ icon="assets/icon.icns",
29
+ bundle_identifier="org.chronocatalog.desktop",
30
+ info_plist={
31
+ "CFBundleDisplayName": "ChronoCatalog",
32
+ "NSHighResolutionCapable": True,
33
+ "LSMinimumSystemVersion": "12.0",
34
+ },
35
+ )
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jakub Stefanski
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,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: chronocatalog-desktop
3
+ Version: 0.1.0
4
+ Summary: Desktop app for ChronoCatalog photo and video archives
5
+ Project-URL: Homepage, https://github.com/chronocatalog/chronocatalog-desktop
6
+ Project-URL: Repository, https://github.com/chronocatalog/chronocatalog-desktop
7
+ Project-URL: Changelog, https://github.com/chronocatalog/chronocatalog-desktop/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/chronocatalog/chronocatalog-desktop/issues
9
+ Author-email: Jakub Stefanski <js@jakubstefanski.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: archive,exif,gui,photography,qt,rename,video
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: MacOS X
15
+ Classifier: Environment :: X11 Applications :: Qt
16
+ Classifier: Intended Audience :: End Users/Desktop
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Multimedia :: Graphics
24
+ Classifier: Topic :: System :: Archiving
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: chronocatalog>=0.2
27
+ Requires-Dist: pyside6-essentials>=6.7
28
+ Requires-Dist: tomlkit>=0.12
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy>=1.13; extra == 'dev'
31
+ Requires-Dist: pyside6>=6.7; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
33
+ Requires-Dist: pytest>=8.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.8; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # ChronoCatalog Desktop
38
+
39
+ Desktop app for [ChronoCatalog](https://github.com/chronocatalog/chronocatalog)
40
+ photo and video archives.
41
+
42
+ The design premise: the CLI already plans everything before touching
43
+ anything, so the app is a renderer of those plans and reports — every
44
+ view is a dry run, and Apply goes through the same validated, journaled
45
+ engine. Views are named after the commands they wrap (Verify, Rename,
46
+ History), and the exact terminal equivalent of every action is one
47
+ click away.
48
+
49
+ ## Status
50
+
51
+ Early development.
52
+
53
+ ## Install
54
+
55
+ **macOS (Apple Silicon):** download `ChronoCatalog-<version>-arm64.dmg`
56
+ from [Releases](https://github.com/chronocatalog/chronocatalog-desktop/releases)
57
+ and drag the app to Applications. The build is not yet signed with an
58
+ Apple Developer ID, so the first launch is blocked: open **System
59
+ Settings → Privacy & Security**, scroll to Security and click
60
+ **Open Anyway**. This is needed once.
61
+
62
+ **Any platform (Python 3.11+):**
63
+
64
+ ```console
65
+ $ uv tool install chronocatalog-desktop
66
+ ```
67
+
68
+ or `pipx install chronocatalog-desktop`. Either way you also need
69
+ [ExifTool](https://exiftool.org/) on `PATH` (macOS:
70
+ `brew install exiftool`).
71
+
72
+ ## Run
73
+
74
+ ```console
75
+ $ chronocatalog-desktop [archive.toml]
76
+ ```
77
+
78
+ Without an argument the app asks for an archive configuration and
79
+ remembers it. Everything is a dry run until Apply, which confirms
80
+ first; every applied change lands in History with Undo (and Resume,
81
+ for interrupted runs).
82
+
83
+ ## Demo archive
84
+
85
+ Builds a small throwaway archive under `demo/archive` (macOS: uses
86
+ `sips`), imports it through the real engine, then plants a date edit,
87
+ an unnamed file and an orphan sidecar so every view has something to
88
+ show:
89
+
90
+ ```console
91
+ $ python demo/make_demo.py
92
+ $ chronocatalog-desktop demo/demo.toml
93
+ ```
94
+
95
+ ## Requirements
96
+
97
+ - Python 3.11+
98
+ - [ExifTool](https://exiftool.org/) on `PATH`
99
+
100
+ ## License
101
+
102
+ [MIT](LICENSE)
@@ -0,0 +1,66 @@
1
+ # ChronoCatalog Desktop
2
+
3
+ Desktop app for [ChronoCatalog](https://github.com/chronocatalog/chronocatalog)
4
+ photo and video archives.
5
+
6
+ The design premise: the CLI already plans everything before touching
7
+ anything, so the app is a renderer of those plans and reports — every
8
+ view is a dry run, and Apply goes through the same validated, journaled
9
+ engine. Views are named after the commands they wrap (Verify, Rename,
10
+ History), and the exact terminal equivalent of every action is one
11
+ click away.
12
+
13
+ ## Status
14
+
15
+ Early development.
16
+
17
+ ## Install
18
+
19
+ **macOS (Apple Silicon):** download `ChronoCatalog-<version>-arm64.dmg`
20
+ from [Releases](https://github.com/chronocatalog/chronocatalog-desktop/releases)
21
+ and drag the app to Applications. The build is not yet signed with an
22
+ Apple Developer ID, so the first launch is blocked: open **System
23
+ Settings → Privacy & Security**, scroll to Security and click
24
+ **Open Anyway**. This is needed once.
25
+
26
+ **Any platform (Python 3.11+):**
27
+
28
+ ```console
29
+ $ uv tool install chronocatalog-desktop
30
+ ```
31
+
32
+ or `pipx install chronocatalog-desktop`. Either way you also need
33
+ [ExifTool](https://exiftool.org/) on `PATH` (macOS:
34
+ `brew install exiftool`).
35
+
36
+ ## Run
37
+
38
+ ```console
39
+ $ chronocatalog-desktop [archive.toml]
40
+ ```
41
+
42
+ Without an argument the app asks for an archive configuration and
43
+ remembers it. Everything is a dry run until Apply, which confirms
44
+ first; every applied change lands in History with Undo (and Resume,
45
+ for interrupted runs).
46
+
47
+ ## Demo archive
48
+
49
+ Builds a small throwaway archive under `demo/archive` (macOS: uses
50
+ `sips`), imports it through the real engine, then plants a date edit,
51
+ an unnamed file and an orphan sidecar so every view has something to
52
+ show:
53
+
54
+ ```console
55
+ $ python demo/make_demo.py
56
+ $ chronocatalog-desktop demo/demo.toml
57
+ ```
58
+
59
+ ## Requirements
60
+
61
+ - Python 3.11+
62
+ - [ExifTool](https://exiftool.org/) on `PATH`
63
+
64
+ ## License
65
+
66
+ [MIT](LICENSE)
@@ -0,0 +1,144 @@
1
+ """Render the app icon: an aperture iris with clock hands in the opening.
2
+
3
+ Regenerates every size from geometry — there is no source image. Small
4
+ sizes are redrawn with heavier strokes and less detail rather than
5
+ downscaled, so the mark stays legible in the Dock and the menu bar.
6
+
7
+ Usage:
8
+ python assets/make_icon.py
9
+
10
+ Writes the window icon into the package resources, and on macOS also
11
+ builds assets/icon.icns via iconutil.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
24
+
25
+ from PySide6 import QtCore, QtGui, QtWidgets
26
+
27
+ GRAPHITE = QtGui.QColor("#1e2128")
28
+ GRAPHITE_EDGE = QtGui.QColor("#16181d")
29
+ AMBER = QtGui.QColor("#e8a33d")
30
+ HANDS = QtGui.QColor("#e9e7e2")
31
+
32
+ #: side of the generated square, one render per icns slot
33
+ ICONSET_SIZES = (16, 32, 64, 128, 256, 512, 1024)
34
+
35
+
36
+ def _pen(color: QtGui.QColor, width: float) -> QtGui.QPen:
37
+ pen = QtGui.QPen(color, width)
38
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
39
+ return pen
40
+
41
+
42
+ def draw(painter: QtGui.QPainter, size: int) -> None:
43
+ center = QtCore.QPointF(size / 2, size / 2)
44
+ small = size <= 64
45
+ tiny = size <= 16
46
+
47
+ tile = QtCore.QRectF(size * 0.06, size * 0.06, size * 0.88, size * 0.88)
48
+ gradient = QtGui.QLinearGradient(0, tile.top(), 0, tile.bottom())
49
+ gradient.setColorAt(0.0, GRAPHITE)
50
+ gradient.setColorAt(1.0, GRAPHITE_EDGE)
51
+ painter.setPen(QtCore.Qt.PenStyle.NoPen)
52
+ painter.setBrush(gradient)
53
+ painter.drawRoundedRect(tile, size * 0.20, size * 0.20)
54
+
55
+ ring_r = size * 0.315
56
+ painter.setPen(_pen(AMBER, size * (0.055 if small else 0.036)))
57
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
58
+ painter.drawEllipse(center, ring_r, ring_r)
59
+
60
+ if not tiny:
61
+ # aperture iris: six pinwheel blades from the rim to the opening
62
+ outer_r = ring_r * 0.92
63
+ open_r = ring_r * 0.54
64
+ painter.setPen(_pen(AMBER, size * (0.045 if small else 0.028)))
65
+ twist = math.radians(62.0)
66
+ for i in range(6):
67
+ start = math.tau * i / 6
68
+ painter.drawLine(
69
+ QtCore.QPointF(
70
+ center.x() + outer_r * math.cos(start),
71
+ center.y() + outer_r * math.sin(start),
72
+ ),
73
+ QtCore.QPointF(
74
+ center.x() + open_r * math.cos(start + twist),
75
+ center.y() + open_r * math.sin(start + twist),
76
+ ),
77
+ )
78
+
79
+ # clock hands at 10:08; the aperture opening is the dial
80
+ hands = ((-60.0, 0.26, 0.046), (35.0, 0.40, 0.032))
81
+ if tiny:
82
+ hands = ((-60.0, 0.42, 0.085), (35.0, 0.62, 0.065))
83
+ elif small:
84
+ hands = ((-60.0, 0.26, 0.060), (35.0, 0.40, 0.048))
85
+ for angle_deg, length, width in hands:
86
+ angle = math.radians(angle_deg - 90.0)
87
+ painter.setPen(_pen(HANDS, size * width))
88
+ painter.drawLine(
89
+ center,
90
+ QtCore.QPointF(
91
+ center.x() + ring_r * length * math.cos(angle),
92
+ center.y() + ring_r * length * math.sin(angle),
93
+ ),
94
+ )
95
+
96
+ if not small:
97
+ painter.setPen(QtCore.Qt.PenStyle.NoPen)
98
+ painter.setBrush(AMBER)
99
+ painter.drawEllipse(center, size * 0.024, size * 0.024)
100
+
101
+
102
+ def render(size: int) -> QtGui.QImage:
103
+ image = QtGui.QImage(size, size, QtGui.QImage.Format.Format_ARGB32)
104
+ image.fill(QtCore.Qt.GlobalColor.transparent)
105
+ painter = QtGui.QPainter(image)
106
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
107
+ draw(painter, size)
108
+ painter.end()
109
+ return image
110
+
111
+
112
+ def main() -> None:
113
+ QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
114
+ assets = Path(__file__).resolve().parent
115
+ package = assets.parent / "src" / "chronocatalog_desktop"
116
+
117
+ resources = package / "resources"
118
+ resources.mkdir(exist_ok=True)
119
+ render(256).save(str(resources / "icon.png"))
120
+
121
+ iconset = assets / "icon.iconset"
122
+ if iconset.exists():
123
+ shutil.rmtree(iconset)
124
+ iconset.mkdir()
125
+ for size in ICONSET_SIZES:
126
+ image = render(size)
127
+ if size < 1024:
128
+ image.save(str(iconset / f"icon_{size}x{size}.png"))
129
+ if size > 16:
130
+ image.save(str(iconset / f"icon_{size // 2}x{size // 2}@2x.png"))
131
+
132
+ if sys.platform == "darwin" and shutil.which("iconutil"):
133
+ subprocess.run(
134
+ ["iconutil", "-c", "icns", str(iconset), "-o", str(assets / "icon.icns")],
135
+ check=True,
136
+ )
137
+ shutil.rmtree(iconset)
138
+ print(f"wrote {resources / 'icon.png'} and {assets / 'icon.icns'}")
139
+ else:
140
+ print(f"wrote {resources / 'icon.png'} and {iconset}/ (no iconutil; icns skipped)")
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()