1
0
mirror of https://github.com/krateng/maloja.git synced 2023-08-10 21:12:55 +03:00
maloja/maloja/malojatime.py
2021-12-10 22:01:29 +01:00

591 lines
16 KiB
Python

import datetime
from datetime import datetime as dtm
from datetime import timezone, timedelta
from calendar import monthrange
from os.path import commonprefix
import math
from doreah.settings import get_settings
OFFSET = get_settings("TIMEZONE")
TIMEZONE = timezone(timedelta(hours=OFFSET))
UTC = datetime.timezone.utc
FIRST_SCROBBLE = int(datetime.datetime.utcnow().replace(tzinfo=UTC).timestamp())
def register_scrobbletime(timestamp):
global FIRST_SCROBBLE
if timestamp < FIRST_SCROBBLE:
FIRST_SCROBBLE = int(timestamp)
# Object that represents a contextual time range relevant for displaying chart information
# there is no smaller unit than days
# also, two distinct objects could represent the same timerange
# (e.g. 2019/03 is not the same as 2019/03/01 - 2019/03/31)
# Generic Time Range
class MTRangeGeneric:
# despite the above, ranges that refer to the exact same real time range should evaluate as equal
def __eq__(self,other):
if not isinstance(other,MTRangeGeneric): return False
return (self.first_stamp() == other.first_stamp() and self.last_stamp() == other.last_stamp())
# gives a hashable object that uniquely identifies this time range
def hashable(self):
return self.first_stamp(),self.last_stamp()
def info(self):
return {**self.__json__(),"uri":self.uri()}
def __json__(self):
return {
"fromstring":self.fromstr(),
"tostr":self.tostr(),
"fromstamp":self.first_stamp(),
"tostamp":self.last_stamp(),
"description":self.desc()
}
def uri(self):
return "&".join(k + "=" + self.urikeys()[k] for k in self.urikeys())
def unlimited(self):
return False
# whether we currently live or will ever again live in this range
def active(self):
return (self.last_stamp() > datetime.datetime.utcnow().timestamp())
# Any range that has one defining base unit, whether week, year, etc.
class MTRangeSingular(MTRangeGeneric):
def fromstr(self):
return str(self)
def tostr(self):
return str(self)
def urikeys(self):
return {"in":str(self)}
# a range that is exactly a gregorian calendar unit (year, month or day)
class MTRangeGregorian(MTRangeSingular):
def __init__(self,*ls):
# in case we want to call with non-unpacked arguments
if isinstance(ls[0], (tuple, list)): ls = ls[0]
self.tup = tuple(ls)
self.precision = len(ls)
self.year = ls[0]
if len(ls)>1: self.month = ls[1]
if len(ls)>2: self.day = ls[2]
dt = [1970,1,1]
dt[:len(ls)] = ls
self.dateobject = datetime.date(dt[0],dt[1],dt[2])
def __str__(self):
return "/".join(str(part) for part in self.tup)
# whether we currently live or will ever again live in this range
# USE GENERIC SUPER METHOD INSTEAD
# def active(self):
# tod = datetime.datetime.utcnow().date()
# if tod.year > self.year: return False
# if self.precision == 1: return True
# if tod.year == self.year:
# if tod.month > self.month: return False
# if self.precision == 2: return True
# if tod.month == self.month and tod.day > self.day: return False
# return True
def desc(self,prefix=False):
prefixes = (None,'in ','in ','on ')
formats = ('%Y','%B','%d')
timeformat = ' '.join(reversed(formats[0:self.precision]))
if prefix: return prefixes[self.precision] + self.dateobject.strftime(timeformat)
else: return self.dateobject.strftime(timeformat)
def informal_desc(self):
# TODO: ignore year when same year etc
now = datetime.datetime.now(tz=datetime.timezone.utc)
today = datetime.date(now.year,now.month,now.day)
if self.precision == 3:
diff = (today - dateobject).days
if diff == 0: return "Today"
if diff == 1: return "Yesterday"
if diff < 7 and diff > 1: return timeobject.strftime("%A")
#elif len(t) == 2:
return self.desc()
# describes only the parts that are different than another range object
def contextual_desc(self,other):
# TODO: more elegant maybe?
if not isinstance(other, MTRangeGregorian): return self.desc()
relevant = self.desc().split(" ")
if self.year == other.year:
relevant.pop()
if self.precision > 1 and other.precision > 1 and self.month == other.month:
relevant.pop()
if self.precision > 2 and other.precision > 2 and self.day == other.day:
relevant.pop()
return " ".join(relevant)
# get objects with one higher precision that start or end this one
def start(self):
if self.precision in [1, 2]: return MTRangeGregorian(*self.tup,1)
return self
def end(self):
if self.precision == 1: return MTRangeGregorian(*self.tup,12)
elif self.precision == 2: return MTRangeGregorian(*self.tup,monthrange(self.year,self.month)[1])
return self
# get highest precision objects (day) that start or end this one
def first_day(self):
if self.precision == 3: return self
else: return self.start().first_day()
def last_day(self):
if self.precision == 3: return self
else: return self.end().last_day()
# get first or last timestamp of this range
def first_stamp(self):
day = self.first_day().dateobject
return int(datetime.datetime.combine(day,datetime.time(tzinfo=TIMEZONE)).timestamp())
def last_stamp(self):
day = self.last_day().dateobject + datetime.timedelta(days=1)
return int(datetime.datetime.combine(day,datetime.time(tzinfo=TIMEZONE)).timestamp() - 1)
# next range of equal length (not exactly same amount of days, but same precision level)
def next(self,step=1):
if abs(step) == math.inf: return None
if self.precision == 1:
return MTRangeGregorian(self.year + step)
elif self.precision == 2:
dt = [self.year,self.month]
dt[1] += step
while dt[1] > 12:
dt[1] -= 12
dt[0] += 1
while dt[1] < 1:
dt[1] += 12
dt[0] -= 1
return MTRangeGregorian(*dt)
elif self.precision == 3:
newdate = self.dateobject + datetime.timedelta(days=step)
return MTRangeGregorian(newdate.year,newdate.month,newdate.day)
def prev(self,step=1):
return self.next(step*(-1))
# a range that is exactly one christian week (starting on sunday)
class MTRangeWeek(MTRangeSingular):
def __init__(self,year=None,week=None):
# do this so we can construct the week with overflow (eg 2020/-3)
thisisoyear_firstday = datetime.date.fromchrcalendar(year,1,1)
self.firstday = thisisoyear_firstday + datetime.timedelta(days=7*(week-1))
self.firstday = datetime.date(self.firstday.year,self.firstday.month,self.firstday.day)
# for compatibility with pre python3.8 (https://bugs.python.org/issue32417)
self.lastday = self.firstday + datetime.timedelta(days=6)
# now get the actual year and week number (in case of overflow)
self.year,self.week,_ = self.firstday.chrcalendar()
def __str__(self):
return f"{self.year}/W{self.week}"
def desc(self,prefix=False):
if prefix:
return f"in Week {self.week} {self.year}"
else:
return f"Week {self.week} {self.year}"
def informal_desc(self):
now = datetime.datetime.now(tz=datetime.timezone.utc)
if now.year == self.year: return f"Week {self.week}"
return self.desc()
def contextual_desc(self,other):
if isinstance(other, MTRangeWeek) and other.year == self.year: return f"Week {self.week}"
return self.desc()
def start(self):
return self.first_day()
def end(self):
return self.last_day()
def first_day(self):
return MTRangeGregorian(self.firstday.year,self.firstday.month,self.firstday.day)
def last_day(self):
return MTRangeGregorian(self.lastday.year,self.lastday.month,self.lastday.day)
def first_stamp(self):
day = self.firstday
return int(datetime.datetime.combine(day,datetime.time(tzinfo=TIMEZONE)).timestamp())
def last_stamp(self):
day = self.lastday + datetime.timedelta(days=1)
return int(datetime.datetime.combine(day,datetime.time(tzinfo=TIMEZONE)).timestamp() - 1)
def next(self,step=1):
if abs(step) == math.inf: return None
return MTRangeWeek(self.year,self.week + step)
# a range that is defined by separate start and end
class MTRangeComposite(MTRangeGeneric):
def __init__(self,since=None,to=None):
since,to = time_pad(since,to)
self.since = since
self.to = to
if isinstance(self.since,MTRangeComposite): self.since = self.since.start()
if isinstance(self.to,MTRangeComposite): self.to = self.to.end()
def __str__(self):
return f"{self.since} - {self.to}"
def fromstr(self):
return str(self.since)
def tostr(self):
return str(self.to)
# whether we currently live or will ever again live in this range
def active(self):
if self.to is None: return True
return self.to.active()
def unlimited(self):
return (self.since is None and self.to is None)
def urikeys(self):
keys = {}
if self.since is not None: keys["since"] = str(self.since)
if self.to is not None: keys["to"] = str(self.to)
return keys
def desc(self,prefix=False):
if self.since is not None and self.to is not None:
if prefix:
return f"from {self.since.contextual_desc(self.to)} to {self.to.desc()}"
else:
return f"{self.since.contextual_desc(self.to)} to {self.to.desc()}"
if self.since is not None and self.to is None:
return f"since {self.since.desc()}"
if self.since is None and self.to is not None:
return f"until {self.to.desc()}"
if self.since is None and self.to is None:
return ""
def informal_desc(self):
# dis gonna be hard
return "Not implemented"
def start(self):
return self.since
def end(self):
return self.to
def first_day(self):
return self.since.first_day()
def last_day(self):
return self.to.last_day()
def first_stamp(self):
if self.since is None: return FIRST_SCROBBLE
else: return self.since.first_stamp()
def last_stamp(self):
if self.to is None: return int(datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).timestamp())
else: return self.to.last_stamp()
def next(self,step=1):
if abs(step) == math.inf: return None
if self.since is None or self.to is None: return None
# hop from the start element by one until we reach the end element
diff = 1
nxt = self.since
while (nxt != self.to):
diff += 1
nxt = nxt.next(step=1)
newstart = self.since.next(step=diff*step)
newend = self.to.next(step=diff*step)
return MTRangeComposite(newstart,newend)
def today():
tod = datetime.datetime.now(tz=TIMEZONE)
return MTRangeGregorian(tod.year,tod.month,tod.day)
def thisweek():
tod = datetime.datetime.now(tz=TIMEZONE)
tod = datetime.date(tod.year,tod.month,tod.day)
y,w,_ = tod.chrcalendar()
return MTRangeWeek(y,w)
def thismonth():
tod = datetime.datetime.now(tz=TIMEZONE)
return MTRangeGregorian(tod.year,tod.month)
def thisyear():
tod = datetime.datetime.now(tz=TIMEZONE)
return MTRangeGregorian(tod.year)
def alltime():
return MTRangeComposite(None,None)
def range_desc(r,**kwargs):
if r is None: return ""
return r.desc(**kwargs)
def time_str(t):
obj = time_fix(t)
return obj.desc()
currenttime_string_representations = (
(today,["today","day"]),
(thisweek,["week","thisweek"]),
(thismonth,["month","thismonth"]),
(thisyear,["year","thisyear"]),
(lambda:None,["alltime"])
)
month_string_representations = (
["january","jan"],
["february","feb"],
["march","mar"],
["april","apr"],
["may"],
["june","jun"],
["july","jul"],
["august","aug"],
["september","sep"],
["october","oct"],
["november","nov"],
["december","dec"],
)
weekday_string_representations = (
["sunday","sun"],
["monday","mon"],
["tuesday","tue"],
["wednesday","wed"],
["thursday","thu"],
["friday","fri"],
["saturday","sat"]
)
def get_last_instance(category,current,target,amount):
offset = (target-current) % -(amount)
return category().next(offset)
str_to_time_range = {
**{s:callable for callable,strlist in currenttime_string_representations for s in strlist},
**{s:(lambda i=index:get_last_instance(thismonth,dtm.utcnow().month,i,12)) for index,strlist in enumerate(month_string_representations,1) for s in strlist},
**{s:(lambda i=index:get_last_instance(today,dtm.utcnow().isoweekday()+1%7,i,7)) for index,strlist in enumerate(weekday_string_representations,1) for s in strlist}
}
# converts strings and stuff to objects
def time_fix(t):
if t is None or isinstance(t,MTRangeGeneric): return t
if isinstance(t, str):
t = t.lower()
if t in str_to_time_range:
return str_to_time_range[t]()
if isinstance(t,str): t = t.split("/")
#if isinstance(t,tuple): t = list(t)
try:
t = [int(p) for p in t]
return MTRangeGregorian(t[:3])
except:
pass
if isinstance(t[1],str) and t[1].startswith("w"):
try:
year = int(t[0])
weeknum = int(t[1][1:])
return MTRangeWeek(year=year,week=weeknum)
except:
raise
def get_range_object(since=None,to=None,within=None):
since,to,within = time_fix(since),time_fix(to),time_fix(within)
# check if we can simplify
if since is not None and to is not None and since == to: within = since
# TODO
if within is not None:
return within
else:
return MTRangeComposite(since,to)
# makes times the same precision level
def time_pad(f,t,full=False):
if f is None or t is None: return f,t
# week handling
if isinstance(f,MTRangeWeek) and isinstance(t,MTRangeWeek):
if full: return f.start(),t.end()
else: return f,t
if not isinstance(f,MTRangeWeek) and isinstance(t,MTRangeWeek):
t = t.end()
if isinstance(f,MTRangeWeek) and not isinstance(t,MTRangeWeek):
f = f.start()
while (f.precision < t.precision) or (full and f.precision < 3):
f = f.start()
while (f.precision > t.precision) or (full and t.precision < 3):
t = t.end()
return f,t
### TIMESTAMPS
def timestamp_desc(t,short=False):
if short:
now = datetime.datetime.now(tz=datetime.timezone.utc)
difference = int(now.timestamp() - t)
timeobj = datetime.datetime.utcfromtimestamp(t)
thresholds = (
(10,"just now"),
(2*60,f"{difference} seconds ago"),
(2*60*60,f"{difference/60:.0f} minutes ago"),
(2*24*60*60,f"{difference/(60*60):.0f} hours ago"),
(5*24*60*60,f"{timeobj.strftime('%A')}"),
(31*24*60*60,f"{difference/(60*60*24):.0f} days ago"),
(12*31*24*60*60,f"{timeobj.strftime('%B')}"),
(math.inf,f"{timeobj.strftime('%Y')}")
)
for t,s in thresholds:
if difference < t: return s.format(sec=difference,obj=datetime.datetime.utcfromtimestamp(t))
else:
timeobject = datetime.datetime.fromtimestamp(t,tz=TIMEZONE)
format = get_settings("TIME_FORMAT")
return timeobject.strftime(format)
def time_stamps(since=None,to=None,within=None,range=None):
if range is None: range = get_range_object(since=since,to=to,within=within)
return range.first_stamp(),range.last_stamp()
def delimit_desc_p(d):
return delimit_desc(**d)
def delimit_desc(step="month",stepn=1,trail=1):
txt = ""
if stepn != 1: txt += str(stepn) + "-"
txt += {"year":"Yearly","month":"Monthly","week":"Weekly","day":"Daily"}[step.lower()]
if trail is math.inf: txt += " Cumulative"
elif trail != 1: txt += " Trailing" #we don't need all the info in the title
return txt
def day_from_timestamp(stamp):
dt = datetime.datetime.fromtimestamp(stamp,tz=TIMEZONE)
return MTRangeGregorian(dt.year,dt.month,dt.day)
def month_from_timestamp(stamp):
dt = datetime.datetime.fromtimestamp(stamp,tz=TIMEZONE)
return MTRangeGregorian(dt.year,dt.month)
def year_from_timestamp(stamp):
dt = datetime.datetime.fromtimestamp(stamp,tz=TIMEZONE)
return MTRangeGregorian(dt.year)
def week_from_timestamp(stamp):
dt = datetime.datetime.fromtimestamp(stamp,tz=TIMEZONE)
d = datetime.date(dt.year,dt.month,dt.day)
y,w,_ = d.chrcalendar()
return MTRangeWeek(y,w)
def from_timestamp(stamp,unit):
if unit == "day": return day_from_timestamp(stamp)
if unit == "week": return week_from_timestamp(stamp)
if unit == "month": return month_from_timestamp(stamp)
if unit == "year": return year_from_timestamp(stamp)
# since, to and within can accept old notations or objects. timerange can only be a new object.
def ranges(since=None,to=None,within=None,timerange=None,step="month",stepn=1,trail=1,max_=None):
(firstincluded,lastincluded) = time_stamps(since=since,to=to,within=within,range=timerange)
d_start = from_timestamp(firstincluded,step)
d_start = d_start.next(stepn-1) #last part of first included range
i = 0
current_end = d_start
current_start = current_end.next((stepn*trail-1)*-1)
#ranges = []
while current_end.first_stamp() < lastincluded and (max_ is None or i < max_):
if current_start == current_end:
yield current_start
#ranges.append(current_start)
else:
yield MTRangeComposite(current_start,current_end)
#ranges.append(MTRangeComposite(current_start,current_end))
current_end = current_end.next(stepn)
current_start = current_end.next((stepn*trail-1)*-1)
i += 1
#return ranges