Skip to content

Commit 1ab8c3f

Browse files
committed
Refactoring and bug fixes
Fix issue with wrong date being displayed when date changes. Support for more timezone formats. Support for using the current time (eg. "time in utc") More flexible date formatting options+
1 parent d1d52e3 commit 1ab8c3f

File tree

4 files changed

+961
-953
lines changed

4 files changed

+961
-953
lines changed

__init__.py

Lines changed: 201 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
# -*- coding: utf-8 -*-
22

33
import datetime
4+
import json
45
import re
56
import traceback
7+
from datetime import datetime
68
from pathlib import Path
7-
from typing import List
9+
from typing import Any, Dict, List, Optional
810

911
import albert
10-
11-
from dates import format_date, parse_date
12+
import dateparser
13+
import pytz
1214

1315
__doc__ = """
1416
Extension for converting between timezones
@@ -18,32 +20,203 @@
1820
Examples:
1921
`10pm PST to CST`
2022
`8am MST in New York`
23+
`Time in IST`
2124
22-
24-hour time and timezone aliases can be set in config.py
25+
Date formats and timezone aliases can be set in config.jsonc
2326
"""
2427
__title__ = "Timezone Convert"
2528
__version__ = "0.0.1"
2629
__authors__ = "Jonah Lawrence"
2730
__py_deps__ = ["dateparser"]
2831

29-
local_timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
30-
3132
timezone_regex = re.compile(
32-
r"(?P<from_time>.*(?:pm|am|\d:\d).*)\s(?P<seperator>to|in)\s(?P<to_tz>.*)", re.I
33+
r"(?P<from_time>.*(?:pm|am|\d:\d|time|now|current).*)\s(?:to|in)\s(?P<to_tz>.*)",
34+
re.I,
3335
)
3436

3537

36-
def get_icon_path() -> str:
38+
def load_config(config_path: Path) -> str:
3739
"""
38-
Get the path to the icon
40+
Strip comments and load the config from the config file.
41+
"""
42+
with config_path.open("r") as config_file:
43+
contents = config_file.read()
44+
contents = re.sub(r"^\s*//.*$", "", contents, flags=re.MULTILINE)
45+
return json.loads(contents)
3946

40-
Returns:
41-
str: The path to the icon.
47+
48+
config_path = Path(__file__).parent / "config.jsonc"
49+
config = load_config(config_path)
50+
51+
52+
class ConversionResult:
53+
"""
54+
Class to hold the result of a timezone conversion.
4255
"""
43-
return str(Path(__file__).parent / "icons" / "clock.svg")
56+
57+
def __init__(
58+
self,
59+
from_time: Optional[datetime] = None,
60+
result_time: Optional[datetime] = None,
61+
):
62+
self.from_time = from_time
63+
self.result_time = result_time
64+
65+
def __format_date(self, date: datetime) -> str:
66+
"""
67+
Convert dates to a specified format
68+
69+
Args:
70+
date (datetime): The date to format
71+
72+
Returns:
73+
str: The formatted date
74+
"""
75+
# %a = Weekday (eg. "Mon"), %d = Day (eg. "01"), %b = Month (eg. "Sep")
76+
date_format = config.get("date_format", "%a %d %b")
77+
# %H = Hours (24-hour clock), %M = Minutes, %I = Hours (12-hour clock), %p = AM/PM
78+
time_format = config.get("time_format", "%I:%M %p")
79+
# format the date and remove leading zeros and trailing spaces
80+
formatted = date.strftime(f"{date_format} {time_format}")
81+
if config.get("remove_leading_zeros", True):
82+
formatted = re.sub(r" 0(\d)", r" \1", formatted)
83+
if config.get("lowercase_am_pm", True):
84+
formatted = formatted.replace("AM", "am").replace("PM", "pm")
85+
return f"{formatted.strip()} {self.__get_timezone(date)}".strip()
86+
87+
def __get_timezone(self, date: datetime) -> str:
88+
"""
89+
Get the timezone of the given date.
90+
91+
Args:
92+
date (datetime): The date to get the timezone of
93+
94+
Returns:
95+
str: The timezone of the given date
96+
"""
97+
tz = date.tzname() or date.strftime("%Z")
98+
# remove '\' from timezone name if it appears
99+
return tz.replace("\\", "")
100+
101+
@property
102+
def formatted_from_time(self) -> Optional[str]:
103+
"""Returns the from_time as a formatted string"""
104+
return self.__format_date(self.from_time) if self.from_time else None
105+
106+
@property
107+
def formatted_result_time(self) -> Optional[str]:
108+
return self.__format_date(self.result_time) if self.result_time else None
109+
110+
def __str__(self) -> str:
111+
return (
112+
f"{self.formatted_result_time} (Converted from {self.formatted_from_time})"
113+
)
114+
115+
def __repr__(self) -> str:
116+
return str(self)
117+
118+
119+
class TimezoneConverter:
120+
def __replace_tz_aliases(self, input_tz: str) -> str:
121+
"""
122+
Note: The dateparser library handles most timezones automatically but this is just to fill in the gaps.
123+
124+
Keywords such as cities can be added to the list of replacements to use as aliases in the config.jsonc file.
125+
126+
Args:
127+
input_tz (str): The timezone or text containing the timezone to check.
128+
129+
Returns:
130+
str: The new timezone name if it was found in the overrides dictionary, otherwise the original timezone name.
131+
"""
132+
tz_aliases = config.get("tz_aliases", {})
133+
return tz_aliases.get(input_tz.lower().strip(), input_tz)
134+
135+
def __parse_date(
136+
self,
137+
date_str: str,
138+
from_tz: Optional[str] = None,
139+
to_tz: Optional[str] = None,
140+
future: bool = True,
141+
base: Optional[datetime] = None,
142+
) -> Optional[datetime]:
143+
"""
144+
Returns datetime object for given date string
145+
146+
Args:
147+
date_str (str): The date string to be parsed.
148+
from_tz (Optional[str]): The timezone to interpret the date as.
149+
to_tz (Optional[str]): The timezone to return the date in.
150+
future (Optional[bool]): Set to true to prefer dates from the future when parsing.
151+
base (datetime): datetime representing where dates should be parsed relative to.
152+
153+
Returns:
154+
Optional[datetime]: The parsed date or None if the date could not be parsed.
155+
"""
156+
# set default base datetime if none is given
157+
base = base or datetime.now()
158+
# set from_tz if date_str contains a timezone alias
159+
tz_aliases = list(config.get("tz_aliases", {}).items())
160+
pytz_timezones = list(zip(pytz.all_timezones, pytz.all_timezones))
161+
for alias, tz_name in tz_aliases + pytz_timezones:
162+
alias = re.escape(alias)
163+
if re.search(fr"(^|\s){alias}(\s|$)", date_str, re.I):
164+
from_tz = tz_name
165+
to_tz = to_tz or from_tz
166+
date_str = re.sub(fr"(^|\s){alias}(\s|$)", r"\1", date_str, flags=re.I)
167+
break
168+
albert.info(f"Using timezone {from_tz} for {date_str}")
169+
albert.info(f"Target timezone is {to_tz}")
170+
# set dateparser settings
171+
settings: Dict[str, Any] = {
172+
"RELATIVE_BASE": base.replace(tzinfo=None),
173+
"RETURN_AS_TIMEZONE_AWARE": True,
174+
**({"TIMEZONE": self.__replace_tz_aliases(from_tz)} if from_tz else {}),
175+
**({"TO_TIMEZONE": self.__replace_tz_aliases(to_tz)} if to_tz else {}),
176+
**({"PREFER_DATES_FROM": "future"} if future else {}),
177+
}
178+
# parse the date
179+
date = None
180+
try:
181+
albert.info(f'Parsing date "{date_str}" with settings {settings}')
182+
# parse the date with dateparser
183+
date = dateparser.parse(date_str, settings=settings)
184+
assert date is not None
185+
# convert the date to the specified timezone
186+
if not date.tzinfo and to_tz:
187+
date = pytz.timezone(self.__replace_tz_aliases(to_tz)).localize(date)
188+
except pytz.exceptions.UnknownTimeZoneError as error:
189+
albert.warning(f"Unknown timezone: {error}")
190+
except AssertionError as error:
191+
albert.warning(f"Could not parse date: {date_str}")
192+
# return the datetime object
193+
return date
194+
195+
def convert_time(self, from_time_input: str, to_tz: str) -> ConversionResult:
196+
"""
197+
Convert a time from one timezone to another
198+
199+
Args:
200+
from_time_input (str): The time to convert
201+
to_tz (str): The timezone to convert the time to
202+
203+
Returns:
204+
TzResult: The resulting time and the time converted from
205+
"""
206+
# parse the input time by itself
207+
from_time = self.__parse_date(from_time_input)
208+
# parse time with target timezone
209+
result_time = self.__parse_date(from_time_input, to_tz=to_tz)
210+
# make sure the date is correct for the from_time
211+
if from_time and result_time and from_time.tzinfo and result_time.tzinfo:
212+
from_time = (
213+
result_time - result_time.utcoffset() + from_time.utcoffset()
214+
).replace(tzinfo=from_time.tzinfo)
215+
# return the result
216+
return ConversionResult(from_time, result_time)
44217

45218

46-
def get_item(text: str, subtext: str) -> albert.Item:
219+
def create_item(text: str, subtext: str) -> albert.Item:
47220
"""
48221
Create an albert.Item from a text and subtext.
49222
@@ -56,7 +229,7 @@ def get_item(text: str, subtext: str) -> albert.Item:
56229
"""
57230
return albert.Item(
58231
id=__title__,
59-
icon=get_icon_path(),
232+
icon=str(Path(__file__).parent / "icons" / "clock.svg"),
60233
text=text,
61234
subtext=subtext,
62235
actions=[albert.ClipAction("Copy result to clipboard", text)],
@@ -73,23 +246,21 @@ def get_items(from_time: str, to_tz: str) -> List[albert.Item]:
73246
Returns:
74247
List[albert.Item]: The list of items to display.
75248
"""
76-
# parse from_time by itself
77-
from_dt = parse_date(from_time)
78-
if not from_dt:
79-
return [get_item(f"Error: Could not parse date: {from_time}", "")]
80-
# parse time with target timezone
81-
result_dt = parse_date(from_time, to_tz=to_tz)
82-
if not result_dt:
83-
return [get_item(f"Error: Could not parse timezone: {to_tz}", "")]
84-
# format the results
85-
from_str = format_date(from_dt)
86-
result_str = format_date(result_dt)
87-
from_tz = from_dt.tzname() or local_timezone.tzname(from_dt) or ""
88-
result_tz = result_dt.tzname() or ""
249+
tc = TimezoneConverter()
250+
# use the current time if no time is given
251+
if from_time.lower().strip() in ("now", "current", "current time", "time"):
252+
from_time = datetime.now().isoformat()
253+
# convert the time
254+
result = tc.convert_time(from_time, to_tz)
255+
# display error messages if unsuccessful
256+
if result.from_time is None:
257+
return [create_item(f"Could not parse date: {from_time}", "")]
258+
if result.result_time is None:
259+
return [create_item(f"Could not parse timezone: {to_tz}", "")]
260+
# return the result
89261
return [
90-
get_item(
91-
f"{result_str} {result_tz}",
92-
f"Converted from {from_str} {from_tz}",
262+
create_item(
263+
result.formatted_result_time, f"Converted from {result.formatted_from_time}"
93264
)
94265
]
95266

0 commit comments

Comments
 (0)