Index: trac/mimeview/api.py
===================================================================
--- trac/mimeview/api.py	(revision 3282)
+++ trac/mimeview/api.py	(working copy)
@@ -23,7 +23,7 @@
 
 from trac.config import IntOption, Option
 from trac.core import *
-from trac.util import to_utf8, to_unicode
+from trac.util import to_utf8, to_unicode, sorted
 from trac.util.markup import escape, Markup, Fragment, html
 
 
@@ -213,11 +213,30 @@
         annotation data."""
 
 
+class IContentConverter(Interface):
+    """An extension point interface for generic MIME based content
+    conversion."""
+
+    def get_conversions():
+        """Return an iterable of tuples in the form (key, name, extension,
+        in_mimetype, out_mimetype, quality) representing the MIME conversions
+        supported and
+        the quality ratio of the conversion in the range 0 to 9, where 0 means
+        no support and 9 means "perfect" support. eg. ('latex', 'LaTeX', 'tex',
+        'text/x-trac-wiki', 'text/plain', 8)"""
+
+    def convert_content(req, mimetype, content, key):
+        """Convert the given content from mimetype to the output MIME type
+        represented by key. Returns a tuple in the form (content,
+        output_mime_type)."""
+
+
 class Mimeview(Component):
     """A generic class to prettify data, typically source code."""
 
     renderers = ExtensionPoint(IHTMLPreviewRenderer)
     annotators = ExtensionPoint(IHTMLPreviewAnnotator)
+    converters = ExtensionPoint(IContentConverter)
 
     default_charset = Option('trac', 'default_charset', 'iso-8859-15',
         """Charset to be used when in doubt.""")
@@ -230,6 +249,58 @@
 
     # Public API
 
+    def get_supported_conversions(self, mimetype):
+        """Return a list of target MIME types in same form as
+        `IContentConverter.get_conversions()`, but with the converter
+        component appended. Output is ordered from best to worst quality."""
+        converters = []
+        for converter in self.converters:
+            for k, n, e, im, om, q in converter.get_conversions():
+                if im == mimetype and q > 0:
+                    converters.append((k, n, e, im, om, q, converter))
+        converters = sorted(converters, key=lambda i: i[-1], reverse=True)
+        return converters
+
+    def convert_content(self, req, mimetype, content, key, filename=None,
+                        url=None):
+        """Convert the given content to the target MIME type represented by
+        `key`, which can be either a MIME type or a key. Returns a tuple of
+        (content, output_mime_type, extension)."""
+        if not content:
+            return ('', 'text/plain;charset=utf-8')
+
+        # Ensure we have a MIME type for this content
+        full_mimetype = mimetype
+        if not full_mimetype:
+            if hasattr(content, 'read'):
+                content = content.read(self.get_max_preview_size())
+            full_mimetype = self.get_mimetype(filename, content)
+        if full_mimetype:
+            mimetype = full_mimetype.split(';')[0].strip() # split off charset
+        else:
+            mimetype = full_mimetype = 'text/plain' # fallback if not binary
+
+        # Choose best converter
+        candidates = self.get_supported_conversions(mimetype)
+        candidates = [c for c in candidates if key in (c[0], c[4])]
+        if not candidates:
+            raise TracError('No available MIME conversions from %s to %s' %
+                            (mimetype, key))
+
+        # First candidate which converts successfully wins.
+        for ck, name, ext, input_mimettype, output_mimetype, quality, \
+                converter in candidates:
+            try:
+                output = converter.convert_content(req, mimetype, content, ck)
+                if not output:
+                    continue
+                return (output[0], output[1], ext)
+            except Exception, e:
+                self.log.warning('MIME conversion using %s failed (%s)'
+                                 % (converter, e), exc_info=True)
+        raise TracError('No available MIME conversions from %s to %s' %
+                        (mimetype, key))
+
     def get_annotation_types(self):
         """Generator that returns all available annotation types."""
         for annotator in self.annotators:
Index: trac/ticket/web_ui.py
===================================================================
--- trac/ticket/web_ui.py	(revision 3282)
+++ trac/ticket/web_ui.py	(working copy)
@@ -17,6 +17,7 @@
 import os
 import re
 import time
+from StringIO import StringIO
 
 from trac.attachment import attachment_to_hdf, Attachment
 from trac.config import BoolOption, Option
@@ -25,11 +26,13 @@
 from trac.ticket import Milestone, Ticket, TicketSystem, ITicketManipulator
 from trac.ticket.notification import TicketNotifyEmail
 from trac.Timeline import ITimelineEventProvider
-from trac.util import format_datetime, get_reporter_id, pretty_timedelta
+from trac.util import format_datetime, get_reporter_id, pretty_timedelta, \
+                      CRLF, http_date
 from trac.util.markup import html, Markup
 from trac.web import IRequestHandler
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner
+from trac.mimeview.api import Mimeview, IContentConverter
 
 
 class TicketModuleBase(Component):
@@ -179,7 +182,8 @@
 
 class TicketModule(TicketModuleBase):
 
-    implements(INavigationContributor, IRequestHandler, ITimelineEventProvider)
+    implements(INavigationContributor, IRequestHandler, ITimelineEventProvider,
+               IContentConverter)
 
     default_version = Option('ticket', 'default_version', '',
         """Default version for newly created tickets.""")
@@ -200,6 +204,24 @@
         """Enable the display of all ticket changes in the timeline
         (''since 0.9'').""")
 
+    # IContentConverter methods
+
+    def get_conversions(self):
+        yield ('csv', 'Comma-delimited Text', 'csv',
+               'application/x-trac-ticket', 'text/plain', 9)
+        yield ('tab', 'Tab-delimited Text', 'csv', 'application/x-trac-ticket',
+               'text/plain', 9)
+        yield ('rss', 'RSS Feed', 'xml', 'application/x-trac-ticket',
+               'application/rss+xml', 9)
+
+    def convert_content(self, req, mimetype, ticket, key):
+        if key == 'csv':
+            return self.export_csv(ticket)
+        elif key == 'tab':
+            return self.export_csv(ticket, sep='\t')
+        elif key == 'rss':
+            return self.export_rss(req, ticket)
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
@@ -255,6 +277,19 @@
 
         self._insert_ticket_data(req, db, ticket, reporter_id)
 
+        format = req.args.get('format')
+        if format:
+            content, output_type, ext = Mimeview(self.env).convert_content(
+                                        req, 'application/x-trac-ticket', ticket,
+                                        format)
+            req.send_response(200)
+            req.send_header('Content-Type', output_type)
+            req.send_header('Content-Disposition',
+                            'filename=#%i.%s' % (ticket.id, ext))
+            req.end_headers()
+            req.write(content)
+            return
+
         # If the ticket is being shown in the context of a query, add
         # links to help navigate in the query result set
         if 'query_tickets' in req.session:
@@ -274,6 +309,14 @@
                 add_link(req, 'up', req.session['query_href'])
 
         add_stylesheet(req, 'common/css/ticket.css')
+
+        # Add registered converters
+        for conversion in Mimeview(self.env).get_supported_conversions(
+                                             'application/x-trac-ticket'):
+            conversion_href = req.href.ticket(ticket.id, format=conversion[0])
+            add_link(req, 'alternate', conversion_href, conversion[1],
+                     conversion[3])
+
         return 'ticket.cs', None
 
     # ITimelineEventProvider methods
@@ -371,6 +414,68 @@
 
     # Internal methods
 
+    def export_csv(self, ticket, sep=',', mimetype='text/plain'):
+        content = StringIO()
+        content.write(sep.join(['id'] + [f['name'] for f in ticket.fields])
+                      + CRLF)
+        content.write(sep.join([unicode(ticket.id)] +
+                                [ticket.values.get(f['name'], '')
+                                 .replace(sep, '_').replace('\\', '\\\\')
+                                 .replace('\n', '\\n').replace('\r', '\\r')
+                                 for f in ticket.fields]) + CRLF)
+        return (content.getvalue(), '%s;charset=utf-8' % mimetype)
+        
+    def export_rss(self, req, ticket):
+        db = self.env.get_db_cnx()
+        changelog = ticket.get_changelog(db=db)
+        curr_author = None
+        curr_date   = 0
+        changes = []
+        change_summary = {}
+
+        description = wiki_to_html(ticket['description'], self.env, req, db)
+        req.hdf['ticket.description.formatted'] = unicode(description)
+
+        def update_title():
+            if not changes: return
+            title = '; '.join(['%s %s' % (', '.join(v), k)
+                               for k, v in change_summary.iteritems()])
+            changes[-1]['title'] = title
+
+        for date, author, field, old, new in changelog:
+            if date != curr_date or author != curr_author:
+                update_title()
+                change_summary = {}
+
+                changes.append({
+                    'date': http_date(date),
+                    'author': author,
+                    'fields': {}
+                })
+                curr_date = date
+                curr_author = author
+            if field == 'comment':
+                change_summary['added'] = ['comment']
+                changes[-1]['comment'] = unicode(wiki_to_html(new, self.env,
+                                                              req, db,
+                                                              absurls=True))
+            elif field == 'description':
+                change_summary.setdefault('changed', []).append(field)
+                changes[-1]['fields'][field] = ''
+            else:
+                change = 'changed'
+                if not old:
+                    change = 'set'
+                elif not new:
+                    change = 'deleted'
+                change_summary.setdefault(change, []).append(field)
+                changes[-1]['fields'][field] = {'old': old,
+                                                'new': new}
+        update_title()
+        req.hdf['ticket.changes'] = changes
+        return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml')
+
+
     def _do_save(self, req, db, ticket):
         if req.perm.has_permission('TICKET_CHGPROP'):
             # TICKET_CHGPROP gives permission to edit the ticket
Index: trac/ticket/query.py
===================================================================
--- trac/ticket/query.py	(revision 3282)
+++ trac/ticket/query.py	(working copy)
@@ -29,8 +29,8 @@
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
 from trac.wiki.macros import WikiMacroBase
+from trac.mimeview.api import Mimeview, IContentConverter
 
-
 class QuerySyntaxError(Exception):
     """Exception raised when a ticket query cannot be parsed from a string."""
 
@@ -344,8 +344,26 @@
 
 class QueryModule(Component):
 
-    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider)
+    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider,
+               IContentConverter)
 
+    # IContentConverter methods
+    def get_conversions(self):
+        yield ('rss', 'RSS Feed', 'xml', 'application/x-trac-query',
+               'application/rss+xml', 9)
+        yield ('csv', 'Comma-delimited Text', 'csv',
+               'application/x-trac-query', 'text/plain', 9)
+        yield ('tab', 'Tab-delimited Text', 'csv', 'application/x-trac-query',
+               'text/plain', 9)
+
+    def convert_content(self, req, mimetype, query, key):
+        if key == 'rss':
+            return self.export_rss(req, query)
+        elif key == 'csv':
+            return self.export_csv(query)
+        elif key == 'tab':
+            return self.export_csv(query, '\t')
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
@@ -392,12 +410,11 @@
                     del req.session[var]
             req.redirect(query.get_href())
 
-        add_link(req, 'alternate', query.get_href(format='rss'), 'RSS Feed',
-                 'application/rss+xml', 'rss')
-        add_link(req, 'alternate', query.get_href(format='csv'),
-                 'Comma-delimited Text', 'text/plain')
-        add_link(req, 'alternate', query.get_href(format='tab'),
-                 'Tab-delimited Text', 'text/plain')
+        # Add registered converters
+        for conversion in Mimeview(self.env).get_supported_conversions(
+                                             'application/x-trac-query'):
+            add_link(req, 'alternate', query.get_href(format=conversion[0]),
+                      conversion[1], conversion[3])
 
         constraints = {}
         for k, v in query.constraints.items():
@@ -415,17 +432,20 @@
         req.hdf['query.constraints'] = constraints
 
         format = req.args.get('format')
-        if format == 'rss':
-            self.display_rss(req, query)
-            return 'query_rss.cs', 'application/rss+xml'
-        elif format == 'csv':
-            self.display_csv(req, query)
-        elif format == 'tab':
-            self.display_csv(req, query, '\t')
-        else:
-            self.display_html(req, query)
-            return 'query.cs', None
+        if format:
+            content, output_type, ext = Mimeview(self.env).convert_content(
+                                        req, 'application/x-trac-query', query,
+                                        format)
+            req.send_response(200)
+            req.send_header('Content-Type', output_type)
+            req.send_header('Content-Disposition', 'filename=query.' + ext)
+            req.end_headers()
+            req.write(content)
+            return
 
+        self.display_html(req, query)
+        return 'query.cs', None
+
     # Internal methods
 
     def _get_constraints(self, req):
@@ -595,22 +615,20 @@
            self.env.is_component_enabled(ReportModule):
             req.hdf['query.report_href'] = req.href.report()
 
-    def display_csv(self, req, query, sep=','):
-        req.send_response(200)
-        req.send_header('Content-Type', 'text/plain;charset=utf-8')
-        req.end_headers()
-
+    def export_csv(self, query, sep=',', mimetype='text/plain'):
+        content = StringIO()
         cols = query.get_columns()
-        req.write(sep.join([col for col in cols]) + CRLF)
+        content.write(sep.join([col for col in cols]) + CRLF)
 
         results = query.execute(self.env.get_db_cnx())
         for result in results:
-            req.write(sep.join([unicode(result[col]).replace(sep, '_')
-                                                    .replace('\n', ' ')
-                                                    .replace('\r', ' ')
-                                for col in cols]) + CRLF)
+            content.write(sep.join([unicode(result[col]).replace(sep, '_')
+                                                        .replace('\n', ' ')
+                                                        .replace('\r', ' ')
+                                    for col in cols]) + CRLF)
+        return (content.getvalue(), '%s;charset=utf-8' % mimetype)
 
-    def display_rss(self, req, query):
+    def export_rss(self, req, query):
         query.verbose = True
         db = self.env.get_db_cnx()
         results = query.execute(db)
@@ -630,6 +648,7 @@
                 groupdesc=query.groupdesc and 1 or None,
                 verbose=query.verbose and 1 or None,
                 **query.constraints)
+        return (req.hdf.render('query_rss.cs'), 'application/rss+xml')
 
     # IWikiSyntaxProvider methods
     
Index: trac/wiki/web_ui.py
===================================================================
--- trac/wiki/web_ui.py	(revision 3282)
+++ trac/wiki/web_ui.py	(working copy)
@@ -33,15 +33,23 @@
 from trac.wiki.api import IWikiPageManipulator
 from trac.wiki.model import WikiPage
 from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner
+from trac.mimeview.api import Mimeview, IContentConverter
 
 
 class WikiModule(Component):
 
     implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
-               ITimelineEventProvider, ISearchSource)
+               ITimelineEventProvider, ISearchSource, IContentConverter)
 
     page_manipulators = ExtensionPoint(IWikiPageManipulator)
 
+    # IContentConverter methods
+    def get_conversions(self):
+        yield ('txt', 'Plain Text', 'txt', 'text/x-trac-wiki', 'text/plain', 9)
+
+    def convert_content(self, req, mimetype, content, key):
+        return (content, 'text/plain;charset=utf-8')
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
@@ -111,12 +119,19 @@
         elif action == 'history':
             self._render_history(req, db, page)
         else:
-            if req.args.get('format') == 'txt':
+            format = req.args.get('format')
+            if format:
+                content, output_type, ext = Mimeview(self.env).convert_content(
+                                            req, 'text/x-trac-wiki', page.text,
+                                            format)
                 req.send_response(200)
-                req.send_header('Content-Type', 'text/plain;charset=utf-8')
+                req.send_header('Content-Type', output_type)
+                req.send_header('Content-Disposition',
+                                'filename=%s.%s' % (page.name, ext))
                 req.end_headers()
-                req.write(page.text)
+                req.write(content)
                 return
+
             self._render_view(req, db, page)
 
         req.hdf['wiki.action'] = action
@@ -362,8 +377,13 @@
             # Ask web spiders to not index old versions
             req.hdf['html.norobots'] = 1
 
-        txt_href = req.href.wiki(page.name, version=version, format='txt')
-        add_link(req, 'alternate', txt_href, 'Plain Text', 'text/plain')
+        # Add registered converters
+        for conversion in Mimeview(self.env).get_supported_conversions(
+                                             'text/x-trac-wiki'):
+            conversion_href = req.href.wiki(page.name, version=version,
+                                            format=conversion[0])
+            add_link(req, 'alternate', conversion_href, conversion[1],
+                     conversion[3])
 
         req.hdf['wiki'] = {'page_name': page.name, 'exists': page.exists,
                            'version': page.version, 'readonly': page.readonly}
Index: templates/ticket_rss.cs
===================================================================
--- templates/ticket_rss.cs	(revision 0)
+++ templates/ticket_rss.cs	(revision 0)
@@ -0,0 +1,42 @@
+<?xml version="1.0"?>
+<!-- RSS generated by Trac v<?cs var:trac.version ?> on <?cs var:trac.time ?> -->
+<rss version="2.0">
+ <channel><?cs 
+  if:project.name_encoded ?>
+   <title><?cs var:project.name_encoded ?>: Ticket <?cs var:title ?></title><?cs 
+  else ?>
+   <title>Ticket <?cs var:title ?></title><?cs 
+  /if ?>
+  <link><?cs var:base_host ?><?cs var:ticket.href ?></link>
+  <description><?cs var:ticket.description.formatted ?></description>
+  <language>en-us</language>
+  <generator>Trac v<?cs var:trac.version ?></generator><?cs 
+  each:change = ticket.changes ?>
+   <item><?cs
+    if:change.author ?><author><?cs var:change.author ?></author><?cs
+    /if ?>
+    <pubDate><?cs var:change.date ?></pubDate>
+    <title><?cs var:change.title ?></title>
+    <link><?cs var:base_host ?><?cs var:ticket.href ?></link>
+    <description>
+    <?cs if:len(change.fields) ?>
+    &lt;ul&gt;<?cs
+    each:field = change.fields ?>
+    &lt;li&gt;&lt;strong&gt;<?cs name:field ?>&lt;/strong&gt; <?cs
+     if:!field.old ?>set to &lt;em&gt;<?cs
+      var:field.new ?>&lt;/em&gt;<?cs
+     elif:field.new ?>changed from &lt;em&gt;<?cs var:field.old
+      ?>&lt;/em&gt; to &lt;em&gt;<?cs
+      var:field.new ?>&lt;/em&gt;.<?cs
+     else ?>deleted<?cs
+     /if ?>&lt;/li&gt;<?cs
+    /each ?>
+    &lt;/ul&gt;
+    <?cs /if ?>
+    <?cs var:change.comment ?>
+    </description>
+    <category>Ticket</category>
+   </item><?cs 
+  /each ?>
+ </channel>
+</rss>

