migrate_ics.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. """Migrate events from an ICS file into a room_cal SQLite store."""
  2. import argparse
  3. import sqlite3
  4. import sys
  5. from datetime import date, datetime
  6. from pathlib import Path
  7. _X_DATA_PROP = 'X-ROOMCAL-DATA'
  8. _CREATE_TABLE = '''
  9. CREATE TABLE IF NOT EXISTS events (
  10. uid TEXT NOT NULL,
  11. calendar_name TEXT NOT NULL,
  12. start TEXT NOT NULL,
  13. end TEXT NOT NULL,
  14. all_day INTEGER NOT NULL,
  15. title TEXT NOT NULL,
  16. description TEXT NOT NULL DEFAULT '',
  17. x_data TEXT,
  18. PRIMARY KEY (uid, calendar_name)
  19. )
  20. '''
  21. _INSERT = '''
  22. INSERT INTO events (uid, calendar_name, start, end, all_day, title, description, x_data)
  23. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  24. '''
  25. _INSERT_OR_REPLACE = '''
  26. INSERT OR REPLACE INTO events (uid, calendar_name, start, end, all_day, title, description, x_data)
  27. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  28. '''
  29. def _naive(dt) -> datetime:
  30. if isinstance(dt, datetime):
  31. return dt.replace(tzinfo=None)
  32. return datetime(dt.year, dt.month, dt.day)
  33. def _parse_vevent(vevent) -> dict | None:
  34. dtstart_prop = vevent.get('DTSTART')
  35. if dtstart_prop is None:
  36. return None
  37. dtstart = dtstart_prop.dt
  38. dtend_prop = vevent.get('DTEND') or vevent.get('DUE')
  39. if dtend_prop is not None:
  40. dtend = dtend_prop.dt
  41. else:
  42. dtend = dtstart
  43. all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
  44. uid = str(vevent.get('UID', ''))
  45. if not uid:
  46. return None
  47. x_data_prop = vevent.get(_X_DATA_PROP)
  48. x_data = str(x_data_prop) if x_data_prop is not None else None
  49. return {
  50. 'uid': uid,
  51. 'start': _naive(dtstart),
  52. 'end': _naive(dtend),
  53. 'all_day': all_day,
  54. 'title': str(vevent.get('SUMMARY', '')),
  55. 'description': str(vevent.get('DESCRIPTION', '')),
  56. 'x_data': x_data,
  57. }
  58. _CALENDAR_NAME = 'Room'
  59. def migrate(ics_path: Path, db_path: Path, overwrite: bool, dry_run: bool) -> None:
  60. calendar_name = _CALENDAR_NAME
  61. try:
  62. from icalendar import Calendar as _iCal
  63. except ImportError:
  64. print('error: icalendar is not installed', file=sys.stderr)
  65. sys.exit(1)
  66. raw = ics_path.read_bytes()
  67. cal = _iCal.from_ical(raw)
  68. events = []
  69. skipped = 0
  70. for component in cal.walk():
  71. if component.name != 'VEVENT':
  72. continue
  73. parsed = _parse_vevent(component)
  74. if parsed is None:
  75. skipped += 1
  76. continue
  77. events.append(parsed)
  78. if skipped:
  79. print(f'warning: skipped {skipped} VEVENT(s) with missing UID or DTSTART', file=sys.stderr)
  80. if not events:
  81. print('no events to import')
  82. return
  83. sql = _INSERT_OR_REPLACE if overwrite else _INSERT
  84. if dry_run:
  85. print(f'dry-run: would import {len(events)} event(s) into {db_path}')
  86. for e in events:
  87. flag = ' [has x_data]' if e['x_data'] else ''
  88. print(f" {e['start'].date()} {'(all-day)' if e['all_day'] else e['start'].time()!s:8} {e['title']!r}{flag}")
  89. return
  90. db_path.parent.mkdir(parents=True, exist_ok=True)
  91. conn = sqlite3.connect(db_path)
  92. try:
  93. conn.execute(_CREATE_TABLE)
  94. inserted = 0
  95. replaced = 0
  96. conflicts = 0
  97. for e in events:
  98. row = (
  99. e['uid'], calendar_name,
  100. e['start'].isoformat(), e['end'].isoformat(),
  101. int(e['all_day']), e['title'], e['description'], e['x_data'],
  102. )
  103. if overwrite:
  104. conn.execute(sql, row)
  105. replaced += 1
  106. else:
  107. try:
  108. conn.execute(sql, row)
  109. inserted += 1
  110. except sqlite3.IntegrityError:
  111. conflicts += 1
  112. conn.commit()
  113. finally:
  114. conn.close()
  115. if overwrite:
  116. print(f'imported {replaced} event(s) into {db_path}')
  117. else:
  118. msg = f'imported {inserted} event(s) into {db_path}'
  119. if conflicts:
  120. msg += f'; {conflicts} skipped (uid already exists — use --overwrite to replace)'
  121. print(msg)
  122. def main() -> None:
  123. parser = argparse.ArgumentParser(
  124. description='Import events from an ICS file into a room_cal SQLite store.',
  125. )
  126. parser.add_argument('ics_file', type=Path, help='Path to the .ics file to import')
  127. parser.add_argument(
  128. '--db',
  129. type=Path,
  130. default=None,
  131. metavar='PATH',
  132. help='SQLite database path (default: from config, or store.db next to config.json)',
  133. )
  134. parser.add_argument(
  135. '--overwrite',
  136. action='store_true',
  137. help='Replace existing events with the same UID instead of skipping',
  138. )
  139. parser.add_argument(
  140. '--dry-run',
  141. action='store_true',
  142. help='Parse and list events without writing anything',
  143. )
  144. args = parser.parse_args()
  145. if not args.ics_file.exists():
  146. print(f'error: {args.ics_file} not found', file=sys.stderr)
  147. sys.exit(1)
  148. db_path = args.db
  149. if db_path is None:
  150. try:
  151. from room_cal._utils import get_config, data_home
  152. cfg = get_config()
  153. raw = cfg.store.sqlite.db_path
  154. if raw:
  155. p = Path(raw)
  156. db_path = p if p.is_absolute() else data_home() / p
  157. else:
  158. db_path = data_home() / 'store.db'
  159. except Exception:
  160. db_path = Path('store.db')
  161. print(f'warning: could not read config; defaulting db path to {db_path}', file=sys.stderr)
  162. migrate(args.ics_file, db_path, args.overwrite, args.dry_run)
  163. if __name__ == '__main__':
  164. main()