11# -*- coding: utf-8 -*-
22
33import datetime
4+ import json
45import re
56import traceback
7+ from datetime import datetime
68from pathlib import Path
7- from typing import List
9+ from typing import Any , Dict , List , Optional
810
911import albert
10-
11- from dates import format_date , parse_date
12+ import dateparser
13+ import pytz
1214
1315__doc__ = """
1416Extension for converting between timezones
1820Examples:
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-
3132timezone_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