|
| 1 | +from datetime import datetime |
| 2 | +from os import utime |
| 3 | +import subprocess |
| 4 | + |
| 5 | +import dateutil |
| 6 | +from .controller import Controller |
| 7 | +from .utils import (count_relative_shift, get_date, set_files_timestamp, |
| 8 | + touch_multiple) |
| 9 | +from typing import Annotated, get_args |
| 10 | +from mininterface import Tag |
| 11 | +from tyro.conf import Positional |
| 12 | +from dataclasses import MISSING, dataclass, field |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +from mininterface.subcommands import Command |
| 16 | +from mininterface.exceptions import ValidationFail |
| 17 | + |
| 18 | +DateFormat = str # Use type as of Python3.12 |
| 19 | + |
| 20 | +c = Controller() |
| 21 | + |
| 22 | + |
| 23 | +@dataclass |
| 24 | +class App(Command): |
| 25 | + # NOTE tyro does not work if a required positional is missing tyro.cli() returns just NonpropagatingMissingType. |
| 26 | + # If this is supported, I might set other attributes like required (date, time). |
| 27 | + # files: Positional[list[Path]] |
| 28 | + files: Positional[list[Path]] = field(default_factory=list) |
| 29 | + """ Files the modification date is to be changed. """ |
| 30 | + |
| 31 | + def init(self): |
| 32 | + if self.files is MISSING or not len(self.files): |
| 33 | + # self.files = m.form({"Choose files": Tag("", annotation=list[Path], validation=not_empty)}) |
| 34 | + self.ref = None |
| 35 | + self.ref_date = None |
| 36 | + return |
| 37 | + |
| 38 | + self.ref = self.files[0] |
| 39 | + if len(self.files) > 1: |
| 40 | + title = f"Touch {len(self.files)} files" |
| 41 | + else: |
| 42 | + title = f"Touch {self.ref.name}" |
| 43 | + |
| 44 | + # NOTE should exist self._facet.set_window_title(title) instead of this workaround |
| 45 | + if hasattr(self._facet.adaptor, "title"): |
| 46 | + self._facet.adaptor.title(title) |
| 47 | + |
| 48 | + self.ref_date = get_date(self.ref) |
| 49 | + |
| 50 | + |
| 51 | +@dataclass |
| 52 | +class Set(App): |
| 53 | + """ Set to a specific time """ |
| 54 | + |
| 55 | + date: Annotated[str, Tag(on_change=c.refresh_title)] = "" |
| 56 | + """ Set specific date """ |
| 57 | + time: Annotated[str, Tag(on_change=c.refresh_title)] = "" |
| 58 | + """ Set specific time """ |
| 59 | + |
| 60 | + def init(self): |
| 61 | + super().init() |
| 62 | + # NOTE program fails on wrong date in GUI |
| 63 | + if self.ref_date: |
| 64 | + self.date = self.date or str(self.ref_date.date()) |
| 65 | + self.time = self.time or str(self.ref_date.time()) |
| 66 | + |
| 67 | + def run(self): |
| 68 | + if bool(self.date) != bool(self.time): |
| 69 | + # NOTE allow only time change (the date would stay) |
| 70 | + print("You have to specify both date and time ") |
| 71 | + quit() |
| 72 | + set_files_timestamp(self.date, self.time, self.files) |
| 73 | + |
| 74 | + |
| 75 | +@dataclass |
| 76 | +class Exif(App): |
| 77 | + """ Read JPEG EXIF metadata with jhead """ |
| 78 | + |
| 79 | + def run(self): |
| 80 | + [subprocess.run(["jhead", "-ft", f]) for f in self.files] |
| 81 | + |
| 82 | + |
| 83 | +@dataclass |
| 84 | +class FromName(App): |
| 85 | + """ Autodetect format""" |
| 86 | + |
| 87 | + # NOTE: this is not supported by mininiterface |
| 88 | + # format: Literal[True] | DateFormat = True |
| 89 | + format: bool | DateFormat = True |
| 90 | + """ |
| 91 | + Fetch the modification time from the file names stem. Set the format as for `datetime.strptime` like '%Y%m%d_%H%M%S'. |
| 92 | + If set to True, the format will be auto-detected. |
| 93 | + If a file name does not match the format or the format cannot be auto-detected, the file remains unchanged. |
| 94 | +
|
| 95 | + Ex: `--from-name True 20240827_154252.heic` → modification time = 27.8.2024 15:42 |
| 96 | + """ |
| 97 | + # NOTE put into the GUI from_name, now the GUI does not allow to set the format |
| 98 | + |
| 99 | + def run(self): |
| 100 | + for p in self.files: |
| 101 | + if self.format is True: # auto detection |
| 102 | + try: |
| 103 | + # 20240828_160619.heic -> "20240828 160619" -> "2024-08-28 16:06:19" |
| 104 | + # IMG_20240101_010053.jpg -> "2024-01-01 01:00:53" |
| 105 | + dt = dateutil.parser.parse(p.stem.replace("IMG_", "").replace("_", " ")) |
| 106 | + except ValueError: |
| 107 | + print(f"Cannot auto detect the date format: {p}") |
| 108 | + continue |
| 109 | + else: |
| 110 | + try: |
| 111 | + dt = datetime.strptime(p.stem, self.format) |
| 112 | + except ValueError: |
| 113 | + print(f"Does not match the format {self.format}: {p}") |
| 114 | + continue |
| 115 | + timestamp = int(dt.timestamp()) |
| 116 | + original = datetime.fromtimestamp(p.stat().st_mtime) |
| 117 | + utime(str(p), (timestamp, timestamp)) |
| 118 | + print(f"Changed {original.isoformat()} → {dt.isoformat()}: {p}") |
| 119 | + |
| 120 | + |
| 121 | +@dataclass |
| 122 | +class Shift(App): |
| 123 | + unit: Annotated[str, Tag(choices=["minutes", "hours"], name="Unit")] = "minutes" |
| 124 | + shift: Annotated[str, Tag(name="How many")] = "0" |
| 125 | + # NOTE: mininterface GUI works bad with negative numbers, hence we use str |
| 126 | + |
| 127 | + def run(self): |
| 128 | + try: |
| 129 | + quantity = int(self.shift) |
| 130 | + except: |
| 131 | + raise ValidationFail(f"Invalid number for shift: {self.shift}") |
| 132 | + touch_multiple(self.files, f"{quantity} {self.unit}") |
| 133 | + |
| 134 | + |
| 135 | +@dataclass |
| 136 | +class RelativeToReference(Set): |
| 137 | + |
| 138 | + reference: Annotated[Path | None, Tag(on_change=c.do_refresh_title)] = None |
| 139 | + """ Relative shift with reference. The reference file is set to the specified date, |
| 140 | + and all other files are shifted by the same amount relative to this reference. |
| 141 | + If not set, the first of the files is used.""" |
| 142 | + |
| 143 | + def init(self): |
| 144 | + super().init() |
| 145 | + if self.reference is None and self.ref: |
| 146 | + self.reference = self.ref |
| 147 | + |
| 148 | + # NOTE this is not nice. It changes the annotation of the whole dataclass. |
| 149 | + # Mininterface should provide a clear init callback so that we might change the values |
| 150 | + # at the beginning and once the self.files changes. |
| 151 | + get_args(self.__annotations__["reference"])[1].choices = self.files |
| 152 | + |
| 153 | + def run(self): |
| 154 | + reference = count_relative_shift(self.date, self.time, self.reference) |
| 155 | + |
| 156 | + # microsecond precision is neglected here, touch does not takes it |
| 157 | + touch_multiple(self.files, f"{reference.days} days {reference.seconds} seconds") |
0 commit comments