#!/usr/bin/env python3 """ imp2ics: Convert OKsoftware "Svátky a výročí" (.imp) files to RFC 5545 iCalendar (.ics) and Google Calendar CSV. Developed for Kof / Dell backup restoration. Supports strict RFC 5545 compliance: - CRLF line endings - 75-octet line folding - DTSTART and DTEND (DATE format, exclusive DTEND = next day) - TRANSP:TRANSPARENT (all-day events don't block user availability) - RRULE:FREQ=YEARLY for recurring dates - Proper text escaping (commas, semicolons, backslashes, newlines) - CSV export for Google Calendar and Microsoft Outlook """ import sys import os import io import csv import glob import re import struct import uuid from datetime import datetime, timedelta, timezone DELPHI_EPOCH = datetime(1899, 12, 30) TYPE_NAMES = { 0: "Narozeniny", 1: "Výročí", 2: "Událost", 3: "Připomenutí", 4: "Svátek" } def normalize_name(name): s = re.sub(r'\s+', ' ', name.strip()) s = re.sub(r'^\*\s*', '* ', s) s = re.sub(r'^\†\s*', '† ', s) return s def escape_ics_text(text): if not text: return "" s = str(text) s = s.replace('\\', '\\\\') s = s.replace(';', r'\;') s = s.replace(',', r'\,') s = s.replace('\r\n', r'\n').replace('\r', r'\n').replace('\n', r'\n') return s def fold_line(line, limit=75): encoded = line.encode('utf-8') if len(encoded) <= limit: return line parts = [] while len(encoded) > limit: cut = limit # Walk backward if we land in the middle of a multi-byte UTF-8 character while cut > 0 and (encoded[cut] & 0xC0) == 0x80: cut -= 1 parts.append(encoded[:cut].decode('utf-8')) encoded = b' ' + encoded[cut:] if encoded: parts.append(encoded.decode('utf-8')) return "\r\n".join(parts) def parse_imp(filepath): with open(filepath, 'rb') as f: data = f.read() pos = 0 tokens = [] while pos < len(data): b = data[pos] pos += 1 if b == 0x00: tokens.append(('END', None)) elif b == 0x01: tokens.append(('LIST', None)) elif b == 0x02: tokens.append(('INT8', data[pos])) pos += 1 elif b == 0x03: tokens.append(('INT16', struct.unpack(' 1, generates repeating occurrences for future years (since CSV does not natively support RRULE recurrence). """ deduped = deduplicate_events(events) output = io.StringIO() writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) writer.writerow([ "Subject", "Start Date", "Start Time", "End Date", "End Time", "All Day Event", "Description", "Location", "Private" ]) current_year = datetime.now().year years_to_project = [current_year + i for i in range(project_upcoming_years)] if project_upcoming_years > 1 else [None] for ev in deduped: dt = ev["date"] etype = ev["type"] ename = ev["name"] tname = TYPE_NAMES.get(etype, "Událost") if etype == 0: base_desc = f"Narozeniny: {ename} (rok narození {dt.year})" elif etype == 1: base_desc = f"Výročí: {ename} (od roku {dt.year})" elif etype == 4: base_desc = f"Svátek: {ename}" else: base_desc = f"{tname}: {ename} (záznam z roku {dt.year})" if project_upcoming_years > 1 and etype != 3: for yr in years_to_project: try: occ_dt = dt.replace(year=yr) except ValueError: occ_dt = dt.replace(year=yr, day=28) age = yr - dt.year if etype == 0 and age >= 0: desc = f"{base_desc} - {age}. narozeniny" elif etype == 1 and age >= 0: desc = f"{base_desc} - {age}. výročí" else: desc = base_desc d_str = occ_dt.strftime("%m/%d/%Y") writer.writerow([ename, d_str, "", d_str, "", "True", desc, "", "False"]) else: d_str = dt.strftime("%m/%d/%Y") writer.writerow([ename, d_str, "", d_str, "", "True", base_desc, "", "False"]) return output.getvalue() def main(): import argparse parser = argparse.ArgumentParser(description="Convert OKsoftware .imp files to iCalendar (.ics) and Google Calendar CSV") parser.add_argument("inputs", nargs="+", help="Files (.imp) or directories") parser.add_argument("-o", "--output", help="Output file path (.ics or .csv)") parser.add_argument("--format", choices=["ics", "csv"], default="ics", help="Output format (default: ics)") parser.add_argument("--name", default="Svátky a výročí", help="Calendar name (X-WR-CALNAME)") parser.add_argument("--project-years", type=int, default=5, help="Number of upcoming years to project in CSV (default: 5)") args = parser.parse_args() files = [] for p in args.inputs: if os.path.isdir(p): files.extend(glob.glob(os.path.join(p, "**", "*.imp"), recursive=True)) else: files.append(p) all_events = [] for f in sorted(files): evs = parse_imp(f) all_events.extend(evs) fmt = args.format if args.output: if args.output.lower().endswith(".csv"): fmt = "csv" elif args.output.lower().endswith(".ics"): fmt = "ics" if fmt == "csv": content = events_to_csv(all_events, project_upcoming_years=args.project_years) else: content = events_to_ics(all_events, cal_name=args.name) if args.output: with open(args.output, 'w', encoding='utf-8') as f: f.write(content) print(f"Exported {len(all_events)} events -> {args.output} ({fmt.upper()})", file=sys.stderr) else: sys.stdout.write(content) if __name__ == '__main__': main()