lunar-calendar/ifc.py

299 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""
International Fixed Calendar (IFC) Converter CLI
Convert between Gregorian calendar and the International Fixed Calendar.
Usage:
ifc # Show today's date in IFC
ifc 2025-12-25 # Convert Gregorian date to IFC
ifc --to-gregorian 7 15 2025 # Convert IFC (Sol 15, 2025) to Gregorian
ifc --range 2025-01-01 2025-01-31 # Convert date range
ifc --calendar 2025 # Show full year calendar
"""
import argparse
import sys
from datetime import datetime, timedelta
from typing import Optional, Tuple, NamedTuple
IFC_MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'Sol', 'July', 'August', 'September', 'October', 'November', 'December'
]
IFC_WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
class IFCDate(NamedTuple):
year: int
month: Optional[int]
day: Optional[int]
special: Optional[str]
weekday: Optional[str]
def __str__(self):
if self.special:
return f"{self.special}, {self.year}"
return f"{IFC_MONTHS[self.month - 1]} {self.day}, {self.year} ({self.weekday})"
def is_leap_year(year: int) -> bool:
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
def day_of_year(date: datetime) -> int:
return (date - datetime(date.year, 1, 1)).days + 1
def gregorian_to_ifc(date: datetime) -> IFCDate:
"""Convert a Gregorian date to IFC date."""
year = date.year
doy = day_of_year(date)
leap = is_leap_year(year)
# Year Day (last day of year)
if doy == (366 if leap else 365):
return IFCDate(year=year, month=None, day=None, special='Year Day', weekday=None)
# Leap Day (day 169 in leap years)
if leap and doy == 169:
return IFCDate(year=year, month=None, day=None, special='Leap Day', weekday=None)
# Adjust for leap day
adjusted_day = doy
if leap and doy > 169:
adjusted_day = doy - 1
# Calculate month and day
month = (adjusted_day - 1) // 28 + 1
day = ((adjusted_day - 1) % 28) + 1
weekday_idx = (day - 1) % 7
return IFCDate(
year=year,
month=month,
day=day,
special=None,
weekday=IFC_WEEKDAYS[weekday_idx]
)
def ifc_to_gregorian(year: int, month: Optional[int] = None, day: Optional[int] = None,
special: Optional[str] = None) -> datetime:
"""Convert an IFC date to Gregorian date."""
leap = is_leap_year(year)
if special == 'year':
doy = 366 if leap else 365
elif special == 'leap':
if not leap:
raise ValueError(f"{year} is not a leap year")
doy = 169
else:
doy = (month - 1) * 28 + day
if leap and month > 6:
doy += 1
return datetime(year, 1, 1) + timedelta(days=doy - 1)
def print_calendar(year: int):
"""Print a full IFC calendar for the given year."""
leap = is_leap_year(year)
print(f"\n{'=' * 60}")
print(f" INTERNATIONAL FIXED CALENDAR - {year}")
print(f" {'(Leap Year)' if leap else ''}")
print(f"{'=' * 60}\n")
for month in range(1, 14):
month_name = IFC_MONTHS[month - 1]
marker = " ***" if month == 7 else "" # Highlight Sol
print(f" {month_name}{marker}")
print(" " + "-" * 28)
print(" Sun Mon Tue Wed Thu Fri Sat")
for week in range(4):
row = " "
for day_in_week in range(7):
day = week * 7 + day_in_week + 1
row += f"{day:3} "
print(row)
# Show Leap Day after June
if month == 6 and leap:
print("\n >>> LEAP DAY <<<\n")
print()
print(" YEAR DAY")
print(" " + "-" * 28)
print(" Worldwide Holiday - Outside Weekly Cycle\n")
print(f"{'=' * 60}\n")
def print_range(start: datetime, end: datetime):
"""Print date range conversion."""
print(f"\n{'Gregorian':<25} {'Fixed Calendar':<30}")
print("-" * 55)
current = start
while current <= end:
ifc = gregorian_to_ifc(current)
greg_str = current.strftime("%b %d, %Y")
if ifc.special:
ifc_str = f"** {ifc.special} **"
else:
ifc_str = f"{IFC_MONTHS[ifc.month - 1]} {ifc.day}"
print(f"{greg_str:<25} {ifc_str:<30}")
current += timedelta(days=1)
print()
def parse_date(date_str: str) -> datetime:
"""Parse various date formats."""
for fmt in ('%Y-%m-%d', '%m/%d/%Y', '%d-%m-%Y', '%Y/%m/%d'):
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"Could not parse date: {date_str}")
def main():
parser = argparse.ArgumentParser(
description='International Fixed Calendar Converter',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
ifc Show today in IFC
ifc 2025-12-25 Convert Gregorian to IFC
ifc --to-gregorian 7 15 2025 Convert Sol 15, 2025 to Gregorian
ifc --range 2025-01-01 2025-01-31 Show date range
ifc --calendar 2025 Print full year calendar
ifc --months List all IFC months
"""
)
parser.add_argument('date', nargs='?', help='Gregorian date (YYYY-MM-DD)')
parser.add_argument('--to-gregorian', '-g', nargs=3, metavar=('MONTH', 'DAY', 'YEAR'),
help='Convert IFC to Gregorian (month day year)')
parser.add_argument('--range', '-r', nargs=2, metavar=('START', 'END'),
help='Convert date range')
parser.add_argument('--calendar', '-c', type=int, metavar='YEAR',
help='Print full year calendar')
parser.add_argument('--months', '-m', action='store_true',
help='List all IFC months')
parser.add_argument('--leap-day', '-l', type=int, metavar='YEAR',
help='Show Leap Day for given year')
parser.add_argument('--year-day', '-y', type=int, metavar='YEAR',
help='Show Year Day for given year')
args = parser.parse_args()
# List months
if args.months:
print("\nInternational Fixed Calendar Months:")
print("-" * 35)
for i, month in enumerate(IFC_MONTHS, 1):
marker = " (new month)" if month == "Sol" else ""
print(f" {i:2}. {month}{marker}")
print("\nPlus: Leap Day (after June) and Year Day (end of year)")
print()
return
# Print calendar
if args.calendar:
print_calendar(args.calendar)
return
# Date range
if args.range:
start = parse_date(args.range[0])
end = parse_date(args.range[1])
if (end - start).days > 366:
print("Error: Range limited to 366 days", file=sys.stderr)
sys.exit(1)
print_range(start, end)
return
# IFC to Gregorian
if args.to_gregorian:
month_str, day_str, year_str = args.to_gregorian
year = int(year_str)
# Handle special days
if month_str.lower() in ('leap', 'leapday', 'leap-day'):
try:
result = ifc_to_gregorian(year, special='leap')
print(f"\nLeap Day {year} = {result.strftime('%A, %B %d, %Y')}\n")
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
return
if month_str.lower() in ('year', 'yearday', 'year-day'):
result = ifc_to_gregorian(year, special='year')
print(f"\nYear Day {year} = {result.strftime('%A, %B %d, %Y')}\n")
return
# Parse month (name or number)
try:
month = int(month_str)
except ValueError:
month_lower = month_str.lower()
try:
month = next(i for i, m in enumerate(IFC_MONTHS, 1)
if m.lower().startswith(month_lower))
except StopIteration:
print(f"Error: Unknown month '{month_str}'", file=sys.stderr)
sys.exit(1)
day = int(day_str)
if not 1 <= day <= 28:
print("Error: Day must be between 1 and 28", file=sys.stderr)
sys.exit(1)
result = ifc_to_gregorian(year, month, day)
ifc_weekday = IFC_WEEKDAYS[(day - 1) % 7]
print(f"\n{IFC_MONTHS[month-1]} {day}, {year} ({ifc_weekday})")
print(f"= {result.strftime('%A, %B %d, %Y')}\n")
return
# Leap Day
if args.leap_day:
year = args.leap_day
if not is_leap_year(year):
print(f"Error: {year} is not a leap year", file=sys.stderr)
sys.exit(1)
result = ifc_to_gregorian(year, special='leap')
print(f"\nLeap Day {year} = {result.strftime('%A, %B %d, %Y')}\n")
return
# Year Day
if args.year_day:
year = args.year_day
result = ifc_to_gregorian(year, special='year')
print(f"\nYear Day {year} = {result.strftime('%A, %B %d, %Y')}\n")
return
# Convert Gregorian to IFC
if args.date:
date = parse_date(args.date)
else:
date = datetime.now()
ifc = gregorian_to_ifc(date)
print(f"\nGregorian: {date.strftime('%A, %B %d, %Y')}")
print(f"Fixed Cal: {ifc}")
if ifc.special:
print(f" (Outside the weekly cycle)")
print()
if __name__ == '__main__':
main()