1 #!/usr/bin/env python 2 3 """ 4 A text interface to received and new events. 5 6 Copyright (C) 2017, 2018 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from email import message_from_file 23 from imiptools import get_handlers, parse_args 24 from imiptools.config import settings 25 from imiptools.content import get_objects_from_itip, handle_calendar_data, \ 26 handle_calendar_object, have_itip_part, \ 27 is_cancel_itip, parse_itip_part 28 from imiptools.data import get_address, get_periods_using_selector, \ 29 get_main_period, get_recurrence_periods, \ 30 get_value, parse_object 31 from imiptools.dates import get_datetime_item, get_time, to_timezone 32 from imiptools.editing import EditingClient, PeriodError, \ 33 form_periods_from_periods 34 from imiptools.handlers import person, person_outgoing 35 from imiptools.mail import Messenger 36 from imiptools.stores import get_journal, get_store 37 from imiptools.utils import decode_part, message_as_string 38 import vRecurrence 39 import sys, os 40 41 # User interface functions. 42 43 echo = False 44 45 def read_input(label): 46 47 """ 48 Read input, prompting using 'label', stripping leading and trailing 49 whitespace, echoing the input if the global 'echo' variable is set. 50 """ 51 52 s = raw_input(label).strip() 53 if echo: 54 print s 55 return s 56 57 def input_with_default(label, default): 58 59 """ 60 Read input, prompting using 'label', parameterising the label with the given 61 'default' and returning the default if no input is given. 62 """ 63 64 return read_input(label % default) or default 65 66 def to_int_or_none(value): 67 68 "Return 'value' as an integer or None for other inputs." 69 70 try: 71 return int(value) 72 except (TypeError, ValueError): 73 return None 74 75 def format_value_ranges(ranges, null_value="-"): 76 77 "Format 'ranges' as a single descriptive string." 78 79 l = [] 80 81 for value_range in ranges: 82 if isinstance(value_range, tuple): 83 start, end = value_range 84 l.append("%s...%s" % (start, end)) 85 elif isinstance(value_range, list): 86 l.append(", ".join(value_range)) 87 elif isinstance(value_range, dict): 88 l.append(", ".join(value_range.keys())) 89 else: 90 l.append(value_range or null_value) 91 92 return ", ".join(l) 93 94 def print_title(text): 95 96 "Print 'text' with simple, fixed-width styling as a title." 97 98 print text 99 print len(text) * "-" 100 101 def print_table(rows, separator_index=0): 102 103 """ 104 Print 'rows' as a simple, fixed-width table. If 'separator_index' is set to 105 a row index present in the rows, a table separator will be produced below 106 that row's data. Otherwise, a separator will appear below the first row. 107 """ 108 109 widths = [] 110 for row in rows: 111 for i, col in enumerate(row): 112 if i >= len(widths): 113 widths.append(len(col)) 114 else: 115 widths[i] = max(widths[i], len(col)) 116 117 for i, row in enumerate(rows): 118 for col, width in zip(row, widths): 119 print "%s%s" % (col, " " * (width - len(col))), 120 print 121 if i == separator_index: 122 for width in widths: 123 print "-" * width, 124 print 125 126 def write(s, filename): 127 128 "Write 's' to a file having the given 'filename'." 129 130 f = filename and open(filename, "w") or None 131 try: 132 print >>(f or sys.stdout), s 133 finally: 134 if f: 135 f.close() 136 137 # Interpret an input file containing a calendar resource. 138 139 def get_itip_from_message(filename): 140 141 "Return iTIP details provided by 'filename'." 142 143 f = open(filename) 144 try: 145 msg = message_from_file(f) 146 finally: 147 f.close() 148 149 all_itip = [] 150 151 for part in msg.walk(): 152 if have_itip_part(part): 153 all_itip.append(parse_itip_part(part)) 154 155 return all_itip 156 157 def get_itip_from_data(filename, charset): 158 159 "Return objects provided by 'filename'." 160 161 f = open(filename) 162 try: 163 itip = parse_object(f, charset, "VCALENDAR") 164 finally: 165 f.close() 166 167 return [itip] 168 169 # Object and request display. 170 171 def show_objects(objects, user, store): 172 173 """ 174 Show details of 'objects', accessed by the given 'user' in the given 175 'store'. 176 """ 177 178 print 179 print_title("Objects") 180 print 181 182 for index, obj in enumerate(objects): 183 recurrenceid = obj.get_recurrenceid() 184 recurrence_label = recurrenceid and " %s" % recurrenceid or "" 185 print "(%d) Summary: %s (%s%s)" % (index, obj.get_value("SUMMARY"), obj.get_uid(), recurrence_label) 186 187 def show_requests(user, store): 188 189 "Show requests available to the given 'user' in the given 'store'." 190 191 requests = store.get_requests(user) 192 193 print 194 print_title("Requests") 195 print 196 197 if not requests: 198 print "No requests are pending." 199 return 200 201 for index, (uid, recurrenceid) in enumerate(requests): 202 obj = store.get_event(user, uid, recurrenceid) 203 recurrence_label = recurrenceid and " %s" % recurrenceid or "" 204 print "(%d) Summary: %s (%s%s)" % (index, obj.get_value("SUMMARY"), obj.get_uid(), recurrence_label) 205 206 # Object details display. 207 208 def show_attendee(attendee_item, index): 209 210 "Show the 'attendee_item' (value and attributes) at 'index'." 211 212 attendee, attr = attendee_item 213 partstat = attr.get("PARTSTAT") 214 print "(%d) %s%s" % (index, attendee, partstat and " (%s)" % partstat or "") 215 216 def show_attendees_raw(attendee_map): 217 218 "Show the 'attendee_map' in a simple raw form." 219 220 for attendee, attr in attendee_map.items(): 221 print attendee 222 223 def show_periods(periods, errors=None): 224 225 "Show 'periods' with any indicated 'errors'." 226 227 main = get_main_period(periods) 228 if main: 229 show_period(main, 0, errors) 230 231 recurrences = get_recurrence_periods(periods) 232 if recurrences: 233 print 234 print_title("Recurrences") 235 for index, p in enumerate(recurrences): 236 show_period(p, index + 1, errors) 237 238 def show_period(p, index, errors=None): 239 240 "Show period 'p' at 'index' with any indicated 'errors'." 241 242 errors = errors and errors.get(index) 243 if p.cancelled: 244 label = "Cancelled" 245 elif p.replacement: 246 label = "Replaced" 247 elif p.new_replacement: 248 label = "To replace" 249 elif p.recurrenceid: 250 label = "Retained" 251 else: 252 label = "New" 253 254 error_label = errors and " (errors: %s)" % ", ".join(errors) or "" 255 print "(%d) %s%s:" % (index, label, error_label), p.get_start(), p.get_end(), p.origin 256 257 def show_periods_raw(periods): 258 259 "Show 'periods' in a simple raw form." 260 261 periods = periods[:] 262 periods.sort() 263 map(show_period_raw, periods) 264 265 def show_period_raw(p): 266 267 "Show period 'p' in a simple raw form." 268 269 print p.get_start(), p.get_end(), p.origin 270 271 def show_rule(selectors): 272 273 "Show recurrence rule specification 'selectors'." 274 275 # Collect limit, selection and frequency details. 276 277 for i, selector in enumerate(selectors): 278 prefix = "(%d) " % i 279 show_rule_selector(selector, prefix) 280 281 print 282 print vRecurrence.to_string(selectors) 283 284 def show_rule_selector(selector, prefix=""): 285 286 "Show the rule 'selector', employing any given 'prefix' for formatting." 287 288 # COUNT 289 290 if isinstance(selector, vRecurrence.LimitSelector): 291 print "%sAt most %d occurrences" % (prefix, selector.args["values"][0]) 292 293 # BYSETPOS 294 295 elif isinstance(selector, vRecurrence.PositionSelector): 296 for value in selector.get_positions(): 297 print "%sSelect occurrence #%d" % (prefix, value) 298 prefix = len(prefix) * " " 299 300 # BYWEEKDAY 301 302 elif isinstance(selector, vRecurrence.WeekDayFilter): 303 for value, index in selector.get_values(): 304 print "%sSelect occurrence #%d (from %s) of weekday %s" % ( 305 prefix, abs(index), index >= 0 and "start" or "end", 306 get_weekday(value)) 307 prefix = len(prefix) * " " 308 309 # BY... 310 311 elif isinstance(selector, vRecurrence.Enum): 312 for value in selector.get_values(): 313 print "%sSelect %s %r" % (prefix, get_resolution(selector.level), 314 value) 315 prefix = len(prefix) * " " 316 317 # YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY 318 319 elif isinstance(selector, vRecurrence.Pattern): 320 print "%sEach %s with interval %d" % (prefix, 321 get_resolution(selector.level), selector.args.get("interval", 1)) 322 323 def get_resolution(level): 324 325 "Return a textual description of the given resolution 'level'." 326 327 levels = ["year", "month", "week", "day in year", "day in month", "day", "hour", "minute", "second"] 328 return levels[level] 329 330 def get_weekday(weekday): 331 332 "Return the name of the given 1-based 'weekday' number." 333 334 weekdays = [None, "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 335 return weekdays[weekday] 336 337 def show_attendee_changes(new, modified, unmodified, removed): 338 339 "Show 'new', 'modified', 'unmodified' and 'removed' periods." 340 341 print 342 print_title("Changes to attendees") 343 print 344 print "New:" 345 show_attendees_raw(new) 346 print 347 print "Modified:" 348 show_attendees_raw(modified) 349 print 350 print "Unmodified:" 351 show_attendees_raw(unmodified) 352 print 353 print "Removed:" 354 show_attendees_raw(removed) 355 356 def show_period_classification(new, replaced, retained, cancelled, obsolete): 357 358 "Show 'new', 'replaced', 'retained', 'cancelled' and 'obsolete' periods." 359 360 print 361 print_title("Period classification") 362 print 363 print "New:" 364 show_periods_raw(new) 365 print 366 print "Replaced:" 367 show_periods_raw(replaced) 368 print 369 print "Retained:" 370 show_periods_raw(retained) 371 print 372 print "Cancelled:" 373 show_periods_raw(cancelled) 374 print 375 print "Obsolete:" 376 show_periods_raw(obsolete) 377 378 def show_changes(modified, unmodified, removed): 379 380 "Show 'modified', 'unmodified' and 'removed' periods." 381 382 print 383 print_title("Changes to periods") 384 print 385 print "Modified:" 386 show_periods_raw(modified) 387 print 388 print "Unmodified:" 389 show_periods_raw(unmodified) 390 print 391 print "Removed:" 392 show_periods_raw(removed) 393 394 def show_attendee_operations(to_invite, to_cancel, to_modify): 395 396 "Show attendees 'to_invite', 'to_cancel' and 'to_modify'." 397 398 print 399 print_title("Attendee update operations") 400 print 401 print "To invite:" 402 show_attendees_raw(to_invite) 403 print 404 print "To cancel:" 405 show_attendees_raw(to_cancel) 406 print 407 print "To modify:" 408 show_attendees_raw(to_modify) 409 410 def show_period_operations(to_unschedule, to_reschedule, to_add, to_exclude, to_set, 411 all_unscheduled, all_rescheduled): 412 413 """ 414 Show operations for periods 'to_unschedule', 'to_reschedule', 'to_add', 415 'to_exclude' and 'to_set' (for updating other calendar participants), and 416 for periods 'all_unscheduled' and 'all_rescheduled' (for publishing event 417 state). 418 """ 419 420 print 421 print_title("Period update and publishing operations") 422 print 423 print "Unschedule:" 424 show_periods_raw(to_unschedule) 425 print 426 print "Reschedule:" 427 show_periods_raw(to_reschedule) 428 print 429 print "Added:" 430 show_periods_raw(to_add) 431 print 432 print "Excluded:" 433 show_periods_raw(to_exclude) 434 print 435 print "Set in object:" 436 show_periods_raw(to_set) 437 print 438 print "All unscheduled:" 439 show_periods_raw(all_unscheduled) 440 print 441 print "All rescheduled:" 442 show_periods_raw(all_rescheduled) 443 444 class TextClient(EditingClient): 445 446 "Simple client with textual output." 447 448 def new_object(self): 449 450 "Create a new object with the current time." 451 452 utcnow = get_time() 453 now = to_timezone(utcnow, self.get_tzid()) 454 obj = EditingClient.new_object(self, "VEVENT") 455 obj.set_value("SUMMARY", "New event") 456 obj["DTSTART"] = [get_datetime_item(now)] 457 obj["DTEND"] = [get_datetime_item(now)] 458 return obj 459 460 def handle_outgoing_object(self): 461 462 "Handle the current object using the outgoing handlers." 463 464 unscheduled_objects, rescheduled_objects, added_objects = \ 465 self.get_publish_objects() 466 467 handlers = get_handlers(self, person_outgoing.handlers, 468 [get_address(self.user)]) 469 470 # Handle the parent object plus any altered periods. 471 472 handle_calendar_object(self.obj, handlers, "PUBLISH") 473 474 for o in unscheduled_objects: 475 handle_calendar_object(o, handlers, "CANCEL") 476 477 for o in rescheduled_objects: 478 handle_calendar_object(o, handlers, "PUBLISH") 479 480 for o in added_objects: 481 handle_calendar_object(o, handlers, "ADD") 482 483 def update_periods_from_rule(self): 484 485 "Update the periods from the rule." 486 487 selectors = self.state.get("rule") 488 periods = self.state.get("periods") 489 490 main_period = get_main_period(periods) 491 tzid = main_period.get_tzid() 492 493 start = main_period.get_start() 494 end = self.get_window_end() or None 495 496 selector = vRecurrence.get_selector(start, selectors) 497 until = None 498 inclusive = False 499 500 # Generate the periods from the rule. 501 502 rule_periods = form_periods_from_periods( 503 get_periods_using_selector(selector, main_period, tzid, 504 start, end, inclusive)) 505 506 # Retain any applicable replacement periods that either modify or cancel 507 # rule periods. 508 # NOTE: To be done. 509 510 self.state.set("periods", rule_periods) 511 512 # Editing methods involving interaction. 513 514 def add_rule_selectors(self): 515 516 "Add rule selectors to the rule details." 517 518 selectors = self.state.get("rule") 519 520 while True: 521 522 # Obtain a command from any arguments. 523 524 s = read_input("Selector: (c)ount, (f)requency, (s)election (or return)> ") 525 args = s.split() 526 cmd = next_arg(args) 527 528 if cmd in ("c", "count", "limit"): 529 add_rule_selector_count(selectors, args) 530 elif cmd in ("f", "freq", "frequency"): 531 add_rule_selector_frequency(selectors, args) 532 elif cmd in ("s", "select", "selection"): 533 add_rule_selector_selection(selectors, args) 534 535 # Remain in the loop unless explicitly terminated. 536 537 elif not cmd or cmd == "end": 538 break 539 540 def edit_attendee(self, index): 541 542 "Edit the attendee at 'index'." 543 544 t = self.can_edit_attendee(index) 545 if t: 546 attendees = self.state.get("attendees") 547 attendee, attr = t 548 del attendees[attendee] 549 attendee = input_with_default("Attendee (%s)? ", attendee) 550 attendees[attendee] = attr 551 552 def edit_period(self, index, args=None): 553 554 "Edit the period at 'index'." 555 556 period = self.can_edit_period(index) 557 if period: 558 edit_period(period, args) 559 period.cancelled = False 560 561 # Change the origin of modified rule periods. 562 563 if period.origin == "RRULE": 564 period.origin = "RDATE" 565 566 # Sort the periods after this change. 567 568 periods = self.state.get("periods") 569 periods.sort() 570 571 def edit_rule_selector(self, index, args): 572 573 "Edit the selector having the given 'index'." 574 575 selectors = self.state.get("rule") 576 selector = self.can_edit_rule_selector(index) 577 578 if not selector: 579 return 580 581 while True: 582 show_rule_selector(selector) 583 584 # Obtain a command from any arguments. 585 586 cmd = next_arg(args) 587 if not cmd: 588 s = read_input("Selector: (e)dit, (r)emove (or return)> ") 589 args = s.split() 590 cmd = next_arg(args) 591 592 # Edit an existing selector. 593 594 if cmd in ("e", "edit"): 595 if isinstance(selector, vRecurrence.LimitSelector): 596 add_rule_selector_count(selectors, args, selector) 597 elif isinstance(selector, vRecurrence.Pattern): 598 add_rule_selector_frequency(selectors, args, selector) 599 else: 600 add_rule_selector_selection(selectors, args, selector) 601 602 # Remove an existing selector. 603 604 elif cmd in ("r", "remove"): 605 del selectors[index] 606 607 # Exit if requested or after a successful 608 # operation. 609 610 elif not cmd: 611 pass 612 else: 613 continue 614 break 615 616 def edit_summary(self, summary=None): 617 618 "Edit or set the 'summary'." 619 620 if self.can_edit_properties(): 621 if not summary: 622 summary = input_with_default("Summary (%s)? ", self.state.get("summary")) 623 self.state.set("summary", summary) 624 625 def finish(self): 626 627 "Finish editing, warning of errors if any occur." 628 629 try: 630 EditingClient.finish(self) 631 except PeriodError: 632 print "Errors exist in the periods." 633 return 634 635 # Diagnostic methods. 636 637 def show_period_classification(self): 638 639 "Show the classification of the periods." 640 641 try: 642 new, replaced, retained, cancelled, obsolete = self.classify_periods() 643 show_period_classification(new, replaced, retained, cancelled, obsolete) 644 except PeriodError: 645 print 646 print "Errors exist in the periods." 647 648 def show_changes(self): 649 650 "Show how the periods have changed." 651 652 try: 653 modified, unmodified, removed = self.classify_period_changes() 654 show_changes(modified, unmodified, removed) 655 except PeriodError: 656 print "Errors exist in the periods." 657 658 is_changed = self.properties_changed() 659 if is_changed: 660 print 661 print "Properties changed:", ", ".join(is_changed) 662 new, modified, unmodified, removed = self.classify_attendee_changes() 663 show_attendee_changes(new, modified, unmodified, removed) 664 665 def show_operations(self): 666 667 "Show the operations required to change the periods for recipients." 668 669 is_changed = self.properties_changed() 670 671 try: 672 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 673 all_unscheduled, all_rescheduled = self.classify_period_operations() 674 show_period_operations(to_unschedule, to_reschedule, to_add, 675 to_exclude, to_set, 676 all_unscheduled, all_rescheduled) 677 except PeriodError: 678 print "Errors exist in the periods." 679 680 to_invite, to_cancel, to_modify = self.classify_attendee_operations() 681 show_attendee_operations(to_invite, to_cancel, to_modify) 682 683 # Output methods. 684 685 def show_message(self, message, plain=False, filename=None): 686 687 """ 688 Show the given mail 'message', decoding to plain text if 'plain' is set 689 to a true value, writing it to 'filename' if indicated. 690 """ 691 692 if plain: 693 decode_part(message) 694 write(message_as_string(message), filename) 695 696 def show_cancel_message(self, plain=False, filename=None): 697 698 "Show the cancel message for uninvited attendees." 699 700 message = self.prepare_cancel_message() 701 if message: 702 self.show_message(message, plain, filename) 703 704 def show_publish_message(self, plain=False, filename=None): 705 706 "Show the publishing message for the updated event." 707 708 message = self.prepare_publish_message() 709 self.show_message(message, plain, filename) 710 711 def show_update_message(self, plain=False, filename=None): 712 713 "Show the update message for the updated event." 714 715 message = self.prepare_update_message() 716 if message: 717 self.show_message(message, plain, filename) 718 719 # General display methods. 720 721 def show_object(self): 722 print 723 print_title("Object details") 724 print 725 print "Summary:", self.state.get("summary") 726 print 727 print "Organiser:", self.state.get("organiser") 728 self.show_attendees() 729 self.show_periods() 730 self.show_rule() 731 self.show_suggested_attendees() 732 self.show_suggested_periods() 733 self.show_conflicting_periods() 734 print 735 print "Object is", self.obj.is_shared() and "shared" or "not shared" 736 737 def show_attendees(self): 738 print 739 print_title("Attendees") 740 attendees = self.state.get("attendees") 741 for index, attendee_item in enumerate(attendees.items()): 742 show_attendee(attendee_item, index) 743 744 def show_periods(self): 745 print 746 print_title("Periods") 747 show_periods(self.state.get("periods"), self.state.get("period_errors")) 748 749 def show_rule(self): 750 selectors = self.state.get("rule") 751 if selectors: 752 print 753 print_title("Period recurrence rule") 754 show_rule(selectors) 755 756 def show_suggested_attendees(self): 757 current_attendee = None 758 for index, (attendee, suggested_item) in enumerate(self.state.get("suggested_attendees")): 759 if attendee != current_attendee: 760 print 761 print_title("Attendees suggested by %s" % attendee) 762 current_attendee = attendee 763 show_attendee(suggested_item, index) 764 765 def show_suggested_periods(self): 766 periods = self.state.get("suggested_periods") 767 current_attendee = None 768 index = 0 769 for attendee, period, operation in periods: 770 if attendee != current_attendee: 771 print 772 print_title("Periods suggested by %s" % attendee) 773 current_attendee = attendee 774 show_period(period, index) 775 print " %s" % (operation == "add" and "Add this period" or "Remove this period") 776 index += 1 777 778 def show_conflicting_periods(self): 779 conflicts = self.get_conflicting_periods() 780 if not conflicts: 781 return 782 print 783 print_title("Conflicting periods") 784 785 conflicts = list(conflicts) 786 conflicts.sort() 787 788 for p in conflicts: 789 print p.summary, p.uid, p.get_start(), p.get_end() 790 791 # Interaction functions. 792 793 def expand_arg(args): 794 795 """ 796 Expand the first argument in 'args' to a pair of arguments if having the 797 form <char><digit>... 798 """ 799 800 if args[0] and args[0][1:].isdigit(): 801 args[:1] = [args[0][0], args[0][1:]] 802 803 def get_text_arg(s): 804 805 """ 806 Split 's' after the first whitespace occurrence, returning the remaining 807 text or None if no such text exists. 808 """ 809 810 return (s.split(None, 1)[1:] or [None])[0] 811 812 def next_arg(args): 813 814 """ 815 Return the first argument from 'args', removing it, or return None if no 816 arguments are left. 817 """ 818 819 if args: 820 arg = args[0] 821 del args[0] 822 return arg 823 return None 824 825 # Editing functions. 826 827 def edit_period(period, args=None): 828 829 "Edit the given 'period'." 830 831 print "Editing start (%s)" % period.get_start() 832 edit_date(period.start, args) 833 print "Editing end (%s)" % period.get_end() 834 edit_date(period.end, args) 835 836 def edit_date(date, args=None): 837 838 "Edit the given 'date' object attributes." 839 840 date.date = next_arg(args) or input_with_default("Date (%s)? ", date.date) 841 date.hour = next_arg(args) or input_with_default("Hour (%s)? ", date.hour) 842 date.minute = next_arg(args) or input_with_default("Minute (%s)? ", date.minute) 843 date.second = next_arg(args) or input_with_default("Second (%s)? ", date.second) 844 date.tzid = next_arg(args) or input_with_default("Time zone (%s)? ", date.tzid) 845 date.reset() 846 847 def add_rule_selector_count(selectors, args, selector=None): 848 849 "Add to 'selectors' a selector imposing a count restriction." 850 851 while True: 852 arg = next_arg(args) 853 if not arg: 854 if selector: 855 arg = input_with_default("Number of occurrences (%d)? ", 856 selector.get_limit()) 857 else: 858 arg = read_input("Number of occurrences? ") 859 860 count = to_int_or_none(arg) 861 862 if count is None: 863 arg = None 864 continue 865 866 # Change or add selector. 867 868 selector = selector or selectors and \ 869 isinstance(selectors[0], vRecurrence.LimitSelector) and \ 870 selectors[0] or None 871 872 if not selector: 873 selector = vRecurrence.new_selector("COUNT") 874 selectors.insert(0, selector) 875 876 selector.set_limit(count) 877 break 878 879 def add_rule_selector_frequency(selectors, args, selector=None): 880 881 "Add to 'selectors' a selector for a frequency." 882 883 while not selector: 884 arg = next_arg(args) 885 if not arg: 886 arg = read_input("Select (y)early, (M)onthly, (w)eekly, (d)aily, " 887 "(h)ourly, (m)inutely, (s)econdly (or return)? ") 888 889 if not arg: 890 return 891 892 arg_lower = arg.lower() 893 894 if arg_lower in ("y", "year", "yearly"): 895 qualifier = "YEARLY" 896 elif arg == "M" or arg_lower in ("month", "monthly"): 897 qualifier = "MONTHLY" 898 elif arg_lower in ("w", "week", "weekly"): 899 qualifier = "WEEKLY" 900 elif arg_lower in ("d", "day", "daily"): 901 qualifier = "DAILY" 902 elif arg_lower in ("h", "hour", "hourly"): 903 qualifier = "HOURLY" 904 elif arg_lower in ("m", "minute", "minutely"): 905 qualifier = "MINUTELY" 906 elif arg_lower in ("s", "second", "secondly"): 907 qualifier = "SECONDLY" 908 else: 909 continue 910 911 break 912 913 while True: 914 arg = next_arg(args) 915 if not arg: 916 if selector: 917 arg = input_with_default("Interval (%d)? ", 918 selector.get_interval()) 919 else: 920 arg = input_with_default("Interval (%d)? ", 1) 921 922 interval = to_int_or_none(arg) 923 924 if interval is None: 925 arg = None 926 else: 927 break 928 929 # Update an existing selector. 930 931 if selector: 932 selector.set_interval(interval) 933 return 934 935 # Create a new selector. 936 937 selector = vRecurrence.new_selector(qualifier) 938 selector.set_interval(interval) 939 940 # Remove any existing frequency selector. 941 942 for index, _selector in enumerate(selectors): 943 if isinstance(_selector, vRecurrence.Pattern): 944 del selectors[index] 945 break 946 947 # Add the new selector and keep the selectors in order. 948 949 selectors.append(selector) 950 vRecurrence.sort_selectors(selectors) 951 952 def add_rule_selector_selection(selectors, args, selector=None): 953 954 "Add to 'selectors' a selector for a particular point in time." 955 956 qualifier = selector and selector.qualifier or None 957 958 while not selector: 959 arg = next_arg(args) 960 if not arg: 961 arg = read_input("Select (M)onths, (w)eeks, (y)eardays, " 962 "m(o)nthdays, week(d)ays, (h)ours, (m)inutes, " 963 "(s)econds (or return)? ") 964 965 if not arg: 966 return 967 968 arg_lower = arg.lower() 969 970 if arg == "M" or arg_lower in ("month", "months"): 971 qualifier = "BYMONTH" 972 elif arg_lower in ("w", "week", "weeks"): 973 qualifier = "BYWEEKNO" 974 elif arg_lower in ("y", "yearday", "yeardays"): 975 qualifier = "BYYEARDAY" 976 elif arg_lower in ("o", "monthday", "monthdays"): 977 qualifier = "BYMONTHDAY" 978 elif arg_lower in ("d", "weekday", "weekdays"): 979 qualifier = "BYDAY" 980 elif arg_lower in ("h", "hour", "hours"): 981 qualifier = "BYHOUR" 982 elif arg_lower in ("m", "minute", "minutes"): 983 qualifier = "BYMINUTE" 984 elif arg_lower in ("s", "second", "seconds"): 985 qualifier = "BYSECOND" 986 else: 987 continue 988 989 break 990 991 if not qualifier: 992 return 993 994 ranges = vRecurrence.get_value_ranges(qualifier) 995 ranges_str = format_value_ranges(ranges[0]) 996 997 values = [] 998 999 while True: 1000 arg = next_arg(args) 1001 if not arg: 1002 arg = read_input("Value (%s) (return to end)? " % ranges_str) 1003 1004 # Stop if no more arguments. 1005 1006 if not arg or arg == "end": 1007 break 1008 1009 # Handle weekdays. 1010 1011 if qualifier == "BYDAY": 1012 value = arg.upper() # help to match weekdays 1013 1014 arg = next_arg(args) 1015 if not arg: 1016 arg = read_input("Occurrence within a month? ") 1017 1018 index = to_int_or_none(arg) 1019 value = vRecurrence.check_values(qualifier, [value, index]) 1020 1021 # Handle all other values. 1022 1023 else: 1024 value = to_int_or_none(arg) 1025 l = vRecurrence.check_values(qualifier, [value]) 1026 value = l and l[0] 1027 1028 # Append valid values. 1029 1030 if value is not None: 1031 values.append(value) 1032 else: 1033 print "Value not recognised." 1034 1035 if not values: 1036 return 1037 1038 # Update an existing selector. 1039 1040 if selector: 1041 selector.set_values(values) 1042 return 1043 1044 # Create a new selector. 1045 1046 selector = vRecurrence.new_selector(qualifier) 1047 selector.set_values(values) 1048 1049 # Remove any existing selector. 1050 1051 for index, _selector in enumerate(selectors): 1052 if _selector.qualifier == selector.qualifier: 1053 del selectors[index] 1054 break 1055 1056 # Add the new selector and keep the selectors in order. 1057 1058 selectors.append(selector) 1059 vRecurrence.sort_selectors(selectors) 1060 1061 def select_object(cl, objects): 1062 1063 "Select using 'cl' an object from the given 'objects'." 1064 1065 print 1066 1067 if objects: 1068 label = "Select object number or (n)ew object or (q)uit> " 1069 else: 1070 label = "Select (n)ew object or (q)uit> " 1071 1072 while True: 1073 try: 1074 cmd = read_input(label) 1075 except EOFError: 1076 return None 1077 1078 if cmd.isdigit(): 1079 index = to_int_or_none(cmd) 1080 1081 if index is not None and 0 <= index < len(objects): 1082 obj = objects[index] 1083 return cl.load_object(obj.get_uid(), obj.get_recurrenceid()) 1084 1085 elif cmd in ("n", "new"): 1086 return cl.new_object() 1087 elif cmd in ("q", "quit", "exit"): 1088 return None 1089 1090 def show_commands(): 1091 1092 "Show editing and inspection commands." 1093 1094 print 1095 print_title("Editing commands") 1096 print 1097 print """\ 1098 a [ <uri> ] 1099 attendee [ <uri> ] 1100 Add attendee 1101 1102 A, attend, attendance 1103 Change attendance/participation 1104 1105 a<digit> 1106 attendee <digit> 1107 Select attendee from list 1108 1109 as<digit> 1110 Add suggested attendee from list 1111 1112 f, finish 1113 Finish editing, confirming changes, proceeding to messaging 1114 1115 h, help, ? 1116 Show this help message 1117 1118 l, list, show 1119 List/show all event details 1120 1121 p, period [ new ] 1122 Add new period 1123 1124 p<digit> 1125 period <digit> 1126 Select period from list 1127 1128 ps<digit> 1129 Add or remove suggested period from list 1130 1131 q, quit, exit 1132 Exit/quit this program 1133 1134 r, reload, reset, restart 1135 Reset event periods (return to editing mode, if already finished) 1136 1137 rule, rrule 1138 Add a period recurrence rule 1139 1140 rule <digit> 1141 rrule <digit> 1142 Select period recurrence rule selector from list 1143 1144 s, summary 1145 Set event summary 1146 """ 1147 1148 print_title("Messaging commands") 1149 print 1150 print """\ 1151 S, send 1152 Send messages to recipients and to self, if appropriate 1153 """ 1154 1155 print_title("Diagnostic commands") 1156 print 1157 print """\ 1158 c, class, classification 1159 Show period classification 1160 1161 C, changes 1162 Show changes made by editing 1163 1164 o, ops, operations 1165 Show update operations 1166 1167 RECURRENCE-ID [ <filename> ] 1168 Show event recurrence identifier, writing to <filename> if specified 1169 1170 UID [ <filename> ] 1171 Show event unique identifier, writing to <filename> if specified 1172 """ 1173 1174 print_title("Message inspection commands") 1175 print 1176 print """\ 1177 P [ <filename> ] 1178 publish [ <filename> ] 1179 Show publishing message, writing to <filename> if specified 1180 1181 R [ <filename> ] 1182 remove [ <filename> ] 1183 cancel [ <filename> ] 1184 Show cancellation message sent to uninvited/removed recipients, writing to 1185 <filename> if specified 1186 1187 U [ <filename> ] 1188 update [ <filename> ] 1189 Show update message, writing to <filename> if specified 1190 """ 1191 1192 def edit_object(cl, obj, handle_outgoing=False): 1193 1194 """ 1195 Edit using 'cl' the given object 'obj'. If 'handle_outgoing' is specified 1196 and set to a true value, the details from outgoing messages are incorporated 1197 into the stored data. 1198 """ 1199 1200 cl.show_object() 1201 print 1202 1203 try: 1204 while True: 1205 role = cl.is_organiser() and "Organiser" or "Attendee" 1206 status = cl.state.get("finished") and " (editing complete)" or "" 1207 1208 s = read_input("%s%s> " % (role, status)) 1209 args = s.split() 1210 1211 if not args or not args[0]: 1212 continue 1213 1214 # Expand short-form arguments. 1215 1216 expand_arg(args) 1217 cmd = next_arg(args) 1218 1219 # Check the status of the periods. 1220 1221 if cmd in ("c", "class", "classification"): 1222 cl.show_period_classification() 1223 print 1224 1225 elif cmd in ("C", "changes"): 1226 cl.show_changes() 1227 print 1228 1229 # Finish editing. 1230 1231 elif cmd in ("f", "finish"): 1232 cl.finish() 1233 1234 # Help. 1235 1236 elif cmd in ("h", "?", "help"): 1237 show_commands() 1238 1239 # Show object details. 1240 1241 elif cmd in ("l", "list", "show"): 1242 cl.show_object() 1243 print 1244 1245 # Show the operations. 1246 1247 elif cmd in ("o", "ops", "operations"): 1248 cl.show_operations() 1249 print 1250 1251 # Quit or exit. 1252 1253 elif cmd in ("q", "quit", "exit"): 1254 break 1255 1256 # Restart editing. 1257 1258 elif cmd in ("r", "reload", "reset", "restart"): 1259 obj = cl.load_object(obj.get_uid(), obj.get_recurrenceid()) 1260 if not obj: 1261 obj = cl.new_object() 1262 cl.reset() 1263 cl.show_object() 1264 print 1265 1266 # Show UID details. 1267 1268 elif cmd == "UID": 1269 filename = get_text_arg(s) 1270 write(obj.get_uid(), filename) 1271 1272 elif cmd == "RECURRENCE-ID": 1273 filename = get_text_arg(s) 1274 write(obj.get_recurrenceid() or "", filename) 1275 1276 # Post-editing operations. 1277 1278 elif cl.state.get("finished"): 1279 1280 # Show messages. 1281 1282 if cmd in ("P", "publish"): 1283 filename = get_text_arg(s) 1284 cl.show_publish_message(plain=not filename, filename=filename) 1285 1286 elif cmd in ("R", "remove", "cancel"): 1287 filename = get_text_arg(s) 1288 cl.show_cancel_message(plain=not filename, filename=filename) 1289 1290 elif cmd in ("U", "update"): 1291 filename = get_text_arg(s) 1292 cl.show_update_message(plain=not filename, filename=filename) 1293 1294 # Definitive finishing action. 1295 1296 elif cmd in ("S", "send"): 1297 1298 # Send update and cancellation messages. 1299 1300 did_send = False 1301 1302 message = cl.prepare_update_message() 1303 if message: 1304 cl.send_message(message, cl.get_recipients()) 1305 did_send = True 1306 1307 to_cancel = cl.state.get("attendees_to_cancel") 1308 if to_cancel: 1309 message = cl.prepare_cancel_message() 1310 if message: 1311 cl.send_message(message, to_cancel) 1312 did_send = True 1313 1314 # Process the object using the person outgoing handler. 1315 1316 if handle_outgoing: 1317 cl.handle_outgoing_object() 1318 1319 # Otherwise, send a message to self with the event details. 1320 1321 else: 1322 message = cl.prepare_publish_message() 1323 if message: 1324 cl.send_message_to_self(message) 1325 did_send = True 1326 1327 # Exit if sending occurred. 1328 1329 if did_send: 1330 break 1331 else: 1332 print "No messages sent. Try making edits or exit manually." 1333 1334 # Editing operations. 1335 1336 elif not cl.state.get("finished"): 1337 1338 # Add or edit attendee. 1339 1340 if cmd in ("a", "attendee"): 1341 value = next_arg(args) 1342 index = to_int_or_none(value) 1343 1344 if index is None: 1345 try: 1346 index = cl.find_attendee(value) 1347 except ValueError: 1348 index = None 1349 1350 # Add an attendee. 1351 1352 if index is None: 1353 cl.add_attendee(value) 1354 if not value: 1355 cl.edit_attendee(-1) 1356 1357 # Edit attendee (using index). 1358 1359 else: 1360 attendee_item = cl.can_remove_attendee(index) 1361 if attendee_item: 1362 while True: 1363 show_attendee(attendee_item, index) 1364 1365 # Obtain a command from any arguments. 1366 1367 cmd = next_arg(args) 1368 if not cmd: 1369 cmd = read_input("Attendee: (e)dit, (r)emove (or return)> ") 1370 if cmd in ("e", "edit"): 1371 cl.edit_attendee(index) 1372 elif cmd in ("r", "remove"): 1373 cl.remove_attendees([index]) 1374 1375 # Exit if requested or after a successful 1376 # operation. 1377 1378 elif not cmd: 1379 pass 1380 else: 1381 continue 1382 break 1383 1384 cl.show_attendees() 1385 print 1386 1387 # Add suggested attendee (using index). 1388 1389 elif cmd in ("as", "attendee-suggested", "suggested-attendee"): 1390 value = next_arg(args) 1391 index = to_int_or_none(value) 1392 1393 if index is not None: 1394 cl.add_suggested_attendee(index) 1395 1396 cl.show_attendees() 1397 print 1398 1399 # Edit attendance. 1400 1401 elif cmd in ("A", "attend", "attendance"): 1402 1403 if not cl.is_attendee() and cl.is_organiser(): 1404 cl.add_attendee(cl.user) 1405 1406 # NOTE: Support delegation. 1407 1408 if cl.can_edit_attendance(): 1409 while True: 1410 1411 # Obtain a command from any arguments. 1412 1413 cmd = next_arg(args) 1414 if not cmd: 1415 cmd = read_input("Attendance: (a)ccept, (d)ecline, (t)entative (or return)> ") 1416 if cmd in ("a", "accept", "accepted", "attend"): 1417 cl.edit_attendance("ACCEPTED") 1418 elif cmd in ("d", "decline", "declined"): 1419 cl.edit_attendance("DECLINED") 1420 elif cmd in ("t", "tentative"): 1421 cl.edit_attendance("TENTATIVE") 1422 1423 # Exit if requested or after a successful operation. 1424 1425 elif not cmd: 1426 pass 1427 else: 1428 continue 1429 break 1430 1431 cl.show_attendees() 1432 print 1433 1434 # Add or edit period. 1435 1436 elif cmd in ("p", "period"): 1437 value = next_arg(args) 1438 index = to_int_or_none(value) 1439 1440 # Add a new period. 1441 1442 if index is None or value == "new": 1443 cl.add_period() 1444 cl.edit_period(-1, args) 1445 1446 # Edit period (using index). 1447 1448 else: 1449 period = cl.can_edit_period(index) 1450 if period: 1451 while True: 1452 show_period_raw(period) 1453 1454 # Obtain a command from any arguments. 1455 1456 cmd = next_arg(args) 1457 if not cmd: 1458 cmd = read_input("Period: (e)dit, (c)ancel, (u)ncancel (or return)> ") 1459 if cmd in ("e", "edit"): 1460 cl.edit_period(index, args) 1461 elif cmd in ("c", "cancel"): 1462 cl.cancel_periods([index]) 1463 elif cmd in ("u", "uncancel", "restore"): 1464 cl.cancel_periods([index], False) 1465 1466 # Exit if requested or after a successful 1467 # operation. 1468 1469 elif not cmd: 1470 pass 1471 else: 1472 continue 1473 break 1474 1475 cl.show_periods() 1476 print 1477 1478 # Apply suggested period (using index). 1479 1480 elif cmd in ("ps", "period-suggested", "suggested-period"): 1481 value = next_arg(args) 1482 index = to_int_or_none(value) 1483 1484 if index is not None: 1485 cl.apply_suggested_period(index) 1486 1487 cl.show_periods() 1488 print 1489 1490 # Specify a recurrence rule. 1491 1492 elif cmd in ("rule", "rrule"): 1493 value = next_arg(args) 1494 index = to_int_or_none(value) 1495 1496 # Add a new rule. 1497 1498 if index is None: 1499 cl.add_rule_selectors() 1500 else: 1501 cl.edit_rule_selector(index, args) 1502 1503 cl.show_rule() 1504 cl.update_periods_from_rule() 1505 print 1506 1507 # Set the summary. 1508 1509 elif cmd in ("s", "summary"): 1510 cl.edit_summary(get_text_arg(s)) 1511 cl.show_object() 1512 print 1513 1514 except EOFError: 1515 return 1516 1517 def main(args): 1518 1519 """ 1520 The main program, employing command line 'args' to initialise the editing 1521 activity. 1522 """ 1523 1524 global echo 1525 1526 if "--help" in args: 1527 show_help(os.path.split(sys.argv[0])[-1]) 1528 return 0 1529 1530 # Parse command line arguments using the standard options plus some extra 1531 # options. 1532 1533 args = parse_args(args, { 1534 "--calendar-data" : ("calendar_data", False), 1535 "--charset" : ("charset", "utf-8"), 1536 "--echo" : ("echo", False), 1537 "-f" : ("filename", None), 1538 "--handle-data" : ("handle_data", False), 1539 "--suppress-bcc" : ("suppress_bcc", False), 1540 "-u" : ("user", None), 1541 "--uid" : ("uid", None), 1542 "--recurrence-id" : ("recurrenceid", None), 1543 "--show-config" : ("show_config", False) 1544 }) 1545 1546 charset = args["charset"] 1547 calendar_data = args["calendar_data"] 1548 echo = args["echo"] 1549 filename = args["filename"] 1550 handle_data = args["handle_data"] 1551 sender = (args["senders"] or [None])[0] 1552 suppress_bcc = args["suppress_bcc"] 1553 user = args["user"] 1554 uid = args["uid"] 1555 recurrenceid = args["recurrenceid"] 1556 1557 # Open a store. 1558 1559 store_type = args.get("store_type") 1560 store_dir = args.get("store_dir") 1561 preferences_dir = args.get("preferences_dir") 1562 1563 # Show configuration and exit if requested. 1564 1565 if args["show_config"]: 1566 print """\ 1567 Store type: %s (%s) 1568 Store directory: %s (%s) 1569 Preferences directory: %s 1570 """ % ( 1571 store_type, settings["STORE_TYPE"], 1572 store_dir, settings["STORE_DIR"], 1573 preferences_dir) 1574 return 0 1575 1576 # Determine the user and sender identities. 1577 1578 if sender and not user: 1579 user = get_uri(sender) 1580 elif user and not sender: 1581 sender = get_address(user) 1582 elif not sender and not user: 1583 print >>sys.stderr, "A sender or a user must be specified." 1584 return 1 1585 1586 # Obtain a store but not a journal. 1587 1588 store = get_store(store_type, store_dir) 1589 journal = None 1590 1591 # Open a messenger for the user. 1592 1593 messenger = Messenger(sender=sender, suppress_bcc=suppress_bcc) 1594 1595 # Open a client for the user. 1596 1597 cl = TextClient(user, messenger, store, journal, preferences_dir) 1598 1599 # Read any input resource, using it to obtain identifier details. 1600 1601 if filename: 1602 if calendar_data: 1603 all_itip = get_itip_from_data(filename, charset) 1604 else: 1605 all_itip = get_itip_from_message(filename) 1606 1607 objects = [] 1608 1609 # Process the objects using the person handler. 1610 1611 if handle_data: 1612 for itip in all_itip: 1613 handled = handle_calendar_data(itip, get_handlers(cl, person.handlers, None)) 1614 if not is_cancel_itip(itip): 1615 objects += handled 1616 1617 # Or just obtain objects from the data. 1618 1619 else: 1620 for itip in all_itip: 1621 handled = get_objects_from_itip(itip, ["VEVENT"]) 1622 if not is_cancel_itip(itip): 1623 objects += handled 1624 1625 # Choose an object to edit. 1626 1627 show_objects(objects, user, store) 1628 obj = select_object(cl, objects) 1629 1630 # Load any indicated object. 1631 1632 elif uid: 1633 obj = cl.load_object(uid, recurrenceid) 1634 1635 # Or create a new object. 1636 1637 else: 1638 obj = cl.new_object() 1639 1640 # Exit without any object. 1641 1642 if not obj: 1643 print >>sys.stderr, "No object loaded." 1644 return 1 1645 1646 # Edit the object. 1647 1648 edit_object(cl, obj, handle_outgoing=handle_data) 1649 1650 def show_help(progname): 1651 print >>sys.stderr, help_text % progname 1652 1653 help_text = """\ 1654 Usage: %s -s <sender> | -u <user> \\ 1655 [ -f <filename> | --uid <uid> [ --recurrence-id <recurrence-id> ] ] \\ 1656 [ --calendar-data --charset ] \\ 1657 [ --handle-data ] \\ 1658 [ -T <store type ] [ -S <store directory> ] \\ 1659 [ -p <preferences directory> ] \\ 1660 [ --echo ] 1661 1662 Identity options: 1663 1664 -s Indicate the user by specifying a sender address 1665 -u Indicate the user by specifying their URI 1666 1667 Input options: 1668 1669 -f Indicates a filename containing a MIME-encoded message or 1670 calendar object 1671 --uid Indicates the UID of a stored calendar object 1672 --recurrence-id Indicates a stored object with a specific RECURRENCE-ID 1673 1674 --calendar-data Indicates that the specified file contains a calendar object 1675 as opposed to a mail message 1676 --charset Specifies the character encoding used by a calendar object 1677 description 1678 1679 Processing options: 1680 1681 --handle-data Cause the input to be handled and stored in the configured 1682 data store 1683 1684 Configuration options (overriding configured defaults): 1685 1686 -p Indicates the location of user preference directories 1687 -S Indicates the location of the calendar data store containing user storage 1688 directories 1689 -T Indicates the store type (the configured value if omitted) 1690 1691 Output options: 1692 1693 --echo Echo received input, useful if consuming input from the 1694 standard input stream and producing a log of the program's 1695 activity 1696 """ 1697 1698 if __name__ == "__main__": 1699 sys.exit(main(sys.argv[1:])) 1700 1701 # vim: tabstop=4 expandtab shiftwidth=4