Index: trunk/RetroStatus/RSS.py
===================================================================
--- trunk/RetroStatus/RSS.py	(revision 201)
+++ trunk/RetroStatus/RSS.py	(revision 201)
@@ -0,0 +1,654 @@
+#!/usr/bin/env python
+
+"""
+RSS.py
+
+Classes for working with RSS channels as arbitrary data structures.
+Requires Python 2.2 or newer and PyXML 0.7.1 or newer.
+
+  ChannelBase - Base class for RSS Channels.
+  CollectionChannel - RSS Channel modeled as a URI-per-entry
+                    dictionary.
+  TrackingChannel - RSS Channel modeled as an item-per-entry
+                    dictionary.
+  RSSParser - Multi-format RSS/XML Parser.
+
+Typically, the *Channel clases will be most useful to developers.
+
+This library provides tools for working with RSS feeds as data
+structures. The core is an RSS parser capable of understanding most
+RSS formats, and a serializer that produces RSS1.0. The RSS channel
+itself can be represented as any arbitrary data structure; two such
+structures are provided both as examples and to service common
+usage. This approach allows channels to be manipulated and stored in
+a fashion that suits both their semantics and the applications that
+access them.
+
+Both the parser and the serializer have the following limitations:
+  - RSS 1.0 "rich content" modules are not supported
+  - RSS 0.9x features that rely on attributes are not supported
+  - RDF is not understood; this library does not expose statements or
+    understand RDF syntax beyond that documented in RSS1.0 (taking
+    into account the previously listed limitations)
+
+The RSS format is made up of three metadata sections (channel,
+image, and textinput) and a list of items. Each individual metadata
+section and each item is passed around as an "item dictionary",
+which is a Python dictionary with (namespace, localname) tuples as
+keys. The values of the dictionaries are always strings; they may
+contain markup, which will be rendered into the RSS/XML when
+serialized.
+
+Individual items are found by using an "item identifier"; this is a
+channel-unique, string identifier for any given item. Item
+identifiers may be generated in a variety of ways, depending on the
+requirements of the channel.
+
+Certain types of channel metadata are automatically generated, and
+will not be returned or honored when accessed. They includes the
+"items", "image" and "textinput" children of the channel element.
+
+
+TODO:
+  - any markup (and the content inside) in item or metadata children
+    (e.g., HTML in a <description> will be silently ignored.
+  - test suite
+  - a function (XPath-based?) to detect a channel's type and return
+    the appropriate class.
+  - pay attention to <rss:items> when appropriate.
+"""
+
+__license__ = """
+Copyright (c) 2004 Mark Nottingham <mnot@pobox.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+__version__ = "0.46"
+
+import UserDict, sys, codecs, sha, types, signal
+import xml.sax as sax
+import xml.sax.saxutils as saxutils
+import cPickle as pickle
+import cStringIO as StringIO
+
+versionURI = 'http://www.mnot.net/python/RSS.py?version=%s' % __version__
+
+
+class _NamespaceMap:
+    """
+    Prefix <-> Namespace map.
+
+    Hold prefix->namespace mappings, and generate new prefixes when
+    necessary. Exposes prefix->URI map as attributes, URI->prefix
+    through getPrefix(URI).
+    """
+
+    def __init__(self):
+        self._nsID = 0  # seed for namespace prefix generation
+        self._prefixMap = {}
+        self.rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+        self.rss10 = 'http://purl.org/rss/1.0/'
+        self.rss09 = 'http://my.netscape.com/rdf/simple/0.9/'
+        self.rss091 = 'http://purl.org/rss/1.0/modules/rss091/'
+        self.dc = 'http://purl.org/dc/elements/1.1/'
+        self.syn = 'http://purl.org/rss/modules/syndication/'
+        self.content = 'http://purl.org/rss/1.0/modules/content/'
+        self.admin = 'http://webns.net/mvcb/'
+        self.ag = 'http://purl.org/rss/modules/aggregation/'
+        self.annotate = 'http://purl.org/rss/1.0/modules/annotate/'
+        self.cp = 'http://my.theinfo.org/changed/1.0/rss/'
+        self.company = 'http://purl.org/rss/1.0/modules/company'
+        self.event = 'http://purl.org/rss/1.0/modules/event/'
+        self.slash = 'http://purl.org/rss/1.0/modules/slash/'
+        self.html = 'http://www.w3.org/html4/'
+
+    def __setattr__(self, attr, value):
+        self.__dict__[attr] = value
+        if attr[0] != '_':
+            self._prefixMap[value] = attr
+
+    def getPrefix(self, URI):
+        """
+        Get the prefix for a given URI; generate one if it
+        doesn't exist.
+        """
+        try:
+            if URI == self.rss10:
+                return None  # special case
+            return self._prefixMap[URI]
+        except KeyError:
+            o = []
+            d = self._nsID
+            while 1:
+                o.insert(0, d % 26)
+                d = d / 26
+                if not d: break
+            candidate = "".join(map(lambda a: chr(a+97), o))
+            self._nsID = self._nsID + 1
+            if candidate in self._prefixMap.values():
+                candidate = self.getPrefix(URI)
+            setattr(self, candidate, URI)
+            return candidate
+
+
+ns = _NamespaceMap()
+
+# possible namespaces for RSS docs (None included for 0.9x)
+rssNamespaces = [ns.rss09, ns.rss10, None]
+
+# major sections of a RSS file
+rssSections = [ (ns.rss10, 'channel'),
+                (ns.rss10, 'image'),
+                (ns.rss10, 'textarea')
+              ]
+
+# RSS core element localnames
+rssElements = ['rss', 'channel', 'image', 'textarea', 'item', 'items',
+               'title', 'link', 'description', 'url']
+
+# RSS elements whose data is in an rdf:resource attribute
+rdfResources = [    (ns.rss10, 'image'),
+                    (ns.rss10, 'textarea'),
+                    (ns.admin, 'errorReportsTo'),
+                    (ns.admin, 'generatorAgent'),
+                    (ns.annotate, 'reference'),
+                    (ns.cp, 'server')
+               ]
+
+
+class ChannelBase:
+    """
+    Base class for RSS Channels.
+
+    A number of generic methods for accessing and setting channel
+    data and metadata are exposed, for the benefit of subclasses.
+    They may be used by applications as well, or the data structure
+    of the subclass may be directly manipulated.
+    """
+
+    def __init__(self):
+        self.encoding = 'utf-8'
+
+    def listItems(self):
+        """List the items in a channel, with a list of identifiers."""
+        pass  # override me
+
+    def addItem(self, item, index=0):
+        """Add an item to the channel. Expects an item dictionary."""
+        pass  # override me
+
+    def getItem(self, identifier):
+        """Get the appropriate item dictionary for a given identifier."""
+        pass  # override me
+
+    def getMD(self, name):
+        """
+        Get the [name] metadata as an item dictionary, where type is
+        a tuple (typically, in the ns:rss10 namespace, with a localname of
+        channel|image|textinput). MUST return an empty dictionary if the
+        metadata isn't found.
+        """
+        pass  # override me
+
+    def setMD(self, name, metadata):
+        """
+        Set the [name] metadata, where name is a tuple (typically,
+        it will be in the ns:rss10 namespace, and have a localname of
+        channel|image|textinput), and metadata is an item dictionary.
+        """
+        pass  # override me
+
+    def parse(self, url, timeout=30):
+        """
+        Fetch a channel representation from a URL and populate
+        the channel.
+        """
+        dh = RSSParser(self)
+        p = sax.sax2exts.make_parser()
+        p.setContentHandler(dh)
+        p.setFeature(sax.handler.feature_namespaces, 1)
+        signal.signal(signal.SIGALRM, self._timeout)
+        signal.alarm(timeout)
+        try:
+            p.parse(str(url))  # URIs are ascii
+        finally:
+            signal.alarm(0)
+        return dh
+
+    def _timeout(self, **args):
+        raise IOError, 'timeout'
+
+    def parseFile(self, file):
+        """Parse a file and populate the channel."""
+        dh = RSSParser(self)
+        p = sax.sax2exts.make_parser()
+        p.setContentHandler(dh)
+        p.setFeature(sax.handler.feature_namespaces, 1)
+        p.parseFile(file)
+        return dh
+
+    def __str__(self):
+        return self.output(self.listItems())
+
+    def output(self, items):
+        """Return the items referred to by a list of identifiers."""
+        assert type(items) is types.ListType, "items must be a list (%s)" % \
+          type(items)
+        out = StringIO.StringIO()
+        o = _XMLGenerator(out, self.encoding, 'replace')
+        channelMD = self.getMD((ns.rss10, "channel"))
+        imageMD = self.getMD((ns.rss10, "image"))
+        textinputMD = self.getMD((ns.rss10, "textinput"))
+        channelMD[(ns.admin, 'generatorAgent')] = versionURI
+
+        # gather namespaces, map prefixes
+        namespaces = {ns.rdf: 1}
+        namespaces.update(dict(
+          channelMD.keys() + imageMD.keys() + textinputMD.keys()))
+        [namespaces.update(dict(i.keys())) for i in map(self.getItem, items)]
+        for namespace in namespaces.keys():
+            o.startPrefixMapping(ns.getPrefix(namespace), namespace)
+
+        # write the XML
+        o.startDocument()
+        o.startElementNS((ns.rdf, 'RDF'), None, {})
+        o.ignorableWhitespace('\n')
+        o.startElementNS(
+          (ns.rss10, 'channel'), None,
+          {(ns.rdf, 'about'): channelMD[(ns.rss10, 'link')]})
+        o.ignorableWhitespace('\n')
+
+        # /channel
+        for name, data in channelMD.items():
+            if name in [(ns.rss10, 'items'), (ns.rss10, 'image'),
+              (ns.rss10, 'textinput')]:
+                continue
+            o.ignorableWhitespace('  ')
+            if name in rdfResources:
+                o.startElementNS(name, None, {(ns.rdf, 'resource'): data})
+            else:
+                if "<" in data:
+                    o.startElementNS(name, None,
+                      {(ns.rdf, "parseType"): "Literal"})
+                else:
+                    o.startElementNS(name, None, {})
+                o.characters(data)
+            o.endElementNS(name, None)
+            o.ignorableWhitespace('\n')
+
+        # /channel/items
+        o.ignorableWhitespace('  ')
+        o.startElementNS((ns.rss10, 'items'), None, {})
+        o.startElementNS((ns.rdf, 'Seq'), None, {})
+        o.ignorableWhitespace('\n')
+        for id in items:
+            o.ignorableWhitespace('   ')
+            o.startElementNS((ns.rdf, 'li'), None,
+              {(ns.rdf, 'resource'): self.getItem(id).get((ns.rss10, 'link'),
+              _make_hash(self.getItem(id)))})
+            o.endElementNS((ns.rdf, 'li'), None)
+            o.ignorableWhitespace('\n')
+        o.ignorableWhitespace('  ')
+        o.endElementNS((ns.rdf, 'Seq'), None)
+        o.endElementNS((ns.rss10, 'items'), None)
+        o.ignorableWhitespace('\n')
+
+        # /channel/image
+        if imageMD.has_key((ns.rss10, 'url')):
+            o.startElementNS((ns.rss10, 'image'), None,
+              {(ns.rdf, 'about'): imageMD[(ns.rss10, 'url')]})
+            o.endElementNS((ns.rss10, 'image'), None)
+            o.ignorableWhitespace('\n')
+
+        # /channel/textinput
+        if textinputMD.has_key((ns.rss10, 'link')):
+            o.startElementNS((ns.rss10, 'textinput'), None,
+              {(ns.rdf, 'about'): textinputMD[(ns.rss10, 'link')]})
+            o.endElementNS((ns.rss10, 'textinput'), None)
+            o.ignorableWhitespace('\n')
+        o.endElementNS((ns.rss10, 'channel'), None)
+        o.ignorableWhitespace('\n')
+
+        # /image
+        if imageMD.has_key((ns.rss10, 'url')):
+            o.startElementNS((ns.rss10, 'image'), None,
+              {(ns.rdf, 'about'): imageMD[(ns.rss10, 'url')]})
+            for name, data in imageMD.items():
+                o.ignorableWhitespace('  ')
+                if name in rdfResources:
+                    o.startElementNS(name, None, {(ns.rdf, 'resource'): data})
+                else:
+                    if "<" in data:
+                        o.startElementNS(name, None,
+                          {(ns.rdf, "parseType"): "Literal"})
+                    else:
+                        o.startElementNS(name, None, {})
+                    o.characters(data)
+                o.endElementNS(name, None)
+                o.ignorableWhitespace('\n')
+            o.endElementNS((ns.rss10, 'image'), None)
+            o.ignorableWhitespace('\n')
+
+        # /textinput
+        if textinputMD.has_key((ns.rss10, 'link')):
+            o.startElementNS((ns.rss10, 'textinput'), None,
+              {(ns.rdf, 'about'): textinputMD[(ns.rss10, 'link')]})
+            for name, data in textinputMD.items():
+                o.ignorableWhitespace('  ')
+                if name in rdfResources:
+                    o.startElementNS(name, None, {(ns.rdf, 'resource'): data})
+                else:
+                    if "<" in data:
+                        o.startElementNS(name, None,
+                          {(ns.rdf, "parseType"): "Literal"})
+                    else:
+                        o.startElementNS(name, None, {})
+                    o.characters(data)
+                o.endElementNS(name, None)
+                o.ignorableWhitespace('\n')
+            o.endElementNS((ns.rss10, 'textinput'), None)
+            o.ignorableWhitespace('\n')
+
+        # /item
+        for id in items:
+            item = self.getItem(id)
+            o.startElementNS(
+              (ns.rss10, 'item'), None, {(ns.rdf, 'about'):
+              item.get((ns.rss10, 'link'), _make_hash(item))})
+            o.ignorableWhitespace('\n')
+            for name, data in item.items():
+                o.ignorableWhitespace('  ')
+                if name in rdfResources:
+                    o.startElementNS(name, None, {(ns.rdf, 'resource'): data})
+                else:
+                    if "<" in data:
+                        o.startElementNS(name, None,
+                          {(ns.rdf, "parseType"): "Literal"})
+                    else:
+                        o.startElementNS(name, None, {})
+                    o.characters(data)
+                o.endElementNS(name, None)
+                o.ignorableWhitespace('\n')
+            o.endElementNS((ns.rss10, 'item'), None)
+            o.ignorableWhitespace('\n')
+        o.endElementNS((ns.rdf, 'RDF'), None)
+        o.endDocument()
+        out.seek(0)
+        return out.read()
+
+
+
+class TrackingChannel(ChannelBase, UserDict.UserDict):
+    """
+    RSS Channel modeled as a URI-per-entry dictionary.
+
+    Item identifiers are (uri, index) tuples, where uri is
+    the rdf:about or rss:link URI, and index indicates the
+    position in a list of a number of times that URI has
+    appeared in the channel.
+
+    This allows "tracking" channels that track the state of
+    a group of resources, such as stock tickers, file state
+    changes, etc.
+
+    For example:
+
+    {
+        (ns.rss10, "channel"):  {
+                     (ns.rss10, "title"): "the channel",
+                     (ns.rss10, "description"): "whatever",
+        },
+        (ns.rss10, "items"):
+           ["http://example.com/foo", "htp://example.com/bar", ... ],
+        "http://example.com/foo" [
+              {
+                (ns.rss10, "title"): "item 1",
+                (ns.rss10, "link"): "http://example.com/",
+                (ns.rss10, "description"): "foo",
+              },
+              {
+                (ns.rss10, "title"): "item 1 revised",
+                (ns.rss10, "link"): "http://example.com/",
+                (ns.rss10, "description"): "foo revisited",
+              },
+        ]
+
+        "http://example.com/bar" [
+                ...
+        ]
+    }
+
+    """
+
+    def __init__(self, data={}):
+        ChannelBase.__init__(self)
+        UserDict.UserDict.__init__(self, data)
+        self.data[(ns.rss10, 'items')] = []
+
+    def listItems(self):
+        return self[(ns.rss10, 'items')]
+
+    def addItem(self, item, index=0):
+    	if index == -1: index = len(self.data[(ns.rss10, 'items')])
+        uri = item.get((ns.rss10, "link"), _make_hash(item)) # shoudn't happen
+        if not self.data.has_key(uri):
+            self.data[uri] = [item]
+        else:
+            self.data[uri].append(item)
+        self.data[(ns.rss10, 'items')].insert(index, (uri, len(self.data[uri])))
+
+    def truncateToLength(self, length):
+        items = self.listItems()
+        data = self.data
+        overage = len(items) - length
+        while overage > 0:
+            del data[items.pop()[0]]
+            overage -= 1
+
+    def getItem(self, identifier):
+        (uri, index) = identifier
+        try:
+            return self.data[uri][index-1]
+        except (KeyError, IndexError):
+            return {}
+
+    def getMD(self, name):
+        return self.data.get(name, {})
+
+    def setMD(self, name, metadata):
+        self.data[name] = metadata
+
+
+
+class CollectionChannel(ChannelBase, UserDict.UserDict):
+    """
+    RSS Channel modeled as an item-per-entry dictionary.
+
+    Each Item is hashed to create a unique entry in the
+    dictionary, no matter how many times a particular
+    URI is in the channel.
+
+    This allows "collection" channels, which are typically
+    used for news updates, etc.
+
+    For example:
+
+    {
+        (ns.rss10, "channel"):  {
+                     (ns.rss10, "title"): "the channel",
+                     (ns.rss10, "description"): "whatever",
+                        },
+        (ns.rss10, "items"): ["ID1", "ID2", ... ],
+        "ID1" {
+                (ns.rss10, "title"): "item 1",
+                (ns.rss10, "link"): "http://example.com/",
+                (ns.rss10, "description"): "foo",
+              },
+        "ID2" {
+                ...
+              }
+    }
+
+    Note that:
+    - items are keyed by a hash-data URI; metadata is keyed
+       by a (namespace, localname) tuple.
+    - (ns.rss10, items) is a property; it cannot be
+      manipulated without manipulating the corresponding
+      (sub-)items (delete, add)
+    - likewise, all item's are properties; adding, deleting,
+      appending an item modifies (ns.rss10, items)
+      correspondingly
+    """
+
+    def __init__(self, data={}):
+        ChannelBase.__init__(self)
+        UserDict.UserDict.__init__(self, data)
+        self.data[(ns.rss10, 'items')] = []
+
+    def listItems(self):
+        return self.data[(ns.rss10, 'items')]
+
+    def addItem(self, item, index=0):
+        """append an item dictionary to the channel"""
+    	if index == -1: index = len(self.data[(ns.rss10, 'items')])		
+        ID = _make_hash(item)
+        self.data[ID] = item
+        self.data[(ns.rss10, 'items')].insert(index, ID)
+
+    def getItem(self, identifier):
+        return self.data.get(identifier, {})
+
+    def getMD(self, name):
+        return self.data.get(name, {})
+
+    def setMD(self, name, metadata):
+        self.data[name] = metadata
+
+
+class _XMLGenerator(saxutils.XMLGenerator):
+    """
+    Modified XMLGenerator.
+
+    Allows modification of encoding error handling, and tries to
+    encode problematic characters as Latin-1 to work around some
+    implementations.
+    """
+
+    def __init__(self, out=None, encoding='iso-8859-1', errors='strict'):
+        saxutils.XMLGenerator.__init__(self, out=out, encoding=encoding)
+        if out is None:
+            out = sys.stdout
+        self._out = codecs.lookup(encoding)[3](out, errors)
+
+    def characters(self, content):
+        try:
+            self._out.write(sax.saxutils.escape(content))
+        except UnicodeError:  # hack for broken content
+            self._out.write(sax.saxutils.escape(unicode(content, 'Latin-1')))
+
+
+class RSSParser(sax.handler.ContentHandler):
+    """
+    Multi-format RSS/XML Parser.
+
+    Parse XML into RSS Channel objects. May optionally be passed a
+    Channel() instance to append to.
+
+    Formats understood include:
+      - RSS 0.9
+      - RSS 0.91
+      - RSS 0.92
+      - RSS 1.0 (EXCEPT "rich content" modules)
+
+    "Core" RSS elements are normalized to the RSS1.0 namespace.
+    """
+
+    def __init__(self, channel, encoding='utf-8'):
+        sax.handler.ContentHandler.__init__(self)
+        self.channel = channel
+        self.encoding = encoding
+        self._context = []
+        self._tmp_item = {}
+        self._tmp_md = {    (ns.rss10, "channel"): {},
+                            (ns.rss10, "image"): {},
+                            (ns.rss10, "textinput"): {},
+                       }
+        self._tmp_buf = ''
+        self.version = None
+
+    def startElementNS(self, name, qname, attrs):
+        if name[1] is 'rss':  # sniff version
+            if name[0] is None:
+                self.version = attrs.get('version', None)
+            else:
+                self.version = name[0]
+        # normalize the rss namespace
+        if name[0] in rssNamespaces and name[1] in rssElements:
+            name = (ns.rss10, name[1])
+        elif name[0] is None:
+            name = (ns.rss091, name[1])
+        self._context.append(name)
+        if name == (ns.rss10, 'item'):
+            self._tmp_item = {}
+            self._tmp_buf = ''
+        elif len(self._context) > 1 and \
+          self._context[-2] == (ns.rss10, 'item') and \
+          name in rdfResources:
+            self._tmp_item[name] = attrs[(ns.rdf, 'resource')]
+		
+
+    def endElementNS(self, name, qname):
+        # normalize the rss namespace
+        if name[0] in rssNamespaces and name[1] in rssElements:
+            name = (ns.rss10, name[1])
+        elif name[0] is None:
+            name = (ns.rss091, name[1])
+        if (ns.rss10, 'item') in self._context:
+            if self._context[-1] == (ns.rss10, 'item'):  # end of an item
+                self.channel.addItem(self._tmp_item, len(self.channel))
+                self._tmp_item = {}
+            elif self._context[-2] == (ns.rss10, 'item'):  # an item's child
+                if name not in rdfResources:
+                    self._tmp_item[name] = self._tmp_buf.strip()
+            else:  # an item's grandchild
+                pass  ###
+        elif len(self._context) > 2 and self._context[-2] in rssSections:
+            # metadata
+            self._tmp_md[self._context[-2]][name] = self._tmp_buf.strip()
+        self._tmp_buf = ''
+        self._context.pop()
+
+    def endDocument(self):
+        for name, metadata in self._tmp_md.items():
+            self.channel.setMD(name, metadata)
+
+    def characters(self, content):
+        self._tmp_buf = self._tmp_buf + content.encode(self.encoding)
+
+
+def _make_hash(data):
+    return "hash-data:SHA:" + sha.new(pickle.dumps(data)).hexdigest()[:20]
+
+
+if __name__ == "__main__":
+    # a simple test
+    c = TrackingChannel()
+    c.parse(sys.argv[1])
+    print c
Index: trunk/RetroStatus/RetroStatus.py
===================================================================
--- trunk/RetroStatus/RetroStatus.py	(revision 201)
+++ trunk/RetroStatus/RetroStatus.py	(revision 201)
@@ -0,0 +1,229 @@
+#!/usr/bin/pythonw
+
+from appscript import app, k
+from aemreceive.sfba import *
+from Foundation import NSLog, NSUserDefaults, NSDate
+from subprocess import call
+from traceback import print_exc
+from cStringIO import StringIO
+from datetime import datetime
+import RSS
+import email, smtplib
+from email.MIMEMessage import MIMEMessage
+from email.MIMEText import MIMEText
+from email.Utils import formataddr, formatdate
+from formatflowed import convertToFlowed
+import os, sys
+import objc # XXX should switch to public DADiskMount/Eject functions
+objc.loadBundle('DiskManagement', globals(),
+                '/System/Library/PrivateFrameworks/DiskManagement.framework')
+
+EVENTS_DIR = os.path.expanduser('~/Sites/RetroStatus')
+EVENTS_MAX = 100
+
+FROM_NAME, FROM_ADDR = 'Retrospect backup server', 'retrospect' + '@rileys.us'
+TO_ADDR = ['%s@rileys.us' for %s in ['julia', 'gary']]
+SMTP_SERVER = 'calamity.bos.sabi.net'
+
+DEBUG = True
+
+# XXX implement Web page with current status
+
+### notifications
+
+GROWL_APP_NAME = 'RetroStatus'
+NOTIFICATION_BACKUP = 'Backup'
+NOTIFICATION_MEDIA = 'Media change'
+NOTIFICATIONS_ALL = [NOTIFICATION_BACKUP, NOTIFICATION_MEDIA]
+EVENTS_FILE = os.path.join(EVENTS_DIR, 'index.rdf')
+EVENTS_FILE_TEMP = EVENTS_FILE + '~'
+
+growl = app('GrowlHelperApp')
+events = RSS.TrackingChannel()
+
+try: os.mkdir(EVENTS_DIR)
+except: pass
+events.setMD((RSS.ns.rss10, 'channel'),
+             {(RSS.ns.rss10, 'title'): 'Retrospect status',
+              (RSS.ns.rss10, 'link'): os.path.basename(EVENTS_FILE)})
+
+def growlNotify(name, title, description, **kw):
+    # XXX restore coalescing support from LocationDo
+    params = dict(with_name=name,
+                  title=title,
+                  description=unicode(description),
+                  application_name=GROWL_APP_NAME,
+                  icon_of_application='Retrospect')
+    params.update(kw)
+    if DEBUG:
+        NSLog('%s: %s' % (title, description))
+    growl.notify(**params)
+
+def addEvent(title, description, preformatted=False):
+    description = description.replace('&', '&amp;').replace('<', '&lt;')
+    if preformatted:
+        description = '<pre>%s</pre>' % description
+    else:
+        description = description.replace('\n\n', '<p>').replace('\n', '<br>')
+    # XXX provide valid URL for event details
+    events.addItem({(RSS.ns.dc, 'date'): datetime.today().isoformat(),
+                    (RSS.ns.rss10, 'title'): title,
+                    (RSS.ns.rss10, 'link'): datetime.today().isoformat(),
+                    (RSS.ns.rss10, 'description'): description})
+    events.truncateToLength(EVENTS_MAX)
+    file(EVENTS_FILE_TEMP, 'w').write(str(events))
+    os.rename(EVENTS_FILE_TEMP, EVENTS_FILE)
+
+def sendMail(subject, text):
+    message = MIMEText(convertToFlowed(text), 'plain; format=flowed')
+    message.set_unixfrom(FROM_ADDR)
+    message['From'] = formataddr((FROM_NAME, FROM_ADDR))
+    message['To'] = TO_ADDR
+    message['Date'] = formatdate(localtime=True)
+    message['Subject'] = subject
+    smtp = smtplib.SMTP(SMTP_SERVER)
+    smtp.sendmail(message['From'], TO_ADDR, message.as_string())
+
+### disk management
+
+DEFAULT_LAST_USED_NAME = 'Last used backup volume name'
+DEFAULT_LAST_USED_DATE = 'Date of last backup volume use'
+
+defaults = NSUserDefaults.standardUserDefaults()
+diskManager = DMManager.sharedManager()
+diskManager.generateConnection()
+
+def diskWithName(name):
+    disks = diskManager.disks()
+    for disk in disks:
+        if disk.volumeName() == name:
+            return disk
+
+def lastUsedDisk():
+    diskName = defaults.get(DEFAULT_LAST_USED_NAME)
+    if diskName:
+        return diskWithName(diskName)
+
+def diskIsMounted(disk):
+    mountPoint = disk.mountPoint()
+    return (mountPoint and os.path.exists(mountPoint))
+
+def ejectDisk(disk):
+    if not diskIsMounted(disk):
+        return True
+    if not diskManager.ejectDisk_synchronous_(disk, True):
+        return False
+    if diskIsMounted(disk):
+        growlNotify(NOTIFICATION_MEDIA, 'Eject failed',
+                    'Using diskutil to eject %s' % disk)
+        return call('diskutil', 'eject', disk.diskIdentifier()) is 0
+    return True
+
+def mountDisk(disk):
+    if diskIsMounted(disk):
+        return True
+    if not diskManager.mountDisk_includeChildren_synchronous_(disk, False, True):
+        return False
+    if not diskIsMounted(disk):
+        growlNotify(NOTIFICATION_MEDIA, 'Mount failed',
+                    'Using diskutil to mount %s' % disk)
+        return call('diskutil', 'mount', disk.diskIdentifier()) is 0
+    return True
+
+### Retrospect event handlers
+
+def retrospectstart(autolaunchboolean): pass
+def scriptstart(scriptname, startdate): pass
+def volumestart(volumename): pass
+def volumeend(volumename, kbcopied, filecount, durationinseconds, backupdate,
+              enddate, startdate, destinationname, clientname, scriptname,
+              backuptypestring, fileerrorcount, volumeerrorcode,
+              volumeerrormessage, zonename='', remotename='',
+              subvolumediskname=''):
+    growlNotify(NOTIFICATION_BACKUP, scriptname,
+                '%s\n%s' % (volumename, volumeerrormessage))
+    addEvent(volumename, '%s\n\n%d file(s) copied; %d had errors.' %
+             (volumeerrormessage, filecount, fileerrorcount))
+
+def mediarequest(mediatypestring, requestedmembername, mediaisknownboolean):
+    disk = diskWithName(requestedmembername)
+    if disk and mountDisk(disk):
+            defaults[DEFAULT_LAST_USED_NAME] = requestedmembername
+            defaults[DEFAULT_LAST_USED_DATE] = NSDate.date()
+            growlNotify(NOTIFICATION_MEDIA, requestedmembername,
+                        'Disk is in use: please DO NOT swap')
+    else:
+        sendMail("IMPORTANT: Can't find disk %s" % requestedmembername,
+                 "Retrospect can't find the disk '%s'.  Please eject the old disk, if any, and check that the correct disk is inserted.")
+
+def mediarequesttimedout(numberofsecondswaited): pass
+
+def scriptend(scriptname, scripterrormessage, errorcount):
+    disk = lastUsedDisk()
+    if disk and ejectDisk(disk):
+        growlNotify(NOTIFICATION_MEDIA, disk.volumeName(),
+                    'Disk no longer in use: OK to swap')
+    addEvent('Completed: scriptname',
+             '%s; %s error(s)' % (scripterrormessage, errorcount))
+
+def scriptcheckfailed(scriptname, failuremessage, scheduledate):
+    disk = lastUsedDisk()
+    if not disk or not diskIsMounted(disk):
+        sendMail("Please swap backup media before %s" % scheduledate.ctime(),
+                 "%s\nEject the old disk, if any, and check that the correct disk is inserted." % failuremessage.replace('\r', '\n'))
+
+def retrospectquit():
+    disk = lastUsedDisk()
+    if not disk or not diskIsMounted(disk):
+        sendMail("Please swap backup media",
+                 "Eject the old disk, if any, and check that the correct disk is inserted.")
+    stopeventloop()
+    sys.exit(0)
+
+def handleEvent(eventName, params):
+    eventHandler = globals().get(eventName)
+    params = dict(zip(params[::2], params[1::2]))
+    for k in params.keys():
+        if params[k] == '': del params[k]
+    eventStr = '%s(%s)' % (eventName, ', '.join(['%s=%r' % (k, v) for k, v
+                                                 in params.iteritems()]))
+    if not eventHandler:
+        addEvent('Unhandled Retrospect event', eventStr)
+        if DEBUG: NSLog('unhandled event: ' + eventStr)
+        return
+    try:
+        eventHandler(**params)
+    except:
+        s = StringIO()
+        print_exc(file=s)
+        addEvent('Retrospect event handler failed',
+                 '%s\n\n%s' % (eventStr, s.getval()), True)
+        if DEBUG:
+            NSLog('event handler failed for %s:\n%s' % (eventStr, s.getval())
+
+installeventhandler(handleEvent, 'ascrpsbr',
+                    ('snam', 'eventName', kAE.typeUnicodeText),
+                    ('usrf', 'params', ArgListOf(kAE.typeWildCard)))
+
+if __name__ == '__main__':
+    growl.register(
+        as_application=GROWL_APP_NAME,
+        all_notifications=NOTIFICATIONS_ALL,
+        default_notifications=NOTIFICATIONS_ALL)
+
+    starteventloop()
+
+# example events:
+
+# retrospectstart(autolaunchboolean=False)
+# scriptstart(scriptname='Boston Backup', startdate=datetime.datetime(2005, 11, 23, 2, 31, 51))
+# volumestart(volumename='Backup Clients:Babs')
+# volumeend(startdate=datetime.datetime(2005, 11, 23, 2, 31, 51), kbcopied=0, enddate=datetime.datetime(2005, 11, 23, 2, 32, 5), backuptypestring='Normal', volumename='Backup Clients:Babs', clientname='Babs', destinationname='Boston backup A', scriptname='Boston Backup', volumeerrormessage='client is not visible on network', backupdate=datetime.datetime(2005, 11, 23, 2, 32, 5), durationinseconds=0, volumeerrorcode=-1028, fileerrorcount=0, remotename='Babs', filecount=0, zonename='10.0.0.9')
+# volumestart(volumename=' Local Desktop:Bookworm')
+# mediarequest: appears even if media is available
+# mediarequest(mediatypestring='disk', requestedmembername='1-Boston backup A', mediaisknownboolean=True)
+# mediarequest(mediatypestring='disk', requestedmembername='1-Boston backup A', mediaisknownboolean=True)
+# volumeend(startdate=datetime.datetime(2005, 11, 23, 2, 31, 51), kbcopied=2291359, enddate=datetime.datetime(2005, 11, 23, 2, 56, 23), backuptypestring='Normal', volumename=' Local Desktop:Bookworm', clientname='Local Desktop', destinationname='Boston backup A', scriptname='Boston Backup', volumeerrormessage='successful', backupdate=datetime.datetime(2005, 11, 23, 2, 31, 51), durationinseconds=1066, volumeerrorcode=0, fileerrorcount=15, remotename='Local Desktop', filecount=72717)
+# scriptend(scriptname='Boston Backup', scripterrormessage='client is not visible on network', errorcount=18)
+# scriptcheckfailed(scriptname='Boston Backup', failuremessage='Appending to disk \xd21-Boston backup B\xd3\r\t149.0 G available (80%) of 186.2 G total capacity', scheduledate=datetime.datetime(2005, 11, 24, 1, 0))
+# retrospectquit()
Index: trunk/RetroStatus/formatflowed.py
===================================================================
--- trunk/RetroStatus/formatflowed.py	(revision 201)
+++ trunk/RetroStatus/formatflowed.py	(revision 201)
@@ -0,0 +1,884 @@
+"""RFC 3676 format=flowed text processing.
+
+This module provides an API to create and display text/plain; format=flowed
+mimetype text.
+
+"""
+
+# Copyright (C) 2005 Martijn Pieters
+# Written by Martijn Pieters <mj@zopatista.com>
+# Development was sponsored by Logicalware (http://www.logicalware.org/)
+# Licensed as Open Source under the same terms as the Python 2.4.1 license,
+# as available at http://www.python.org/2.4.1/license.html
+
+__revision_id__ = '$Id: formatflowed.py 27 2005-09-17 19:13:48Z mj $'
+
+import re
+import textwrap
+
+__all__ = [
+    'PARAGRAPH',
+    'FIXED',
+    'SIGNATURE_SEPARATOR',
+    'FormatFlowedDecoder',
+    'FormatFlowedEncoder',
+    'decode',
+    'encode',
+    'convertToWrapped',
+    'convertToFlowed'
+]
+
+# Constants denoting the various text chunk types recognized by format=flowed
+PARAGRAPH, FIXED, SIGNATURE_SEPARATOR = range(3)
+
+
+# -- Public classes ----------------------------------------------------
+
+
+class FormatFlowedDecoder:
+    """Object for converting format=flowed text to other formats
+
+    The following instance attributes influence the interpretation of
+    format=flowed text:
+      delete_space (default: False)
+        Delete the trailing space before the CRLF on flowed lines before
+        interpreting the line on flowed input, corresponds to the DelSp mime
+        parameter
+      character_set (default: us-ascii)
+        The encoding of text passed in. Text is decoded to unicode using this
+        encoding, using the default error handing scheme.
+
+    """
+    def __init__(self, delete_space=False, character_set='us-ascii'):
+        self.delete_space = delete_space
+        self.character_set = character_set
+
+    # -- Private methods -----------------------------------------------
+
+    def _stripquotes(self, line):
+        """Remove quotemarks from the start of the line
+
+        Returns the number of quotemarks stripped and the stripped line:
+
+            >>> decoder = FormatFlowedDecoder()
+            >>> decoder._stripquotes(u'>>> quoted line')
+            (3, u' quoted line')
+
+        Non-quoted lines are returned unchanged:
+
+            >>> decoder._stripquotes(u'non-quoted line')
+            (0, u'non-quoted line')
+
+        """
+        stripped = line.lstrip('>')
+        return len(line) - len(stripped), stripped
+
+    def _stripstuffing(self, line):
+        """Remove the optional leading space
+
+        Returns the stripped line:
+
+            >>> decoder = FormatFlowedDecoder()
+            >>> decoder._stripstuffing(u' stuffed line')
+            u'stuffed line'
+
+        Non-stuffed lines are returned unchanged:
+
+            >>> decoder._stripstuffing(u'non-stuffed line')
+            u'non-stuffed line'
+
+        Additional spacing is preserved:
+
+            >>> decoder._stripstuffing(u'  extra leading space')
+            u' extra leading space'
+
+        """
+        if line.startswith(u' '):
+            return line[1:]
+        return line
+
+    def _stripflow(self, line):
+        """Remove the trailing flow space is delete_space is set
+
+        The instance attribute delete_space is False by default thus this
+        method returns the line unchanged:
+
+            >>> decoder = FormatFlowedDecoder()
+            >>> decoder._stripflow(u'flowed line ')
+            u'flowed line '
+
+        But if the delete_space attribute has been set to True the flow space
+        is removed:
+
+            >>> decoder = FormatFlowedDecoder(delete_space=True)
+            >>> decoder._stripflow(u'flowed line ')
+            u'flowed line'
+
+        Only one flow space is removed:
+        >>> decoder._stripflow(u'extra whitespace  ')
+        u'extra whitespace '
+
+        """
+        if self.delete_space and line.endswith(u' '):
+            return line[:-1]
+        return line
+
+    # -- Public API ----------------------------------------------------
+
+    def decode(self, flowed):
+        """Decode flowed text
+
+        Returns an iterable serving a sequence of (information, chunk)
+        tuples. information is a dictionary with the following fields:
+          type
+            One of PARAGRAPH, FIXED, SIGNATURE_SEPARATOR
+          quotedepth
+            Number of quotemarks found on the text chunk
+
+        chunk is a unicode string. All text is unwrapped and without any
+        quotemarks; when displaying these chunks, the appropriate quotemarks
+        should be added again, and chunks of type PARAGRAPH should be
+        displayed wrapped. Chunks of type FIXED should be displayed
+        unwrapped.
+
+
+        Examples
+        --------
+
+        Here is a simple example:
+
+            >>> CRLF = '\\r\\n'
+            >>> decoder = FormatFlowedDecoder()
+            >>> result = decoder.decode(CRLF.join((
+            ... ">> `Take some more tea,' the March Hare said to Alice, very ",
+            ... ">> earnestly.",
+            ... ">",
+            ... "> `I've had nothing yet,' Alice replied in an offended ",
+            ... "> tone, `so I can't take more.'",
+            ... "",
+            ... "`You mean you can't take less,' said the Hatter: `it's very ",
+            ... "easy to take more than nothing.'",
+            ... "",
+            ... "-- ",
+            ... "Lewis Carroll")))
+            >>> list(result) == [
+            ...   ({'quotedepth': 2, 'type': PARAGRAPH},
+            ...     u"`Take some more tea,' the March Hare said to Alice, "
+            ...     u"very earnestly."),
+            ...   ({'quotedepth': 1, 'type': FIXED}, u""),
+            ...   ({'quotedepth': 1, 'type': PARAGRAPH},
+            ...    u"`I've had nothing yet,' Alice replied in an offended "
+            ...    u"tone, `so I can't take more.'"),
+            ...   ({'quotedepth': 0, 'type': FIXED}, u""),
+            ...   ({'quotedepth': 0, 'type': PARAGRAPH},
+            ...    u"`You mean you can't take less,' said the Hatter: `it's "
+            ...    u"very easy to take more than nothing.'"),
+            ...   ({'quotedepth': 0, 'type': FIXED}, u""),
+            ...   ({'quotedepth': 0, 'type': SIGNATURE_SEPARATOR}, u"-- "),
+            ...   ({'quotedepth': 0, 'type': FIXED}, u"Lewis Carroll")
+            ... ]
+            True
+
+
+        Improperly closed paragraphs
+        ----------------------------
+
+        The decoder can deal with various cases of improperly format=flowed
+        messages. Paragraphs normally end with a fixed line, but the following
+        cases are also considered paragraph-closing cases:
+
+        - A change in quotedepth:
+
+            >>> result = decoder.decode(CRLF.join((
+            ... "> Depth one paragraph with flow space. ",
+            ... ">> Depth two paragraph with flow space. ",
+            ... "Depth zero paragraph with fixed line.")))
+            >>> list(result) == [
+            ...   ({'quotedepth': 1, 'type': PARAGRAPH},
+            ...    u"Depth one paragraph with flow space. "),
+            ...   ({'quotedepth': 2, 'type': PARAGRAPH},
+            ...    u"Depth two paragraph with flow space. "),
+            ...   ({'quotedepth': 0, 'type': FIXED},
+            ...    u"Depth zero paragraph with fixed line.")]
+            True
+
+        - A signature separator:
+
+            >>> result = decoder.decode(CRLF.join((
+            ... "A paragraph with flow space. ",
+            ... "-- ")))
+            >>> list(result) == [
+            ...   ({'quotedepth': 0, 'type': PARAGRAPH},
+            ...    u"A paragraph with flow space. "),
+            ...   ({'quotedepth': 0, 'type': SIGNATURE_SEPARATOR}, u"-- ")]
+            True
+
+        - The end of the message:
+
+            >>> result = decoder.decode(CRLF.join((
+            ... "A paragraph with flow space. ",)))
+            >>> list(result) == [
+            ...   ({'quotedepth': 0, 'type': PARAGRAPH},
+            ...    u"A paragraph with flow space. ")]
+            True
+
+
+        Decoder options
+        ---------------
+
+        The delete_space attribute of the FormatFlowedDecoder class can be used
+        to control wether or not the trailing space on flowed lines should be
+        retained; this is used to encode flowed text where spaces are rare:
+
+            >>> decoder = FormatFlowedDecoder(delete_space=True)
+            >>> result = decoder.decode(CRLF.join((
+            ... "Contrived example with a word- ",
+            ... "break across the paragraph.")))
+            >>> list(result) == [
+            ...   ({'quotedepth': 0, 'type': PARAGRAPH},
+            ...    u'Contrived example with a word-break across the '
+            ...    u'paragraph.')]
+            True
+
+        Note that the characterset determines what how to interpret a space
+        and a quote marker. The cp037 characterset does not encode these
+        characters the same way, for example:
+
+            >>> decoder = FormatFlowedDecoder(character_set='cp037')
+            >>> result = decoder.decode(CRLF.join((
+            ... "n@\\xe3\\x88\\x89\\xa2@\\x89\\xa2@\\x81@\\x98\\xa4\\x96\\xa3"
+            ... "\\x85\\x84@\\x97\\x81\\x99\\x81\\x87\\x99\\x81\\x97\\x88@",
+            ... "n@\\x85\\x95\\x83\\x96\\x84\\x85\\x84@\\x89\\x95@\\x83\\x97"
+            ... "\\xf0\\xf3\\xf7K")))
+            >>> list(result) == [
+            ...   ({'quotedepth': 1, 'type': PARAGRAPH},
+            ...    u'This is a quoted paragraph encoded in cp037.')]
+            True
+
+        """
+        para = u''
+        pinfo = {'type': PARAGRAPH}
+        for line in flowed.split('\r\n'):
+            line = line.decode(self.character_set)
+            quotedepth, line = self._stripquotes(line)
+            line = self._stripstuffing(line)
+            if line == '-- ':
+                # signature separator
+                if para:
+                    # exception case: flowed line followed by sig-sep
+                    yield (pinfo, para)
+                    pinfo = {'type': PARAGRAPH}
+                    para = u''
+                yield ({'type': SIGNATURE_SEPARATOR,
+                        'quotedepth': quotedepth}, line)
+                continue
+            if line.endswith(u' '):
+                # flowed line; collect into a paragraph
+                if quotedepth != pinfo.get('quotedepth', quotedepth):
+                    # exception case: flowed line followed by quotedepth change
+                    yield (pinfo, para)
+                    pinfo = {'type': PARAGRAPH}
+                    para = u''
+                para += self._stripflow(line)
+                pinfo['quotedepth'] = quotedepth
+                continue
+            # fixed line
+            if para:
+                # completed paragraph
+                if quotedepth != pinfo.get('quotedepth', quotedepth):
+                    # exception case: flowed line followed by quotedepth change
+                    yield (pinfo, para)
+                    pinfo = {'type': PARAGRAPH}
+                    para = u''
+                else:
+                    yield (pinfo, para + line)
+                    pinfo = {'type': PARAGRAPH}
+                    para = u''
+                    continue
+            yield ({'type': FIXED, 'quotedepth': quotedepth}, line)
+
+        if para:
+            # exception case: last line was a flowed line
+            yield (pinfo, para)
+
+
+class FormatFlowedEncoder:
+    """Object to generate format=flowed text
+
+    The following attributes influence the flowed formatting of text:
+      extra_space (default: False)
+        Use an extra space to create flowed lines; this requires that the
+        DelSpace flag will be set true on the Content-Type mime header. Use
+        this flag on texts that have little or no spaces to break on.
+      character_set (default: us-ascii)
+        Encode the output to this character set.
+      spacestuff_quoted (default: True)
+        Always spacestuff quoted chunks, i.e. place a space between the quote
+        markers and the text.
+      width (default: 78)
+        The maximum line width generated for flowed paragraphs; fixed lines
+        can still exceed this width. This value does not include the CRLF
+        line endings.
+
+    """
+    def __init__(self, extra_space=False, character_set='us-ascii',
+                 spacestuff_quoted=True, width=78):
+        self.extra_space = extra_space
+        self.character_set = character_set
+        self.spacestuff_quoted = spacestuff_quoted
+        self.width = width
+
+    def _spacestuff(self, line, force=False):
+        """Prepend a space to lines starting with ' ', '>' or 'From'
+
+        Returns the altered line. Set 'force' to True to skip the tests and
+        always prepend the space regardless:
+
+            >>> encoder = FormatFlowedEncoder()
+            >>> encoder._spacestuff(u' leading space needs to be preserved')
+            u'  leading space needs to be preserved'
+            >>> encoder._spacestuff(u'> can be confused for a quotemark')
+            u' > can be confused for a quotemark'
+            >>> encoder._spacestuff(u'From is often escaped by MTAs')
+            u' From is often escaped by MTAs'
+            >>> encoder._spacestuff(u'Padding is considered harmless')
+            u'Padding is considered harmless'
+            >>> encoder._spacestuff(u'So forcing it is fine', True)
+            u' So forcing it is fine'
+
+        Note that empty lines can never be spacestuffed:
+
+            >>> encoder._spacestuff(u'')
+            u''
+
+        """
+        if not line:
+            return line
+        # Although the RFC doesn't say so explicitly, in practice 'From' only
+        # needs escaping when (1) not quoted and (2) actually encoded as
+        # 'From' (so independent of the unicode sequence u'From').
+        # For simplicity's sake, we spacestuff it any time a line starts with
+        # it before adding quotemarks and encoding the line.
+        if force or line[0] in (' ', '>') or line.startswith('From'):
+            return u' ' + line
+        return line
+
+    # -- Public API ----------------------------------------------------
+
+    def encode(self, chunks):
+        """Encode chunks of text to format=flowed
+
+        chunks
+          An iterable sequence of (information, text) tuples, where information
+          is a dictionary with 'type' and 'quotedepth' keys. The 'type' value
+          is one of PARAGRAPH, FIXED or SIGNATURE-SEPARATOR, and the
+          'quotedepth' value a positive integer indicating the quoting depth.
+          text should be the unicode text to be encoded.
+
+        Example
+        -------
+
+        To illustrate, an example:
+
+            >>> chunks = (
+            ...   ({'quotedepth': 2, 'type': PARAGRAPH},
+            ...     u"`Take some more tea,' the March Hare said to Alice, "
+            ...     u"very earnestly."),
+            ...   ({'quotedepth': 1, 'type': FIXED}, u""),
+            ...   ({'quotedepth': 1, 'type': PARAGRAPH},
+            ...    u"`I've had nothing yet,' Alice replied in an offended "
+            ...    u"tone, `so I can't take more.'"),
+            ...   ({'quotedepth': 0, 'type': FIXED}, u""),
+            ...   ({'quotedepth': 0, 'type': PARAGRAPH},
+            ...    u"`You mean you can't take less,' said the Hatter: `it's "
+            ...    u"very easy to take more than nothing.'"),
+            ...   ({'quotedepth': 0, 'type': FIXED}, u""),
+            ...   ({'quotedepth': 0, 'type': SIGNATURE_SEPARATOR}, u"-- "),
+            ...   ({'quotedepth': 0, 'type': PARAGRAPH}, u"Carol Lewis"),
+            ... )
+            >>> result = FormatFlowedEncoder(width=45).encode(chunks)
+            >>> result.split('\\r\\n') == [
+            ...   ">> `Take some more tea,' the March Hare said ",
+            ...   ">> to Alice, very earnestly.",
+            ...   ">",
+            ...   "> `I've had nothing yet,' Alice replied in ",
+            ...   "> an offended tone, `so I can't take more.'",
+            ...   "",
+            ...   "`You mean you can't take less,' said the ",
+            ...   "Hatter: `it's very easy to take more than ",
+            ...   "nothing.'",
+            ...   "",
+            ...   "-- ",
+            ...   "Carol Lewis",
+            ...   ""]
+            True
+
+        """
+        encoded = []
+        for info, text in chunks:
+            encoded.append(self.encodeChunk(text, **info))
+        return ''.join(encoded)
+
+    def encodeChunk(self, chunk, type=PARAGRAPH, quotedepth=0):
+        """Encode a chunk of text to format=flowed
+
+        The chunk is encoded to format=flowed text, controlled by the
+        following arguments.
+        chunk
+          The unicode text to be encoded. Newlines are considered to be
+          whitespace and will be converted to spaces.
+        type (default: PARAGRAPH)
+          Chunk type; one of PARAGRAPH, FIXED or SIGNATURE_SEPARATOR. When
+          called with type SIGNATURE_SEPARATOR the chunk is ignored and '-- '
+          is written out.
+        quotedepth (default: 0)
+          The quote depth of the chunk.
+
+
+        Examples
+        --------
+
+        The encoder has to deal with three types of text chunks. To illustrate,
+        we create a encoder instance geared:
+
+            >>> encoder = FormatFlowedEncoder(width=45)
+
+        We can then use this encoder to encode some examples of these different
+        types:
+
+        - fixed lines:
+
+            >>> encoder.encodeChunk(u'A fixed line remains unaltered', FIXED)
+            'A fixed line remains unaltered\\r\\n'
+            >>> encoder.encodeChunk(u'Although quoting is prepended', FIXED, 2)
+            '>> Although quoting is prepended\\r\\n'
+            >>> encoder.encodeChunk(u'Trailing spaces are removed  ', FIXED)
+            'Trailing spaces are removed\\r\\n'
+            >>> encoder.encodeChunk(u'> and special first chars are fluffed',
+            ...                     FIXED)
+            ' > and special first chars are fluffed\\r\\n'
+
+        - a paragraph (the default type):
+
+            >>> result = encoder.encodeChunk(
+            ...   u"`Take some more tea,' the March Hare said to Alice, "
+            ...   u"very earnestly.")
+            >>> result == ("`Take some more tea,' the March Hare said \\r\\n"
+            ...            "to Alice, very earnestly.\\r\\n")
+            True
+            >>> result = encoder.encodeChunk(
+            ...   u"`I've had nothing yet,' Alice replied in an offended "
+            ...   u"tone, `so I can't take more.'", PARAGRAPH, 1)
+            >>> result == ("> `I've had nothing yet,' Alice replied in \\r\\n"
+            ...            "> an offended tone, `so I can't take more.'\\r\\n")
+            True
+            >>> result = encoder.encodeChunk(
+            ...   u'The   wrapping   deals   quite   well  with > eratic '
+            ...   u'spacing and space fluffs characters where needed.')
+            >>> result == ("The   wrapping   deals   quite   well  with \\r\\n"
+            ...            " > eratic spacing and space fluffs \\r\\n"
+            ...            "characters where needed.\\r\\n")
+            True
+
+        - signature separators:
+
+            >>> encoder.encodeChunk(u'-- ', SIGNATURE_SEPARATOR)
+            '-- \\r\\n'
+            >>> encoder.encodeChunk(u'-- ', SIGNATURE_SEPARATOR, 3)
+            '>>> -- \\r\\n'
+
+          Note that the actual chunk value is ignored for this type:
+
+            >>> encoder.encodeChunk(u'foobar', SIGNATURE_SEPARATOR)
+            '-- \\r\\n'
+
+
+        Encoder options
+        ---------------
+
+        The encoding can be influenced by several instance attributes; the
+        width attribute was used for the paragraph demonstrations. Others
+        include 'extra_space', 'character_set' and 'spacestuff_quoted':
+
+        - extra_space generates extra spaces on flowed lines so flowed lines
+          can be broken on something other than whitespace:
+
+            >>> encoder = FormatFlowedEncoder(extra_space=True, width=45)
+            >>> result = encoder.encodeChunk(
+            ...   u'This is useful for texts with many word-breaks or few '
+            ...   u'spaces')
+            >>> result == ("This is useful for texts with many word- \\r\\n"
+            ...            "breaks or few spaces\\r\\n")
+            True
+
+        - character_set controls the output encoding:
+
+            >>> encoder = FormatFlowedEncoder(character_set='cp037')
+            >>> result = encoder.encodeChunk(u'Can you read me now?',
+            ...                              quotedepth=1)
+            >>> result == ('n@\\xc3\\x81\\x95@\\xa8\\x96\\xa4@\\x99\\x85\\x81'
+            ...            '\\x84@\\x94\\x85@\\x95\x96\\xa6o\\r\\n')
+            True
+
+        - spacestuff_quoted causes quoted lines to be spacestuffed by default;
+          this makes for slightly more readable quoted text output. It is on
+          by default, but can be switched off:
+
+            >>> encoder = FormatFlowedEncoder(spacestuff_quoted=False)
+            >>> encoder.encodeChunk(u'Look Ma! No space!', quotedepth=1)
+            '>Look Ma! No space!\\r\\n'
+
+
+        RFC 2822 compliance
+        -------------------
+
+        Note that RFC 2822 requires that generated lines never exceed the
+        hard limit of 998 characters without the CRLF at the end. The encoder
+        has to enforce this by chopping the lines up into pieces not exceeding
+        that length:
+
+            >>> encoder = FormatFlowedEncoder()
+            >>> result = encoder.encodeChunk(u'-' * 1500, FIXED)
+            >>> result = result.split('\\r\\n')
+            >>> len(result)
+            3
+            >>> len(result[0])
+            998
+            >>> result == ['-' * 998, '-' * 502, '']
+            True
+
+        """
+        # cleanup: replace newlines with spaces and remove trailing spaces
+        chunk = ' '.join(chunk.rstrip().splitlines())
+
+        # Pre-encode quoting
+        quotemarker = u'>' * quotedepth
+        quotemarker = quotemarker.encode(self.character_set)
+        forcestuff = self.spacestuff_quoted and quotedepth > 0
+
+        if type == SIGNATURE_SEPARATOR:
+            chunk = u'-- '
+
+        if type == PARAGRAPH:
+            # Maximum width is reduced by stuffing and quotemarkers
+            width = self.width - len(quotemarker) - 2
+            if width <= 0:
+                raise ValueError('Not enough width for both quoting and text')
+            wrapper = _FlowedTextWrapper(width, self.extra_space)
+            chunk = wrapper.wrap(chunk)
+        else:
+            chunk = [chunk]
+
+        lines = []
+        for line in chunk:
+            # add space to flowed lines (all but last); this is an extra space
+            # if the wrapping of paragraphs included spaces at the end of the
+            # lines.
+            if line != chunk[-1]:
+                line += ' '
+            line = self._spacestuff(line, forcestuff)
+            line = quotemarker + line.encode(self.character_set)
+
+            # Enforce a hard limit of 998 characters per line (excluding CRLF)
+            # Unfortunately we can only enforce this *after* encoding,
+            # otherwise we could flow lines that are too long.
+            while len(line) > 998:
+                lines.append(line[:998])
+                line = line[998:]
+
+            lines.append(line)
+
+        lines.append('') # ensure last ending CRLF
+        return '\r\n'.join(lines)
+
+
+# -- Convenience functions ---------------------------------------------
+
+
+def decode(flowed, **kwargs):
+    """Convert format=flowed text
+
+    See the FormatFlowedDecoder.decode docstring for more information. All
+    keyword arguments are passed to the FormatFlowedDecoder instance.
+
+    """
+    decoder = FormatFlowedDecoder(**kwargs)
+    return decoder.decode(flowed)
+
+def encode(chunks, **kwargs):
+    """Convert chunks of text to format=flowed
+
+    See the FormatFlowedEncoder.encode docstring for more information. All
+    keyword arguments are passed to the FormatFlowedEncoder instance.
+
+    """
+    encoder = FormatFlowedEncoder(**kwargs)
+    return encoder.encode(chunks)
+
+def convertToWrapped(flowed, width=78, quote=u'>', wrap_fixed=True, **kwargs):
+    """Covert flowed text to encoded and wrapped text
+
+    Create text suitable for a proportional font, fixed with, plain text
+    display. The argements are interpreted as follows:
+      flowed
+        The format=flowed formatted text to convert
+      width (default: 78)
+        The maximum line length at which to wrap paragraphs.
+      quote (default: u'>')
+        Character sequence to use to mark quote depths; it is multiplied with
+        the quotedepth to quote a line. If this sequence does not end in a
+        space a space is added between the quotemars and the line.
+      wrap_fixed (default: True)
+        If true, fixed text chunks are wrapped to the given  width as well,
+        including hard word breaks if a word exceeds the line width
+
+      The remaining arguments are used as arguments to FormatFlowedDecoder.
+
+      Here is a simple example:
+
+        >>> CRLF = '\\r\\n'
+        >>> result = convertToWrapped(CRLF.join((
+        ... ">> `Take some more tea,' the March Hare said to Alice, very ",
+        ... ">> earnestly.",
+        ... ">",
+        ... "> `I've had nothing yet,' Alice replied in an offended ",
+        ... "> tone, `so I can't take more.'",
+        ... "",
+        ... "`You mean you can't take less,' said the Hatter: `it's very ",
+        ... "easy to take more than nothing.'",
+        ... "",
+        ... "-- ",
+        ... "Lewis Caroll")), width=60)
+        >>> result.split('\\n') == [
+        ...   ">> `Take some more tea,' the March Hare said to Alice, very",
+        ...   ">> earnestly.",
+        ...   "> ",
+        ...   "> `I've had nothing yet,' Alice replied in an offended tone,",
+        ...   "> `so I can't take more.'",
+        ...   "",
+        ...   "`You mean you can't take less,' said the Hatter: `it's very",
+        ...   "easy to take more than nothing.'",
+        ...   "",
+        ...   "-- ",
+        ...   "Lewis Caroll"]
+        True
+
+    """
+    result = []
+    for info, chunk in decode(flowed, **kwargs):
+        type = info['type']
+        quotedepth = info['quotedepth']
+        quotemarker = quotedepth and quote * quotedepth or u''
+        if quotemarker and quote[-1] != u' ':
+            quotemarker += u' '
+        if type == FIXED and not wrap_fixed:
+            result.append(quotemarker + chunk)
+        elif not chunk or type == SIGNATURE_SEPARATOR:
+            result.append(quotemarker + chunk)
+        else:
+            result.extend(textwrap.wrap(chunk, width,
+                                        replace_whitespace=False,
+                                        initial_indent=quotemarker,
+                                        subsequent_indent=quotemarker))
+    return u'\n'.join(result)
+
+def convertToFlowed(text, quotechars=u'>|%', **kwargs):
+    """Convert plain text to format=flowed
+
+    Attempt to interpret the plain text as paragraphs and fixed lines,
+    creating a format=flowed encoded text. The paragraph detection is fairly
+    simple and probably not suitable for real-world email.
+
+    text
+      Unicode text to be converted. Paragraphs are detected based on
+      whitelines between them, making all lines with extra linespace at the
+      start fixed to preserve that whitespace.
+    quotechars (default: u'>|%')
+      A set of characters recognized as quote markers; used to detect quote
+      depth.
+
+    Additional kwargs are passed on to FormatFlowedEncoder.
+
+    """
+    encoder = FormatFlowedEncoder(**kwargs)
+    return encoder.encode(_parseFlowableChunks(text, quotechars))
+
+
+# -- Private classes and methods ---------------------------------------
+
+
+class _FlowedTextWrapper(textwrap.TextWrapper):
+    """Custom text wrapper for flowed text
+
+    When not using extra spaces, only break on spaces; when we are using
+    extra spaces, don't swallow whitespace at the start and end of lines, but
+    do break long words (as they can be reconstructed with DelSpace on).
+
+    """
+    def __init__(self, width=78, extra_space=False):
+        textwrap.TextWrapper.__init__(self, width,
+                                      break_long_words=extra_space)
+        self.extra_space = extra_space
+        if not extra_space:
+            self.wordsep_re = re.compile(r'(\s+)')
+
+    def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
+        # _handle_long_word taken from python 2.5 CVS speed optimisation
+        # Can be removed if this is used with python 2.5
+        space_left = max(width - cur_len, 1)
+        if self.break_long_words:
+            cur_line.append(reversed_chunks[-1][:space_left])
+            reversed_chunks[-1] = reversed_chunks[-1][space_left:]
+        elif not cur_line:
+            cur_line.append(reversed_chunks.pop())
+
+    def _wrap(self, chunks):
+        # Simplified and customized version of textwrap.TextWrapper
+        # Based on textwrapper rev. 1.37 in python CVS, with speed optimisation
+        lines = []
+        chunks.reverse()
+        while chunks:
+            cur_line = []
+            cur_len = 0
+            width = self.width
+
+            # Don't strip space at the start of a line when using extra_space
+            # because spaces are significant there.
+            if chunks[-1].strip() == '' and lines and not self.extra_space:
+                del chunks[-1]
+
+            while chunks:
+                l = len(chunks[-1])
+                if cur_len + l <= width:
+                    cur_line.append(chunks.pop())
+                    cur_len += l
+                else:
+                    break
+
+            if chunks and len(chunks[-1]) > width:
+                self._handle_long_word(chunks, cur_line, cur_len, width)
+
+            # Don't drop space at end of line if using extra_space for
+            # marking flowed lines because otherwise there is no space between
+            # this line and the next when decoding the flowed text
+            if cur_line and cur_line[-1].strip() == '' and not self.extra_space:
+                del cur_line[-1]
+
+            if cur_line:
+                lines.append(''.join(cur_line))
+        return lines
+
+
+def _parseFlowableChunks(text, quotechars='>%|'):
+    """Parse out encodeble chunks, determining chunk type
+
+    First step is to remove and count quoting marks, determining the quotedepth
+    of the text. Then the type of the lines is detected.
+
+    Paragraphs are determined by terminating lines; terminating lines are
+    changes in quoting (depth or quoting used, signatures or fixed lines (see
+    below)
+
+    Fixed lines are used for lines with nothing but whitespace and for lines
+    with whitespace prepended (indented lines).
+
+    Any line with only two dashes at the start and whitespace is a signature
+    seperator.
+
+    Example code:
+
+        >>> result = _parseFlowableChunks(u'\\n'.join((
+        ...     u'Normal text, as long as they are not delimited by empty ',
+        ...     u'lines will be considered paragraphs and will be parsed as ',
+        ...     u'such.',
+        ...     u'',
+        ...     u'> > Quoting will be detected as well, and as long as it is ',
+        ...     u'> > consistent text will be collected into one paragraph.',
+        ...     u'> Changes in depth trigger a new paragraph.',
+        ...     u'>      Leading whitespace makes for fixed lines.',
+        ...     u'Signature separators are dealt with accordingly:',
+        ...     u'-- '
+        ... )))
+        >>> result.next() == ({'type': PARAGRAPH, 'quotedepth': 0},
+        ...     u'Normal text, as long as they are not delimited by empty '
+        ...     u'lines will be considered paragraphs and will be parsed as '
+        ...     u'such.')
+        True
+        >>> result.next() == ({'type': FIXED, 'quotedepth': 0}, u'')
+        True
+        >>> result.next() == ({'type': PARAGRAPH, 'quotedepth': 2},
+        ...     u'Quoting will be detected as well, and as long as it is '
+        ...     u'consistent text will be collected into one paragraph.')
+        True
+        >>> result.next() == ({'type': PARAGRAPH, 'quotedepth': 1},
+        ...     u'Changes in depth trigger a new paragraph.')
+        True
+        >>> result.next() == ({'type': FIXED, 'quotedepth': 1},
+        ...     u'     Leading whitespace makes for fixed lines.')
+        True
+        >>> result.next() == ({'type': PARAGRAPH, 'quotedepth': 0},
+        ...     u'Signature separators are dealt with accordingly:')
+        True
+        >>> result.next() == ({'type': SIGNATURE_SEPARATOR, 'quotedepth': 0},
+        ...     u'-- ')
+        True
+        >>> result.next()
+        Traceback (most recent call last):
+            ...
+        StopIteration
+
+    """
+    # Match quotemarks with limited whitespace around them
+    qm_match = re.compile('(^\s{0,2}([%s]\s?)+)' % quotechars).match
+    # Find all quotemarks
+    qm_findall = re.compile('[%s]' % quotechars).findall
+
+    quotedepth = 0
+    quotemarks = ''
+    para = u''
+
+    for line in text.splitlines():
+        has_quotes = qm_match(line)
+        same_quotes = quotemarks and line.startswith(quotemarks)
+        if (has_quotes and not same_quotes) or (not has_quotes and quotedepth):
+            # Change in quoting
+            if para:
+                yield {'type': PARAGRAPH, 'quotedepth': quotedepth}, para
+                para = u''
+
+            quotemarks = has_quotes and has_quotes.group(0) or u''
+            quotedepth = len(qm_findall(quotemarks))
+
+        line = line[len(quotemarks):]
+
+        if line.rstrip() == u'--':
+            # signature separator
+            if para:
+                yield {'type': PARAGRAPH, 'quotedepth': quotedepth}, para
+                para = u''
+
+            yield {'type': SIGNATURE_SEPARATOR, 'quotedepth': quotedepth}, line
+            continue
+
+        if line.strip() == u'' or line.lstrip() != line:
+            # Fixed line
+            if para:
+                yield {'type': PARAGRAPH, 'quotedepth': quotedepth}, para
+                para = u''
+
+            yield {'type': FIXED, 'quotedepth': quotedepth}, line
+            continue
+
+        # Paragraph line; store and loop to next line
+        para += line
+
+    if para:
+        yield {'type': PARAGRAPH, 'quotedepth': quotedepth}, para
+
+def _test(verbose=False):
+    import doctest
+    return doctest.testmod(verbose=verbose)
+
+if __name__ == '__main__':
+    import sys
+    _test('-v' in sys.argv)
Index: trunk/RetroStatus/setup.py
===================================================================
--- trunk/RetroStatus/setup.py	(revision 201)
+++ trunk/RetroStatus/setup.py	(revision 201)
@@ -0,0 +1,18 @@
+from distutils.core import setup
+import py2app
+
+# Retrospect wants the event handler called "Retrospect Event Handler" without
+# the .app suffix; py2app doesn't want to create apps with no suffix
+class TrueEmpty(object):
+    def __str__(self): return ''
+    def __add__(self, other): return other
+    def __radd__(self, other): return other
+
+setup(name='Retrospect Event Handler',
+      version='1.0a1',
+      app=['RetroStatus.py'],
+      options=dict(py2app=
+                   dict(extension=TrueEmpty(),
+                        iconfile='RetroClient.icns',
+                        plist=dict(NSAppleScriptEnabled=True,
+                                   CFBundleIdentifier='net.sabi.RetroStatus'))))
