diff --git a/khal/cli.py b/khal/cli.py index 51ad78b92..e31aa7312 100644 --- a/khal/cli.py +++ b/khal/cli.py @@ -493,10 +493,43 @@ def search(ctx, format, json, search_string, include_calendar, exclude_calendar) help=('The format of the events.')) @click.option('--show-past', help=('Show events that have already occurred as options'), is_flag=True) +@click.option('--summary', '-s', + help=('Update the event summary.')) +@click.option('--description', help=('Update the event description.')) +@click.option('--location', '-l', + help=('Update the event location.')) +@click.option('--categories', '-g', + help=('Update the event categories, comma separated.')) +@click.option('--url', '-u', + help=('Update the event url.')) +@click.option('--start', + help=('Update the event start datetime.')) +@click.option('--end', + help=('Update the event end datetime.')) +@click.option('--alarms', '-m', + help=('Alarm times for the event as DELTAs comma separated')) +@click.option('--repeat', '-r', + help=('Update recurrence: daily, weekly, monthly or yearly.')) +@click.option('--repeat-until', + help=('Update until when the event should repeat (or "None").')) +@click.option('--all', help=('Edit all matching events in non-interactive mode.'), + is_flag=True) +@click.option('--delete', help=('Delete matching events.'), + is_flag=True) @click.argument('search_string', nargs=-1) @click.pass_context -def edit(ctx, format, search_string, show_past, include_calendar, exclude_calendar): - '''Interactively edit (or delete) events matching the search string.''' +def edit(ctx, format, search_string, show_past, summary, description, location, categories, + url, start, end, alarms, repeat, repeat_until, all, delete, include_calendar, + exclude_calendar): + '''Edit (or delete) events matching the search string. + + When run without any of the edit flags (--summary, --description, etc.), + this command runs in interactive mode, allowing you to edit events one at + a time. + + When run with edit flags, this command runs in non-interactive mode. + If only one event matches the search string, it is edited directly. + If multiple events match, the command will error out unless --all is used.''' try: controllers.edit( build_collection( @@ -507,7 +540,19 @@ def edit(ctx, format, search_string, show_past, include_calendar, exclude_calend format=format, allow_past=show_past, locale=ctx.obj['conf']['locale'], - conf=ctx.obj['conf'] + conf=ctx.obj['conf'], + summary=summary, + description=description, + location=location, + categories=categories, + url=url, + start=start, + end=end, + alarms=alarms, + repeat=repeat, + repeat_until=repeat_until, + edit_all=all, + delete=delete, ) except FatalError as error: logger.debug(error, exc_info=True) diff --git a/khal/controllers.py b/khal/controllers.py index 4cd774d8e..6134278d7 100644 --- a/khal/controllers.py +++ b/khal/controllers.py @@ -603,26 +603,263 @@ def edit_event(event, collection, locale, allow_quit=False, width=80): collection.update(event) -def edit(collection, search_string, locale, format=None, allow_past=False, conf=None): +def _edit_non_interactive( + collection, + matching_events, + search_string, + locale, + format, + conf, + edit_all=False, + delete=False, + summary=None, + description=None, + location=None, + categories=None, + url=None, + start=None, + end=None, + alarms=None, + repeat=None, + repeat_until=None, +): + term_width, _ = get_terminal_size() + now = conf['locale']['local_timezone'].localize(dt.datetime.now()) + + if len(matching_events) > 1 and not edit_all: + echo(f"Multiple events found matching '{search_string}':") + for event in matching_events: + event_text = textwrap.wrap( + human_formatter(format)(event.attributes(relative_to=now)), term_width + ) + echo(''.join(event_text)) + raise FatalError( + f"Multiple events found ({len(matching_events)}). " + "Use --all to edit all of them, or refine your search." + ) + + for event in matching_events: + edited = False + + if delete: + collection.delete(event.href, event.etag, event.calendar) + echo(f"Deleted: {event.summary}") + continue + + changes = [] + + if summary is not None: + old_summary = event.summary + event.update_summary(summary) + edited = True + changes.append(f"summary: '{old_summary}' -> '{summary}'") + + if description is not None: + old_desc = event.description + if description == 'None': + description = '' + event.update_description(description) + edited = True + old_str = old_desc if old_desc else '(none)' + new_str = description if description else '(none)' + changes.append(f"description: '{old_str}' -> '{new_str}'") + + if location is not None: + if location == 'None': + location = '' + old_loc = event.location + event.update_location(location) + edited = True + old_str = old_loc if old_loc else '(none)' + new_str = location if location else '(none)' + changes.append(f"location: '{old_str}' -> '{new_str}'") + + if categories is not None: + old_cats = event.categories + if categories == 'None': + event.update_categories([]) + new_cats_display = '(none)' + else: + new_cats = [cat.strip() for cat in categories.split(',')] + event.update_categories(new_cats) + new_cats_display = ','.join(new_cats) + edited = True + old_cats_display = ','.join(old_cats) if old_cats else '(none)' + changes.append(f"categories: '{old_cats_display}' -> '{new_cats_display}'") + + if url is not None: + if url == 'None': + url = '' + old_url = event.url + event.update_url(url) + edited = True + old_str = old_url if old_url else '(none)' + new_str = url if url else '(none)' + changes.append(f"url: '{old_str}' -> '{new_str}'") + + if start is not None or end is not None: + old_start = event.start + old_end = event.end + + if start is None: + start_dt = old_start + else: + try: + start_dt = parse_datetime.guessdatetimefstr([start], locale)[0] + except Exception as e: + raise FatalError(f"Error parsing start datetime: {e}") + + if end is None: + end_dt = old_end + else: + try: + end_dt = parse_datetime.guessdatetimefstr([end], locale)[0] + except Exception as e: + raise FatalError(f"Error parsing end datetime: {e}") + + event.update_start_end(start_dt, end_dt) + edited = True + if start is not None: + changes.append(f"start: '{old_start}' -> '{start_dt}'") + if end is not None: + changes.append(f"end: '{old_end}' -> '{end_dt}'") + + if alarms is not None: + old_alarms = event.alarms + if alarms == 'None': + event.update_alarms([]) + new_alarms = [] + else: + new_alarms = [] + for a in alarms.split(','): + try: + alarm_trig = -1 * parse_datetime.guesstimedeltafstr(a.strip()) + new_alarm = (alarm_trig, event.description) + new_alarms.append(new_alarm) + except Exception as e: + raise FatalError(f"Error parsing alarm: {e}") + event.update_alarms(new_alarms) + edited = True + old_count = str(len(old_alarms)) + ' alarms' if old_alarms else '(none)' + new_count = str(len(new_alarms)) + ' alarms' if new_alarms else '(none)' + changes.append(f"alarms: {old_count} -> {new_count}") + + if repeat is not None or repeat_until is not None: + old_rrule = event.recurobject + if repeat == 'None': + event.update_rrule(None) + changes.append('repeat: cleared') + edited = True + elif repeat is not None: + until = repeat_until + if until == 'None': + until = None + rrule = parse_datetime.rrulefstr(repeat, until, locale, event.start.tzinfo) + event.update_rrule(rrule) + old_freq = old_rrule.get('freq', 'none') if old_rrule else 'none' + until_str = f' until={until}' if until else '' + changes.append(f"repeat: '{old_freq}' -> '{repeat}{until_str}'") + edited = True + + if edited: + event.increment_sequence() + collection.update(event) + echo(f"Edited: {event.summary}") + for change in changes: + echo(f' {change}') + + +def _edit_interactive(collection, matching_events, format, locale, conf): + term_width, _ = get_terminal_size() + now = conf['locale']['local_timezone'].localize(dt.datetime.now()) + + for event in matching_events: + event_text = textwrap.wrap( + human_formatter(format)(event.attributes(relative_to=now)), term_width + ) + echo(''.join(event_text)) + if not edit_event(event, collection, locale, allow_quit=True, width=term_width): + return + + +def edit( + collection, + search_string, + locale, + format=None, + allow_past=False, + conf=None, + summary=None, + description=None, + location=None, + categories=None, + url=None, + start=None, + end=None, + alarms=None, + repeat=None, + repeat_until=None, + edit_all=False, + delete=False, +): if conf is not None: if format is None: format = conf['view']['event_format'] - term_width, _ = get_terminal_size() + non_interactive = any( + [ + summary is not None, + description is not None, + location is not None, + categories is not None, + url is not None, + start is not None, + end is not None, + alarms is not None, + repeat is not None, + repeat_until is not None, + delete, + ] + ) + now = conf['locale']['local_timezone'].localize(dt.datetime.now()) events = sorted(collection.search(search_string)) + matching_events = [] for event in events: if not allow_past: if event.allday and event.end < now.date(): continue elif not event.allday and event.end_local < now: continue - event_text = textwrap.wrap(human_formatter(format)( - event.attributes(relative_to=now)), term_width) - echo(''.join(event_text)) - if not edit_event(event, collection, locale, allow_quit=True, width=term_width): - return + matching_events.append(event) + + if not matching_events: + raise FatalError(f"No events found matching '{search_string}'") + + if non_interactive: + _edit_non_interactive( + collection, + matching_events, + search_string, + locale, + format, + conf, + edit_all=edit_all, + delete=delete, + summary=summary, + description=description, + location=location, + categories=categories, + url=url, + start=start, + end=end, + alarms=alarms, + repeat=repeat, + repeat_until=repeat_until, + ) + else: + _edit_interactive(collection, matching_events, format, locale, conf) def interactive(collection, conf): diff --git a/tests/cli_test.py b/tests/cli_test.py index 5300a3207..082a0a7db 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -900,6 +900,86 @@ def test_edit(runner): assert not result.exception +def test_edit_non_interactive(runner): + runner = runner() + result = runner.invoke(main_khal, ['new', '13.03.2016', 'Test Event']) + assert not result.exception + + format = '{title}' + result = runner.invoke(main_khal, ['edit', 'Test Event', '--summary', 'Updated Event']) + assert not result.exception + + result = runner.invoke(main_khal, ['list', '--format', format, '--day-format', '', '13.03.2016']) + assert 'Updated Event' in result.output + assert 'Test Event' not in result.output + + +def test_edit_non_interactive_multiple_fields(runner): + runner = runner() + result = runner.invoke(main_khal, ['new', '14.03.2016', 'Meeting']) + assert not result.exception + + result = runner.invoke(main_khal, [ + 'edit', 'Meeting', + '--summary', 'Team Meeting', + '--location', 'Conference Room A', + '--description', 'Weekly sync' + ]) + assert not result.exception + + format = '{title} {location} {description}' + result = runner.invoke(main_khal, ['list', '--format', format, '--day-format', '', '14.03.2016']) + assert 'Team Meeting' in result.output + assert 'Conference Room A' in result.output + assert 'Weekly sync' in result.output + + +def test_edit_non_interactive_multiple_matches_error(runner): + runner = runner() + result = runner.invoke(main_khal, ['new', '15.03.2016', 'Important Meeting']) + assert not result.exception + result = runner.invoke(main_khal, ['new', '16.03.2016', 'Important Call']) + assert not result.exception + + result = runner.invoke(main_khal, ['edit', 'Important', '--summary', 'Test']) + assert result.exit_code != 0 + assert 'Multiple events found' in result.output + + +def test_edit_non_interactive_multiple_matches_with_all(runner): + runner = runner() + result = runner.invoke(main_khal, ['new', '15.03.2016', 'Review']) + assert not result.exception + result = runner.invoke(main_khal, ['new', '16.03.2016', 'Code Review']) + assert not result.exception + + result = runner.invoke(main_khal, ['edit', 'Review', '--location', 'Online', '--all']) + assert not result.exception + + format = '{title} {location}' + result = runner.invoke(main_khal, ['list', '--format', format, '--day-format', '', '15.03.2016', '17.03.2016']) + assert 'Review Online' in result.output + assert 'Code Review Online' in result.output + + +def test_edit_delete(runner): + runner = runner() + result = runner.invoke(main_khal, ['new', '17.03.2016', 'Delete Me']) + assert not result.exception + + result = runner.invoke(main_khal, ['edit', 'Delete Me', '--delete']) + assert not result.exception + + result = runner.invoke(main_khal, ['list', '--format', '{title}', '--day-format', '', '17.03.2016']) + assert 'Delete Me' not in result.output + + +def test_edit_no_match(runner): + runner = runner() + result = runner.invoke(main_khal, ['edit', 'NonExistent', '--summary', 'Test']) + assert result.exit_code != 0 + assert 'No events found' in result.output or 'No events found' in str(result.exception) + def test_new(runner): runner = runner(print_new='path')