pic-archiver 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.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: pic-archiver
3
+ Version: 0.1.0
4
+ Summary: Rename, sort, and safely archive exported picture and video files.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: Pillow
8
+ Requires-Dist: piexif
9
+ Requires-Dist: rawpy
10
+ Requires-Dist: Send2Trash
11
+
12
+ # PicArchiver
13
+
14
+ Photos ライブラリなどから書き出した写真・動画をリネームし、`archive/` と `raw/` に安全に整理するためのツールです。
15
+
16
+ ## 1. ディレクトリ構成
17
+
18
+ 写真管理用の親ディレクトリを `PA_ROOT` と呼びます。`PA_ROOT` を指定しない場合は、`parc` を実行したカレントディレクトリが親になります。
19
+
20
+ ```text
21
+ PA_ROOT/
22
+ ├── import/ # 処理対象ファイル
23
+ ├── archive/ # アーカイブ出力先(年/年-月-日 の階層で保存)
24
+ ├── raw/ # RAW ファイル出力先(拡張子ごとに分類)
25
+ ├── .stage/ # 反映前の作業コピー
26
+ └── .backups/ # apply 時の退避先
27
+ ```
28
+
29
+ ## 2. 写真を書き出す
30
+
31
+ 1. 内蔵ライブラリを起動する
32
+ 2. すべての写真を選択する
33
+ 3. `PA_ROOT/import/` に書き出す
34
+
35
+ ## 3. ターミナルで実行する
36
+
37
+ 初回だけ、PyPI からインストールします。
38
+
39
+ ```bash
40
+ pip install pic-archiver
41
+ ```
42
+
43
+ PyPI に公開する前に GitHub から直接インストールする場合は、次のコマンドを使います。
44
+
45
+ ```bash
46
+ pip install "git+https://github.com/Ryota-Nitto/PicArchiver.git"
47
+ ```
48
+
49
+ 最新版に更新したい場合は、再インストールします。
50
+
51
+ ```bash
52
+ pip install --upgrade --force-reinstall pic-archiver
53
+ ```
54
+
55
+ 開発中にこのディレクトリの変更を即反映したい場合だけ、リポジトリのディレクトリで代わりに `pip install -e .` を使います。
56
+
57
+ `parc` が見つからない場合は、`pip` の script 置き場に `PATH` を通します。Homebrew Python では `/opt/homebrew/bin`、ユーザーインストールでは `~/.local/bin` になることがあります。
58
+
59
+ ```bash
60
+ export PATH="$HOME/.local/bin:$PATH"
61
+ ```
62
+
63
+ 以後は、どのディレクトリからでも実行できます。
64
+
65
+ ```bash
66
+ # import, archive, raw を含む親ディレクトリを指定する場合(推奨)
67
+ export PA_ROOT=/path/to/pictures-root
68
+
69
+ # 変更をステージして、差分を確認
70
+ parc stage
71
+
72
+ # 問題なければ適用
73
+ parc apply
74
+ ```
75
+
76
+ `parc` 単体では使えるコマンド一覧を表示します。`parc stage` は作業コピーを作って整理処理を実行し、差分を表示します。`parc apply` は差分を反映し、上書き・削除される既存ファイルを `.backups/YYYYMMDD_HHMMSS/` に退避します。退避ファイルがなかった場合、今回分の空バックアップディレクトリは削除されます。成功後、`import/` の中身はゴミ箱へ移ります。`.stage` は次回を速くするため削除せずに残します。
77
+
78
+ ## 4. Option
79
+
80
+ ### 取り込みをやり直す
81
+
82
+ `stage` したあとで、取り込みたくない画像や修正したい内容が見つかった場合は、`import/` を修正してから `parc stage` を再実行します。既存の `.stage` がある場合は、捨てずに同期して再利用します。
83
+
84
+ 完全に作り直したい場合だけ `--replace` を使います。
85
+
86
+ ```bash
87
+ parc stage --replace
88
+ ```
89
+
90
+ ### ステージ後、即座に適用する
91
+
92
+ ```bash
93
+ parc stage --apply
94
+ ```
95
+
96
+ `parc apply` と同じ確認プロンプトが出ます。
97
+
98
+ ### Photos ライブラリを統合する
99
+
100
+ 1. 内蔵ライブラリを複製する
101
+ 2. 統合先の Photos ライブラリを起動する
102
+ 3. `ファイル > 読み込む` で複製した内蔵ライブラリを選択する
103
+ 4. すべての項目を読み込む
104
+ 5. ライブラリにすべての写真が読み込まれたことを確認する
105
+ 6. 内蔵ライブラリの写真・動画を全選択し、ゴミ箱へ移動する
106
+ 7. 複製した内蔵ライブラリを削除する
107
+
108
+ ### ディレクトリを個別に指定する
109
+
110
+ `PA_ROOT` 配下の標準構成を使わない場合だけ指定します。
111
+
112
+ ```bash
113
+ export PA_IMPORT_DIR=/path/to/import
114
+ export PA_ARCHIVE_DIR=/path/to/archive
115
+ export PA_RAW_DIR=/path/to/raw
116
+ export PA_STAGE_ROOT=/path/to/stage
117
+ export PA_BACKUP_ROOT=/path/to/backups
118
+ ```
119
+
120
+ ### 直接実行する(非推奨)
121
+
122
+ `python main.py` を直接実行すると、指定した `PA_ROOT` または `PA_IMPORT_DIR` 内のファイルを直接リネーム・移動します。上書きされる対象はバックアップされません。
123
+
124
+ ```bash
125
+ python main.py
126
+ ```
127
+
128
+ ### 出力例
129
+
130
+ ```text
131
+ Processing files in: /path/to/project/import
132
+ Processing: IMG_1234.JPG
133
+ Moved: IMG_1234.JPG -> /path/to/project/archive/2026/2026-01-12/20260112_145828_001.jpg
134
+ Done
135
+ ```
136
+
137
+ ## 主要ファイル
138
+
139
+ - `main.py`: エントリーポイント
140
+ - `parc`: どのディレクトリからでも実行するためのコマンド
141
+ - `pic_archiver.py`: コア処理(日時抽出・リネーム・振り分け)
142
+ - `config.py`: パス設定(デフォルトはこのリポジトリのディレクトリ、環境変数で上書き可)
143
+ - `*.sh`: 作業コピーで確認してから反映するための一連のコマンド
@@ -0,0 +1,132 @@
1
+ # PicArchiver
2
+
3
+ Photos ライブラリなどから書き出した写真・動画をリネームし、`archive/` と `raw/` に安全に整理するためのツールです。
4
+
5
+ ## 1. ディレクトリ構成
6
+
7
+ 写真管理用の親ディレクトリを `PA_ROOT` と呼びます。`PA_ROOT` を指定しない場合は、`parc` を実行したカレントディレクトリが親になります。
8
+
9
+ ```text
10
+ PA_ROOT/
11
+ ├── import/ # 処理対象ファイル
12
+ ├── archive/ # アーカイブ出力先(年/年-月-日 の階層で保存)
13
+ ├── raw/ # RAW ファイル出力先(拡張子ごとに分類)
14
+ ├── .stage/ # 反映前の作業コピー
15
+ └── .backups/ # apply 時の退避先
16
+ ```
17
+
18
+ ## 2. 写真を書き出す
19
+
20
+ 1. 内蔵ライブラリを起動する
21
+ 2. すべての写真を選択する
22
+ 3. `PA_ROOT/import/` に書き出す
23
+
24
+ ## 3. ターミナルで実行する
25
+
26
+ 初回だけ、PyPI からインストールします。
27
+
28
+ ```bash
29
+ pip install pic-archiver
30
+ ```
31
+
32
+ PyPI に公開する前に GitHub から直接インストールする場合は、次のコマンドを使います。
33
+
34
+ ```bash
35
+ pip install "git+https://github.com/Ryota-Nitto/PicArchiver.git"
36
+ ```
37
+
38
+ 最新版に更新したい場合は、再インストールします。
39
+
40
+ ```bash
41
+ pip install --upgrade --force-reinstall pic-archiver
42
+ ```
43
+
44
+ 開発中にこのディレクトリの変更を即反映したい場合だけ、リポジトリのディレクトリで代わりに `pip install -e .` を使います。
45
+
46
+ `parc` が見つからない場合は、`pip` の script 置き場に `PATH` を通します。Homebrew Python では `/opt/homebrew/bin`、ユーザーインストールでは `~/.local/bin` になることがあります。
47
+
48
+ ```bash
49
+ export PATH="$HOME/.local/bin:$PATH"
50
+ ```
51
+
52
+ 以後は、どのディレクトリからでも実行できます。
53
+
54
+ ```bash
55
+ # import, archive, raw を含む親ディレクトリを指定する場合(推奨)
56
+ export PA_ROOT=/path/to/pictures-root
57
+
58
+ # 変更をステージして、差分を確認
59
+ parc stage
60
+
61
+ # 問題なければ適用
62
+ parc apply
63
+ ```
64
+
65
+ `parc` 単体では使えるコマンド一覧を表示します。`parc stage` は作業コピーを作って整理処理を実行し、差分を表示します。`parc apply` は差分を反映し、上書き・削除される既存ファイルを `.backups/YYYYMMDD_HHMMSS/` に退避します。退避ファイルがなかった場合、今回分の空バックアップディレクトリは削除されます。成功後、`import/` の中身はゴミ箱へ移ります。`.stage` は次回を速くするため削除せずに残します。
66
+
67
+ ## 4. Option
68
+
69
+ ### 取り込みをやり直す
70
+
71
+ `stage` したあとで、取り込みたくない画像や修正したい内容が見つかった場合は、`import/` を修正してから `parc stage` を再実行します。既存の `.stage` がある場合は、捨てずに同期して再利用します。
72
+
73
+ 完全に作り直したい場合だけ `--replace` を使います。
74
+
75
+ ```bash
76
+ parc stage --replace
77
+ ```
78
+
79
+ ### ステージ後、即座に適用する
80
+
81
+ ```bash
82
+ parc stage --apply
83
+ ```
84
+
85
+ `parc apply` と同じ確認プロンプトが出ます。
86
+
87
+ ### Photos ライブラリを統合する
88
+
89
+ 1. 内蔵ライブラリを複製する
90
+ 2. 統合先の Photos ライブラリを起動する
91
+ 3. `ファイル > 読み込む` で複製した内蔵ライブラリを選択する
92
+ 4. すべての項目を読み込む
93
+ 5. ライブラリにすべての写真が読み込まれたことを確認する
94
+ 6. 内蔵ライブラリの写真・動画を全選択し、ゴミ箱へ移動する
95
+ 7. 複製した内蔵ライブラリを削除する
96
+
97
+ ### ディレクトリを個別に指定する
98
+
99
+ `PA_ROOT` 配下の標準構成を使わない場合だけ指定します。
100
+
101
+ ```bash
102
+ export PA_IMPORT_DIR=/path/to/import
103
+ export PA_ARCHIVE_DIR=/path/to/archive
104
+ export PA_RAW_DIR=/path/to/raw
105
+ export PA_STAGE_ROOT=/path/to/stage
106
+ export PA_BACKUP_ROOT=/path/to/backups
107
+ ```
108
+
109
+ ### 直接実行する(非推奨)
110
+
111
+ `python main.py` を直接実行すると、指定した `PA_ROOT` または `PA_IMPORT_DIR` 内のファイルを直接リネーム・移動します。上書きされる対象はバックアップされません。
112
+
113
+ ```bash
114
+ python main.py
115
+ ```
116
+
117
+ ### 出力例
118
+
119
+ ```text
120
+ Processing files in: /path/to/project/import
121
+ Processing: IMG_1234.JPG
122
+ Moved: IMG_1234.JPG -> /path/to/project/archive/2026/2026-01-12/20260112_145828_001.jpg
123
+ Done
124
+ ```
125
+
126
+ ## 主要ファイル
127
+
128
+ - `main.py`: エントリーポイント
129
+ - `parc`: どのディレクトリからでも実行するためのコマンド
130
+ - `pic_archiver.py`: コア処理(日時抽出・リネーム・振り分け)
131
+ - `config.py`: パス設定(デフォルトはこのリポジトリのディレクトリ、環境変数で上書き可)
132
+ - `*.sh`: 作業コピーで確認してから反映するための一連のコマンド
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+ import os
3
+
4
+ def _root_path():
5
+ """Return the parent directory containing import/archive/raw."""
6
+ value = os.environ.get("PA_ROOT")
7
+ if not value:
8
+ return Path.cwd().resolve()
9
+ return Path(value).expanduser().resolve()
10
+
11
+
12
+ ARCHIVE_ROOT = _root_path()
13
+
14
+ def _env_path(*names, default):
15
+ """Return the first existing env var among names, otherwise default."""
16
+ for name in names:
17
+ value = os.environ.get(name)
18
+ if value:
19
+ return Path(value).expanduser().resolve()
20
+ return Path(default).resolve()
21
+
22
+
23
+ # Defaults point at the current working directory unless explicitly overridden.
24
+ IMPORT_DIR = _env_path("PA_IMPORT_DIR", default=ARCHIVE_ROOT / "import")
25
+ ARCHIVE_DIR = _env_path("PA_ARCHIVE_DIR", default=ARCHIVE_ROOT / "archive")
26
+ RAW_DIR = _env_path("PA_RAW_DIR", default=ARCHIVE_ROOT / "raw")
@@ -0,0 +1,7 @@
1
+ """
2
+ メインエントリーポイント - 写真・動画の自動整理
3
+ """
4
+ from pic_archiver import main
5
+
6
+ if __name__ == '__main__':
7
+ main()
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from send2trash import send2trash
13
+
14
+
15
+ def _absolute_path(value: str | Path) -> Path:
16
+ path = Path(value).expanduser()
17
+ if not path.is_absolute():
18
+ path = Path.cwd() / path
19
+ return path.resolve()
20
+
21
+
22
+ def _paths() -> dict[str, Path]:
23
+ root_value = os.environ.get("PA_ROOT") or Path.cwd()
24
+ pa_root = _absolute_path(root_value)
25
+ return {
26
+ "pa_root": pa_root,
27
+ "import": _absolute_path(os.environ.get("PA_IMPORT_DIR") or pa_root / "import"),
28
+ "archive": _absolute_path(os.environ.get("PA_ARCHIVE_DIR") or pa_root / "archive"),
29
+ "raw": _absolute_path(os.environ.get("PA_RAW_DIR") or pa_root / "raw"),
30
+ "stage": _absolute_path(os.environ.get("PA_STAGE_ROOT") or pa_root / ".stage"),
31
+ "backup": _absolute_path(os.environ.get("PA_BACKUP_ROOT") or pa_root / ".backups"),
32
+ }
33
+
34
+
35
+ def _file_count(path: Path) -> int:
36
+ if not path.exists():
37
+ return 0
38
+ return sum(1 for child in path.rglob("*") if child.is_file())
39
+
40
+
41
+ def _print_progress_bar(done_count: int, total_count: int) -> None:
42
+ width = 20
43
+ percent = 100 if total_count <= 0 else min(done_count * 100 // total_count, 100)
44
+ display_done = min(done_count, total_count)
45
+ filled = percent * width // 100
46
+ bar = "#" * filled + "-" * (width - filled)
47
+ print(f"\r[{bar}] {percent:3d}% ({display_done}/{total_count} files)", end="", flush=True)
48
+
49
+
50
+ def _run(command: list[str]) -> None:
51
+ subprocess.run(command, check=True)
52
+
53
+
54
+ def _copy_with_hardlinks(step: int, name: str, source_dir: Path, target_dir: Path, reuse_stage: bool) -> None:
55
+ source_dir.mkdir(parents=True, exist_ok=True)
56
+ target_dir.mkdir(parents=True, exist_ok=True)
57
+ action = "Refreshing" if reuse_stage else "Copying"
58
+ print(f"[{step}/3] {action} {name}: {source_dir} -> {target_dir}")
59
+
60
+ command = [
61
+ "rsync",
62
+ "-a",
63
+ "--delete",
64
+ f"--link-dest={source_dir}",
65
+ f"{source_dir}/",
66
+ f"{target_dir}/",
67
+ ]
68
+ if name != "archive":
69
+ _run(command)
70
+ return
71
+
72
+ total_files = _file_count(source_dir)
73
+ if total_files == 0:
74
+ _run(command)
75
+ return
76
+
77
+ print("Archive refresh progress:" if reuse_stage else "Archive copy progress:")
78
+ process = subprocess.Popen(command)
79
+ while process.poll() is None:
80
+ _print_progress_bar(_file_count(target_dir), total_files)
81
+ time.sleep(1)
82
+
83
+ _print_progress_bar(total_files, total_files)
84
+ print()
85
+ if process.returncode:
86
+ raise subprocess.CalledProcessError(process.returncode, command)
87
+
88
+
89
+ def prepare_command(args: argparse.Namespace) -> int:
90
+ paths = _paths()
91
+ stage_root = paths["stage"]
92
+ reuse_stage = False
93
+
94
+ if args.replace:
95
+ shutil.rmtree(stage_root, ignore_errors=True)
96
+ elif stage_root.exists():
97
+ reuse_stage = True
98
+
99
+ stage_root.mkdir(parents=True, exist_ok=True)
100
+ _copy_with_hardlinks(1, "import", paths["import"], stage_root / "import", reuse_stage)
101
+ _copy_with_hardlinks(2, "archive", paths["archive"], stage_root / "archive", reuse_stage)
102
+ _copy_with_hardlinks(3, "raw", paths["raw"], stage_root / "raw", reuse_stage)
103
+
104
+ print(f"Prepared stage: {stage_root}")
105
+ print("Next: parc run")
106
+ return 0
107
+
108
+
109
+ def run_command(_: argparse.Namespace) -> int:
110
+ paths = _paths()
111
+ stage_root = paths["stage"]
112
+ if not (stage_root / "import").is_dir():
113
+ print(f"Stage import directory not found: {stage_root / 'import'}", file=sys.stderr)
114
+ print("Run parc prepare first.", file=sys.stderr)
115
+ return 1
116
+
117
+ os.environ["PA_IMPORT_DIR"] = str(stage_root / "import")
118
+ os.environ["PA_ARCHIVE_DIR"] = str(stage_root / "archive")
119
+ os.environ["PA_RAW_DIR"] = str(stage_root / "raw")
120
+ os.environ["PYTHONUNBUFFERED"] = "1"
121
+
122
+ from pic_archiver import main as archiver_main
123
+
124
+ archiver_main()
125
+ print("Staged archive update complete.")
126
+ print("Next: parc review")
127
+ return 0
128
+
129
+
130
+ def review_command(_: argparse.Namespace) -> int:
131
+ paths = _paths()
132
+ stage_root = paths["stage"]
133
+ if not (stage_root / "archive").is_dir() or not (stage_root / "raw").is_dir():
134
+ print(f"Stage archive/raw directories not found: {stage_root}", file=sys.stderr)
135
+ print("Run parc prepare and parc run first.", file=sys.stderr)
136
+ return 1
137
+
138
+ print("Archive changes:", flush=True)
139
+ _run(["rsync", "-aivn", "--8-bit-output", "--delete", f"{stage_root / 'archive'}/", f"{paths['archive']}/"])
140
+ print()
141
+ print("Raw changes:", flush=True)
142
+ _run(["rsync", "-aivn", "--8-bit-output", "--delete", f"{stage_root / 'raw'}/", f"{paths['raw']}/"])
143
+ print()
144
+ print("No files were changed. To apply approved changes, run:")
145
+ print(" parc apply")
146
+ return 0
147
+
148
+
149
+ def apply_command(_: argparse.Namespace) -> int:
150
+ paths = _paths()
151
+ stage_root = paths["stage"]
152
+ if not (stage_root / "archive").is_dir() or not (stage_root / "raw").is_dir():
153
+ print(f"Stage archive/raw directories not found: {stage_root}", file=sys.stderr)
154
+ return 1
155
+
156
+ backup_dir = paths["backup"] / datetime.now().strftime("%Y%m%d_%H%M%S")
157
+ print("This will update:")
158
+ print(f" {paths['archive']}")
159
+ print(f" {paths['raw']}")
160
+ print(flush=True)
161
+ print("Changed or deleted destination files will be kept in:")
162
+ print(f" {backup_dir}")
163
+ print()
164
+
165
+ answer = input("Apply staged changes? [y/N] ")
166
+ if answer not in {"y", "Y", "yes", "YES"}:
167
+ print("Cancelled.")
168
+ return 1
169
+
170
+ (backup_dir / "archive").mkdir(parents=True, exist_ok=True)
171
+ (backup_dir / "raw").mkdir(parents=True, exist_ok=True)
172
+ _run(["rsync", "-a", "--delete", "--backup", f"--backup-dir={backup_dir / 'archive'}", f"{stage_root / 'archive'}/", f"{paths['archive']}/"])
173
+ _run(["rsync", "-a", "--delete", "--backup", f"--backup-dir={backup_dir / 'raw'}", f"{stage_root / 'raw'}/", f"{paths['raw']}/"])
174
+
175
+ print("Applied staged changes.")
176
+ if any(child.is_file() for child in backup_dir.rglob("*")):
177
+ print(f"Backup directory: {backup_dir}")
178
+ else:
179
+ shutil.rmtree(backup_dir)
180
+ print("No backup files were created.")
181
+ print(f"Kept stage for faster next run: {stage_root}")
182
+
183
+ if paths["import"].is_dir():
184
+ for entry in sorted(paths["import"].iterdir(), key=lambda path: path.name):
185
+ send2trash(str(entry))
186
+ print(f"Trashed import contents: {paths['import']}")
187
+ else:
188
+ print(f"Import directory not found, skipped trashing: {paths['import']}")
189
+ return 0
190
+
191
+
192
+ def _run_with_progress(label: str, func, args: argparse.Namespace, keepalive: bool = True) -> int:
193
+ print(f"==> {label}")
194
+ if not keepalive:
195
+ return func(args)
196
+ start = time.monotonic()
197
+ last = start
198
+ result = func(args)
199
+ elapsed = int(time.monotonic() - start)
200
+ if elapsed >= 10 and time.monotonic() - last >= 10:
201
+ print(f"Still running ({elapsed}s): {label}")
202
+ return result
203
+
204
+
205
+ def stage_command(args: argparse.Namespace) -> int:
206
+ result = _run_with_progress(
207
+ "Preparing stage with replacement" if args.replace else "Preparing stage",
208
+ prepare_command,
209
+ args,
210
+ keepalive=False,
211
+ )
212
+ if result:
213
+ return result
214
+ result = _run_with_progress("Running picture archiving in stage", run_command, args)
215
+ if result:
216
+ return result
217
+ result = _run_with_progress("Reviewing staged changes", review_command, args)
218
+ if result:
219
+ return result
220
+ if args.apply:
221
+ print("==> Applying staged changes")
222
+ return apply_command(args)
223
+ return 0
224
+
225
+
226
+ def build_parser() -> argparse.ArgumentParser:
227
+ parser = argparse.ArgumentParser(prog="parc")
228
+ subparsers = parser.add_subparsers(dest="command")
229
+
230
+ stage = subparsers.add_parser("stage", help="Prepare a stage, run archiving, and show the review diff.")
231
+ stage.add_argument("--replace", action="store_true", help="Recreate the stage work tree.")
232
+ stage.add_argument("--apply", action="store_true", help="Apply staged changes after review.")
233
+ stage.set_defaults(func=stage_command)
234
+
235
+ prepare = subparsers.add_parser("prepare", help="Create the stage work tree.")
236
+ prepare.add_argument("--replace", action="store_true", help="Recreate the stage work tree.")
237
+ prepare.set_defaults(func=prepare_command)
238
+
239
+ subparsers.add_parser("run", help="Run archiving inside the existing stage.").set_defaults(func=run_command)
240
+ subparsers.add_parser("review", help="Show staged changes without applying them.").set_defaults(func=review_command)
241
+ subparsers.add_parser("apply", help="Apply staged changes to archive/raw.").set_defaults(func=apply_command)
242
+ return parser
243
+
244
+
245
+ def main(argv: list[str] | None = None) -> int:
246
+ parser = build_parser()
247
+ args = parser.parse_args(argv)
248
+ if not hasattr(args, "func"):
249
+ parser.print_help()
250
+ return 0
251
+ try:
252
+ return args.func(args)
253
+ except subprocess.CalledProcessError as exc:
254
+ print(f"Command failed: {' '.join(str(part) for part in exc.cmd)}", file=sys.stderr)
255
+ return exc.returncode
256
+
257
+
258
+ if __name__ == "__main__":
259
+ raise SystemExit(main())
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: pic-archiver
3
+ Version: 0.1.0
4
+ Summary: Rename, sort, and safely archive exported picture and video files.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: Pillow
8
+ Requires-Dist: piexif
9
+ Requires-Dist: rawpy
10
+ Requires-Dist: Send2Trash
11
+
12
+ # PicArchiver
13
+
14
+ Photos ライブラリなどから書き出した写真・動画をリネームし、`archive/` と `raw/` に安全に整理するためのツールです。
15
+
16
+ ## 1. ディレクトリ構成
17
+
18
+ 写真管理用の親ディレクトリを `PA_ROOT` と呼びます。`PA_ROOT` を指定しない場合は、`parc` を実行したカレントディレクトリが親になります。
19
+
20
+ ```text
21
+ PA_ROOT/
22
+ ├── import/ # 処理対象ファイル
23
+ ├── archive/ # アーカイブ出力先(年/年-月-日 の階層で保存)
24
+ ├── raw/ # RAW ファイル出力先(拡張子ごとに分類)
25
+ ├── .stage/ # 反映前の作業コピー
26
+ └── .backups/ # apply 時の退避先
27
+ ```
28
+
29
+ ## 2. 写真を書き出す
30
+
31
+ 1. 内蔵ライブラリを起動する
32
+ 2. すべての写真を選択する
33
+ 3. `PA_ROOT/import/` に書き出す
34
+
35
+ ## 3. ターミナルで実行する
36
+
37
+ 初回だけ、PyPI からインストールします。
38
+
39
+ ```bash
40
+ pip install pic-archiver
41
+ ```
42
+
43
+ PyPI に公開する前に GitHub から直接インストールする場合は、次のコマンドを使います。
44
+
45
+ ```bash
46
+ pip install "git+https://github.com/Ryota-Nitto/PicArchiver.git"
47
+ ```
48
+
49
+ 最新版に更新したい場合は、再インストールします。
50
+
51
+ ```bash
52
+ pip install --upgrade --force-reinstall pic-archiver
53
+ ```
54
+
55
+ 開発中にこのディレクトリの変更を即反映したい場合だけ、リポジトリのディレクトリで代わりに `pip install -e .` を使います。
56
+
57
+ `parc` が見つからない場合は、`pip` の script 置き場に `PATH` を通します。Homebrew Python では `/opt/homebrew/bin`、ユーザーインストールでは `~/.local/bin` になることがあります。
58
+
59
+ ```bash
60
+ export PATH="$HOME/.local/bin:$PATH"
61
+ ```
62
+
63
+ 以後は、どのディレクトリからでも実行できます。
64
+
65
+ ```bash
66
+ # import, archive, raw を含む親ディレクトリを指定する場合(推奨)
67
+ export PA_ROOT=/path/to/pictures-root
68
+
69
+ # 変更をステージして、差分を確認
70
+ parc stage
71
+
72
+ # 問題なければ適用
73
+ parc apply
74
+ ```
75
+
76
+ `parc` 単体では使えるコマンド一覧を表示します。`parc stage` は作業コピーを作って整理処理を実行し、差分を表示します。`parc apply` は差分を反映し、上書き・削除される既存ファイルを `.backups/YYYYMMDD_HHMMSS/` に退避します。退避ファイルがなかった場合、今回分の空バックアップディレクトリは削除されます。成功後、`import/` の中身はゴミ箱へ移ります。`.stage` は次回を速くするため削除せずに残します。
77
+
78
+ ## 4. Option
79
+
80
+ ### 取り込みをやり直す
81
+
82
+ `stage` したあとで、取り込みたくない画像や修正したい内容が見つかった場合は、`import/` を修正してから `parc stage` を再実行します。既存の `.stage` がある場合は、捨てずに同期して再利用します。
83
+
84
+ 完全に作り直したい場合だけ `--replace` を使います。
85
+
86
+ ```bash
87
+ parc stage --replace
88
+ ```
89
+
90
+ ### ステージ後、即座に適用する
91
+
92
+ ```bash
93
+ parc stage --apply
94
+ ```
95
+
96
+ `parc apply` と同じ確認プロンプトが出ます。
97
+
98
+ ### Photos ライブラリを統合する
99
+
100
+ 1. 内蔵ライブラリを複製する
101
+ 2. 統合先の Photos ライブラリを起動する
102
+ 3. `ファイル > 読み込む` で複製した内蔵ライブラリを選択する
103
+ 4. すべての項目を読み込む
104
+ 5. ライブラリにすべての写真が読み込まれたことを確認する
105
+ 6. 内蔵ライブラリの写真・動画を全選択し、ゴミ箱へ移動する
106
+ 7. 複製した内蔵ライブラリを削除する
107
+
108
+ ### ディレクトリを個別に指定する
109
+
110
+ `PA_ROOT` 配下の標準構成を使わない場合だけ指定します。
111
+
112
+ ```bash
113
+ export PA_IMPORT_DIR=/path/to/import
114
+ export PA_ARCHIVE_DIR=/path/to/archive
115
+ export PA_RAW_DIR=/path/to/raw
116
+ export PA_STAGE_ROOT=/path/to/stage
117
+ export PA_BACKUP_ROOT=/path/to/backups
118
+ ```
119
+
120
+ ### 直接実行する(非推奨)
121
+
122
+ `python main.py` を直接実行すると、指定した `PA_ROOT` または `PA_IMPORT_DIR` 内のファイルを直接リネーム・移動します。上書きされる対象はバックアップされません。
123
+
124
+ ```bash
125
+ python main.py
126
+ ```
127
+
128
+ ### 出力例
129
+
130
+ ```text
131
+ Processing files in: /path/to/project/import
132
+ Processing: IMG_1234.JPG
133
+ Moved: IMG_1234.JPG -> /path/to/project/archive/2026/2026-01-12/20260112_145828_001.jpg
134
+ Done
135
+ ```
136
+
137
+ ## 主要ファイル
138
+
139
+ - `main.py`: エントリーポイント
140
+ - `parc`: どのディレクトリからでも実行するためのコマンド
141
+ - `pic_archiver.py`: コア処理(日時抽出・リネーム・振り分け)
142
+ - `config.py`: パス設定(デフォルトはこのリポジトリのディレクトリ、環境変数で上書き可)
143
+ - `*.sh`: 作業コピーで確認してから反映するための一連のコマンド
@@ -0,0 +1,12 @@
1
+ README.md
2
+ config.py
3
+ main.py
4
+ parc_cli.py
5
+ pic_archiver.py
6
+ pyproject.toml
7
+ pic_archiver.egg-info/PKG-INFO
8
+ pic_archiver.egg-info/SOURCES.txt
9
+ pic_archiver.egg-info/dependency_links.txt
10
+ pic_archiver.egg-info/entry_points.txt
11
+ pic_archiver.egg-info/requires.txt
12
+ pic_archiver.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ parc = parc_cli:main
@@ -0,0 +1,4 @@
1
+ Pillow
2
+ piexif
3
+ rawpy
4
+ Send2Trash
@@ -0,0 +1,4 @@
1
+ config
2
+ main
3
+ parc_cli
4
+ pic_archiver
@@ -0,0 +1,512 @@
1
+ """
2
+ PicArchiver - 写真・動画ファイルの自動整理・リネーム・アーカイブツール
3
+ """
4
+ from pathlib import Path
5
+ import os
6
+ import datetime
7
+ import subprocess
8
+ from PIL import Image
9
+ from PIL.ExifTags import TAGS
10
+ import piexif
11
+ from send2trash import send2trash
12
+
13
+ from config import IMPORT_DIR, ARCHIVE_DIR, RAW_DIR
14
+
15
+ RAW_SUBDIRS = {
16
+ ".cr2": "canon cr2",
17
+ ".nef": "nikon nef",
18
+ ".arw": "sony arw",
19
+ }
20
+ VIDEO_EXTS = {".mov", ".mp4", ".avi"}
21
+ IMAGE_EXTS = {".jpeg", ".jpg", ".heic", ".png", ".webp"}
22
+
23
+
24
+ def _normalize_image_ext(ext):
25
+ """出力用の拡張子を正規化する"""
26
+ ext = ext.lower()
27
+ if ext == ".jpg":
28
+ return ".jpeg"
29
+ return ext
30
+
31
+
32
+ # ===== DateTime Extraction Functions =====
33
+
34
+ def _normalize_subsec(subsec):
35
+ """subsec を必ず3桁にそろえ、短い場合は右側に 0 を足す"""
36
+ if subsec is None:
37
+ return "000"
38
+ if isinstance(subsec, bytes):
39
+ subsec = subsec.decode("UTF-8", errors="ignore")
40
+ subsec = str(subsec).strip()
41
+ if not subsec:
42
+ return "000"
43
+ # 1桁/2桁は右側に 0 を足し、4桁以上は先頭3桁を残す
44
+ if len(subsec) >= 3:
45
+ return subsec[:3]
46
+ return subsec.ljust(3, "0")
47
+
48
+ def get_exif_of_image(file):
49
+ """EXIFデータを取得"""
50
+ with Image.open(str(file)) as im:
51
+ try:
52
+ exif = im._getexif()
53
+ except AttributeError:
54
+ return {}
55
+
56
+ if not exif:
57
+ return {}
58
+
59
+ exif_table = {}
60
+ for tag_id, value in exif.items():
61
+ tag = TAGS.get(tag_id, tag_id)
62
+ exif_table[tag] = value
63
+ return exif_table
64
+
65
+
66
+ def get_datetime_exif(file):
67
+ """JPEG向け撮影日時の取得"""
68
+ exif_table = get_exif_of_image(file)
69
+ date = exif_table['DateTimeOriginal'].split(' ')[0].split(':')
70
+ time = exif_table['DateTimeOriginal'].split(' ')[1].split(':')
71
+ subsec = _normalize_subsec(exif_table.get('SubsecTimeOriginal', '000'))
72
+ return date, time, subsec
73
+
74
+
75
+ def get_datetime_cr2exif(file):
76
+ """CR2向け撮影日時の取得"""
77
+ exif_dict = piexif.load(str(file))
78
+ datetime_str = exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal]
79
+ subsec = exif_dict["Exif"].get(piexif.ExifIFD.SubSecTimeOriginal, b"000")
80
+ datetime_str = datetime_str.decode("UTF-8")
81
+ subsec = subsec.decode("UTF-8")
82
+ date = datetime_str.split(' ')[0].split(':')
83
+ time = datetime_str.split(' ')[1].split(':')
84
+ subsec = _normalize_subsec(subsec)
85
+ return date, time, subsec
86
+
87
+
88
+ def get_datetime_raw_exif(file):
89
+ """NEF/ARW向け撮影日時の取得"""
90
+ exif_table = get_exif_of_image(file)
91
+ date_time = exif_table.get('DateTimeOriginal')
92
+ if not date_time:
93
+ raise KeyError('DateTimeOriginal not found')
94
+
95
+ date = date_time.split(' ')[0].split(':')
96
+ time = date_time.split(' ')[1].split(':')
97
+ subsec = _normalize_subsec(exif_table.get('SubsecTimeOriginal', '000'))
98
+ return date, time, subsec
99
+
100
+
101
+ def get_creation_datetime(file):
102
+ """ファイルシステムの作成日時を取得"""
103
+ bt = os.stat(file).st_birthtime
104
+ d = datetime.datetime.fromtimestamp(bt)
105
+ return _datetime_to_parts(d)
106
+
107
+
108
+ def _get_filesystem_datetimes(file):
109
+ """ファイルシステムから取得できる作成日時・変更日時を返す"""
110
+ stat = Path(file).stat()
111
+ candidates = [datetime.datetime.fromtimestamp(stat.st_mtime)]
112
+ birthtime = getattr(stat, "st_birthtime", None)
113
+ if birthtime is not None:
114
+ candidates.append(datetime.datetime.fromtimestamp(birthtime))
115
+ return candidates
116
+
117
+
118
+ def get_filesystem_oldest_datetime(file):
119
+ """作成日時・変更日時のうち古い方を取得"""
120
+ return _datetime_to_parts(min(_get_filesystem_datetimes(file)))
121
+
122
+
123
+ def _datetime_to_parts(d):
124
+ """datetimeをファイル名用の日付・時刻・ミリ秒に変換"""
125
+ year = d.year
126
+ month = d.month
127
+ day = d.day
128
+ hour = d.hour
129
+ minute = d.minute
130
+ second = d.second
131
+ subsec = d.microsecond // 1000
132
+
133
+ year = str(year)
134
+ month = f"{month:02d}" if isinstance(month, int) else f"{int(month):02d}"
135
+ day = f"{day:02d}" if isinstance(day, int) else f"{int(day):02d}"
136
+ hour = f"{hour:02d}" if isinstance(hour, int) else f"{int(hour):02d}"
137
+ minute = f"{minute:02d}" if isinstance(minute, int) else f"{int(minute):02d}"
138
+ second = f"{second:02d}" if isinstance(second, int) else f"{int(second):02d}"
139
+
140
+ subsec = _normalize_subsec(subsec)
141
+
142
+ return [year, month, day], [hour, minute, second], subsec
143
+
144
+
145
+ def _parse_exiftool_datetime(value):
146
+ """exiftoolの日時文字列をdatetimeに変換"""
147
+ if not value:
148
+ return None
149
+
150
+ value = value.strip()
151
+ date_part, time_part = value.split(" ", 1)
152
+ year, month, day = [int(part) for part in date_part.split(":")]
153
+
154
+ parts = time_part.split()
155
+ time_value = parts[0]
156
+ timezone_value = parts[1] if len(parts) > 1 else None
157
+
158
+ offset = None
159
+ if len(time_value) >= 6 and time_value[-6] in ["+", "-"]:
160
+ offset = time_value[-6:]
161
+ time_value = time_value[:-6]
162
+ elif time_value.upper().endswith("Z"):
163
+ offset = "Z"
164
+ time_value = time_value[:-1]
165
+ elif timezone_value:
166
+ offset = timezone_value
167
+
168
+ hour_str, minute_str, second_str = time_value.split(":")
169
+ if "." in second_str:
170
+ second_whole, fraction = second_str.split(".", 1)
171
+ microsecond = int(fraction[:6].ljust(6, "0"))
172
+ else:
173
+ second_whole = second_str
174
+ microsecond = 0
175
+
176
+ tzinfo = None
177
+ if offset and offset.upper() != "Z":
178
+ sign = 1 if offset[0] == "+" else -1
179
+ offset_hour = int(offset[1:3])
180
+ offset_minute = int(offset[4:6])
181
+ tzinfo = datetime.timezone(
182
+ sign * datetime.timedelta(hours=offset_hour, minutes=offset_minute)
183
+ )
184
+ elif offset and offset.upper() == "Z":
185
+ tzinfo = datetime.timezone.utc
186
+
187
+ d = datetime.datetime(
188
+ year,
189
+ month,
190
+ day,
191
+ int(hour_str),
192
+ int(minute_str),
193
+ int(second_whole),
194
+ microsecond,
195
+ tzinfo=tzinfo,
196
+ )
197
+ if d.tzinfo:
198
+ d = d.astimezone()
199
+ return d.replace(tzinfo=None)
200
+
201
+
202
+ def _parse_filename_datetime(file):
203
+ """YYYYMMDD_HHMMSS_sss形式のファイル名からdatetimeを取得"""
204
+ stem = Path(file).stem
205
+ parts = stem.split("_")
206
+ if len(parts) < 3:
207
+ return None
208
+ date_value, time_value, subsec = parts[:3]
209
+ if not (len(date_value) == 8 and len(time_value) == 6):
210
+ return None
211
+ if not (date_value.isdigit() and time_value.isdigit() and subsec[:3].isdigit()):
212
+ return None
213
+ try:
214
+ return datetime.datetime(
215
+ int(date_value[0:4]),
216
+ int(date_value[4:6]),
217
+ int(date_value[6:8]),
218
+ int(time_value[0:2]),
219
+ int(time_value[2:4]),
220
+ int(time_value[4:6]),
221
+ int(subsec[:3].ljust(3, "0")) * 1000,
222
+ )
223
+ except ValueError:
224
+ return None
225
+
226
+
227
+ def _parts_to_datetime(date, time, subsec):
228
+ return datetime.datetime(
229
+ int(date[0]),
230
+ int(date[1]),
231
+ int(date[2]),
232
+ int(time[0]),
233
+ int(time[1]),
234
+ int(time[2]),
235
+ int(_normalize_subsec(subsec)) * 1000,
236
+ )
237
+
238
+
239
+ def _preserve_filename_timestamp_if_close(file, datetime_info):
240
+ """動画メタデータとファイル名が近い場合は、ファイル名のミリ秒を保持"""
241
+ filename_dt = _parse_filename_datetime(file)
242
+ if not filename_dt:
243
+ return datetime_info
244
+
245
+ date, time, subsec = datetime_info
246
+ metadata_dt = _parts_to_datetime(date, time, subsec)
247
+ if abs((filename_dt - metadata_dt).total_seconds()) <= 4:
248
+ return _datetime_to_parts(filename_dt)
249
+
250
+ return datetime_info
251
+
252
+
253
+ def _run_exiftool_datetimes(file, tags, quicktime_utc=False):
254
+ command = ["exiftool", "-s", "-s", "-s"]
255
+ if quicktime_utc:
256
+ command.extend(["-api", "QuickTimeUTC=1"])
257
+ command.extend(tags)
258
+ command.append(str(file))
259
+ try:
260
+ result = subprocess.run(command, capture_output=True, text=True, check=True)
261
+ except (FileNotFoundError, subprocess.SubprocessError):
262
+ return []
263
+
264
+ dates = []
265
+ for line in result.stdout.splitlines():
266
+ try:
267
+ d = _parse_exiftool_datetime(line)
268
+ except Exception:
269
+ continue
270
+ if d:
271
+ dates.append(d)
272
+
273
+ return dates
274
+
275
+
276
+ def _run_exiftool_datetime(file, tags, quicktime_utc=False):
277
+ dates = _run_exiftool_datetimes(file, tags, quicktime_utc=quicktime_utc)
278
+ return dates[0] if dates else None
279
+
280
+
281
+ def _oldest_datetime_info(file, metadata_datetimes):
282
+ """メタデータと作成/変更日時の候補から最古の日時を採用する"""
283
+ candidates = [d for d in metadata_datetimes if d]
284
+ candidates.extend(_get_filesystem_datetimes(file))
285
+ return _preserve_filename_timestamp_if_close(file, _datetime_to_parts(min(candidates)))
286
+
287
+
288
+ def _select_quicktime_create_date(file):
289
+ """QuickTimeのCreateDateをローカル/UTC解釈のうちファイル変更日時に近い方で読む"""
290
+ quicktime_local = _run_exiftool_datetime(file, ["-QuickTime:CreateDate"], quicktime_utc=False)
291
+ quicktime_utc = _run_exiftool_datetime(file, ["-QuickTime:CreateDate"], quicktime_utc=True)
292
+ if quicktime_local and quicktime_utc:
293
+ file_modify = datetime.datetime.fromtimestamp(Path(file).stat().st_mtime)
294
+ local_delta = abs((file_modify - quicktime_local).total_seconds())
295
+ utc_delta = abs((file_modify - quicktime_utc).total_seconds())
296
+ return quicktime_utc if utc_delta < local_delta else quicktime_local
297
+ return quicktime_local or quicktime_utc
298
+
299
+
300
+ def get_datetime_video_metadata(file):
301
+ """動画の内部メタデータと作成/変更日時から最古の日時を取得"""
302
+ metadata_datetimes = []
303
+
304
+ timezone_tags = ["-Keys:CreationDate", "-UserData:DateTimeOriginal"]
305
+ metadata_datetimes.extend(_run_exiftool_datetimes(file, timezone_tags, quicktime_utc=True))
306
+
307
+ if Path(file).suffix.lower() == ".mp4":
308
+ metadata_datetimes.extend(_run_exiftool_datetimes(
309
+ file,
310
+ ["-Composite:SubSecDateTimeOriginal", "-ExifIFD:DateTimeOriginal", "-QuickTime:CreateDate"],
311
+ quicktime_utc=True,
312
+ ))
313
+
314
+ quicktime_create_date = _select_quicktime_create_date(file)
315
+ if quicktime_create_date:
316
+ metadata_datetimes.append(quicktime_create_date)
317
+
318
+ return _oldest_datetime_info(file, metadata_datetimes)
319
+
320
+
321
+ def get_datetime_image_metadata(file):
322
+ """HEIC/PNG/WebPのメタデータと作成/変更日時から最古の日時を取得"""
323
+ metadata_datetimes = _run_exiftool_datetimes(
324
+ file,
325
+ [
326
+ "-Composite:SubSecDateTimeOriginal",
327
+ "-EXIF:DateTimeOriginal",
328
+ "-QuickTime:CreateDate",
329
+ "-Keys:CreationDate",
330
+ ],
331
+ quicktime_utc=True,
332
+ )
333
+ return _oldest_datetime_info(file, metadata_datetimes)
334
+
335
+
336
+ def get_datetime(file):
337
+ """ファイルの撮影日時を取得(形式に応じた処理)"""
338
+ file = Path(file)
339
+ _, ext = os.path.splitext(str(file))
340
+ ext = ext.lower()
341
+
342
+ try:
343
+ if ext in ['.jpeg', '.jpg']:
344
+ try:
345
+ return get_datetime_exif(file)
346
+ except Exception:
347
+ return get_creation_datetime(file)
348
+ elif ext == '.cr2':
349
+ try:
350
+ return get_datetime_cr2exif(file)
351
+ except Exception:
352
+ return get_creation_datetime(file)
353
+ elif ext in ['.nef', '.arw']:
354
+ try:
355
+ return get_datetime_raw_exif(file)
356
+ except Exception:
357
+ return get_creation_datetime(file)
358
+ elif ext in ['.mov', '.mp4', '.avi']:
359
+ try:
360
+ return get_datetime_video_metadata(file)
361
+ except Exception:
362
+ return get_filesystem_oldest_datetime(file)
363
+ elif ext in ['.heic', '.png', '.webp']:
364
+ try:
365
+ return get_datetime_image_metadata(file)
366
+ except Exception:
367
+ return get_filesystem_oldest_datetime(file)
368
+ else:
369
+ print(f"{file} is not a supported image or video file")
370
+ return None
371
+ except Exception as e:
372
+ print(f"Error getting datetime for {file}: {e}")
373
+ return None
374
+
375
+
376
+ # ===== File Renaming Functions =====
377
+
378
+ def _build_filename(year, month, day, hour, minute, second, subsec, ext):
379
+ """統一フォーマットでファイル名を生成"""
380
+ return f"{year}{month}{day}_{hour}{minute}{second}_{subsec}{ext}"
381
+
382
+
383
+ def _find_existing_event_archive_dir(year, month, day):
384
+ """同日付のイベント名付き archive フォルダがあれば返す"""
385
+ date_prefix = f"{year}-{month}-{day}"
386
+ year_dir = ARCHIVE_DIR / year
387
+ if not year_dir.exists():
388
+ return None
389
+
390
+ candidates = []
391
+ for child in year_dir.iterdir():
392
+ if not child.is_dir():
393
+ continue
394
+ if not child.name.startswith(date_prefix):
395
+ continue
396
+ # "yyyy-mm-dd" ちょうどのフォルダより、イベント名付きフォルダを優先する
397
+ suffix = child.name[len(date_prefix):].strip()
398
+ candidates.append((0 if suffix else 1, child.name, child))
399
+
400
+ if not candidates:
401
+ return None
402
+
403
+ candidates.sort()
404
+ return candidates[0][2]
405
+
406
+
407
+ def _unique_target_path(target_path, source):
408
+ """同名ファイルがある場合は同じパスへ上書きする"""
409
+ source = Path(source).resolve()
410
+ target_path = Path(target_path)
411
+ if not target_path.exists() or target_path.resolve() == source:
412
+ return target_path
413
+ return target_path
414
+
415
+
416
+ def rename_and_sort(file):
417
+ """ファイルのリネームと整理"""
418
+ file = Path(file)
419
+ _, ext = os.path.splitext(str(file))
420
+ ext = _normalize_image_ext(ext)
421
+
422
+ datetime_info = get_datetime(file)
423
+ if not datetime_info:
424
+ print(f"Skipping {file.name} - unable to extract datetime")
425
+ return
426
+
427
+ date, time, subsec = datetime_info
428
+ year = date[0]
429
+ month = date[1]
430
+ day = date[2]
431
+ hour = time[0]
432
+ minute = time[1]
433
+ second = time[2]
434
+
435
+ filename = _build_filename(year, month, day, hour, minute, second, subsec, ext)
436
+
437
+ # RAW系ファイルは拡張子ごとのサブディレクトリへ、それ以外はARCHIVE_DIRへ
438
+ if ext in RAW_SUBDIRS:
439
+ target_dir = RAW_DIR / RAW_SUBDIRS[ext]
440
+ else:
441
+ # 既存の "yyyy-mm-dd イベント名" フォルダがあれば優先して使う
442
+ target_dir = _find_existing_event_archive_dir(year, month, day)
443
+ if target_dir is None:
444
+ # なければ従来通り日付フォルダを作る
445
+ target_dir = ARCHIVE_DIR / year / f"{year}-{month}-{day}"
446
+
447
+ target_dir.mkdir(parents=True, exist_ok=True)
448
+ target_path = target_dir / filename
449
+
450
+ target_path = _unique_target_path(target_path, file)
451
+
452
+ file.rename(target_path)
453
+ print(f"Moved: {file.name} -> {target_path}")
454
+
455
+
456
+ def remove_same_file(directories):
457
+ """同じベースネームの重複ファイルを削除"""
458
+ for directory in directories:
459
+ os.chdir(directory)
460
+ files = os.listdir()
461
+ bases = []
462
+ exts = []
463
+ for file in files:
464
+ base, ext = os.path.splitext(file)
465
+ ext = ext.lower()
466
+ for i in range(len(bases)):
467
+ if bases[i] == base and exts[i] == ext:
468
+ send2trash(Path(file))
469
+ bases.append(base)
470
+ exts.append(ext)
471
+
472
+
473
+ def remove_number(file):
474
+ """ファイル名の末尾の番号をリセット"""
475
+ base, ext = os.path.splitext(file)
476
+ if base.endswith(')'):
477
+ base = base[:-4]
478
+ os.renames(file, f"{base}{ext}")
479
+
480
+
481
+ def lower_name(file):
482
+ """拡張子を小文字に"""
483
+ base, ext = os.path.splitext(file)
484
+ ext = _normalize_image_ext(ext)
485
+ os.renames(file, f"{base}{ext}")
486
+
487
+
488
+ # ===== Main Function =====
489
+
490
+ def main():
491
+ """メイン処理 - IMPORT_DIRのファイルを処理"""
492
+ if not IMPORT_DIR.exists():
493
+ raise FileNotFoundError(f"Import directory not found: {IMPORT_DIR}")
494
+
495
+ print(f"Processing files in: {IMPORT_DIR}", flush=True)
496
+ files = sorted([f for f in IMPORT_DIR.iterdir() if f.is_file() and not f.name.startswith(".")])
497
+
498
+ if not files:
499
+ print("No files to process.", flush=True)
500
+ return
501
+
502
+ total = len(files)
503
+ print(f"Found {total} file(s) to process.", flush=True)
504
+ for index, file_path in enumerate(files, start=1):
505
+ print(f"[{index}/{total}] Processing: {file_path.name}", flush=True)
506
+ rename_and_sort(file_path)
507
+
508
+ print("Done", flush=True)
509
+
510
+
511
+ if __name__ == '__main__':
512
+ main()
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pic-archiver"
7
+ version = "0.1.0"
8
+ description = "Rename, sort, and safely archive exported picture and video files."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "Pillow",
13
+ "piexif",
14
+ "rawpy",
15
+ "Send2Trash",
16
+ ]
17
+
18
+ [project.scripts]
19
+ parc = "parc_cli:main"
20
+
21
+ [tool.setuptools]
22
+ py-modules = ["config", "main", "parc_cli", "pic_archiver"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+