googlecl-0.9.13/0000755000175100017510000000000011545217651013624 5ustar orignihnorignihngooglecl-0.9.13/man/0000755000175100017510000000000011545217651014377 5ustar orignihnorignihngooglecl-0.9.13/man/google.10000644000175100017510000002202311543257044015732 0ustar orignihnorignihn.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.37.1. .TH GOOGLE "1" "March 2011" "google 0.9.13" "User Commands" .SH NAME google \- command-line access to (some) Google services .SH SYNOPSIS .B google [\fIpicasa|blogger|youtube|docs|contacts|calendar|finance\fR] \fITASK \fR[\fIoptions\fR] .SH DESCRIPTION This program provides command\-line access to (some) google services via their gdata APIs. Called without a service name, it starts an interactive session. .PP NOTE: GoogleCL will interpret arguments as required options in the order they appear in the descriptions below, excluding options set in the configuration file and non\-primary terms in parenthesized OR groups. For example: .IP \f(CW$ google picasa get my_album .\fR .PP is interpreted as "google picasa get \fB\-\-title\fR=\fImy_album\fR \fB\-\-dest=\fR. .IP \f(CW$ google contacts list john\fR .PP is interpreted as "$ google contacts list \fB\-\-fields=\fR \fB\-\-title\fR=\fIjohn\fR \fB\-\-delimiter=\fR," (only true if you have not removed the default definition in the config file!) .IP \f(CW$ google docs get my_doc .\fR .PP is interpreted as "$ google docs get \fB\-\-title\fR=\fImy_doc\fR \fB\-\-dest=\fR. (folder is NOT set, since the title option is satisfied first.) .PP Available tasks for service picasa: 'get', 'create', 'list', 'list\-albums', 'tag', 'post', 'delete' .IP get: Download albums .IP Requires: title AND dest Optional: owner, format, photo .IP create: Create an album .IP Requires: title Optional: src, date, summary, tags, access .IP list: List photos .IP Requires: fields AND delimiter Optional: title, query, owner, photo .IP list\-albums: List albums .IP Requires: fields AND delimiter Optional: title, owner .IP tag: Tag/caption photos .IP Requires: (title OR query) AND (tags OR summary) Optional: owner, photo .IP post: Post photos to an album .IP Requires: title AND src Optional: tags, owner, photo, summary .IP delete: Delete photos or albums .IP Requires: (title OR query) Optional: photo .PP Available tasks for service blogger: 'post', 'tag', 'list', 'delete' .IP post: Post content. .IP Requires: src AND blog Optional: title, tags, access .IP tag: Label posts .IP Requires: blog AND title AND tags .IP list: List posts in a blog .IP Requires: fields AND blog AND delimiter Optional: title, owner .IP delete: Delete a post. .IP Requires: blog AND title .PP Available tasks for service youtube: 'post', 'tag', 'list', 'delete' .IP post: Post a video. .IP Requires: src AND category AND devkey Optional: title, summary, tags, access .IP tag: Add tags to a video and/or change its category. .IP Requires: title AND (tags OR category) AND devkey .IP list: List videos by user. .IP Requires: fields AND delimiter Optional: title, owner .IP delete: Delete videos. .IP Requires: title AND devkey .PP Available tasks for service docs: 'edit', 'delete', 'list', 'upload', 'get' .IP edit: Edit a document .IP Requires: title Optional: format, editor, folder .IP delete: Delete documents .IP Requires: title Optional: folder .IP list: List documents .IP Requires: fields AND delimiter Optional: title, folder .IP upload: Upload a document .IP Requires: src Optional: title, folder, format .IP get: Download a document .IP Requires: (title OR folder) AND dest Optional: format .PP Available tasks for service contacts: 'list', 'list\-groups', 'add', 'add\-groups', 'delete\-groups', 'delete' .IP list: List contacts .IP Requires: fields AND title AND delimiter .IP list\-groups: List contact groups .IP Requires: title .IP add: Add contacts .IP Requires: src .IP add\-groups: Add contact group(s) .IP Requires: title .IP delete\-groups: Delete contact group(s) .IP Requires: title .IP delete: Delete contacts .IP Requires: title .PP Available tasks for service calendar: 'add', 'list', 'today', 'delete' .IP add: Add event to a calendar .IP Requires: src Optional: cal .IP list: List events on a calendar .IP Requires: fields AND delimiter Optional: title, query, date, cal .IP today: List events for the next 24 hours .IP Requires: fields AND delimiter Optional: title, query, cal .IP delete: Delete event from a calendar .IP Requires: (title OR query) Optional: date, cal .PP Available tasks for service finance: 'list\-txn', 'delete\-pos', 'create\-pos', 'delete\-txn', 'create', 'create\-txn', 'list', 'list\-pos', 'delete' .IP list\-txn: List transactions .IP Requires: title AND ticker .IP delete\-pos: Delete positions .IP Requires: title Optional: ticker .IP create\-pos: Create position .IP Requires: title AND ticker .IP delete\-txn: Delete transactions .IP Requires: title AND ticker Optional: txnid .IP create: Create a portfolio .IP Requires: title AND currency .IP create\-txn: Create transaction .IP Requires: title AND ticker AND ttype AND shares AND price Optional: shares, price, date, commission, currency, notes .IP list: List portfolios .IP Requires: none Optional: fields .IP list\-pos: List positions .IP Requires: title Optional: fields .IP delete: Delete portfolios .IP Requires: title .SH OPTIONS .TP \fB\-\-version\fR show program's version number and exit .TP \fB\-h\fR, \fB\-\-help\fR show this help message and exit .TP \fB\-\-access\fR=\fIACCESS\fR Specify access/visibility level of an upload .TP \fB\-\-blog\fR=\fIBLOG\fR Blogger only \- specify a blog other than your primary. .TP \fB\-\-cal\fR=\fICAL\fR Calendar only \- specify a calendar other than your primary. .TP \fB\-c\fR CATEGORY, \fB\-\-category\fR=\fICATEGORY\fR YouTube only \- specify video categories as a commaseparated list, e.g. "Film, Travel" .TP \fB\-\-commission\fR=\fICOMMISSION\fR Finance only \- specify commission for transaction .TP \fB\-\-config\fR=\fICONFIG\fR Specify location of config file. .TP \fB\-\-currency\fR=\fICURRENCY\fR Finance only \- specify currency for portfolio .TP \fB\-\-devtags\fR=\fIDEVTAGS\fR YouTube only \- specify developer tags as a commaseparated list. .TP \fB\-\-devkey\fR=\fIDEVKEY\fR YouTube only \- specify a developer key .TP \fB\-d\fR DATE, \fB\-\-date\fR=\fIDATE\fR Calendar only \- date of the event to add/look for. Can also specify a range with a comma. Picasa only \- sets the date of the album Finance only \- transaction creation date .TP \fB\-\-debug\fR Enable all debugging output, including HTTP data .TP \fB\-\-delimiter\fR=\fIDELIMITER\fR Specify a delimiter for the output of the list task. .TP \fB\-\-dest\fR=\fIDEST\fR Destination. Typically, where to save data being downloaded. .TP \fB\-\-draft\fR Blogger only \- post as a draft. Shorthand for \fB\-\-access\fR=\fIdraft\fR .TP \fB\-\-editor\fR=\fIEDITOR\fR Docs only \- editor to use on a file. .TP \fB\-\-fields\fR=\fIFIELDS\fR Fields to list with list task. .TP \fB\-f\fR FOLDER, \fB\-\-folder\fR=\fIFOLDER\fR Docs only \- specify folder(s) to upload to / search in. .TP \fB\-\-force\-auth\fR Force validation step for re\-used access tokens (Overrides \fB\-\-skip\-auth\fR). .TP \fB\-\-format\fR=\fIFORMAT\fR Docs only \- format to download documents as. .TP \fB\-\-hostid\fR=\fIHOSTID\fR Label the machine being used. .TP \fB\-n\fR TITLE, \fB\-\-title\fR=\fITITLE\fR Title of the item .TP \fB\-\-no\-convert\fR Google Apps Premier only \- do not convert the file on upload. (Else converts to native Google Docs format) .TP \fB\-\-notes\fR=\fINOTES\fR Finance only \- specify notes for transaction .TP \fB\-o\fR OWNER, \fB\-\-owner\fR=\fIOWNER\fR Username or ID of the owner of the resource. For example, 'picasa list\-albums \fB\-o\fR bob' to list bob's albums .TP \fB\-\-photo\fR=\fIPHOTO\fR Picasa only \- specify title or name of photo(s) .TP \fB\-\-price\fR=\fIPRICE\fR Finance only \- specify price for transaction .TP \fB\-q\fR QUERY, \fB\-\-query\fR=\fIQUERY\fR Full text query string for specifying items. Searches on titles, captions, and tags. .TP \fB\-\-quiet\fR Print only prompts and error messages .TP \fB\-\-reminder\fR=\fIREMINDER\fR Calendar only \- specify time for added event's reminder, e.g. "10m", "3h", "1d" .TP \fB\-\-shares\fR=\fISHARES\fR Finance only \- specify amount of shares for transaction .TP \fB\-\-skip\-auth\fR Skip validation step for re\-used access tokens. .TP \fB\-\-src\fR=\fISRC\fR Source. Typically files to upload. .TP \fB\-s\fR SUMMARY, \fB\-\-summary\fR=\fISUMMARY\fR Description of the upload, or file containing the description. .TP \fB\-t\fR TAGS, \fB\-\-tags\fR=\fITAGS\fR Tags for item, e.g. "Sunsets, Earth Day" .TP \fB\-\-ticker\fR=\fITICKER\fR Finance only \- specify ticker .TP \fB\-\-ttype\fR=\fITTYPE\fR Finance only \- specify transaction type, e.g. "Bye", "Sell", "Buy to Cover", "Sell Short" .TP \fB\-\-txnid\fR=\fITXNID\fR Finance only \- specify transaction id .TP \fB\-u\fR USER, \fB\-\-user\fR=\fIUSER\fR Username to log in with for the service. .TP \fB\-v\fR, \fB\-\-verbose\fR Print all messages. .TP \fB\-\-yes\fR Answer "yes" to all prompts .SH EXAMPLES .nf google blogger post \-\-title 'foo' 'command line posting' google calendar add 'Lunch with Jim at noon tomorrow' google contacts list \-\-title '.*' \-\-fields name,email,phone > contacts.csv google docs edit \-\-title 'Shopping list' google picasa create \-\-title 'Cat Photos' ~/photos/cats/*.jpg google youtube post \-\-category Education killer_robots.avi googlecl-0.9.13/src/0000755000175100017510000000000011545217651014413 5ustar orignihnorignihngooglecl-0.9.13/src/googlecl/0000755000175100017510000000000011545217651016206 5ustar orignihnorignihngooglecl-0.9.13/src/googlecl/blogger/0000755000175100017510000000000011545217651017627 5ustar orignihnorignihngooglecl-0.9.13/src/googlecl/blogger/__init__.py0000644000175100017510000001053111471270004021725 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import googlecl import googlecl.base service_name = __name__.split('.')[-1] LOGGER_NAME = __name__ SECTION_HEADER = service_name.upper() def _map_access_string(access_string): """Map an access string to a value Blogger will understand. In this case, Blogger only cares about "is draft" so 'public' gets mapped to False, everything else to True. Returns: Boolean indicating True (is a draft) or False (is not a draft). """ if not access_string: return False if access_string == 'public': return False return True class BloggerEntryToStringWrapper(googlecl.base.BaseEntryToStringWrapper): @property def access(self): """Access level (draft or public).""" if self.entry.control and self.entry.control.draft.text == 'yes': return 'draft' else: return 'public' @property def author(self): """Author.""" # Name of author 'x' name is in entry.author[x].name.text text_extractor = lambda entry: getattr(getattr(entry, 'name'), 'text') return self._join(self.entry.author, text_extractor=text_extractor) @property def tags(self): return self.intra_property_delimiter.join( [c.term for c in self.entry.category if c.term]) labels = tags #=============================================================================== # Each of the following _run_* functions execute a particular task. # # Keyword arguments: # client: Client to the service being used. # options: Contains all attributes required to perform the task # args: Additional arguments passed in on the command line, may or may not be # required #=============================================================================== def _run_post(client, options, args): content_list = options.src + args entry_list = client.UploadPosts(content_list, blog_title=options.blog, post_title=options.title, is_draft=_map_access_string(options.access)) if options.tags: client.LabelPosts(entry_list, options.tags) def _run_delete(client, options, args): titles_list = googlecl.build_titles_list(options.title, args) post_entries = client.GetPosts(blog_title=options.blog, post_titles=titles_list) client.DeleteEntryList(post_entries, 'post', options.prompt) def _run_list(client, options, args): titles_list = googlecl.build_titles_list(options.title, args) entries = client.GetPosts(options.blog, titles_list, user_id=options.owner or 'default') for entry in entries: print googlecl.base.compile_entry_string( BloggerEntryToStringWrapper(entry), options.fields.split(','), delimiter=options.delimiter) def _run_tag(client, options, args): titles_list = googlecl.build_titles_list(options.title, args) entries = client.GetPosts(options.blog, titles_list) client.LabelPosts(entries, options.tags) TASKS = {'delete': googlecl.base.Task('Delete a post.', callback=_run_delete, required=['blog', 'title']), 'post': googlecl.base.Task('Post content.', callback=_run_post, required=['src', 'blog'], optional=['title', 'tags', 'access']), 'list': googlecl.base.Task('List posts in a blog', callback=_run_list, required=['fields', 'blog', 'delimiter'], optional=['title', 'owner']), 'tag': googlecl.base.Task('Label posts', callback=_run_tag, required=['blog', 'title', 'tags'])} googlecl-0.9.13/src/googlecl/blogger/service.py0000640000175100017510000001656511471514040021640 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Service details and instances for the Blogger service.""" from __future__ import with_statement __author__ = 'tom.h.miller@gmail.com (Tom Miller)' import atom import gdata import gdata.blogger import gdata.blogger.service import logging import os from googlecl import safe_encode import googlecl.base import googlecl.service from googlecl.blogger import SECTION_HEADER LOG = logging.getLogger(googlecl.blogger.LOGGER_NAME) class BloggerServiceCL(gdata.blogger.service.BloggerService, googlecl.service.BaseServiceCL): """Command-line-friendly service for the Blogger API. Some of this is based off gdata/samples/blogger/BloggerExampleV1.py """ def __init__(self, config): """Constructor.""" gdata.blogger.service.BloggerService.__init__(self, account_type='GOOGLE') googlecl.service.BaseServiceCL.__init__(self, SECTION_HEADER, config) def _upload_content(self, post_title, content, blog_id=None, is_draft=False): """Uploads content. Keyword arguments: blog_title: Title of the blog to post to. title: Title to give the post. content: String to get posted. This may be contents from a file, but NOT the path itself! is_draft: If this content is a draft post or not. (Default False) Returns: Entry of post. (Returns same results as self.AddPost()) """ entry = gdata.blogger.BlogPostEntry() entry.title = atom.Title(title_type='xhtml', text=post_title) entry.content = atom.Content(content_type='html', text=content) if is_draft: control = atom.Control() control.draft = atom.Draft(text='yes') entry.control = control return self.AddPost(entry, blog_id) def _get_blog_id(self, blog_title=None, user_id='default'): """Return the blog ID of the blog that matches blog_title. Keyword arguments: blog_title: Name or title of the blog. user_id: Profile ID of blog's owner as seen in the profile view URL. Default 'default' for the authenticated user. Returns: Blog ID (blog_entry.GetSelfLink().href.split('/')[-1]) if a blog is found matching the user and blog_title. None otherwise. """ blog_entry = self.GetSingleEntry('/feeds/' + user_id + '/blogs', blog_title) if blog_entry: return blog_entry.GetSelfLink().href.split('/')[-1] else: if blog_title is not None: LOG.error('Did not find a blog with title matching %s', blog_title) else: LOG.error('No blogs found!') return None def is_token_valid(self, test_uri='/feeds/default/blogs'): """Check that the token being used is valid.""" return googlecl.service.BaseServiceCL.IsTokenValid(self, test_uri) IsTokenValid = is_token_valid def get_posts(self, blog_title=None, post_titles=None, user_id='default'): """Get entries for posts that match a title. Keyword arguments: blog_title: Name or title of the blog the post is in. (Default None) post_titles: string or list Titles that the posts should have. Default None, for all posts user_id: Profile ID of blog's owner as seen in the profile view URL. (Default 'default' for authenticated user) Returns: List of posts that match parameters, or [] if none do. """ blog_id = self._get_blog_id(blog_title, user_id) if blog_id: uri = '/feeds/' + blog_id + '/posts/default' return self.GetEntries(uri, post_titles) else: return [] GetPosts = get_posts def label_posts(self, post_entries, tags): """Add or remove labels on a list of posts. Keyword arguments: post_entries: List of post entry objects. tags: String representation of tags in a comma separated list. For how tags are generated from the string, see googlecl.base.generate_tag_sets(). """ scheme = 'http://www.blogger.com/atom/ns#' remove_set, add_set, replace_tags = googlecl.base.generate_tag_sets(tags) successes = [] for post in post_entries: # No point removing tags if we're replacing all of them. if remove_set and not replace_tags: # Keep categories if they meet one of two criteria: # 1) Are of a different scheme than the one we're looking at, or # 2) Are of the same scheme, but the term is in the 'remove' set post.category = [c for c in post.category \ if c.scheme != scheme or \ (c.scheme == scheme and c.term not in remove_set)] if replace_tags: # Remove categories that match the scheme we are updating. post.category = [c for c in post.category if c.scheme != scheme] if add_set: new_tags = [atom.Category(term=tag, scheme=scheme) for tag in add_set] post.category.extend(new_tags) updated_post = self.UpdatePost(post) if updated_post: successes.append(updated_post) return successes LabelPosts = label_posts def upload_posts(self, content_list, blog_title=None, post_title=None, is_draft=False): """Uploads posts. Args: content_list: List of filenames or content to upload. blog_title: Name of the blog to upload to. post_title: Name to give the post(s). is_draft: Set True to upload as private draft(s), False to make upload(s) public. Returns: List of entries of successful posts. """ max_size = 500000 entry_list = [] blog_id = self._get_blog_id(blog_title) if not blog_id: return [] for content_string in content_list: if os.path.exists(content_string): with open(content_string, 'r') as content_file: content = content_file.read(max_size) if content_file.read(1): LOG.warning('Only read first %s bytes of file %s' % (max_size, content_string)) if not post_title: title = os.path.basename(content_string).split('.')[0] else: if not post_title: title = 'New post' content = content_string try: entry = self._upload_content(post_title or title, content, blog_id=blog_id, is_draft=is_draft) except self.request_error, err: LOG.error(safe_encode('Failed to post: ' + unicode(err))) else: entry_list.append(entry) if entry.control and entry.control.draft.text == 'yes': html_link = _build_draft_html(entry) else: html_link = entry.GetHtmlLink().href LOG.info('Post created: %s', html_link) return entry_list UploadPosts = upload_posts SERVICE_CLASS = BloggerServiceCL def _build_draft_html(entry): template = 'http://www.blogger.com/post-edit.g?blogID=%s&postID=%s' return template % (entry.GetBlogId(), entry.GetPostId()) googlecl-0.9.13/src/googlecl/calendar/0000755000175100017510000000000011545217651017757 5ustar orignihnorignihngooglecl-0.9.13/src/googlecl/calendar/__init__.py0000644000175100017510000003520011543236366022072 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Data for GoogleCL's calendar service.""" import datetime import googlecl import googlecl.base import logging import re import time from googlecl.calendar.date import DateRangeParser service_name = __name__.split('.')[-1] LOGGER_NAME = __name__ SECTION_HEADER = service_name.upper() LOG = logging.getLogger(LOGGER_NAME) # Rename to reduce verbosity safe_encode = googlecl.safe_encode def condense_recurring_events(events): seen_ids = [] combined_events = [] for event in events: print "looking at event %s" % event.title.text if event.original_event.id not in seen_ids: seen_ids.append(event.original_event.id) combined_events.append(event) return combined_events def convert_reminder_string(reminder): """Convert reminder string to minutes integer. Keyword arguments: reminder: String representation of time, e.g. '10' for 10 minutes, '1d' for one day, '3h' for three hours, etc. Returns: Integer of reminder converted to minutes. Raises: ValueError if conversion failed. """ if not reminder: return None unit = reminder.lower()[-1] value = reminder[:-1] if unit == 's': return int(value) / 60 elif unit == 'm': return int(value) elif unit == 'h': return int(value) * 60 elif unit == 'd': return int(value) * 60 * 24 elif unit == 'w': return int(value) * 60 * 24 * 7 else: return int(reminder) def filter_recurring_events(events, recurrences_expanded): if recurrences_expanded: is_recurring = lambda event: event.original_event else: is_recurring = lambda event: event.recurrence return [e for e in events if not is_recurring(e)] def filter_single_events(events, recurrences_expanded): if recurrences_expanded: is_single = lambda event: not event.original_event else: is_single = lambda event: not event.recurrence return [e for e in events if not is_single(e)] def filter_all_day_events_outside_range(start_date, end_date, events): if start_date: if start_date.all_day: start_datetime = start_date.local else: start_datetime = datetime.datetime(year=start_date.local.year, month=start_date.local.month, day=start_date.local.day) if end_date: if end_date.all_day: inclusive_end_datetime = end_date.local + datetime.timedelta(hours=24) else: end_datetime = datetime.datetime(year=end_date.local.year, month=end_date.local.month, day=end_date.local.day) new_events = [] for event in events: try: start = datetime.datetime.strptime(event.when[0].start_time, '%Y-%m-%d') end = datetime.datetime.strptime(event.when[0].end_time, '%Y-%m-%d') except ValueError, err: if str(err).find('unconverted data remains') == -1: raise err else: #Errors that complain of unconverted data are events with duration new_events.append(event) else: if ((not start_date or start >= start_datetime) and (not end_date or end <= inclusive_end_datetime)): new_events.append(event) elif event.recurrence: # While writing the below comment, I was 90% sure it was true. Testing # this case, however, showed that things worked out just fine -- the # events were filtered out. I must have misunderstood the "when" data. # The tricky case: an Event that describes a recurring all-day event. # In the rare case that: # NO recurrences occur in the given range AND AT LEAST ONE recurrence # occurs just outside the given range (AND it's an all-day recurrence), # we will incorrectly return this event. # This is unavoidable unless we a) perform another query or b) # incorporate a recurrence parser. new_events.append(event) return new_events def filter_canceled_events(events, recurrences_expanded): AT_LEAST_ONE_EVENT = 'not dead yet!' canceled_recurring_events = {} ongoing_events = [] is_canceled = lambda e: e.event_status.value == 'CANCELED' or not e.when for event in events: print 'looking at event %s' % event.title.text if recurrences_expanded: if event.original_event: print 'event is original: %s' % event.title.text try: status = canceled_recurring_events[event.original_event.id] except KeyError: status = None if is_canceled(event) and status != AT_LEAST_ONE_EVENT: print 'adding event to canceled: %s' % event.title.text canceled_recurring_events[event.original_event.id] = event if not is_canceled(event): print 'at least one more of: %s' % event.title.text canceled_recurring_events[event.original_event.id]= AT_LEAST_ONE_EVENT ongoing_events.append(event) # If recurrences have not been expanded, we can't tell if they were # canceled or not. if not is_canceled(event): ongoing_events.append(event) for event in canceled_recurring_events.values(): if event != AT_LEAST_ONE_EVENT: ongoing_events.remove(event) return ongoing_events def get_datetimes(cal_entry): """Get datetime objects for the start and end of the event specified by a calendar entry. Keyword arguments: cal_entry: A CalendarEventEntry. Returns: (start_time, end_time, freq) where start_time - datetime object of the start of the event. end_time - datetime object of the end of the event. freq - string that tells how often the event repeats (NoneType if the event does not repeat (does not have a gd:recurrence element)). """ if cal_entry.recurrence: return parse_recurrence(cal_entry.recurrence.text) else: freq = None when = cal_entry.when[0] try: # Trim the string data from "when" to only include down to seconds start_time_data = time.strptime(when.start_time[:19], '%Y-%m-%dT%H:%M:%S') end_time_data = time.strptime(when.end_time[:19], '%Y-%m-%dT%H:%M:%S') except ValueError: # Try to handle date format for all-day events start_time_data = time.strptime(when.start_time, '%Y-%m-%d') end_time_data = time.strptime(when.end_time, '%Y-%m-%d') return (start_time_data, end_time_data, freq) def parse_recurrence(time_string): """Parse recurrence data found in event entry. Keyword arguments: time_string: Value of entry's recurrence.text field. Returns: Tuple of (start_time, end_time, frequency). All values are in the user's current timezone (I hope). start_time and end_time are datetime objects, and frequency is a dictionary mapping RFC 2445 RRULE parameters to their values. (http://www.ietf.org/rfc/rfc2445.txt, section 4.3.10) """ # Google calendars uses a pretty limited section of RFC 2445, and I'm # abusing that here. This will probably break if Google ever changes how # they handle recurrence, or how the recurrence string is built. data = time_string.split('\n') start_time_string = data[0].split(':')[-1] start_time = time.strptime(start_time_string,'%Y%m%dT%H%M%S') end_time_string = data[1].split(':')[-1] end_time = time.strptime(end_time_string,'%Y%m%dT%H%M%S') freq_string = data[2][6:] freq_properties = freq_string.split(';') freq = {} for prop in freq_properties: key, value = prop.split('=') freq[key] = value return (start_time, end_time, freq) class CalendarEntryToStringWrapper(googlecl.base.BaseEntryToStringWrapper): def __init__(self, entry, config): """Initialize a CalendarEntry wrapper. Args: entry: CalendarEntry to interpret to strings. config: Configuration parser. Needed for some values. """ googlecl.base.BaseEntryToStringWrapper.__init__(self, entry) self.config_parser = config @property def when(self): """When event takes place.""" start_date, end_date, freq = get_datetimes(self.entry) print_format = self.config_parser.lazy_get(SECTION_HEADER, 'date_print_format') start_text = time.strftime(print_format, start_date) end_text = time.strftime(print_format, end_date) value = start_text + ' - ' + end_text if freq: if freq.has_key('BYDAY'): value += ' (' + freq['BYDAY'].lower() + ')' else: value += ' (' + freq['FREQ'].lower() + ')' return value @property def where(self): """Where event takes place""" return self._join(self.entry.where, text_attribute='value_string') def _list(client, options, args): cal_user_list = client.get_calendar_user_list(options.cal) if not cal_user_list: LOG.error('No calendar matches "' + options.cal + '"') return titles_list = googlecl.build_titles_list(options.title, args) parser = DateRangeParser() date_range = parser.parse(options.date) for cal in cal_user_list: print '' print safe_encode('[' + unicode(cal) + ']') single_events = client.get_events(cal.user, start_date=date_range.start, end_date=date_range.end, titles=titles_list, query=options.query, split=False) for entry in single_events: print googlecl.base.compile_entry_string( CalendarEntryToStringWrapper(entry, client.config), options.fields.split(','), delimiter=options.delimiter) #=============================================================================== # Each of the following _run_* functions execute a particular task. # # Keyword arguments: # client: Client to the service being used. # options: Contains all attributes required to perform the task # args: Additional arguments passed in on the command line, may or may not be # required #=============================================================================== def _run_list(client, options, args): # If no other search parameters are mentioned, set date to be # today. (Prevent user from retrieving all events ever) if not (options.title or args or options.query or options.date): options.date = 'today,' _list(client, options, args) def _run_list_today(client, options, args): options.date = 'today' _list(client, options, args) def _run_add(client, options, args): cal_user_list = client.get_calendar_user_list(options.cal) if not cal_user_list: LOG.error('No calendar matches "' + options.cal + '"') return reminder_in_minutes = convert_reminder_string(options.reminder) events_list = options.src + args reminder_results = [] for cal in cal_user_list: if options.date: results = client.full_add_event(events_list, cal.user, options.date, reminder_in_minutes) else: results = client.quick_add_event(events_list, cal.user) if reminder_in_minutes is not None: reminder_results = client.add_reminders(cal.user, results, reminder_in_minutes) if LOG.isEnabledFor(logging.DEBUG): for entry in results + reminder_results: LOG.debug('ID: %s, status: %s, reason: %s', entry.batch_id.text, entry.batch_status.code, entry.batch_status.reason) for entry in results: LOG.info('Event created: %s' % entry.GetHtmlLink().href) def _run_delete(client, options, args): cal_user_list = client.get_calendar_user_list(options.cal) if not cal_user_list: LOG.error('No calendar matches "' + options.cal + '"') return parser = DateRangeParser() date_range = parser.parse(options.date) titles_list = googlecl.build_titles_list(options.title, args) for cal in cal_user_list: single_events, recurring_events = client.get_events(cal.user, start_date=date_range.start, end_date=date_range.end, titles=titles_list, query=options.query, expand_recurrence=True) if options.prompt: LOG.info(safe_encode('For calendar ' + unicode(cal))) if single_events: client.DeleteEntryList(single_events, 'event', options.prompt) if recurring_events: if date_range.specified_as_range: # if the user specified a date that was a range... client.delete_recurring_events(recurring_events, date_range.start, date_range.end, cal.user, options.prompt) else: client.delete_recurring_events(recurring_events, date_range.start, None, cal.user, options.prompt) if not (single_events or recurring_events): LOG.warning('No events found that match your options!') TASKS = {'list': googlecl.base.Task('List events on a calendar', callback=_run_list, required=['fields', 'delimiter'], optional=['title', 'query', 'date', 'cal']), 'today': googlecl.base.Task('List events for the next 24 hours', callback=_run_list_today, required=['fields', 'delimiter'], optional=['title', 'query', 'cal']), 'add': googlecl.base.Task('Add event to a calendar', callback=_run_add, required='src', optional='cal'), 'delete': googlecl.base.Task('Delete event from a calendar', callback=_run_delete, required=[['title', 'query']], optional=['date', 'cal'])} googlecl-0.9.13/src/googlecl/calendar/date.py0000644000175100017510000004341111470233032021235 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Classes and functions for manipulating strings into dates. Some parts are specific to Google Calendar.""" __author__ = 'thmiller@google.com (Tom Miller)' import datetime import re import googlecl.base import time import logging LOG = logging.getLogger("date.py") QUERY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.000Z' ACCEPTED_DAY_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S' ACCEPTED_DAY_FORMATS = ['%Y-%m-%d', '%m/%d', '%m/%d/%Y', '%m/%d/%y', '%b %d', '%B %d', '%b %d %Y', '%B %d %Y'] ACCEPTED_TIME_FORMATS = ['%I%p', '%I %p', '%I:%M%p', '%I:%M %p', '%H:%M'] # Regular expression for strings that specify a time that could be afternoon or # morning. First group will be the hour, second the minutes. AMBIGUOUS_TIME_REGEX = '((?:1[0-2])|(?:[1-9]))?(?::([0-9]{2}))?$' _DAY_TIME_TOKENIZERS = ['@', ' at '] _RANGE_TOKENIZERS = [','] class Error(Exception): """Base error for this module.""" pass class ParsingError(Error): """Failed to parse a token.""" def __init__(self, token): self.token = token def __str__(self): return 'Failed to parse "%s"' % self.token def datetime_today(): """Creates a datetime object with zeroed-out time parameters.""" return datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) def determine_duration(duration_token): """Determines a duration from a non-time token. Args: duration_token: String of hours and minutes. Returns: Timedelta object representing positive offset of hours and minutes. """ hour, minute = parse_ambiguous_time(duration_token) if not (hour or minute): LOG.error('Duration must be in form of [hours][:minutes]') return None return datetime.timedelta(hours=hour, minutes=minute) def get_utc_timedelta(): """Return the UTC offset of local zone at present time as a timedelta.""" if time.localtime().tm_isdst and time.daylight: return datetime.timedelta(hours=time.altzone/3600) else: return datetime.timedelta(hours=time.timezone/3600) def parse_ambiguous_time(time_token): """Parses an ambiguous time into an hour and minute value. Args: time_token: Ambiguous time to be parsed. "Ambiguous" means it could be before noon or after noon. For example, "5:30" or "12". Returns: Tuple of (hour, minute). The hour is still not on a 24 hour clock. """ ambiguous_time = re.match(AMBIGUOUS_TIME_REGEX, time_token) if not ambiguous_time: return None, None hour_text = ambiguous_time.group(1) minute_text = ambiguous_time.group(2) if hour_text: hour = int(hour_text) else: hour = 0 if minute_text: minute = int(minute_text) else: minute = 0 return hour, minute def split_string(string, tokenizers=None): """Splits a string based on a list of potential substrings. Strings will only be split once, if at all. That is, at most two tokens can be returned, even if a tokenizer is found in multiple positions. The left-most tokenizer will be used to split. Args: string: String to split. tokenizers: List of strings that should act as a point to split around. Default None to use range tokenizers defined in this module. Returns: Tuple of (left_token, [True|False], right_token). The middle element is True if a tokenizer was found in the provided string, and is False otherwise. """ if not string: return ('', False, '') if not tokenizers: tokenizers = _RANGE_TOKENIZERS for tokenizer in tokenizers: if string.find(tokenizer) != -1: left_token, _, right_token = string.partition(tokenizer) return (left_token.strip(), True, right_token.strip()) return (string.strip(), False, '') class Date(object): def __init__(self, local_datetime=None, utc_datetime=None, all_day=False): """Initializes the object. The datetime objects passed in are treated as naive -- no timezone info will be read from them. Args: local_datetime: A datetime object that specifies the date and time in the local timezone. Default None to set off utc_datetime. utc_datetime: Datetime object that specifies date and time in Coordinated Universal Time (UTC). Default None to set off local_datetime. all_day: Set True to indicate this Date is associated with an all day, or "time-less" date. Default False. Raises: Error: local_datetime and utc_datetime are both left undefined. """ if not (local_datetime or utc_datetime): raise Error('Need to provide a local or UTC datetime') if local_datetime: self.local = local_datetime if not utc_datetime: self.utc = self.local + get_utc_timedelta() if utc_datetime: self.utc = utc_datetime if not local_datetime: self.local = self.utc - get_utc_timedelta() self.all_day = all_day def __add__(self, other): """Returns a Date with other added to its time.""" return Date(utc_datetime=(self.utc + other), all_day=self.all_day) def __sub__(self, other): """Returns a Date with other subtracted from its time.""" return Date(utc_datetime=(self.utc - other), all_day=self.all_day) def __str__(self): """Formats local datetime info into human-friendly string.""" basic_string_format = '%m/%d/%Y' if self.all_day: return self.local.strftime(basic_string_format) else: return self.local.strftime(basic_string_format + ' %H:%M') def to_format(self, format_string): """Converts UTC data to specific format string.""" return self.utc.strftime(format_string) def to_inclusive_query(self): """Converts UTC data to query-friendly, date-inclusive string. Note: This behavior is specific to Google Calendar. """ if self.all_day: # If it's an all-day date, we need to boost the time by a day to make it # inclusive. new_datetime = self.utc + datetime.timedelta(hours=24) else: # The smallest unit Calendar appears to concern itself with # is minutes, so add a minute to make it inclusive. new_datetime = self.utc + datetime.timedelta(minutes=1) return new_datetime.strftime(QUERY_DATE_FORMAT) def to_query(self): """Converts UTC data to a query-friendly string.""" return self.to_format(QUERY_DATE_FORMAT) def to_timestamp(self): """Converts UTC data to timestamp in seconds. Returns: Seconds since the epoch as a float. """ return time.mktime(time.strptime(self.utc.strftime(format_string), '%Y-%m-%dT%H:%M')) def to_when(self): """Returns datetime info formatted to Google Calendar "when" style.""" if self.all_day: # All day events must leave off hour data. return self.to_format('%Y-%m-%d') else: # Otherwise, treated like a query string. return self.to_query() class DateParser(object): """Produces Date objects given data.""" def __init__(self, today=None, now=None): """Initializes the DateParser object. Args: today: Function capable of giving the current local date. Default None to use datetime_today now: Function capable of giving the current local time. Default None to use datetime.datetime.now """ if today is None: today = datetime_today if now is None: now = datetime.datetime.now self.today = today self.now = now def parse(self, text, base=None, shift_dates=True): """Parses text into a Date object. Args: text: String representation of one date, or an offset from a date. Will be interpreted as local time, unless "UTC" appears somewhere in the text. base: Starting point for this Date. Used if the text represents an hour, or an offset. shift_dates: If the date is earlier than self.today(), and the year is not specified, shift it to the future. True by default. Set to False if you want to set a day in the past without referencing the year. For example, today is 10/25/2010. Parsing "10/24" with shift_dates=True will return a date of 10/24/2011. If shift_dates=False, will return a date of 10/24/2010. Returns: Date object. Raises: ParsingError: Given text could not be parsed. """ local_datetime = None day = None all_day = False try: # Unlikely anyone uses this, but if so, it's done in one shot all_info = datetime.datetime.strptime(text, ACCEPTED_DAY_TIME_FORMAT) except ValueError: pass else: return Date(local_datetime=all_info, all_day=False) day_token, _, time_token = split_string(text, _DAY_TIME_TOKENIZERS) if not (day_token or time_token): raise ParsingError(text) past_time_to_tomorrow = False if day_token: day = self.determine_day(day_token, shift_dates) if day is None: # If we couldn't figure out the day... # ...Calendar will shift times that already happened to tomorrow past_time_to_tomorrow = True # ...Maybe the day_token is actually a time_token time_token = day_token if base: # ... and we'll use the starting point passed in. day = base else: # If there's no time token, we're parsing an all day date. all_day = not bool(time_token) if time_token: if time_token.startswith('+'): delta = determine_duration(time_token.lstrip('+')) if delta and not day: # Durations go off of right now. day = self.now() else: time_offset = self.determine_time(time_token) if time_offset is None: delta = None else: if past_time_to_tomorrow and self._time_has_passed(time_offset): delta = datetime.timedelta(hours=time_offset.hour + 24, minutes=time_offset.minute) else: delta = datetime.timedelta(hours=time_offset.hour, minutes=time_offset.minute) if not day: # Hour/minutes (i.e. not durations) go off of the date. day = self.today() if delta is not None: local_datetime = day + delta else: local_datetime = day if local_datetime: return Date(local_datetime=local_datetime, all_day=all_day) else: raise ParsingError(text) def _day_has_passed(self, date): """"Checks to see if date has passed. Args: date: Datetime object to compare to today. Returns: True if date is earlier than today, False otherwise. """ today = self.today() return (date.month < today.month or (date.month == today.month and date.day < today.day)) def determine_day(self, day_token, shift_dates): """Parses day token into a date. Args: day_token: String to be interpreted as a year, month, and day. shift_dates: Indicates if past dates should be shifted to next year. Set True to move the date to next year if the date has already occurred this year, False otherwise. Returns: Datetime object with year, month, and day fields filled in if the day_token could be parsed. Otherwise, None. """ if day_token == 'tomorrow': return self.today() + datetime.timedelta(hours=24) elif day_token == 'today': return self.today() else: date, valid_format = self._extract_time(day_token, ACCEPTED_DAY_FORMATS) if not date: LOG.debug('%s did not match any expected day formats' % day_token) return None # If the year was not explicitly mentioned... # (strptime will set a default year of 1900) if valid_format.lower().find('%y') == -1: if self._day_has_passed(date) and shift_dates: date = date.replace(year=self.today().year + 1) else: date = date.replace(year=self.today().year) return date def determine_time(self, time_token): """Parses time token into a time. Note: ambiguous times like "6" are converted according to how Google Calendar interprets them. So "1" - "6" are converted to 13-18 on a 24 hour clock. Args: time_token: String to be interpreted as an hour and minute. Returns: Time object with hour and minute fields filled in if the time_token could be parsed. Otherwise, None. """ hour, minute = parse_ambiguous_time(time_token) if (hour or minute): # The ambiguous hours arranged in order, according to Google Calendar: # 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5, 6 if 1 <= hour and hour <= 6: hour += 12 else: tmp, _ = self._extract_time(time_token, ACCEPTED_TIME_FORMATS) if not tmp: LOG.debug('%s did not match any expected time formats') return None hour = tmp.hour minute = tmp.minute return datetime.time(hour=hour, minute=minute) def _extract_time(self, time_string, possible_formats): """Returns date data contained in a string. Args: time_string: String representing a date and/or time. possible_formats: List of possible formats "time" may be in. Returns: Tuple of (datetime, format) with the datetime object populated by data found in "time" according to the returned format, or (None, None) if no formats matched. """ for time_format in possible_formats: try: date = datetime.datetime.strptime(time_string, time_format) except ValueError, err: continue else: return date, time_format return None, None def _time_has_passed(self, time_container): """Checks if time has already passed the current time. Args: time_container: Object with hour and minute fields. Returns: True if the given time has passed, False otherwise. """ now = self.now() return (time_container.hour < now.hour or (time_container.hour == now.hour and time_container.minute < now.minute)) class DateRange(object): """Holds info on a range of dates entered by the user.""" def __init__(self, start, end, is_range): """Initializes the object. Args: start: Start of the range. end: End of the range. is_range: Set True if the user specified this range as a range, False if it was interpreted as a range. """ self.start = start self.end = end self.specified_as_range = is_range def to_when(self): """Returns Google Calendar friendly text for "when" attribute. Raises: Error: starting point or ending point are not defined. """ if not self.start or not self.end: raise Error('Cannot convert range of dates without start and end points.') else: start = self.start if not self.specified_as_range: # If only a start date was given... if self.start.all_day: end = self.start + datetime.timedelta(hours=24) else: end = self.start + datetime.timedelta(hours=1) else: end = self.end return start.to_when(), end.to_when() class DateRangeParser(object): """Parser that treats strings as ranges.""" def __init__(self, today=None, now=None, range_tokenizers=None): """Initializes the object. Args: today: Callback that returns the date. now: Callback that returns the date and time. range_tokenizers: List of strings that will be used to split date strings into tokens. Default None to use module default. """ self.date_parser = DateParser(today, now) if range_tokenizers is None: range_tokenizers = _RANGE_TOKENIZERS self.range_tokenizers = range_tokenizers def parse(self, date_string, shift_dates=False): """"Parses a string into a start and end date. Note: This is Google Calendar specific. If date_string does not contain a range tokenizer, it will be treated as the starting date of a one day range. Args: date_string: String to parse. shift_dates: Whether or not to shift a date to next year if it has occurred earlier than today. See documentation for DateParser. Default False. Returns: Tuple of (start_date, end_date), representing start and end dates of the range. Either may be None, in which case it is an open range (i.e. from start until the distant future, or from the distant past until the end date.) If date_string is empty or None, this will be (None, None). """ start_date = None end_date = None start_text, is_range, end_text = split_string(date_string, self.range_tokenizers) if start_text: start_date = self.date_parser.parse(start_text, shift_dates=shift_dates) if end_text: if start_date: base = start_date.local else: base = None end_date = self.date_parser.parse(end_text, base=base, shift_dates=shift_dates) elif not is_range: # If no range tokenizer was given, the end date is effectively the day # after the given date. end_date = start_date return DateRange(start_date, end_date, is_range) googlecl-0.9.13/src/googlecl/calendar/date_test.py0000644000175100017510000002001511470233032022267 0ustar orignihnorignihn#!/usr/bin/python # # Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for calendar dates.""" __author__ = 'thmiller@google.com (Tom Miller)' import date import unittest from datetime import datetime from datetime import timedelta # If "now" changes, the ONE_DAY, edge, and trick tests # below must be changed carefully!! def static_now(): return datetime(year=2010, month=10, day=22, hour=14, minute=5) def static_today(): return static_now().replace(hour=0, minute=0, second=0, microsecond=0) NOW = static_now() YEAR = NOW.year ONE_DAY = timedelta(hours=24) MONTH_DAY_TESTS = {'1/1': datetime(year=YEAR+1, month=1, day=1), '12/31': datetime(year=YEAR, month=12, day=31), '10/30': datetime(year=YEAR, month=10, day=30), '2/28': datetime(year=YEAR+1, month=2, day=28), '11/1': datetime(year=YEAR, month=11, day=1)} TIME_TESTS = {'7pm': NOW.replace(hour=19, minute=0), '8am': NOW.replace(hour=8, minute=0) + ONE_DAY, '01:00': NOW.replace(hour=1, minute=0) + ONE_DAY, '14:00': NOW.replace(hour=14, minute=0) + ONE_DAY} AMBIGUOUS_TESTS = {'6': NOW.replace(hour=18, minute=0), '7:01': NOW.replace(hour=7, minute=1) + ONE_DAY, '4:59': NOW.replace(hour=16, minute=59)} FULL_DATE_TESTS = {'7/15/2011': datetime(year=2011, month=7, day=15), '4/30/2000': datetime(year=2000, month=4, day=30), '2010-06-02': datetime(year=2010, month=6, day=2), '2010-9-2': datetime(year=2010, month=9, day=2), 'March 30 2010': datetime(year=2010, month=3, day=30), 'Jan 1 1970': datetime(year=1970, month=1, day=1)} JOINED_TESTS = {'06/15/11 at 6': datetime(year=2011, day=15, month=6, hour=18), '3/4@19:00': datetime(year=YEAR+1, day=4, month=3, hour=19), '10/22/2012 @ 12:53pm': datetime(year=2012, month=10, day=22, hour=12, minute=53), 'Aug 23 2020 at 5pm': datetime(year=2020, month=8, day=23, hour=17)} EDGE_TESTS = {'10/22/2010 @ 14:05': NOW, '10/22 @ 14:04': NOW.replace(minute=4), '2:05pm': NOW, 'tomorrow at 2:04': NOW.replace(minute=4) + ONE_DAY, '10/21 at 00:00': datetime(year=YEAR+1, month=10, day=21)} TRICK_TESTS = {'today at 2': NOW.replace(minute=0), '10/22 at 2': NOW.replace(minute=0)} DURATION_TESTS = {'+3': NOW + timedelta(hours=3), '+1:20': NOW + timedelta(hours=1, minutes=20), '+:45': NOW + timedelta(minutes=45)} FAIL_TESTS = [',', '@', '115', 'notadate', '1-4'] RANGE_BASE_TEXT = '11/3 @ 9pm' RANGE_EXPECTED_START = datetime(year=YEAR, month=11, day=3, hour=21) class ParsingDatesTest(unittest.TestCase): def assertConvertedEqual(self, text, expected, actual): if expected != actual: self.fail('%s was not parsed correctly (%s != %s)' % (text, expected, actual)) class ParseSingleDateTest(ParsingDatesTest): def setUp(self): self.parser = date.DateParser(static_today, static_now) def runTestSet(self, test_dict): for text, expected_date in test_dict.items(): parsed_date = self.parser.parse(text) self.assertConvertedEqual(text, expected_date, parsed_date.local) def testDatetimeParse(self): parsed_date = self.parser.parse('2010-01-20T13:45:00') self.assertEqual(datetime(year=2010, month=1, day=20, hour=13, minute=45), parsed_date.local) def testMonthDayParsing(self): self.runTestSet(MONTH_DAY_TESTS) def testTimeParsing(self): self.runTestSet(TIME_TESTS) def testAmbiguous(self): self.runTestSet(AMBIGUOUS_TESTS) def testFullDateParsing(self): self.runTestSet(FULL_DATE_TESTS) def testJoined(self): self.runTestSet(JOINED_TESTS) def testEdge(self): self.runTestSet(EDGE_TESTS) def testTrick(self): self.runTestSet(TRICK_TESTS) def testDuration(self): self.runTestSet(DURATION_TESTS) def testFailures(self): for text in FAIL_TESTS: try: self.parser.parse(text) except date.ParsingError, err: pass else: self.fail('Expected ParsingError for %s' % text) class ParseDateRange(ParsingDatesTest): def setUp(self): self.parser = date.DateRangeParser(static_today, static_now) def testSingleDate(self): date_range = self.parser.parse(RANGE_BASE_TEXT) self.assertConvertedEqual(RANGE_BASE_TEXT, RANGE_EXPECTED_START, date_range.start.local) self.assertEqual(date_range.end.local, RANGE_EXPECTED_START) self.assertFalse(date_range.specified_as_range) def testStartRange(self): text = RANGE_BASE_TEXT + ',' date_range = self.parser.parse(text) self.assertConvertedEqual(text, RANGE_EXPECTED_START, date_range.start.local) self.assertEqual(date_range.end, None) self.assertTrue(date_range.specified_as_range) def testStartAndDurationRange(self): text = RANGE_BASE_TEXT + ',+5' date_range = self.parser.parse(text) self.assertConvertedEqual(RANGE_BASE_TEXT, RANGE_EXPECTED_START, date_range.start.local) self.assertEqual(date_range.end.local, RANGE_EXPECTED_START + timedelta(hours=5)) self.assertTrue(date_range.specified_as_range) def testAllDurationRange(self): text = '+2,+3' date_range = self.parser.parse(text) self.assertEqual(date_range.start.local, NOW + timedelta(hours=2)) self.assertEqual(date_range.end.local, NOW + timedelta(hours=5)) self.assertTrue(date_range.specified_as_range) def testEndDurationRange(self): text = ',+3' date_range = self.parser.parse(text) self.assertEqual(date_range.start, None) self.assertEqual(date_range.end.local, NOW + timedelta(hours=3)) self.assertTrue(date_range.specified_as_range) def testDurationStart(self): text = '+1' + ',' + RANGE_BASE_TEXT date_range = self.parser.parse(text) self.assertEqual(date_range.start.local, NOW + timedelta(hours=1)) self.assertConvertedEqual(RANGE_BASE_TEXT, RANGE_EXPECTED_START, date_range.end.local) self.assertTrue(date_range.specified_as_range) def testStartEndRange(self): end_text = '11/29' text = RANGE_BASE_TEXT + ',' + end_text date_range = self.parser.parse(text) self.assertConvertedEqual(RANGE_BASE_TEXT, RANGE_EXPECTED_START, date_range.start.local) self.assertConvertedEqual(end_text, datetime(year=YEAR, month=11, day=29), date_range.end.local) self.assertTrue(date_range.specified_as_range) def testEndRange(self): text = ',' + RANGE_BASE_TEXT date_range = self.parser.parse(text) self.assertEqual(date_range.start, None) self.assertConvertedEqual(text, RANGE_EXPECTED_START, date_range.end.local) self.assertTrue(date_range.specified_as_range) if __name__ == '__main__': unittest.main() googlecl-0.9.13/src/googlecl/calendar/service.py0000644000175100017510000003610311543241104021760 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Service details and instances for the Picasa service. Some use cases: Add event: calendar add "Lunch with Tony on Tuesday at 12:00" List events for today: calendar today """ __author__ = 'tom.h.miller@gmail.com (Tom Miller)' import gdata.calendar.service import googlecl.base import googlecl.service import logging import urllib from googlecl import safe_encode, safe_decode from googlecl.calendar import SECTION_HEADER from googlecl.calendar.date import DateRangeParser LOG = logging.getLogger(googlecl.calendar.LOGGER_NAME) USER_BATCH_URL_FORMAT = \ gdata.calendar.service.DEFAULT_BATCH_URL.replace('default', '%s') class CalendarError(googlecl.base.Error): """Base error for Calendar errors.""" pass class EventsNotFound(CalendarError): """No events matching given parameters were found.""" pass class Calendar(): """Wrapper class for some calendar entry data.""" def __init__(self, cal_entry=None, user=None, name=None): """Parse a CalendarEntry into "user" and human-readable names, or take them directly.""" if cal_entry: # Non-primary calendar feeds look like this: # http:blah/.../feeds/JUNK%40group.calendar.google.com/private/full # So grab the part after /feeds/ and unquote it. self.user = urllib.unquote(cal_entry.content.src.split('/')[-3]) self.name = safe_decode(cal_entry.title.text) else: self.user = user self.name = name def __str__(self): return self.name class CalendarServiceCL(gdata.calendar.service.CalendarService, googlecl.service.BaseServiceCL): """Extends gdata.calendar.service.CalendarService for the command line. This class adds some features focused on using Calendar via an installed app with a command line interface. """ def __init__(self, config): """Constructor.""" gdata.calendar.service.CalendarService.__init__(self) googlecl.service.BaseServiceCL.__init__(self, SECTION_HEADER, config) def _batch_delete_recur(self, event, cal_user, start_date=None, end_date=None): """Delete a subset of instances of recurring events.""" request_feed = gdata.calendar.CalendarEventFeed() # Don't need to decode event.title.text here because it's not being # displayed to the user. Totally internal. _, recurring_events = self.get_events(cal_user, start_date=start_date, end_date=end_date, titles=event.title.text, expand_recurrence=True) delete_events = [e for e in recurring_events if e.original_event and e.original_event.id == event.original_event.id] if not delete_events: raise EventsNotFound map(request_feed.AddDelete, [None], delete_events, [None]) self.ExecuteBatch(request_feed, USER_BATCH_URL_FORMAT % cal_user) def add_reminders(self, calendar_user, events, minutes): """Add default reminders to events. Keyword arguments: calendar_user: "User" of the calendar. events: List of events to add reminder to. minutes: Number of minutes before each event to send reminder. Returns: List of events with batch results. """ request_feed = gdata.calendar.CalendarEventFeed() for event in events: if event.when: for a_when in event.when: a_when.reminder.append(gdata.calendar.Reminder(minutes=minutes)) else: LOG.debug('No "when" data for event!') event.when.append(gdata.calendar.When()) event.when[0].reminder.append(gdata.calendar.Reminder(minutes=minutes)) request_feed.AddUpdate(entry=event) response_feed = self.ExecuteBatch(request_feed, USER_BATCH_URL_FORMAT % calendar_user) return response_feed.entry AddReminders = add_reminders def delete_recurring_events(self, events, start_date, end_date, cal_user, prompt): """Delete recurring events from a calendar. Keyword arguments: events: List of non-expanded calendar events to delete. start_date: Date specifying the start of events (inclusive). end_date: Date specifying the end of events (inclusive). None for no end date. cal_user: "User" of the calendar to delete events from. prompt: True if we should prompt before deleting events, False otherwise. """ # option_list is a list of tuples, (prompt_string, deletion_instruction) # prompt_string gets displayed to the user, # deletion_instruction is a special value that will let the program know # what to do. # 'ALL' -- delete all events in the series. # 'NONE' -- don't delete anything. # 'TWIXT' -- delete events between start_date and end_date. # 'ON' -- delete events on the single date given. # 'ONAFTER' -- delete events on and after the date given. deletion_choice = 'ALL' option_list = [('All events in this series', deletion_choice)] if start_date and end_date: deletion_choice = 'TWIXT' option_list.append(('Instances between %s and %s' % (start_date, end_date), deletion_choice)) elif start_date or end_date: delete_date = (start_date or end_date) option_list.append(('Instances on %s' % delete_date, 'ON')) option_list.append(('All events on and after %s' % delete_date, 'ONAFTER')) deletion_choice = 'ON' option_list.append(('Do not delete', 'NONE')) prompt_str = '' for i, option in enumerate(option_list): prompt_str += str(i) + ') ' + option[0] + '\n' # Condense events so that the user isn't prompted for the same event # multiple times. This is assuming that recurring events have been expanded. events = googlecl.calendar.condense_recurring_events(events) for event in events: if prompt: delete_selection = -1 while delete_selection < 0 or delete_selection > len(option_list)-1: msg = 'Delete "%s"?\n%s' %\ (safe_decode(event.title.text), prompt_str) try: delete_selection = int(raw_input(safe_encode(msg))) except ValueError: continue deletion_choice = option_list[delete_selection][1] # deletion_choice has either been picked by the prompt, or is the default # value. The default value is determined by the date info passed in, # and should be the "least destructive" option. if deletion_choice == 'ALL': self._delete_original_event(event, cal_user) elif deletion_choice == 'TWIXT': self._batch_delete_recur(event, cal_user, start_date=start_date, end_date=end_date) elif deletion_choice == 'ON': self._batch_delete_recur(event, cal_user, start_date=delete_date, end_date=delete_date) elif deletion_choice == 'ONAFTER': self._batch_delete_recur(event, cal_user, start_date=delete_date) elif deletion_choice != 'NONE': raise CalendarError('Got unexpected batch deletion command!') DeleteRecurringEvents = delete_recurring_events def _delete_original_event(self, expanded_event, cal_user): """Deletes the original event corresponding to an expanded recurrence. Args: expanded_event: Expanded recurrence. Should contain the "original_event" attribute. cal_user: Calendar user, used to retrieve events. """ _, recurring_events = self.get_events(cal_user, query=expanded_event.title.text, expand_recurrence=False) for event in recurring_events: if event.id.text.split('/')[-1] == expanded_event.original_event.id: LOG.debug('Matched on event %s, deleting without prompt' % event.title.text) self.Delete(event.GetEditLink().href) def full_add_event(self, titles, calendar_user, date, reminder): """Create an event piece by piece (no quick add). Args: titles: List of titles of events. calendar_user: "User" of the calendar to add to. date: Text representation of a date and/or time. reminder: Number of minutes before event to send reminder. Set to 0 for no reminder. Returns: Response entries from batch-inserting the events. """ import atom request_feed = gdata.calendar.CalendarEventFeed() # start_text, _, end_text = googlecl.calendar.date.split_string(date, [',']) parser = DateRangeParser() date_range = parser.parse(date) start_time, end_time = date_range.to_when() for title in titles: event = gdata.calendar.CalendarEventEntry() event.title = atom.Title(text=title) when = gdata.calendar.When(start_time=start_time, end_time=end_time) if reminder: when.reminder.append(gdata.calendar.Reminder(minutes=reminder)) event.when.append(when) request_feed.AddInsert(event, 'insert-' + title[0:5]) response_feed = self.ExecuteBatch(request_feed, USER_BATCH_URL_FORMAT % calendar_user) return response_feed.entry def quick_add_event(self, quick_add_strings, calendar_user): """Add an event using the Calendar Quick Add feature. Keyword arguments: quick_add_strings: List of strings to be parsed by the Calendar service, as if it was entered via the "Quick Add" function. calendar_user: "User" of the calendar to add to. Returns: The event that was added, or None if the event was not added. """ import atom request_feed = gdata.calendar.CalendarEventFeed() for i, event_str in enumerate(quick_add_strings): event = gdata.calendar.CalendarEventEntry() event.content = atom.Content(text=event_str) event.quick_add = gdata.calendar.QuickAdd(value='true') request_feed.AddInsert(event, 'insert-' + event_str[0:5] + str(i)) response_feed = self.ExecuteBatch(request_feed, USER_BATCH_URL_FORMAT % calendar_user) return response_feed.entry QuickAddEvent = quick_add_event def get_calendar_user_list(self, cal_name=None): """Get "user" name and human-readable name for one or more calendars. The "user" for a calendar is an awful misnomer for the ID for the calendar. To get events for a calendar, you can form a query with cal_list = self.get_calendar_user_list('my calendar name') if cal_list: query = gdata.calendar.CalendarEventQuery(user=cal_list[0].user) Keyword arguments: cal_name: Name of the calendar to match. Default None to return the an instance representing only the default / main calendar. Returns: A list of Calendar instances, or None of there were no matches for cal_name. """ if not cal_name: return [Calendar(user='default', name=self.email)] else: cal_list = self.GetEntries('/calendar/feeds/default/allcalendars/full', cal_name, converter=gdata.calendar.CalendarListFeedFromString) if cal_list: return [Calendar(cal) for cal in cal_list] return None GetCalendarUserList = get_calendar_user_list def get_events(self, calendar_user, start_date=None, end_date=None, titles=None, query=None, expand_recurrence=True, split=True): """Get events. Keyword arguments: calendar_user: "user" of the calendar to get events for. See get_calendar_user_list. start_date: Start date of the event(s). Default None. end_date: End date of the event(s). Default None. titles: string or list Title(s) to look for in the event, supporting regular expressions. Default None for any title. query: Query string (not encoded) for doing full-text searches on event titles and content. expand_recurrence: If true, expand recurring events per the 'singleevents' query parameter. Otherwise, don't. split: Split events into "one-time" and "recurring" events. Returns: List of events from calendar that match the given params. """ query = gdata.calendar.service.CalendarEventQuery(user=calendar_user, text_query=query) if start_date: query.start_min = start_date.to_query() if end_date: # End dates are naturally exclusive, so make it inclusive. query.start_max = end_date.to_inclusive_query() if expand_recurrence: query.singleevents = 'true' query.orderby = 'starttime' query.sortorder = 'ascend' events = self.GetEntries(query.ToUri(), titles, converter=gdata.calendar.CalendarEventFeedFromString) if split: single_events = googlecl.calendar.filter_recurring_events(events, expand_recurrence) recurring_events = googlecl.calendar.filter_single_events(events, expand_recurrence) if start_date or end_date: # Because of how the "when" info on all-day events is stored, we need to # do a filter step to remove all-day events on the edge of the date # range. single_events = \ googlecl.calendar.filter_all_day_events_outside_range(start_date, end_date, single_events) recurring_events = \ googlecl.calendar.filter_all_day_events_outside_range(start_date, end_date, recurring_events) return single_events, recurring_events else: if start_date or end_date: return googlecl.calendar.filter_all_day_events_outside_range(start_date, end_date, events) else: return events GetEvents = get_events def is_token_valid(self, test_uri='/calendar/feeds/default/private/full'): """Check that the token being used is valid.""" return googlecl.service.BaseServiceCL.IsTokenValid(self, test_uri) IsTokenValid = is_token_valid SERVICE_CLASS = CalendarServiceCL googlecl-0.9.13/src/googlecl/config/0000755000175100017510000000000011545217651017453 5ustar orignihnorignihngooglecl-0.9.13/src/googlecl/config/__init__.py0000644000175100017510000000616411527110761021565 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import ConfigParser import googlecl import parser def _create_basic_options(): """Set the most basic options in the config file.""" import googlecl.docs import googlecl.contacts import googlecl.calendar import googlecl.youtube import getpass import socket # These may be useful to define at the module level, but for now, # keep them here. # REMEMBER: updating these means you need to update the CONFIG readme. default_hostid = getpass.getuser() + '@' + socket.gethostname() _youtube = {'max_results': '50'} _contacts = {'fields': 'name,email'} _calendar = {'fields': 'title,when'} _general = {'max_retries': '2', 'retry_delay': '0.5', 'regex': 'True', 'url_field': 'site', 'fields': 'title,url-site', 'missing_field_value': 'N/A', 'date_print_format': '%b %d %H:%M', 'cap_results': 'False', 'hostid': default_hostid} _docs = {'document_format': 'txt', 'spreadsheet_format': 'xls', 'presentation_format': 'ppt', 'drawing_format': 'png', 'format': 'txt', 'spreadsheet_editor': 'openoffice.org', 'presentation_editor': 'openoffice.org'} return {googlecl.docs.SECTION_HEADER: _docs, googlecl.contacts.SECTION_HEADER: _contacts, googlecl.calendar.SECTION_HEADER: _calendar, googlecl.youtube.SECTION_HEADER: _youtube, 'GENERAL': _general} def get_config_path(filename='config', default_directories=None, create_missing_dir=False): """Get the full path to the configuration file. See googlecl.get_xdg_path() """ return googlecl.get_xdg_path(filename, 'CONFIG', default_directories, create_missing_dir) def load_configuration(path=None): """Loads configuration file. Args: path: Path to the configuration file. Default None for the default location. Returns: Configuration parser. """ if not path: path = get_config_path(create_missing_dir=True) if not path: LOG.error('Could not create config directory!') return False config = parser.ConfigParser(ConfigParser.ConfigParser) config.associate(path) made_changes = config.ensure_basic_options(_create_basic_options()) if made_changes: config.write_out_parser() # Set the encoding again, now that the config file is loaded. # (the config file may have a default encoding setting) googlecl.TERMINAL_ENCODING = googlecl.determine_terminal_encoding(config) return config googlecl-0.9.13/src/googlecl/config/parser.py0000644000175100017510000001317111471524625021324 0ustar orignihnorignihn# Copyright (C) 2010 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Enhanced configuration file parser.""" from __future__ import with_statement import logging import os.path LOGGER_NAME = __name__ LOG = logging.getLogger(LOGGER_NAME) class ConfigParser(object): def __init__(self, config_parser_class): """Initializes the object. Args: config_parser: Class that acts as a configuration file parser. """ self.parser = config_parser_class() self.path = None def associate(self, config_file_path): """Associates parser with a config file. Config file is read from config_file_path as well. """ if os.path.exists(config_file_path): LOG.debug('Reading configuration from %s', config_file_path) self.parser.read(config_file_path) else: LOG.debug('Config file does not exist, starting with empty parser') self.path = config_file_path def ensure_basic_options(self, basic_options): """Sets options if they are missing. Args: basic_options: Nested dictionary in the form of {section header: {option: value, option: value}, section_header: {option: value, option: value} ...} Returns: True if some of the options in basic_options were not set already, False otherwise. """ made_changes = False for section_name, section_options in basic_options.iteritems(): if not self.parser.has_section(section_name): self.parser.add_section(section_name) missing_options = (set(section_options.keys()) - set(self.parser.options(section_name))) for option in missing_options: self.set(section_name, option, section_options[option]) if missing_options and not made_changes: made_changes = True return made_changes def get(self, section, option): """Returns option in section. No backup sections or defaults are returned by this function. If the section or option does not exist, the config parser will raise an error. Returns: String from config file. """ return self.parser.get(section, option) def lazy_get(self, section, option, default=None, option_type=None, backup_section='GENERAL'): """Returns option from config file. Tries to retrieve