# HG changeset patch # User Paul Boddie # Date 1509057893 -7200 # Node ID b4ffc23dd9c8b921d964c04603245983319d9370 # Parent 38af07c9d65a622cd31c212fc8308396991321b2 Added a text-based client for general use and for testing assistance. diff -r 38af07c9d65a -r b4ffc23dd9c8 imip_text_client.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imip_text_client.py Fri Oct 27 00:44:53 2017 +0200 @@ -0,0 +1,905 @@ +#!/usr/bin/env python + +from email import message_from_file +from imiptools import parse_args +from imiptools.client import ClientForObject +from imiptools.config import settings +from imiptools.content import get_objects_from_itip, have_itip_part, parse_itip_part +from imiptools.data import get_address, get_main_period, get_recurrence_periods, get_value +from imiptools.dates import get_datetime_item, get_time, to_timezone +from imiptools.editing import EditingClient, PeriodError +from imiptools.mail import Messenger +from imiptools.stores import get_journal, get_store +from imiptools.utils import decode_part, message_as_string +import sys + +# User interface functions. + +echo = False + +def read_input(label): + s = raw_input(label).strip() + if echo: + print s + return s + +def input_with_default(label, default): + return read_input(label % default) or default + +def print_title(text): + print text + print len(text) * "-" + +def write(s, filename): + f = filename and open(filename, "w") or None + try: + print >>(f or sys.stdout), s + finally: + if f: + f.close() + +# Interpret an input file containing a calendar resource. + +def get_objects(filename): + + "Return objects provided by 'filename'." + + f = open(filename) + try: + msg = message_from_file(f) + finally: + f.close() + + all_objects = [] + + for part in msg.walk(): + itip = parse_itip_part(part) + method = itip and get_value(itip, "METHOD") + + # Ignore cancelled objects since only active objects are of interest. + + if method != "CANCEL": + all_objects += get_objects_from_itip(itip, ["VEVENT"]) + + return all_objects + +def show_objects(objects, user, store): + + """ + Show details of 'objects', accessed by the given 'user' in the given + 'store'. + """ + + for index, obj in enumerate(objects): + recurrenceid = obj.get_recurrenceid() + recurrence_label = recurrenceid and " %s" % recurrenceid or "" + print "(%d) Summary: %s (%s%s)" % (index, obj.get_value("SUMMARY"), obj.get_uid(), recurrence_label) + +def show_attendee(attendee_item, index): + + "Show the 'attendee_item' (value and attributes) at 'index'." + + attendee, attr = attendee_item + partstat = attr.get("PARTSTAT") + print "(%d) %s%s" % (index, attendee, partstat and " (%s)" % partstat or "") + +def show_attendees_raw(attendee_map): + + "Show the 'attendee_map' in a simple raw form." + + for attendee, attr in attendee_map.items(): + print attendee + +def show_periods(periods, errors=None): + + "Show 'periods' with any indicated 'errors'." + + main = get_main_period(periods) + if main: + show_period(main, 0, errors) + + recurrences = get_recurrence_periods(periods) + if recurrences: + print + print_title("Recurrences") + for index, p in enumerate(recurrences): + show_period(p, index + 1, errors) + +def show_period(p, index, errors=None): + + "Show period 'p' at 'index' with any indicated 'errors'." + + errors = errors and errors.get(index) + if p.replacement: + if p.cancelled: + label = "Cancelled" + else: + label = "Replaced" + else: + if p.new_replacement: + label = "To replace" + elif p.recurrenceid: + label = "Retained" + else: + label = "New" + + error_label = errors and " (errors: %s)" % ", ".join(errors) or "" + print "(%d) %s%s:" % (index, label, error_label), p.get_start(), p.get_end(), p.origin + +def show_periods_raw(periods): + + "Show 'periods' in a simple raw form." + + periods = periods[:] + periods.sort() + map(show_period_raw, periods) + +def show_period_raw(p): + + "Show period 'p' in a simple raw form." + + print p.get_start(), p.get_end(), p.origin + +def show_attendee_changes(new, modified, unmodified, removed): + + "Show 'new', 'modified', 'unmodified' and 'removed' periods." + + print + print_title("Changes to attendees") + print + print "New:" + show_attendees_raw(new) + print + print "Modified:" + show_attendees_raw(modified) + print + print "Unmodified:" + show_attendees_raw(unmodified) + print + print "Removed:" + show_attendees_raw(removed) + +def show_period_classification(new, replaced, retained, cancelled, obsolete): + + "Show 'new', 'replaced', 'retained', 'cancelled' and 'obsolete' periods." + + print + print_title("Period classification") + print + print "New:" + show_periods_raw(new) + print + print "Replaced:" + show_periods_raw(replaced) + print + print "Retained:" + show_periods_raw(retained) + print + print "Cancelled:" + show_periods_raw(cancelled) + print + print "Obsolete:" + show_periods_raw(obsolete) + +def show_changes(modified, unmodified, removed): + + "Show 'modified', 'unmodified' and 'removed' periods." + + print + print_title("Changes to periods") + print + print "Modified:" + show_periods_raw(modified) + print + print "Unmodified:" + show_periods_raw(unmodified) + print + print "Removed:" + show_periods_raw(removed) + +def show_attendee_operations(to_invite, to_cancel, to_modify): + + "Show attendees 'to_invite', 'to_cancel' and 'to_modify'." + + print + print_title("Attendee update operations") + print + print "To invite:" + show_attendees_raw(to_invite) + print + print "To cancel:" + show_attendees_raw(to_cancel) + print + print "To modify:" + show_attendees_raw(to_modify) + +def show_period_operations(to_unschedule, to_reschedule, to_add, to_exclude, to_set, + all_unscheduled, all_rescheduled): + + """ + Show operations for periods 'to_unschedule', 'to_reschedule', 'to_add', + 'to_exclude' and 'to_set' (for updating other calendar participants), and + for periods 'all_unscheduled' and 'all_rescheduled' (for publishing event + state). + """ + + print + print_title("Period update and publishing operations") + print + print "Unschedule:" + show_periods_raw(to_unschedule) + print + print "Reschedule:" + show_periods_raw(to_reschedule) + print + print "Added:" + show_periods_raw(to_add) + print + print "Excluded:" + show_periods_raw(to_exclude) + print + print "Set in object:" + show_periods_raw(to_set) + print + print "All unscheduled:" + show_periods_raw(all_unscheduled) + print + print "All rescheduled:" + show_periods_raw(all_rescheduled) + +class TextClient(EditingClient): + + "Simple client with textual output." + + def new_object(self): + + "Create a new object with the current time." + + utcnow = get_time() + now = to_timezone(utcnow, self.get_tzid()) + obj = EditingClient.new_object(self, "VEVENT") + obj.set_value("SUMMARY", "New event") + obj["DTSTART"] = [get_datetime_item(now)] + obj["DTEND"] = [get_datetime_item(now)] + return obj + + # Editing methods involving interaction. + + def edit_attendee(self, index): + + "Edit the attendee at 'index'." + + t = self.can_edit_attendee(index) + if t: + attendees = self.state.get("attendees") + attendee, attr = t + del attendees[attendee] + attendee = input_with_default("Attendee (%s)? ", attendee) + attendees[attendee] = attr + + def edit_period(self, index, args=None): + period = self.can_edit_period(index) + if period: + edit_period(period, args) + period.cancelled = False + + # Sort the periods after this change. + + periods = self.state.get("periods") + periods.sort() + + def edit_summary(self, summary=None): + if self.can_edit_properties(): + if not summary: + summary = input_with_default("Summary (%s)? ", self.state.get("summary")) + self.state.set("summary", summary) + + def finish(self): + try: + EditingClient.finish(self) + except PeriodError: + print "Errors exist in the periods." + return + + # Diagnostic methods. + + def show_period_classification(self): + try: + new, replaced, retained, cancelled, obsolete = self.classify_periods() + show_period_classification(new, replaced, retained, cancelled, obsolete) + except PeriodError: + print + print "Errors exist in the periods." + + def show_changes(self): + try: + modified, unmodified, removed = self.classify_period_changes() + show_changes(modified, unmodified, removed) + except PeriodError: + print "Errors exist in the periods." + + is_changed = self.properties_changed() + if is_changed: + print + print "Properties changed:", ", ".join(is_changed) + new, modified, unmodified, removed = self.classify_attendee_changes() + show_attendee_changes(new, modified, unmodified, removed) + + def show_operations(self): + is_changed = self.properties_changed() + + try: + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = self.classify_period_operations() + show_period_operations(to_unschedule, to_reschedule, to_add, + to_exclude, to_set, + all_unscheduled, all_rescheduled) + except PeriodError: + print "Errors exist in the periods." + + to_invite, to_cancel, to_modify = self.classify_attendee_operations() + show_attendee_operations(to_invite, to_cancel, to_modify) + + # Output methods. + + def show_message(self, message, plain=False, filename=None): + if plain: + decode_part(message) + write(message_as_string(message), filename) + + def show_cancel_message(self, plain=False, filename=None): + + "Show the cancel message for uninvited attendees." + + message = self.prepare_cancel_message() + if message: + self.show_message(message, plain, filename) + + def show_publish_message(self, plain=False, filename=None): + + "Show the publishing message for the updated event." + + message = self.prepare_publish_message() + self.show_message(message, plain, filename) + + def show_update_message(self, plain=False, filename=None): + + "Show the update message for the updated event." + + message = self.prepare_update_message() + if message: + self.show_message(message, plain, filename) + + # General display methods. + + def show_object(self): + print + print "Summary:", self.state.get("summary") + print + print "Organiser:", self.state.get("organiser") + self.show_attendees() + self.show_periods() + self.show_suggested_attendees() + self.show_suggested_periods() + self.show_conflicting_periods() + + def show_attendees(self): + print + print_title("Attendees") + attendees = self.state.get("attendees") + for index, attendee_item in enumerate(attendees.items()): + show_attendee(attendee_item, index) + + def show_periods(self): + print + print_title("Periods") + show_periods(self.state.get("periods"), self.state.get("period_errors")) + + def show_suggested_attendees(self): + current_attendee = None + for index, (attendee, suggested_item) in enumerate(self.state.get("suggested_attendees")): + if attendee != current_attendee: + print + print_title("Attendees suggested by %s" % attendee) + current_attendee = attendee + show_attendee(suggested_item, index) + + def show_suggested_periods(self): + periods = self.state.get("suggested_periods") + current_attendee = None + index = 0 + for attendee, period, operation in periods: + if attendee != current_attendee: + print + print_title("Periods suggested by %s" % attendee) + current_attendee = attendee + show_period(period, index) + print " %s" % (operation == "add" and "Add this period" or "Remove this period") + index += 1 + + def show_conflicting_periods(self): + conflicts = self.get_conflicting_periods() + if not conflicts: + return + print + print_title("Conflicting periods") + + conflicts = list(conflicts) + conflicts.sort() + + for p in conflicts: + print p.summary, p.uid, p.get_start(), p.get_end() + +# Interaction functions. + +def expand_arg(args): + if args[0] and args[0][1:].isdigit(): + args[:1] = [args[0][0], args[0][1:]] + +def get_filename_arg(cmd): + return (cmd.split()[1:] or [None])[0] + +def next_arg(args): + if args: + arg = args[0] + del args[0] + return arg + return None + +def edit_period(period, args=None): + + "Edit the given 'period'." + + print "Editing start (%s)" % period.get_start() + edit_date(period.start, args) + print "Editing end (%s)" % period.get_end() + edit_date(period.end, args) + +def edit_date(date, args=None): + + "Edit the given 'date' object attributes." + + date.date = next_arg(args) or input_with_default("Date (%s)? ", date.date) + date.hour = next_arg(args) or input_with_default("Hour (%s)? ", date.hour) + date.minute = next_arg(args) or input_with_default("Minute (%s)? ", date.minute) + date.second = next_arg(args) or input_with_default("Second (%s)? ", date.second) + date.tzid = next_arg(args) or input_with_default("Time zone (%s)? ", date.tzid) + date.reset() + +def select_object(cl, objects): + print + while True: + try: + cmd = read_input("Object or (n)ew object or (q)uit> ") + except EOFError: + return None + + if cmd.isdigit(): + index = int(cmd) + if 0 <= index < len(objects): + obj = objects[index] + return cl.load_object(obj.get_uid(), obj.get_recurrenceid()) + elif cmd in ("n", "new"): + return cl.new_object() + elif cmd in ("q", "quit", "exit"): + return None + +def show_help(): + print + print_title("Editing commands") + print + print """\ +a [ ] +attendee [ ] + Add attendee + +A, attend, attendance + Change attendance/participation + +a +attendee + Select attendee from list + +as + Add suggested attendee from list + +f, finish + Finish editing, confirming changes, proceeding to messaging + +h, help, ? + Show this help message + +l, list, show + List/show all event details + +p, period + Add new period + +p +period + Select period from list + +ps + Add or remove suggested period from list + +q, quit, exit + Exit/quit this program + +r, reload, reset, restart + Reset event periods (return to editing mode, if already finished) + +s, summary + Set event summary +""" + + print_title("Diagnostic commands") + print + print """\ +c, class, classification + Show period classification + +C, changes + Show changes made by editing + +o, ops, operations + Show update operations + +RECURRENCE-ID [ ] + Show event recurrence identifier, writing to if specified + +UID [ ] + Show event unique identifier, writing to if specified +""" + + print_title("Messaging commands") + print + print """\ +P [ ] +publish [ ] + Show publishing message, writing to if specified + +R [ ] +remove [ ] +cancel [ ] + Show cancellation message sent to uninvited/removed recipients, writing to + if specified + +U [ ] +update [ ] + Show update message, writing to if specified +""" + +def edit_object(cl, obj): + cl.show_object() + print + + try: + while True: + role = cl.is_organiser() and "Organiser" or "Attendee" + status = cl.state.get("finished") and " (editing complete)" or "" + + cmd = read_input("%s%s> " % (role, status)) + + args = cmd.split() + + if not args or not args[0]: + continue + + # Check the status of the periods. + + if cmd in ("c", "class", "classification"): + cl.show_period_classification() + print + + elif cmd in ("C", "changes"): + cl.show_changes() + print + + # Finish editing. + + elif cmd in ("f", "finish"): + cl.finish() + + # Help. + + elif cmd in ("h", "?", "help"): + show_help() + + # Show object details. + + elif cmd in ("l", "list", "show"): + cl.show_object() + print + + # Show the operations. + + elif cmd in ("o", "ops", "operations"): + cl.show_operations() + print + + # Quit or exit. + + elif cmd in ("q", "quit", "exit"): + break + + # Restart editing. + + elif cmd in ("r", "reload", "reset", "restart"): + obj = cl.load_object(obj.get_uid(), obj.get_recurrenceid()) + if not obj: + obj = cl.new_object() + cl.reset() + cl.show_object() + print + + # Show UID details. + + elif args[0] == "UID": + filename = get_filename_arg(cmd) + write(obj.get_uid(), filename) + + elif args[0] == "RECURRENCE-ID": + filename = get_filename_arg(cmd) + write(obj.get_recurrenceid() or "", filename) + + # Post-editing operations. + + elif cl.state.get("finished"): + + # Show messages. + + if args[0] in ("P", "publish"): + filename = get_filename_arg(cmd) + cl.show_publish_message(plain=not filename, filename=filename) + + elif args[0] in ("R", "remove", "cancel"): + filename = get_filename_arg(cmd) + cl.show_cancel_message(plain=not filename, filename=filename) + + elif args[0] in ("U", "update"): + filename = get_filename_arg(cmd) + cl.show_update_message(plain=not filename, filename=filename) + + # Editing operations. + + elif not cl.state.get("finished"): + + # Expand short-form arguments. + + expand_arg(args) + + # Add or edit attendee. + + if args[0] in ("a", "attendee"): + + args = args[1:] + value = next_arg(args) + + if value and value.isdigit(): + index = int(value) + else: + try: + index = cl.find_attendee(value) + except ValueError: + index = None + + # Add an attendee. + + if index is None: + cl.add_attendee(value) + if not value: + cl.edit_attendee(-1) + + # Edit attendee (using index). + + else: + attendee_item = cl.can_remove_attendee(index) + if attendee_item: + while True: + show_attendee(attendee_item, index) + + # Obtain a command from any arguments. + + cmd = next_arg(args) + if not cmd: + cmd = read_input(" (e)dit, (r)emove (or return)> ") + if cmd in ("e", "edit"): + cl.edit_attendee(index) + elif cmd in ("r", "remove"): + cl.remove_attendees([index]) + elif not cmd: + pass + else: + continue + break + + cl.show_attendees() + print + + # Add suggested attendee (using index). + + elif args[0] in ("as", "attendee-suggested", "suggested-attendee"): + try: + index = int(args[1]) + cl.add_suggested_attendee(index) + except ValueError: + pass + cl.show_attendees() + print + + # Edit attendance. + + elif args[0] in ("A", "attend", "attendance"): + + args = args[1:] + + if not cl.is_attendee() and cl.is_organiser(): + cl.add_attendee(cl.user) + + # NOTE: Support delegation. + + if cl.can_edit_attendance(): + while True: + + # Obtain a command from any arguments. + + cmd = next_arg(args) + if not cmd: + cmd = read_input(" (a)ccept, (d)ecline, (t)entative (or return)> ") + if cmd in ("a", "accept", "accepted", "attend"): + cl.edit_attendance("ACCEPTED") + elif cmd in ("d", "decline", "declined"): + cl.edit_attendance("DECLINED") + elif cmd in ("t", "tentative"): + cl.edit_attendance("TENTATIVE") + elif not cmd: + pass + else: + continue + break + + cl.show_attendees() + print + + # Add or edit period. + + elif args[0] in ("p", "period"): + + args = args[1:] + value = next_arg(args) + + if value and value.isdigit(): + index = int(value) + else: + index = None + + # Add a new period. + + if index is None: + cl.add_period() + cl.edit_period(-1) + + # Edit period (using index). + + else: + period = cl.can_edit_period(index) + if period: + while True: + show_period_raw(period) + + # Obtain a command from any arguments. + + cmd = next_arg(args) + if not cmd: + cmd = read_input(" (e)dit, (c)ancel, (u)ncancel (or return)> ") + if cmd in ("e", "edit"): + cl.edit_period(index, args) + elif cmd in ("c", "cancel"): + cl.cancel_periods([index]) + elif cmd in ("u", "uncancel", "restore"): + cl.cancel_periods([index], False) + elif not cmd: + pass + else: + continue + break + + cl.show_periods() + print + + # Apply suggested period (using index). + + elif args[0] in ("ps", "period-suggested", "suggested-period"): + try: + index = int(args[1]) + cl.apply_suggested_period(index) + except ValueError: + pass + cl.show_periods() + print + + # Set the summary. + + elif args[0] in ("s", "summary"): + cl.edit_summary(cmd.split(None, 1)[1]) + cl.show_object() + print + + except EOFError: + return + +def main(args): + global echo + + # Parse command line arguments using the standard options plus some extra + # options. + + args = parse_args(args, { + "--echo" : ("echo", False), + "-f" : ("filename", None), + "--suppress-bcc" : ("suppress_bcc", False), + "-u" : ("user", None), + }) + + echo = args["echo"] + filename = args["filename"] + sender = (args["senders"] or [None])[0] + suppress_bcc = args["suppress_bcc"] + user = args["user"] + + # Determine the user and sender identities. + + if sender and not user: + user = get_uri(sender) + elif user and not sender: + sender = get_address(user) + elif not sender and not user: + print >>sys.stderr, "A sender or a user must be specified." + sys.exit(1) + + # Open a store. + + store_type = args.get("store_type") + store_dir = args.get("store_dir") + preferences_dir = args.get("preferences_dir") + + store = get_store(store_type, store_dir) + journal = None + + # Open a messenger for the user. + + messenger = Messenger(sender=sender, suppress_bcc=suppress_bcc) + + # Open a client for the user. + + cl = TextClient(user, messenger, store, journal, preferences_dir) + + # Read any input resource. + + if filename: + objects = get_objects(filename) + + # Choose an object to edit. + + show_objects(objects, user, store) + obj = select_object(cl, objects) + + # Exit without any object. + + if not obj: + print >>sys.stderr, "Object not loaded." + sys.exit(1) + + # Or create a new object. + + else: + obj = cl.new_object() + + # Edit the object. + + edit_object(cl, obj) + +if __name__ == "__main__": + main(sys.argv[1:]) + +# vim: tabstop=4 expandtab shiftwidth=4