From 22d2d14aeceb4aa1f0dc8a7cc05aa30c18f18cab Mon Sep 17 00:00:00 2001 From: Olivier Date: Thu, 8 Apr 2021 11:54:35 +0100 Subject: [PATCH] Initial commit. --- .gitignore | 2 + phototrie/__init__.py | 0 phototrie/batchrename.py | 65 ++++++++++++++++++++ phototrie/datename.py | 96 ++++++++++++++++++++++++++++++ phototrie/phototrie.py | 104 ++++++++++++++++++++++++++++++++ setup.py | 125 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 392 insertions(+) create mode 100644 .gitignore create mode 100644 phototrie/__init__.py create mode 100644 phototrie/batchrename.py create mode 100644 phototrie/datename.py create mode 100644 phototrie/phototrie.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e739599 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea +/.venv diff --git a/phototrie/__init__.py b/phototrie/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/phototrie/batchrename.py b/phototrie/batchrename.py new file mode 100644 index 0000000..3f70d0e --- /dev/null +++ b/phototrie/batchrename.py @@ -0,0 +1,65 @@ +import os +import re +import sys +import argparse +from typing import List + + +def cli(): + parser = argparse.ArgumentParser( + description="Renames files in batch by regex.", + ) + + parser.add_argument( + "pattern", + type=str, + help="regex pattern to be replaced (on the basename)", + ) + + parser.add_argument( + "replacement", + type=str, + help="replacement pattern", + ) + + parser.add_argument( + "files", + type=str, + nargs="*", + help="list of files to rename", + ) + + parser.add_argument( + "--apply", + "-y", + action="store_true", + help="actually apply the replacement (dry-run if omitted)", + ) + + args = parser.parse_args(sys.argv[1:]) + + pattern = re.compile(args.pattern) + + for filename in sys.argv[3:]: + base = os.path.basename(filename) + dirn = os.path.dirname(filename) + new_base = pattern.sub(args.replacement, base) + + if base == new_base: + print(f"no match: {filename}", file=sys.stderr) + continue + + target_filename = dirn + "/" + new_base + + if os.path.exists(target_filename): + print(f"target exists: {target_filename}", file=sys.stderr) + continue + + if args.apply: + os.rename(filename, target_filename) + else: + print(f"{filename} → {target_filename}", file=sys.stderr) + + +if __name__ == "__main__": + cli() diff --git a/phototrie/datename.py b/phototrie/datename.py new file mode 100644 index 0000000..89b1457 --- /dev/null +++ b/phototrie/datename.py @@ -0,0 +1,96 @@ +import argparse +import os +import sys +import exifread +import re + +DATETIME_KEY = "Image DateTime" +DATETIME_PATTERN = re.compile(r"^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$") + + +def get_new_name(filename, date_matches): + dirn = os.path.dirname(filename) + # base = os.path.basename(filename) + extension = os.path.splitext(filename)[1] + + year, month, date, hour, second, minute = date_matches.groups() + suffix = 0 + + while True: + if suffix == 0: + target = ( + f"{dirn}/{year}-{month}-{date}_{hour}:{second}:{minute}.{extension}" + ) + else: + target = f"{dirn}/{year}-{month}-{date}_{hour}:{second}:{minute}_{suffix}.{extension}" + + if not os.path.exists(target): + return target + + suffix += 1 + + +def cli(): + parser = argparse.ArgumentParser( + description="Renames files in batch by regex.", + ) + + parser.add_argument( + "files", + type=str, + nargs="*", + help="list of files to rename", + ) + + parser.add_argument( + "--apply", + "-y", + action="store_true", + help="actually apply the replacement (dry-run if omitted)", + ) + + args = parser.parse_args(sys.argv[1:]) + + for filename in args.files: + f = open(filename, "rb") + tags = exifread.process_file(f) + + if DATETIME_KEY not in tags: + print(f"No datetime tag found for: {filename}", file=sys.stderr) + continue + + match = DATETIME_PATTERN.match(tags[DATETIME_KEY]) + if match is None: + print( + f"Invalid datetime tag ({tags[DATETIME_KEY]!r}) found for: {filename}", + file=sys.stderr, + ) + + # also search for raw CR2 + unextended = os.path.splitext(filename)[0] + found_raw = None + if os.path.exists(unextended + ".CR2"): + found_raw = unextended + ".CR2" + else: + unextended_basename = os.path.basename(unextended) + try_next = os.path.dirname(filename) + "/CR2/" + unextended_basename + if os.path.exists(try_next): + found_raw = try_next + + # for k, v in tags.items(): + # print(f"{k} = {v}") + # print(tags[DATETIME_KEY]) + # print(found_raw) + + if args.apply: + os.rename(filename, get_new_name(filename, match)) + if found_raw is not None: + os.rename(found_raw, get_new_name(found_raw, match)) + else: + print(filename, "→", get_new_name(filename, match)) + if found_raw is not None: + print(found_raw, "→", get_new_name(found_raw, match)) + + +if __name__ == "__main__": + cli() diff --git a/phototrie/phototrie.py b/phototrie/phototrie.py new file mode 100644 index 0000000..e8439c2 --- /dev/null +++ b/phototrie/phototrie.py @@ -0,0 +1,104 @@ +import os +import shutil +from tempfile import mkdtemp +from tkinter import * +from typing import List, Tuple, Optional + +from PIL import Image, ImageTk + +NAMES = {"b": "bad", "g": "good", "p": "pristine"} + + +def prepare_processable(search_in: str): + tmp_dir = mkdtemp("") + to_process = [] + extension = "." + sys.argv[1] + for file in sorted(os.listdir(search_in)): + if file.endswith(extension): + full_path = search_in + "/" + file + thumb_path = tmp_dir + "/" + file + ".jpg" + image = Image.open(file) + image.thumbnail((512, 512)) + image.save(thumb_path) + + # also search for raw CR2 + unextended = os.path.splitext(file)[0] + found_raw = None + if os.path.exists(unextended + ".CR2"): + found_raw = unextended + ".CR2" + else: + unextended_basename = os.path.basename(unextended) + try_next = os.path.dirname(file) + "/CR2/" + unextended_basename + if os.path.exists(try_next): + found_raw = try_next + + to_process.append((full_path, thumb_path, found_raw)) + + return to_process, tmp_dir + + +class App: + def __init__( + self, to_process: List[Tuple[str, str, Optional[str]]], sort_dest: str + ): + self.sort_dest = sort_dest + self.top = Tk() + self.processing_now = None + self.to_process = to_process + + image = self.load_next_image() + self.display = Label(self.top, image=image) + self.display.image = image # prevents GC + self.display.grid() + + self.top.bind("", self.callback) + self.top.bind("", self.callback) + self.top.bind("

", self.callback) + + def callback(self, e): + name = NAMES[e.char] + print(self.processing_now[0], name) + shutil.move(self.processing_now[0], self.sort_dest + "/" + name) + if self.processing_now[2] is not None: + # also move the raw + shutil.move(self.processing_now[2], self.sort_dest + "/" + name) + os.remove(self.processing_now[1]) + + try: + next_img = self.load_next_image() + except IndexError: + self.top.quit() + return + self.display.configure(image=next_img) + self.display.image = next_img # prevents GC + + def load_next_image(self): + next = self.to_process.pop(0) + self.processing_now = next + + _full_path, thumb_path, _raw_path = next + img = Image.open(thumb_path) + photo_img = ImageTk.PhotoImage(img) + return photo_img + + +def cli(): + print(" [file extension to process e.g. CR2 or jpg.") + print("keybinds: B 'bad', 'G' good, 'P' pristine") + + to_process, tmp_dir = prepare_processable(".") + + dirs = "./bad", "./good", "./pristine" + + for directory in dirs: + if not os.path.isdir(directory): + os.mkdir(directory) + + app = App(to_process, ".") + + app.top.mainloop() + os.rmdir(tmp_dir) + + +if __name__ == "__main__": + cli() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..814752c --- /dev/null +++ b/setup.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import io +import os +import sys +from shutil import rmtree + +from setuptools import find_packages, setup, Command + +# Package meta-data. +NAME = "phototrie" +DESCRIPTION = "Tools for sorting photos" +URL = "https://bics.ga/reivilibre/phototrie" +EMAIL = "reivi@librepush.net" +AUTHOR = "Olivier 'reivilibre'" +REQUIRES_PYTHON = ">=3.7.0" +VERSION = "0.1.0" + +# What packages are required for this module to be executed? +REQUIRED = [ + "ExifRead", + "Pillow", +] + + +# What packages are optional? +EXTRAS = { + +} + +# The rest you shouldn't have to touch too much :) +# ------------------------------------------------ +# Except, perhaps the License and Trove Classifiers! +# If you do change the License, remember to change the Trove Classifier for that! + +here = os.path.abspath(os.path.dirname(__file__)) + +# Import the README and use it as the long-description. +# Note: this will only work if 'README.md' is present in your MANIFEST.in file! +try: + with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: + long_description = "\n" + f.read() +except FileNotFoundError: + long_description = DESCRIPTION + +# Load the package's __version__.py module as a dictionary. +about = {} +if not VERSION: + project_slug = NAME.lower().replace("-", "_").replace(" ", "_") + with open(os.path.join(here, project_slug, "__version__.py")) as f: + exec(f.read(), about) +else: + about["__version__"] = VERSION + + +class UploadCommand(Command): + """Support setup.py upload.""" + + description = "Build and publish the package." + user_options = [] + + @staticmethod + def status(s): + """Prints things in bold.""" + print("\033[1m{0}\033[0m".format(s)) + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + try: + self.status("Removing previous builds…") + rmtree(os.path.join(here, "dist")) + except OSError: + pass + + self.status("Building Source and Wheel (universal) distribution…") + os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) + + self.status("Uploading the package to PyPI via Twine…") + os.system("twine upload dist/*") + + self.status("Pushing git tags…") + os.system("git tag v{0}".format(about["__version__"])) + os.system("git push --tags") + + sys.exit() + + +# Where the magic happens: +setup( + name=NAME, + version=about["__version__"], + description=DESCRIPTION, + long_description=long_description, + long_description_content_type="text/markdown", + author=AUTHOR, + author_email=EMAIL, + python_requires=REQUIRES_PYTHON, + url=URL, + packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), + # If your package is a single module, use this instead of 'packages': + # py_modules=['mypackage'], + entry_points={ + "console_scripts": [ + "phototrie=phototrie.phototrie:cli", + "batchrename=phototrie.batchrename:cli", + "datename=phototrie.datename:cli", + ], + }, + install_requires=REQUIRED, + extras_require=EXTRAS, + include_package_data=True, + # TODO license='GPL3', + classifiers=[ + # Trove classifiers + # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers + "Programming Language :: Python", + "Programming Language :: Python :: 3", + ], +)