# # Gramps - a GTK+/GNOME based genealogy program # # Copyright (C) 2000-2007 Donald N. Allingham # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # $Id$ """Support for dates """ __author__ = "Donald N. Allingham" __revision__ = "$Revision$" #------------------------------------------------------------------------ # # Python modules # #------------------------------------------------------------------------ from gettext import gettext as _ #------------------------------------------------------------------------ # # Set up logging # #------------------------------------------------------------------------ import logging log = logging.getLogger(".Date") #------------------------------------------------------------------------- # # Gnome/GTK modules # #------------------------------------------------------------------------- #------------------------------------------------------------------------ # # Gramps modules # #------------------------------------------------------------------------ from _CalSdn import * #------------------------------------------------------------------------ # # Constants # #------------------------------------------------------------------------ #obtain the ranges once, they do not change! try: import Config _DATE_BEFORE_RANGE = Config.get(Config.DATE_BEFORE_RANGE) _DATE_AFTER_RANGE = Config.get(Config.DATE_AFTER_RANGE) _DATE_ABOUT_RANGE = Config.get(Config.DATE_ABOUT_RANGE) except ImportError: # RelLib used as module not part of GRAMPS _DATE_BEFORE_RANGE = 9999 _DATE_AFTER_RANGE = 9999 _DATE_ABOUT_RANGE = 10 #------------------------------------------------------------------------- # # DateError exception # #------------------------------------------------------------------------- class DateError(Exception): """Error used to report Date errors""" def __init__(self, value=""): Exception.__init__(self) self.value = value def __str__(self): return self.value #------------------------------------------------------------------------- # # Date class # #------------------------------------------------------------------------- class Date: """ The core date handling class for GRAMPs. Supports partial dates, compound dates and alternate calendars. """ MOD_NONE = 0 MOD_BEFORE = 1 MOD_AFTER = 2 MOD_ABOUT = 3 MOD_RANGE = 4 MOD_SPAN = 5 MOD_TEXTONLY = 6 QUAL_NONE = 0 QUAL_ESTIMATED = 1 QUAL_CALCULATED = 2 CAL_GREGORIAN = 0 CAL_JULIAN = 1 CAL_HEBREW = 2 CAL_FRENCH = 3 CAL_PERSIAN = 4 CAL_ISLAMIC = 5 EMPTY = (0, 0, 0, False) _POS_DAY = 0 _POS_MON = 1 _POS_YR = 2 _POS_SL = 3 _POS_RDAY = 4 _POS_RMON = 5 _POS_RYR = 6 _POS_RSL = 7 _calendar_convert = [ gregorian_sdn, julian_sdn, hebrew_sdn, french_sdn, persian_sdn, islamic_sdn, ] _calendar_change = [ gregorian_ymd, julian_ymd, hebrew_ymd, french_ymd, persian_ymd, islamic_ymd, ] calendar_names = ["Gregorian", "Julian", "Hebrew", "French Republican", "Persian", "Islamic"] ui_calendar_names = [_("Gregorian"), _("Julian"), _("Hebrew"), _("French Republican"), _("Persian"), _("Islamic")] def __init__(self, source=None): """ Creates a new Date instance. """ if source: self.calendar = source.calendar self.modifier = source.modifier self.quality = source.quality self.dateval = source.dateval self.text = source.text self.sortval = source.sortval else: self.calendar = Date.CAL_GREGORIAN self.modifier = Date.MOD_NONE self.quality = Date.QUAL_NONE self.dateval = Date.EMPTY self.text = u"" self.sortval = 0 def serialize(self, no_text_date=False): """ Convert to a series of tuples for data storage """ if no_text_date: text = u'' else: text = self.text return (self.calendar, self.modifier, self.quality, self.dateval, text, self.sortval) def unserialize(self, data): """ Load from the format created by serialize """ (self.calendar, self.modifier, self.quality, self.dateval, self.text, self.sortval) = data return self def copy(self, source): """ Copy all the attributes of the given Date instance to the present instance, without creating a new object. """ self.calendar = source.calendar self.modifier = source.modifier self.quality = source.quality self.dateval = source.dateval self.text = source.text self.sortval = source.sortval def __cmp__(self, other): """ Comparison function. Allows the usage of equality tests. This allows you do run statements like 'date1 <= date2' """ if isinstance(other, Date): return cmp(self.sortval, other.sortval) else: return -1 def is_equal(self, other): """ Return 1 if the given Date instance is the same as the present instance IN ALL REGARDS. Needed, because the __cmp__ only looks at the sorting value, and ignores the modifiers/comments. """ if self.modifier == other.modifier \ and self.modifier == Date.MOD_TEXTONLY: value = self.text == other.text else: value = (self.calendar == other.calendar and self.modifier == other.modifier and self.quality == other.quality and self.dateval == other.dateval) return value def get_start_stop_range(self): """ Returns the minimal start_date, and a maximal stop_date corresponding to this date, given in Gregorian calendar. Useful in doing range overlap comparisons between different dates. Note that we stay in (YR,MON,DAY) """ def yr_mon_day(dateval): """ Local function to swap order for easy comparisons, and correct year of slash date. Slash date is given as year1/year2, where year1 is Julian year, and year2=year1+1 the Gregorian year """ if dateval[Date._POS_SL] : return (dateval[Date._POS_YR]+1, dateval[Date._POS_MON], dateval[Date._POS_DAY]) else : return (dateval[Date._POS_YR], dateval[Date._POS_MON], dateval[Date._POS_DAY]) def date_offset(dateval, offset): """ Local function to do date arithmetic: add the offset, return (year,month,day) in the Gregorian calendar """ new_date = Date() new_date.set_yr_mon_day(dateval[0], dateval[1], dateval[2]) return Date._calendar_change[Date.CAL_GREGORIAN]( new_date.sortval + offset) datecopy = Date(self) #we do all calculation in Gregorian calendar datecopy.convert_calendar(Date.CAL_GREGORIAN) start = yr_mon_day(datecopy.get_start_date()) stop = yr_mon_day(datecopy.get_stop_date()) if stop == (0, 0, 0): stop = start stopmax = list(stop) if stopmax[0] == 0: # then use start_year, if one stopmax[0] = start[Date._POS_YR] if stopmax[1] == 0: stopmax[1] = 12 if stopmax[2] == 0: stopmax[2] = 31 startmin = list(start) if startmin[1] == 0: startmin[1] = 1 if startmin[2] == 0: startmin[2] = 1 # if BEFORE, AFTER, or ABOUT/EST, adjust: if self.modifier == Date.MOD_BEFORE: stopmax = date_offset(startmin, -1) fdiff = _DATE_BEFORE_RANGE startmin = (stopmax[0] - fdiff, stopmax[1], stopmax[2]) elif self.modifier == Date.MOD_AFTER: startmin = date_offset(stopmax, 1) fdiff = _DATE_AFTER_RANGE stopmax = (startmin[0] + fdiff, startmin[1], startmin[2]) elif (self.modifier == Date.MOD_ABOUT or self.quality == Date.QUAL_ESTIMATED): fdiff = _DATE_ABOUT_RANGE startmin = (startmin[0] - fdiff, startmin[1], startmin[2]) stopmax = (stopmax[0] + fdiff, stopmax[1], stopmax[2]) # return tuples not lists, for comparisons return (tuple(startmin), tuple(stopmax)) def match(self, other_date): """ The other comparisons for Date don't actually look for anything other than a straight match, or a simple comparison of the sortval. This method allows a more sophisticated comparison looking for any overlap between two possible dates, date spans, and qualities. Returns True if part of other_date matches part of the date-span defined by self """ if (other_date.modifier == Date.MOD_TEXTONLY or self.modifier == Date.MOD_TEXTONLY): ###from DateHandler import displayer # If either date is just text, then we can only compare textual # representations # Use text as originally given or display date to format # in preferences? That is use self.text or displayer ? # It is unclean to import DateHandler in RelLib ! ###self_text = displayer.display(self) self_text = self.text ##DEBUG: print ' TEXT COMPARE ONLY ' return (self_text.upper().find(other_date.text.upper()) != -1) # Obtain minimal start and maximal stop in Gregorian calendar other_start, other_stop = other_date.get_start_stop_range() self_start, self_stop = self.get_start_stop_range() ##DEBUG print " date compare:", self_start, self_stop, other_start, ##DEBUG other_stop # If some overlap then match is True, otherwise False. if ((self_start <= other_start <= self_stop) or (self_start <= other_stop <= self_stop) or (other_start <= self_start <= other_stop) or (other_start <= self_stop <= other_stop)): return True else: return False def __str__(self): """ Produces a string representation of the Date object. If the date is not valid, the text representation is displayed. If the date is a range or a span, a string in the form of 'YYYY-MM-DD - YYYY-MM-DD' is returned. Otherwise, a string in the form of 'YYYY-MM-DD' is returned. """ if self.quality == Date.QUAL_ESTIMATED: qual = "est " elif self.quality == Date.QUAL_CALCULATED: qual = "calc " else: qual = "" if self.modifier == Date.MOD_BEFORE: pref = "bef " elif self.modifier == Date.MOD_AFTER: pref = "aft " elif self.modifier == Date.MOD_ABOUT: pref = "abt " else: pref = "" if self.calendar != Date.CAL_GREGORIAN: cal = " (%s)" % Date.calendar_names[self.calendar] else: cal = "" if self.modifier == Date.MOD_TEXTONLY: val = self.text elif self.modifier == Date.MOD_RANGE or self.modifier == Date.MOD_SPAN: val = "%04d-%02d-%02d - %04d-%02d-%02d" % ( self.dateval[Date._POS_YR], self.dateval[Date._POS_MON], self.dateval[Date._POS_DAY], self.dateval[Date._POS_RYR], self.dateval[Date._POS_RMON], self.dateval[Date._POS_RDAY]) else: val = "%04d-%02d-%02d" % ( self.dateval[Date._POS_YR], self.dateval[Date._POS_MON], self.dateval[Date._POS_DAY]) return "%s%s%s%s" % (qual, pref, val, cal) def get_sort_value(self): """ Returns the sort value of Date object. If the value is a text string, 0 is returned. Otherwise, the calculated sort date is returned. The sort date is rebuilt on every assignment. The sort value is an integer representing the value. A date of March 5, 1990 would have the value of 19900305. """ return self.sortval def get_modifier(self): """ Returns an integer indicating the calendar selected. The valid values are:: MOD_NONE = no modifier (default) MOD_BEFORE = before MOD_AFTER = after MOD_ABOUT = about MOD_RANGE = date range MOD_SPAN = date span MOD_TEXTONLY = text only """ return self.modifier def set_modifier(self, val): """ Sets the modifier for the date. """ if val not in (Date.MOD_NONE, Date.MOD_BEFORE, Date.MOD_AFTER, Date.MOD_ABOUT, Date.MOD_RANGE, Date.MOD_SPAN, Date.MOD_TEXTONLY): raise DateError("Invalid modifier") self.modifier = val def get_quality(self): """ Returns an integer indicating the calendar selected. The valid values are:: QUAL_NONE = normal (default) QUAL_ESTIMATED = estimated QUAL_CALCULATED = calculated """ return self.quality def set_quality(self, val): """ Sets the quality selected for the date. """ if val not in (Date.QUAL_NONE, Date.QUAL_ESTIMATED, Date.QUAL_CALCULATED): raise DateError("Invalid quality") self.quality = val def get_calendar(self): """ Returns an integer indicating the calendar selected. The valid values are:: CAL_GREGORIAN - Gregorian calendar CAL_JULIAN - Julian calendar CAL_HEBREW - Hebrew (Jewish) calendar CAL_FRENCH - French Republican calendar CAL_PERSIAN - Persian calendar CAL_ISLAMIC - Islamic calendar """ return self.calendar def set_calendar(self, val): """ Sets the calendar selected for the date. """ if val not in (Date.CAL_GREGORIAN, Date.CAL_JULIAN, Date.CAL_HEBREW, Date.CAL_FRENCH, Date.CAL_PERSIAN, Date.CAL_ISLAMIC): raise DateError("Invalid calendar") self.calendar = val def get_start_date(self): """ Returns a tuple representing the start date. If the date is a compound date (range or a span), it is the first part of the compound date. If the date is a text string, a tuple of (0, 0, 0, False) is returned. Otherwise, a date of (DD, MM, YY, slash) is returned. If slash is True, then the date is in the form of 1530/1. """ if self.modifier == Date.MOD_TEXTONLY: val = Date.EMPTY else: val = self.dateval[0:4] return val def get_stop_date(self): """ Returns a tuple representing the second half of a compound date. If the date is not a compound date, (including text strings) a tuple of (0, 0, 0, False) is returned. Otherwise, a date of (DD, MM, YY, slash) is returned. If slash is True, then the date is in the form of 1530/1. """ if self.modifier == Date.MOD_RANGE or self.modifier == Date.MOD_SPAN: val = self.dateval[4:8] else: val = Date.EMPTY return val def _get_low_item(self, index): """ Returns the item specified """ if self.modifier == Date.MOD_TEXTONLY: val = 0 else: val = self.dateval[index] return val def _get_low_item_valid(self, index): """ Determines if the item specified is valid """ if self.modifier == Date.MOD_TEXTONLY: val = False else: val = self.dateval[index] != 0 return val def _get_high_item(self, index): """ Returns the item specified """ if self.modifier == Date.MOD_SPAN or self.modifier == Date.MOD_RANGE: val = self.dateval[index] else: val = 0 return val def get_year(self): """ Returns the year associated with the date. If the year is not defined, a zero is returned. If the date is a compound date, the lower date year is returned. """ return self._get_low_item(Date._POS_YR) def set_yr_mon_day(self, year, month, day): """ Sets the year, month, and day values """ dv = list(self.dateval) dv[Date._POS_YR] = year dv[Date._POS_MON] = month dv[Date._POS_DAY] = day self.dateval = tuple(dv) self._calc_sort_value() def set_year(self, year): """ Sets the year value """ self.dateval = self.dateval[0:2] + (year, ) + self.dateval[3:] self._calc_sort_value() def get_year_valid(self): """ Returns true if the year is valid """ return self._get_low_item_valid(Date._POS_YR) def get_month(self): """ Returns the month associated with the date. If the month is not defined, a zero is returned. If the date is a compound date, the lower date month is returned. """ return self._get_low_item(Date._POS_MON) def get_month_valid(self): """ Returns true if the month is valid """ return self._get_low_item_valid(Date._POS_MON) def get_day(self): """ Returns the day of the month associated with the date. If the day is not defined, a zero is returned. If the date is a compound date, the lower date day is returned. """ return self._get_low_item(Date._POS_DAY) def get_day_valid(self): """ Returns true if the day is valid """ return self._get_low_item_valid(Date._POS_DAY) def get_valid(self): """ Returns true if any part of the date is valid""" return self.modifier != Date.MOD_TEXTONLY def get_stop_year(self): """ Returns the day of the year associated with the second part of a compound date. If the year is not defined, a zero is returned. """ return self._get_high_item(Date._POS_RYR) def get_stop_month(self): """ Returns the month of the month associated with the second part of a compound date. If the month is not defined, a zero is returned. """ return self._get_high_item(Date._POS_RMON) def get_stop_day(self): """ Returns the day of the month associated with the second part of a compound date. If the day is not defined, a zero is returned. """ return self._get_high_item(Date._POS_RDAY) def get_high_year(self): """ Returns the high year estimate. For compound dates with non-zero stop year, the stop year is returned. Otherwise, the start year is returned. """ if self.is_compound(): ret = self.get_stop_year() if ret: return ret else: return self.get_year() def get_text(self): """ Returns the text value associated with an invalid date. """ return self.text def set(self, quality, modifier, calendar, value, text=None): """ Sets the date to the specified value. Parameters are:: quality - The date quality for the date (see get_quality for more information) modified - The date modifier for the date (see get_modifier for more information) calendar - The calendar associated with the date (see get_calendar for more information). value - A tuple representing the date information. For a non-compound date, the format is (DD, MM, YY, slash) and for a compound date the tuple stores data as (DD, MM, YY, slash1, DD, MM, YY, slash2) text - A text string holding either the verbatim user input or a comment relating to the date. The sort value is recalculated. """ if modifier in (Date.MOD_NONE, Date.MOD_BEFORE, Date.MOD_AFTER, Date.MOD_ABOUT) and len(value) < 4: raise DateError("Invalid value. Should be: (DD, MM, YY, slash)") if modifier in (Date.MOD_RANGE, Date.MOD_SPAN) and len(value) < 8: raise DateError("Invalid value. Should be: (DD, MM, " "YY, slash1, DD, MM, YY, slash2)") if modifier not in (Date.MOD_NONE, Date.MOD_BEFORE, Date.MOD_AFTER, Date.MOD_ABOUT, Date.MOD_RANGE, Date.MOD_SPAN, Date.MOD_TEXTONLY): raise DateError("Invalid modifier") if quality not in (Date.QUAL_NONE, Date.QUAL_ESTIMATED, Date.QUAL_CALCULATED): raise DateError("Invalid quality") if calendar not in (Date.CAL_GREGORIAN, Date.CAL_JULIAN, Date.CAL_HEBREW, Date.CAL_FRENCH, Date.CAL_PERSIAN, Date.CAL_ISLAMIC): raise DateError("Invalid calendar") self.quality = quality self.modifier = modifier self.calendar = calendar self.dateval = value year = max(value[Date._POS_YR], 1) month = max(value[Date._POS_MON], 1) day = max(value[Date._POS_DAY], 1) if year == 0 and month == 0 and day == 0: self.sortval = 0 else: func = Date._calendar_convert[calendar] self.sortval = func(year, month, day) if text: self.text = text def _calc_sort_value(self): """ Calculates the numerical sort value associated with the date """ year = max(self.dateval[Date._POS_YR], 1) month = max(self.dateval[Date._POS_MON], 1) day = max(self.dateval[Date._POS_DAY], 1) if year == 0 and month == 0 and day == 0: self.sortval = 0 else: func = Date._calendar_convert[self.calendar] self.sortval = func(year, month, day) def convert_calendar(self, calendar): """ Converts the date from the current calendar to the specified calendar. """ if calendar == self.calendar: return (year, month, day) = Date._calendar_change[calendar](self.sortval) if self.is_compound(): ryear = max(self.dateval[Date._POS_RYR], 1) rmonth = max(self.dateval[Date._POS_RMON], 1) rday = max(self.dateval[Date._POS_RDAY], 1) sdn = Date._calendar_convert[self.calendar](ryear, rmonth, rday) (nyear, nmonth, nday) = Date._calendar_change[calendar](sdn) self.dateval = (day, month, year, self.dateval[Date._POS_SL], nday, nmonth, nyear, self.dateval[Date._POS_RSL]) else: self.dateval = (day, month, year, self.dateval[Date._POS_SL]) self.calendar = calendar def set_as_text(self, text): """ Sets the day to a text string, and assigns the sort value to zero. """ self.modifier = Date.MOD_TEXTONLY self.text = text self.sortval = 0 def set_text_value(self, text): """ Sets the text string to a given text. """ self.text = text def is_empty(self): """ Returns True if the date contains no information (empty text). """ return (self.modifier == Date.MOD_TEXTONLY and not self.text) or \ (self.get_start_date()==Date.EMPTY and self.get_stop_date()==Date.EMPTY) def is_compound(self): """ Returns True if the date is a date range or a date span. """ return self.modifier == Date.MOD_RANGE \ or self.modifier == Date.MOD_SPAN def is_regular(self): """ Returns True if the date is a regular date. The regular date is a single exact date, i.e. not text-only, not a range or a span, not estimated/calculated, not about/before/after date, and having year, month, and day all non-zero. """ return self.modifier == Date.MOD_NONE \ and self.quality == Date.QUAL_NONE \ and self.get_year_valid() and self.get_month_valid() \ and self.get_day_valid() if __name__ == "__main__": # Test function. Call it as follows from the command line (so as to find # imported modules): # export PYTHONPATH=/path/to/gramps/src python src/RelLib/_Date.py # from DateHandler import _DateParser df = _DateParser.DateParser() # date factory def test_date(d1, d2, expected1, expected2 = None): if expected2 == None: expected2 = expected1 pos1 = 1 if expected1 : pos1 = 0 pos2 = 1 if expected2 : pos2 = 0 date1 = df.parse(d1) date2 = df.parse(d2) wrong = 0 print "Testing '%s' and '%s'" % (d1, d2) val = date2.match(date1) try: assert(val == expected1) print [" correct: they match!" ," correct: they do not match!"][pos1] except: print " Wrong! got %s" % (not expected1) wrong += 1 val = date1.match(date2) try: assert(val == expected2) print [" correct: they match!" ," correct: they do not match!"][pos2] except: print " Wrong! got %s" % (not expected2) wrong += 1 return {"incorrect": wrong, "correct": 2 - wrong } stats = {'incorrect':0, 'correct':0} # create a bunch of tests: # most are symmetric: #date1, date2, does d1 match d2? does d2 match d1? tests = [("before 1960", "before 1961", True), ("before 1960", "before 1960", True), ("before 1961", "before 1961", True), ("jan 1, 1960", "jan 1, 1960", True), ("dec 31, 1959", "dec 31, 1959", True), ("before 1960", "jan 1, 1960", False), ("before 1960", "dec 31, 1959", True), ("abt 1960", "1960", True), ("abt 1960", "before 1960", True), ("1960", "1960", True), ("1960", "after 1960", False), ("1960", "before 1960", False), ("abt 1960", "abt 1960", True), ("before 1960", "after 1960", False), ("after jan 1, 1900", "jan 2, 1900", True), ("abt jan 1, 1900", "jan 1, 1900", True), ("from 1950 to 1955", "1950", True), ("from 1950 to 1955", "1951", True), ("from 1950 to 1955", "1952", True), ("from 1950 to 1955", "1953", True), ("from 1950 to 1955", "1954", True), ("from 1950 to 1955", "1955", True), ("from 1950 to 1955", "1956", False), ("from 1950 to 1955", "dec 31, 1955", True), ("from 1950 to 1955", "jan 1, 1955", True), ("from 1950 to 1955", "dec 31, 1949", False), ("from 1950 to 1955", "jan 1, 1956", False), ("after jul 4, 1980", "jul 4, 1980", False), ("after jul 4, 1980", "before jul 4, 1980", False), ("after jul 4, 1980", "about jul 4, 1980", True), ("after jul 4, 1980", "after jul 4, 1980", True), ("between 1750 and 1752", "1750", True), ("between 1750 and 1752", "about 1750", True), ("between 1750 and 1752", "between 1749 and 1750", True), ("between 1750 and 1752", "1749", False), ("invalid date", "invalid date", True), ("invalid date", "invalid", False, True), ("invalid date 1", "invalid date 2", False), ("abt jan 1, 2000", "dec 31, 1999", True), ("jan 1, 2000", "dec 31, 1999", False), ("aft jan 1, 2000", "dec 31, 1999", False), ("after jan 1, 2000", "after dec 31, 1999", True), ("after dec 31, 1999", "after jan 1, 2000", True), ("1 31, 2000", "jan 1, 2000", False), ("dec 31, 1999", "jan 1, 2000", False), ("jan 1, 2000", "before dec 31, 1999", False), ("aft jan 1, 2000", "before dec 31, 1999", False), ("before jan 1, 2000", "after dec 31, 1999", False), ("jan 1, 2000/1", "jan 1, 2000", False), ("jan 1, 2000/1", "jan 1, 2001", True), ("about 1984", "about 2005", False), ("about 1990", "about 2005", True), ("about 2007", "about 2006", True), ("about 1995", "after 2000", True), ("about 1995", "after 2005", False), ("about 2007", "about 2003", True), ("before 2007", "2000", True), # different calendar, same date ("Aug 3, 1982", "14 Thermidor 190 (French Republican)", True), ("after Aug 3, 1982", "before 14 Thermidor 190 (French Republican)", False), ("ab cd", "54 ab cd 2000", True, False), ] # test them: for testdata in tests: results = test_date(*testdata) for result in results: stats[result] += results[result] for result in stats: print result, stats[result]