Skip to content

Commit e839e4a

Browse files
committed
subcommands
1 parent e8401bf commit e839e4a

File tree

6 files changed

+172
-192
lines changed

6 files changed

+172
-192
lines changed

README.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Install with a single command from [PyPi](https://pypi.org/project/touch-timesta
1515

1616
```bash
1717
pip install touch-timestamp
18+
touch-timestamp --integrate-to-system # bash completion wizzard
1819
```
1920

2021
# Docs
@@ -37,14 +38,14 @@ Everything can be achieved via CLI flag. See the `--help`.
3738

3839
Let's take fetching the time from the file name as an example.
3940

40-
Should you end up with files that keep the date in the file name, use the `--from-name` parameter. In the help, you see that True trigger an automatic detection of the time and date format.
41+
Should you end up with files that keep the date in the file name, use the `from-name` command. In the help, you see that without setting format, it triggers an automatic detection of the time and date format.
4142

4243
```bash
43-
$ touch-timestamp 20240828_160619.heic --from-name True
44+
$ touch-timestamp from-name 20240828_160619.heic
4445
Changed 2001-01-01T12:00:00 → 2024-08-28T16:06:19: 20240828_160619.heic
4546
```
4647

4748

4849
## Krusader user action
4950

50-
To change the file timestamps easily from Krusader, import this [user action](extra/touch-timestamp-krusader-useraction.xml): `touch-timestamp %aList("Selected")%`
51+
To change the file timestamps easily from Krusader, import this [user action](extra/touch-timestamp-krusader-useraction.xml): `touch-timestamp subcommand %aList("Selected")%`

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "touch-timestamp"
7-
version = "0.3.8"
7+
version = "0.4.0"
88
description = "Change file timestamps with a dialog window."
99
authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
1010
license = "GPL-3.0-or-later"

touch_timestamp/app.py

+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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")

touch_timestamp/controller.py

+6-88
Original file line numberDiff line numberDiff line change
@@ -5,94 +5,15 @@
55
import dateutil.parser
66
from mininterface import Mininterface, Tag
77

8-
from .env import Env
8+
# from .env import Env
99
from .utils import (count_relative_shift, get_date, set_files_timestamp,
1010
touch_multiple)
1111

1212

1313
class Controller:
14-
def __init__(self, m: Mininterface[Env]):
15-
self.m = m
16-
self.files = m.env.files
17-
14+
def __init__(self):
1815
self._used_relative = False
1916

20-
def process_cli(self):
21-
env = self.m.env
22-
if bool(env.date) != bool(env.time):
23-
# NOTE allow only time change, the date stays
24-
print("You have to specify both date and time ")
25-
quit()
26-
if env.date and env.time:
27-
if env.reference:
28-
self.referenced_shift()
29-
else:
30-
self.specific_time()
31-
elif env.from_exif:
32-
self._exif()
33-
elif env.shift:
34-
self.relative_time()
35-
elif env.from_name:
36-
self.from_name()
37-
else:
38-
return False
39-
return True # something has been processed
40-
41-
def from_name_helper(self):
42-
# NOTE: get rid of this method when Mininterface is able to handle env.from_name `bool | DateFormat`
43-
self.m.env.from_name = True
44-
self.from_name()
45-
46-
def from_name(self):
47-
e = self.m.env
48-
for p in e.files:
49-
if e.from_name is True: # auto detection
50-
try:
51-
# 20240828_160619.heic -> "20240828 160619" -> "2024-08-28 16:06:19"
52-
# IMG_20240101_010053.jpg -> "2024-01-01 01:00:53"
53-
dt = dateutil.parser.parse(p.stem.replace("IMG_", "").replace("_", " "))
54-
except ValueError:
55-
print(f"Cannot auto detect the date format: {p}")
56-
continue
57-
else:
58-
try:
59-
dt = datetime.strptime(p.stem, e.from_name)
60-
except ValueError:
61-
print(f"Does not match the format {e.from_name}: {p}")
62-
continue
63-
timestamp = int(dt.timestamp())
64-
original = datetime.fromtimestamp(p.stat().st_mtime)
65-
utime(str(p), (timestamp, timestamp))
66-
print(f"Changed {original.isoformat()}{dt.isoformat()}: {p}")
67-
68-
def specific_time(self):
69-
e = self.m.env
70-
set_files_timestamp(e.date, e.time, e.files)
71-
72-
def _exif(self):
73-
[subprocess.run(["jhead", "-ft", f]) for f in self.files]
74-
75-
def relative_time(self):
76-
e = self.m.env
77-
quantity = e.shift
78-
if e.shift_action == "subtract":
79-
quantity *= -1
80-
touch_multiple(self.files, f"{quantity} {e.unit}")
81-
82-
def fetch_exif(self):
83-
self.m.facet.set_title("")
84-
if self.m.is_yes("Fetches the times from the EXIF if the fails are JPGs."):
85-
self._exif()
86-
else:
87-
self.m.alert("Ok, exits")
88-
89-
def referenced_shift(self):
90-
e = self.m.env
91-
reference = count_relative_shift(e.date, e.time, e.reference)
92-
93-
# microsecond precision is neglected here, touch does not takes it
94-
touch_multiple(self.m.env.files, f"{reference.days} days {reference.seconds} seconds")
95-
9617
def refresh_title(self, tag: Tag):
9718
if self._used_relative:
9819
self.do_refresh_title(tag)
@@ -102,21 +23,18 @@ def do_refresh_title(self, tag: Tag):
10223
self._used_relative = True
10324
def r(d): return d.replace(microsecond=0)
10425

105-
# e: Env = tag.facet._env
106-
e = self.m.env
26+
form = tag.facet._form
27+
e = form["RelativeToReference"][""] # NOTE this is awful. How to access them better?
10728

108-
files = e.files
29+
files = form["files"].val
10930
dates = [get_date(p) for p in files]
11031

111-
# if e.reference:
112-
shift = count_relative_shift(e.date, e.time, e.reference)
32+
shift = count_relative_shift(e["date"].val, e["time"].val, e["reference"].val)
11333

11434
tag.facet.set_title(f"Relative with reference preview"
11535
f"\nCurrently, {len(files)} files have time span:"
11636
f"\n{r(min(dates))}{r(max(dates))}"
11737
f"\nIt will be shifted by {shift} to:"
11838
f"\n{r(shift+min(dates))}{r(shift+max(dates))}")
119-
# else:
120-
# tag.facet.set_title("Touch")
12139

12240
# NOTE: when mininterface allow form refresh, fetch the date and time from the newly-chosen anchor field

touch_timestamp/env.py

-44
This file was deleted.

0 commit comments

Comments
 (0)