diff --git a/src/apps/storetool/bongo/storetool/CalendarCommands.py b/src/apps/storetool/bongo/storetool/CalendarCommands.py index d2854c9..26d8580 100644 --- a/src/apps/storetool/bongo/storetool/CalendarCommands.py +++ b/src/apps/storetool/bongo/storetool/CalendarCommands.py @@ -6,6 +6,7 @@ import re import time import random import hashlib +from urllib.request import urlopen import email from email.mime.text import MIMEText @@ -27,6 +28,29 @@ def _smtp_eols(value): return value.replace("\r\n", "\n").replace("\r", "\n").replace( "\n", "\r\n") + +def _collection_name(path): + return path.rstrip("/").rsplit("/", 1)[-1] + + +def _event_payload(calendar, event): + lines = [ + "BEGIN:VCALENDAR\r\n", + "VERSION:2.0\r\n", + "PRODID:-//Bongo Project//bongo-storetool//EN\r\n", + ] + for timezone in calendar.contents.get("vtimezone", []): + lines.append(timezone.serialize()) + lines.append(event.serialize()) + lines.append("END:VCALENDAR\r\n") + return "".join(lines) + + +def _event_filename(event): + uid = str(event.uid.value).encode("utf-8") + return "import-" + hashlib.sha256(uid).hexdigest() + + class CalendarsCommand(Command): log = logging.getLogger("Bongo.StoreTool") @@ -44,7 +68,7 @@ class CalendarsCommand(Command): cals = list(store.List("/calendars", props=["bongo.calendar.url"])) for cal in cals: subd = "bongo.calendar.url" in cal.props and "Yes" or None - rows.append([subd, cal.filename, + rows.append([subd, _collection_name(cal.filename), cal.props.get("bongo.calendar.url")]) finally: store.Quit() @@ -62,7 +86,7 @@ class CalendarEventsCommand(Command): def _FindCalendar(self, store, name): cals = list(store.List("/calendars")) for cal in cals: - if cal.filename == name: + if _collection_name(cal.filename) == name: return cal def Run(self, options, args): @@ -161,7 +185,7 @@ class CalendarDeleteCommand(Command): def _FindCalendar(self, store, name): cals = list(store.List("/calendars")) for cal in cals: - if cal.filename == name: + if _collection_name(cal.filename) == name: return cal def Run(self, options, args): @@ -214,26 +238,58 @@ class CalendarImportCommand(Command): usage="%prog %cmd ") def Run(self, options, args): - from libbongo.libs import msgapi + from libbongo._calendar import cal if len(args) < 2: self.print_usage() self.exit() (name, file) = args - + if not re.fullmatch(r"[A-Za-z0-9._~-]{1,128}", name): + self.error("calendar name contains unsupported characters") if re.search("^[^/]+://", file): - url = file + with urlopen(file, timeout=30) as response: + payload = response.read(16 * 1024 * 1024 + 1) + if len(payload) > 16 * 1024 * 1024: + self.error("calendar source is larger than 16 MiB") + text = payload.decode("utf-8", "strict") else: file = os.path.realpath(file) - if not os.access(file, os.R_OK): self.exit("File doesn't exist or isn't readable: %s" % file) + with open(file, "r", encoding="utf-8") as source: + text = source.read(16 * 1024 * 1024 + 1) + if len(text.encode("utf-8")) > 16 * 1024 * 1024: + self.error("calendar source is larger than 16 MiB") - url = "file://" + file + calendars = list(vobject.readComponents(text)) + if not calendars: + self.error("calendar source has no VCALENDAR component") - uid = msgapi.IcsImport(options.store, name, None, url, None, None) - print("Imported: uid is", hex(uid)) + store = connect_store(options) + imported = 0 + calendar_path = "/calendars/" + name + try: + store.Create(calendar_path, existingOk=True) + for calendar in calendars: + for event in calendar.contents.get("vevent", []): + if not hasattr(event, "uid") or not str(event.uid.value): + self.error("calendar event has no UID") + document = cal.IcalToJson(_event_payload(calendar, event)) + if document is None: + self.error("calendar event could not be converted") + filename = _event_filename(event) + path = "/events/" + filename + if store.Info(path) is None: + store.Write( + "/events", DocTypes.Event, str(document), + filename=filename, link=calendar_path) + else: + store.Replace(path, str(document)) + imported += 1 + finally: + store.Quit() + print("Imported %d event(s) into %s." % (imported, calendar_path)) class CalendarPublishCommand(Command): log = logging.getLogger("Bongo.StoreTool") diff --git a/src/apps/storetool/bongo/storetool/ContactCommands.py b/src/apps/storetool/bongo/storetool/ContactCommands.py index adb1dd5..b9afa84 100644 --- a/src/apps/storetool/bongo/storetool/ContactCommands.py +++ b/src/apps/storetool/bongo/storetool/ContactCommands.py @@ -9,6 +9,11 @@ from bongo.Contact import Contact from bongo.store.StoreClient import DocTypes from bongo.storetool.Connection import connect_store + +def _collection_name(path): + return path.rstrip("/").rsplit("/", 1)[-1] + + class AddressbooksCommand(Command): log = logging.getLogger("Bongo.StoreTool") @@ -25,7 +30,7 @@ class AddressbooksCommand(Command): try: abs = list(store.List("/addressbook")) for ab in abs: - rows.append((ab.filename,)) + rows.append((_collection_name(ab.filename),)) finally: store.Quit() @@ -42,7 +47,7 @@ class AddressbookContactsCommand(Command): def _FindAddressbook(self, store, name): abs = list(store.List("/addressbook")) for ab in abs: - if ab.filename == name: + if _collection_name(ab.filename) == name: return ab def Run(self, options, args): diff --git a/src/apps/storetool/tests/test_python3_runtime.py b/src/apps/storetool/tests/test_python3_runtime.py index a3a05f0..f9bdcb5 100644 --- a/src/apps/storetool/tests/test_python3_runtime.py +++ b/src/apps/storetool/tests/test_python3_runtime.py @@ -30,6 +30,9 @@ from bongo import table from bongo.Console import wrap from bongo.external.simpletal import simpleTAL, simpleTALES from bongo.store.StoreClient import CalendarACL, StoreClient +from bongo.storetool.CalendarCommands import (_collection_name, + _event_filename, + _event_payload) from bongo.storetool.Connection import connect_store from bongo.storetool.ExportMailbox import MaildirMailbox, MboxMailbox @@ -68,6 +71,24 @@ class Python3RuntimeTests(unittest.TestCase): "alice", "alice-store", host="store.example.test", port=1689, authPassword="secret") + def test_calendar_import_splits_events_and_normalizes_collection_names(self): + import vobject + + source = vobject.readOne( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "UID:event@example.test\r\n" + "DTSTART:20260724T080000Z\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n") + event = source.vevent + payload = _event_payload(source, event) + self.assertEqual(_collection_name("/calendars/personal"), "personal") + self.assertIn("BEGIN:VEVENT", payload) + self.assertIn("UID:event@example.test", payload) + self.assertRegex(_event_filename(event), r"^import-[0-9a-f]{64}$") + def test_console_table_and_unicode_template_are_text(self): self.assertIsInstance(wrap("Grüße 世界", width=8), str) self.assertEqual(table.format_table(["Name"], [["Müller"]]),