"""Migrate events from an ICS file into a room_cal SQLite store.""" import argparse import sqlite3 import sys from datetime import date, datetime from pathlib import Path _X_DATA_PROP = 'X-ROOMCAL-DATA' _CREATE_TABLE = ''' CREATE TABLE IF NOT EXISTS events ( uid TEXT NOT NULL, calendar_name TEXT NOT NULL, start TEXT NOT NULL, end TEXT NOT NULL, all_day INTEGER NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', x_data TEXT, PRIMARY KEY (uid, calendar_name) ) ''' _INSERT = ''' INSERT INTO events (uid, calendar_name, start, end, all_day, title, description, x_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''' _INSERT_OR_REPLACE = ''' INSERT OR REPLACE INTO events (uid, calendar_name, start, end, all_day, title, description, x_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''' def _naive(dt) -> datetime: if isinstance(dt, datetime): return dt.replace(tzinfo=None) return datetime(dt.year, dt.month, dt.day) def _parse_vevent(vevent) -> dict | None: dtstart_prop = vevent.get('DTSTART') if dtstart_prop is None: return None dtstart = dtstart_prop.dt dtend_prop = vevent.get('DTEND') or vevent.get('DUE') if dtend_prop is not None: dtend = dtend_prop.dt else: dtend = dtstart all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime) uid = str(vevent.get('UID', '')) if not uid: return None x_data_prop = vevent.get(_X_DATA_PROP) x_data = str(x_data_prop) if x_data_prop is not None else None return { 'uid': uid, 'start': _naive(dtstart), 'end': _naive(dtend), 'all_day': all_day, 'title': str(vevent.get('SUMMARY', '')), 'description': str(vevent.get('DESCRIPTION', '')), 'x_data': x_data, } _CALENDAR_NAME = 'Room' def migrate(ics_path: Path, db_path: Path, overwrite: bool, dry_run: bool) -> None: calendar_name = _CALENDAR_NAME try: from icalendar import Calendar as _iCal except ImportError: print('error: icalendar is not installed', file=sys.stderr) sys.exit(1) raw = ics_path.read_bytes() cal = _iCal.from_ical(raw) events = [] skipped = 0 for component in cal.walk(): if component.name != 'VEVENT': continue parsed = _parse_vevent(component) if parsed is None: skipped += 1 continue events.append(parsed) if skipped: print(f'warning: skipped {skipped} VEVENT(s) with missing UID or DTSTART', file=sys.stderr) if not events: print('no events to import') return sql = _INSERT_OR_REPLACE if overwrite else _INSERT if dry_run: print(f'dry-run: would import {len(events)} event(s) into {db_path}') for e in events: flag = ' [has x_data]' if e['x_data'] else '' print(f" {e['start'].date()} {'(all-day)' if e['all_day'] else e['start'].time()!s:8} {e['title']!r}{flag}") return db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) try: conn.execute(_CREATE_TABLE) inserted = 0 replaced = 0 conflicts = 0 for e in events: row = ( e['uid'], calendar_name, e['start'].isoformat(), e['end'].isoformat(), int(e['all_day']), e['title'], e['description'], e['x_data'], ) if overwrite: conn.execute(sql, row) replaced += 1 else: try: conn.execute(sql, row) inserted += 1 except sqlite3.IntegrityError: conflicts += 1 conn.commit() finally: conn.close() if overwrite: print(f'imported {replaced} event(s) into {db_path}') else: msg = f'imported {inserted} event(s) into {db_path}' if conflicts: msg += f'; {conflicts} skipped (uid already exists — use --overwrite to replace)' print(msg) def main() -> None: parser = argparse.ArgumentParser( description='Import events from an ICS file into a room_cal SQLite store.', ) parser.add_argument('ics_file', type=Path, help='Path to the .ics file to import') parser.add_argument( '--db', type=Path, default=None, metavar='PATH', help='SQLite database path (default: from config, or store.db next to config.json)', ) parser.add_argument( '--overwrite', action='store_true', help='Replace existing events with the same UID instead of skipping', ) parser.add_argument( '--dry-run', action='store_true', help='Parse and list events without writing anything', ) args = parser.parse_args() if not args.ics_file.exists(): print(f'error: {args.ics_file} not found', file=sys.stderr) sys.exit(1) db_path = args.db if db_path is None: try: from room_cal._utils import get_config, data_home cfg = get_config() raw = cfg.store.sqlite.db_path if raw: p = Path(raw) db_path = p if p.is_absolute() else data_home() / p else: db_path = data_home() / 'store.db' except Exception: db_path = Path('store.db') print(f'warning: could not read config; defaulting db path to {db_path}', file=sys.stderr) migrate(args.ics_file, db_path, args.overwrite, args.dry_run) if __name__ == '__main__': main()