From b5ebd23d2205cbe74b7cfec2e06cd55940639fda Mon Sep 17 00:00:00 2001 From: David Barnett Date: Sun, 25 Aug 2024 17:04:29 -0600 Subject: [PATCH 1/2] Avoid creating duplicate events by respecting the UID field For events that contain a UID field, use Google Calendar's event "import" feature instead of "insert" to avoid creating duplicates. Note that events with UID populated may still get processed as new/updated events if the SEQUENCE number is also incremented (https://www.kanzaki.com/docs/ical/sequence.html). --- gcalcli/gcal.py | 52 ++++++++++++++++++++++++++----------------------- gcalcli/ics.py | 6 ++++++ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/gcalcli/gcal.py b/gcalcli/gcal.py index 09a3c42..043a8e8 100644 --- a/gcalcli/gcal.py +++ b/gcalcli/gcal.py @@ -1464,32 +1464,36 @@ def ImportICS(self, verbose=False, dump=False, reminders=None, continue self._add_reminders(event, reminders) + if not verbose: - new_event = self._retry_with_backoff( - self.get_events().insert( - calendarId=self.cals[0]['id'], body=event - ) - ) - hlink = new_event.get('htmlLink') - self.printer.msg('New event added: %s\n' % hlink, 'green') - continue + # Don't prompt, just assume user wants to import. + pass + else: + # Prompt for whether to import. + self.printer.msg('\n[S]kip [i]mport [q]uit: ', 'magenta') + val = input() + if not val or val.lower() == 's': + continue + elif val.lower() == 'q': + sys.exit(0) + elif val.lower() == 'i': + # Import requested, continue + pass + else: + self.printer.err_msg('Error: invalid input\n') + sys.exit(1) - self.printer.msg('\n[S]kip [i]mport [q]uit: ', 'magenta') - val = input() - if not val or val.lower() == 's': - continue - if val.lower() == 'i': - new_event = self._retry_with_backoff( - self.get_events().insert( - calendarId=self.cals[0]['id'], body=event - ) + # Import event + import_method = ( + self.get_events().import_ if 'iCalUID' in event + else self.get_events().insert) + new_event = self._retry_with_backoff( + import_method( + calendarId=self.cals[0]['id'], body=event ) - hlink = new_event.get('htmlLink') - self.printer.msg('New event added: %s\n' % hlink, 'green') - elif val.lower() == 'q': - sys.exit(0) - else: - self.printer.err_msg('Error: invalid input\n') - sys.exit(1) + ) + hlink = new_event.get('htmlLink') + self.printer.msg(f'New event added: {hlink}\n', 'green') + # TODO: return the number of events added return True diff --git a/gcalcli/ics.py b/gcalcli/ics.py index 413f0ad..755e4ef 100644 --- a/gcalcli/ics.py +++ b/gcalcli/ics.py @@ -134,4 +134,10 @@ def CreateEventFromVOBJ( {'displayName': attendee.name, 'email': email} ) + if hasattr(ve, 'uid'): + uid = ve.uid.value.strip() + if verbose: + print(f'UID..........{uid}') + event['iCalUID'] = uid + return event From fbd141262d15b66479721bc9ad6d248e0967c124 Mon Sep 17 00:00:00 2001 From: David Barnett Date: Mon, 26 Aug 2024 11:22:51 -0600 Subject: [PATCH 2/2] Fix `import` bugs, add opt-out flag, add test, update ChangeLog --- ChangeLog | 2 ++ gcalcli/argparsers.py | 7 +++++ gcalcli/gcal.py | 63 +++++++++++++++++++++++++++++++++++++------ gcalcli/ics.py | 17 +++++++++--- tests/test_gcalcli.py | 8 ++++++ 5 files changed, 85 insertions(+), 12 deletions(-) diff --git a/ChangeLog b/ChangeLog index 5f61754..17baa29 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,8 @@ v4.4.0 * Fix lots of bugs by switching from deprecated oauth2client to google_auth_oauthlib * Friendlier help output when `import` command is missing vobject extra + * `import` command more gracefully handles existing events to avoid duplicates + and unnecessary edits (tsheinen, cryhot) * Handle encoding/decoding errors more gracefully by replacing with placeholder chars instead of blowing up * Fix `--lineart` option failing with unicode errors diff --git a/gcalcli/argparsers.py b/gcalcli/argparsers.py index 34056dd..df5e185 100644 --- a/gcalcli/argparsers.py +++ b/gcalcli/argparsers.py @@ -415,6 +415,13 @@ def get_argument_parser(): _import.add_argument( '--dump', '-d', action='store_true', help='Print events and don\'t import') + _import.add_argument( + '--use-legacy-import', + action='store_true', + help='Use legacy "insert" operation instead of new graceful "import" ' + 'operation when importing calendar events. Note this option will be ' + 'removed in future releases.', + ) default_cmd = 'notify-send -u critical -i appointment-soon -a gcalcli %s' remind = sub.add_parser( diff --git a/gcalcli/gcal.py b/gcalcli/gcal.py index 043a8e8..2ea0ef3 100644 --- a/gcalcli/gcal.py +++ b/gcalcli/gcal.py @@ -22,7 +22,7 @@ from google.auth.transport.requests import Request # type: ignore from . import actions, ics, utils -from ._types import Cache, CalendarListEntry +from ._types import Cache, CalendarListEntry, Event from .actions import ACTIONS from .conflicts import ShowConflicts from .details import _valid_title, ACTION_DEFAULT, DETAILS_DEFAULT, HANDLERS @@ -1417,6 +1417,21 @@ def Remind(self, minutes, command, use_reminders=False): if not pid: os.execvp(cmd[0], cmd) + def _event_should_use_new_import_api( + self, event: Event, cal: CalendarListEntry + ) -> bool: + """Evaluates whether a given event should use the newer "import" API. + + Returns true if the user hasn't opted out and the event is cleanly + importable. + """ + if not self.options.get('use_legacy_import'): + return False + event_includes_self = any( + 'self' in a or a['email'] == cal['id'] + for a in event.get('attendees', [])) + return 'iCalUID' in event and event_includes_self + def ImportICS(self, verbose=False, dump=False, reminders=None, icsFile=None): if not ics.has_vobject_support(): @@ -1456,6 +1471,18 @@ def ImportICS(self, verbose=False, dump=False, reminders=None, verbose=verbose, default_tz=self.cals[0]['timeZone'], printer=self.printer) + if not dump and any( + self._event_should_use_new_import_api(event, self.cals[0]) + for event in events_to_import): + self.printer.msg( + '\n' + 'NOTE: This import will use a new graceful import feature in ' + 'gcalcli to avoid creating duplicate events (see ' + 'https://github.com/insanum/gcalcli/issues/492).\n' + 'If you see any issues, you can cancel and retry with ' + '--use-legacy-import to restore the old behavior.\n\n') + time.sleep(1) + for event in events_to_import: if not event: continue @@ -1485,15 +1512,35 @@ def ImportICS(self, verbose=False, dump=False, reminders=None, # Import event import_method = ( - self.get_events().import_ if 'iCalUID' in event + self.get_events().import_ if ( + self._event_should_use_new_import_api(event, self.cals[0])) else self.get_events().insert) - new_event = self._retry_with_backoff( - import_method( - calendarId=self.cals[0]['id'], body=event + try: + new_event = self._retry_with_backoff( + import_method(calendarId=self.cals[0]['id'], body=event)) + except HttpError as e: + try: + is_skipped_dupe = any(detail.get('reason') == 'duplicate' + for detail in e.error_details) + except Exception: + # Fail gracefully so weird error responses don't blow up. + is_skipped_dupe = False + event_label = ( + f'"{event["summary"]}"' if event.get('summary') + else f"with start {event['start']}" ) - ) - hlink = new_event.get('htmlLink') - self.printer.msg(f'New event added: {hlink}\n', 'green') + if is_skipped_dupe: + # TODO: #492 - Offer to force import dupe anyway? + self.printer.msg( + f'Skipped duplicate event {event_label}.\n') + else: + self.printer.err_msg( + f'Failed to import event {event_label}.\n') + self.printer.msg(f'Event details: {event}\n') + self.printer.debug_msg(f'Error details: {e}\n') + else: + hlink = new_event.get('htmlLink') + self.printer.msg(f'New event added: {hlink}\n', 'green') # TODO: return the number of events added return True diff --git a/gcalcli/ics.py b/gcalcli/ics.py index 755e4ef..812e4dc 100644 --- a/gcalcli/ics.py +++ b/gcalcli/ics.py @@ -3,7 +3,7 @@ import importlib.util import io from datetime import datetime -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, List, Optional from gcalcli.printer import Printer from gcalcli.utils import localize_datetime @@ -17,14 +17,18 @@ def has_vobject_support() -> bool: def get_events( ics: io.TextIOBase, verbose: bool, default_tz: str, printer: Printer -) -> Iterable[Optional[EventBody]]: +) -> List[Optional[EventBody]]: import vobject + events: List[Optional[EventBody]] = [] for v in vobject.readComponents(ics): - for ve in v.vevent_list: - yield CreateEventFromVOBJ( + events.extend( + CreateEventFromVOBJ( ve, verbose=verbose, default_tz=default_tz, printer=printer ) + for ve in v.vevent_list + ) + return events def CreateEventFromVOBJ( @@ -139,5 +143,10 @@ def CreateEventFromVOBJ( if verbose: print(f'UID..........{uid}') event['iCalUID'] = uid + if hasattr(ve, 'sequence'): + sequence = ve.sequence.value.strip() + if verbose: + print(f'Sequence.....{sequence}') + event['sequence'] = sequence return event diff --git a/tests/test_gcalcli.py b/tests/test_gcalcli.py index d68d086..de2bfb8 100644 --- a/tests/test_gcalcli.py +++ b/tests/test_gcalcli.py @@ -277,6 +277,14 @@ def test_import(PatchedGCalI): assert gcal.ImportICS(icsFile=open(vcal_path, errors='replace')) +def test_legacy_import(PatchedGCalI): + cal_names = parse_cal_names(['jcrowgey@uw.edu']) + gcal = PatchedGCalI( + cal_names=cal_names, default_reminders=True, use_legacy_import=True) + vcal_path = TEST_DATA_DIR + '/vv.txt' + assert gcal.ImportICS(icsFile=open(vcal_path, errors='replace')) + + def test_parse_reminder(): MINS_PER_DAY = 60 * 24 MINS_PER_WEEK = MINS_PER_DAY * 7