Edgewall Software

Ticket #2296: content-converter.diff

File content-converter.diff, 20.5 KB (added by athomas, 3 years ago)

New patch, but interface now returns the default extension for each conversion

  • trac/mimeview/api.py

     
    2323 
    2424from trac.config import IntOption, Option 
    2525from trac.core import * 
    26 from trac.util import to_utf8, to_unicode 
     26from trac.util import to_utf8, to_unicode, sorted 
    2727from trac.util.markup import escape, Markup, Fragment, html 
    2828 
    2929 
     
    213213        annotation data.""" 
    214214 
    215215 
     216class IContentConverter(Interface): 
     217    """An extension point interface for generic MIME based content 
     218    conversion.""" 
     219 
     220    def get_conversions(): 
     221        """Return an iterable of tuples in the form (key, name, extension, 
     222        in_mimetype, out_mimetype, quality) representing the MIME conversions 
     223        supported and 
     224        the quality ratio of the conversion in the range 0 to 9, where 0 means 
     225        no support and 9 means "perfect" support. eg. ('latex', 'LaTeX', 'tex', 
     226        'text/x-trac-wiki', 'text/plain', 8)""" 
     227 
     228    def convert_content(req, mimetype, content, key): 
     229        """Convert the given content from mimetype to the output MIME type 
     230        represented by key. Returns a tuple in the form (content, 
     231        output_mime_type).""" 
     232 
     233 
    216234class Mimeview(Component): 
    217235    """A generic class to prettify data, typically source code.""" 
    218236 
    219237    renderers = ExtensionPoint(IHTMLPreviewRenderer) 
    220238    annotators = ExtensionPoint(IHTMLPreviewAnnotator) 
     239    converters = ExtensionPoint(IContentConverter) 
    221240 
    222241    default_charset = Option('trac', 'default_charset', 'iso-8859-15', 
    223242        """Charset to be used when in doubt.""") 
     
    230249 
    231250    # Public API 
    232251 
     252    def get_supported_conversions(self, mimetype): 
     253        """Return a list of target MIME types in same form as 
     254        `IContentConverter.get_conversions()`, but with the converter 
     255        component appended. Output is ordered from best to worst quality.""" 
     256        converters = [] 
     257        for converter in self.converters: 
     258            for k, n, e, im, om, q in converter.get_conversions(): 
     259                if im == mimetype and q > 0: 
     260                    converters.append((k, n, e, im, om, q, converter)) 
     261        converters = sorted(converters, key=lambda i: i[-1], reverse=True) 
     262        return converters 
     263 
     264    def convert_content(self, req, mimetype, content, key, filename=None, 
     265                        url=None): 
     266        """Convert the given content to the target MIME type represented by 
     267        `key`, which can be either a MIME type or a key. Returns a tuple of 
     268        (content, output_mime_type, extension).""" 
     269        if not content: 
     270            return ('', 'text/plain;charset=utf-8') 
     271 
     272        # Ensure we have a MIME type for this content 
     273        full_mimetype = mimetype 
     274        if not full_mimetype: 
     275            if hasattr(content, 'read'): 
     276                content = content.read(self.get_max_preview_size()) 
     277            full_mimetype = self.get_mimetype(filename, content) 
     278        if full_mimetype: 
     279            mimetype = full_mimetype.split(';')[0].strip() # split off charset 
     280        else: 
     281            mimetype = full_mimetype = 'text/plain' # fallback if not binary 
     282 
     283        # Choose best converter 
     284        candidates = self.get_supported_conversions(mimetype) 
     285        candidates = [c for c in candidates if key in (c[0], c[4])] 
     286        if not candidates: 
     287            raise TracError('No available MIME conversions from %s to %s' % 
     288                            (mimetype, key)) 
     289 
     290        # First candidate which converts successfully wins. 
     291        for ck, name, ext, input_mimettype, output_mimetype, quality, \ 
     292                converter in candidates: 
     293            try: 
     294                output = converter.convert_content(req, mimetype, content, ck) 
     295                if not output: 
     296                    continue 
     297                return (output[0], output[1], ext) 
     298            except Exception, e: 
     299                self.log.warning('MIME conversion using %s failed (%s)' 
     300                                 % (converter, e), exc_info=True) 
     301        raise TracError('No available MIME conversions from %s to %s' % 
     302                        (mimetype, key)) 
     303 
    233304    def get_annotation_types(self): 
    234305        """Generator that returns all available annotation types.""" 
    235306        for annotator in self.annotators: 
  • trac/ticket/web_ui.py

     
    1717import os 
    1818import re 
    1919import time 
     20from StringIO import StringIO 
    2021 
    2122from trac.attachment import attachment_to_hdf, Attachment 
    2223from trac.config import BoolOption, Option 
     
    2526from trac.ticket import Milestone, Ticket, TicketSystem, ITicketManipulator 
    2627from trac.ticket.notification import TicketNotifyEmail 
    2728from trac.Timeline import ITimelineEventProvider 
    28 from trac.util import format_datetime, get_reporter_id, pretty_timedelta 
     29from trac.util import format_datetime, get_reporter_id, pretty_timedelta, \ 
     30                      CRLF, http_date 
    2931from trac.util.markup import html, Markup 
    3032from trac.web import IRequestHandler 
    3133from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 
    3234from trac.wiki import wiki_to_html, wiki_to_oneliner 
     35from trac.mimeview.api import Mimeview, IContentConverter 
    3336 
    3437 
    3538class TicketModuleBase(Component): 
     
    179182 
    180183class TicketModule(TicketModuleBase): 
    181184 
    182     implements(INavigationContributor, IRequestHandler, ITimelineEventProvider) 
     185    implements(INavigationContributor, IRequestHandler, ITimelineEventProvider, 
     186               IContentConverter) 
    183187 
    184188    default_version = Option('ticket', 'default_version', '', 
    185189        """Default version for newly created tickets.""") 
     
    200204        """Enable the display of all ticket changes in the timeline 
    201205        (''since 0.9'').""") 
    202206 
     207    # IContentConverter methods 
     208 
     209    def get_conversions(self): 
     210        yield ('csv', 'Comma-delimited Text', 'csv', 
     211               'application/x-trac-ticket', 'text/plain', 9) 
     212        yield ('tab', 'Tab-delimited Text', 'csv', 'application/x-trac-ticket', 
     213               'text/plain', 9) 
     214        yield ('rss', 'RSS Feed', 'xml', 'application/x-trac-ticket', 
     215               'application/rss+xml', 9) 
     216 
     217    def convert_content(self, req, mimetype, ticket, key): 
     218        if key == 'csv': 
     219            return self.export_csv(ticket) 
     220        elif key == 'tab': 
     221            return self.export_csv(ticket, sep='\t') 
     222        elif key == 'rss': 
     223            return self.export_rss(req, ticket) 
     224 
    203225    # INavigationContributor methods 
    204226 
    205227    def get_active_navigation_item(self, req): 
     
    255277 
    256278        self._insert_ticket_data(req, db, ticket, reporter_id) 
    257279 
     280        format = req.args.get('format') 
     281        if format: 
     282            content, output_type, ext = Mimeview(self.env).convert_content( 
     283                                        req, 'application/x-trac-ticket', ticket, 
     284                                        format) 
     285            req.send_response(200) 
     286            req.send_header('Content-Type', output_type) 
     287            req.send_header('Content-Disposition', 
     288                            'filename=#%i.%s' % (ticket.id, ext)) 
     289            req.end_headers() 
     290            req.write(content) 
     291            return 
     292 
    258293        # If the ticket is being shown in the context of a query, add 
    259294        # links to help navigate in the query result set 
    260295        if 'query_tickets' in req.session: 
     
    274309                add_link(req, 'up', req.session['query_href']) 
    275310 
    276311        add_stylesheet(req, 'common/css/ticket.css') 
     312 
     313        # Add registered converters 
     314        for conversion in Mimeview(self.env).get_supported_conversions( 
     315                                             'application/x-trac-ticket'): 
     316            conversion_href = req.href.ticket(ticket.id, format=conversion[0]) 
     317            add_link(req, 'alternate', conversion_href, conversion[1], 
     318                     conversion[3]) 
     319 
    277320        return 'ticket.cs', None 
    278321 
    279322    # ITimelineEventProvider methods 
     
    371414 
    372415    # Internal methods 
    373416 
     417    def export_csv(self, ticket, sep=',', mimetype='text/plain'): 
     418        content = StringIO() 
     419        content.write(sep.join(['id'] + [f['name'] for f in ticket.fields]) 
     420                      + CRLF) 
     421        content.write(sep.join([unicode(ticket.id)] + 
     422                                [ticket.values.get(f['name'], '') 
     423                                 .replace(sep, '_').replace('\\', '\\\\') 
     424                                 .replace('\n', '\\n').replace('\r', '\\r') 
     425                                 for f in ticket.fields]) + CRLF) 
     426        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
     427         
     428    def export_rss(self, req, ticket): 
     429        db = self.env.get_db_cnx() 
     430        changelog = ticket.get_changelog(db=db) 
     431        curr_author = None 
     432        curr_date   = 0 
     433        changes = [] 
     434        change_summary = {} 
     435 
     436        description = wiki_to_html(ticket['description'], self.env, req, db) 
     437        req.hdf['ticket.description.formatted'] = unicode(description) 
     438 
     439        def update_title(): 
     440            if not changes: return 
     441            title = '; '.join(['%s %s' % (', '.join(v), k) 
     442                               for k, v in change_summary.iteritems()]) 
     443            changes[-1]['title'] = title 
     444 
     445        for date, author, field, old, new in changelog: 
     446            if date != curr_date or author != curr_author: 
     447                update_title() 
     448                change_summary = {} 
     449 
     450                changes.append({ 
     451                    'date': http_date(date), 
     452                    'author': author, 
     453                    'fields': {} 
     454                }) 
     455                curr_date = date 
     456                curr_author = author 
     457            if field == 'comment': 
     458                change_summary['added'] = ['comment'] 
     459                changes[-1]['comment'] = unicode(wiki_to_html(new, self.env, 
     460                                                              req, db, 
     461                                                              absurls=True)) 
     462            elif field == 'description': 
     463                change_summary.setdefault('changed', []).append(field) 
     464                changes[-1]['fields'][field] = '' 
     465            else: 
     466                change = 'changed' 
     467                if not old: 
     468                    change = 'set' 
     469                elif not new: 
     470                    change = 'deleted' 
     471                change_summary.setdefault(change, []).append(field) 
     472                changes[-1]['fields'][field] = {'old': old, 
     473                                                'new': new} 
     474        update_title() 
     475        req.hdf['ticket.changes'] = changes 
     476        return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml') 
     477 
     478 
    374479    def _do_save(self, req, db, ticket): 
    375480        if req.perm.has_permission('TICKET_CHGPROP'): 
    376481            # TICKET_CHGPROP gives permission to edit the ticket 
  • trac/ticket/query.py

     
    2929from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 
    3030from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider 
    3131from trac.wiki.macros import WikiMacroBase 
     32from trac.mimeview.api import Mimeview, IContentConverter 
    3233 
    33  
    3434class QuerySyntaxError(Exception): 
    3535    """Exception raised when a ticket query cannot be parsed from a string.""" 
    3636 
     
    344344 
    345345class QueryModule(Component): 
    346346 
    347     implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider) 
     347    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider, 
     348               IContentConverter) 
    348349 
     350    # IContentConverter methods 
     351    def get_conversions(self): 
     352        yield ('rss', 'RSS Feed', 'xml', 'application/x-trac-query', 
     353               'application/rss+xml', 9) 
     354        yield ('csv', 'Comma-delimited Text', 'csv', 
     355               'application/x-trac-query', 'text/plain', 9) 
     356        yield ('tab', 'Tab-delimited Text', 'csv', 'application/x-trac-query', 
     357               'text/plain', 9) 
     358 
     359    def convert_content(self, req, mimetype, query, key): 
     360        if key == 'rss': 
     361            return self.export_rss(req, query) 
     362        elif key == 'csv': 
     363            return self.export_csv(query) 
     364        elif key == 'tab': 
     365            return self.export_csv(query, '\t') 
     366 
    349367    # INavigationContributor methods 
    350368 
    351369    def get_active_navigation_item(self, req): 
     
    392410                    del req.session[var] 
    393411            req.redirect(query.get_href()) 
    394412 
    395         add_link(req, 'alternate', query.get_href(format='rss'), 'RSS Feed', 
    396                  'application/rss+xml', 'rss') 
    397         add_link(req, 'alternate', query.get_href(format='csv'), 
    398                  'Comma-delimited Text', 'text/plain') 
    399         add_link(req, 'alternate', query.get_href(format='tab'), 
    400                  'Tab-delimited Text', 'text/plain') 
     413        # Add registered converters 
     414        for conversion in Mimeview(self.env).get_supported_conversions( 
     415                                             'application/x-trac-query'): 
     416            add_link(req, 'alternate', query.get_href(format=conversion[0]), 
     417                      conversion[1], conversion[3]) 
    401418 
    402419        constraints = {} 
    403420        for k, v in query.constraints.items(): 
     
    415432        req.hdf['query.constraints'] = constraints 
    416433 
    417434        format = req.args.get('format') 
    418         if format == 'rss': 
    419             self.display_rss(req, query) 
    420             return 'query_rss.cs', 'application/rss+xml' 
    421         elif format == 'csv': 
    422             self.display_csv(req, query) 
    423         elif format == 'tab': 
    424             self.display_csv(req, query, '\t') 
    425         else: 
    426             self.display_html(req, query) 
    427             return 'query.cs', None 
     435        if format: 
     436            content, output_type, ext = Mimeview(self.env).convert_content( 
     437                                        req, 'application/x-trac-query', query, 
     438                                        format) 
     439            req.send_response(200) 
     440            req.send_header('Content-Type', output_type) 
     441            req.send_header('Content-Disposition', 'filename=query.' + ext) 
     442            req.end_headers() 
     443            req.write(content) 
     444            return 
    428445 
     446        self.display_html(req, query) 
     447        return 'query.cs', None 
     448 
    429449    # Internal methods 
    430450 
    431451    def _get_constraints(self, req): 
     
    595615           self.env.is_component_enabled(ReportModule): 
    596616            req.hdf['query.report_href'] = req.href.report() 
    597617 
    598     def display_csv(self, req, query, sep=','): 
    599         req.send_response(200) 
    600         req.send_header('Content-Type', 'text/plain;charset=utf-8') 
    601         req.end_headers() 
    602  
     618    def export_csv(self, query, sep=',', mimetype='text/plain'): 
     619        content = StringIO() 
    603620        cols = query.get_columns() 
    604         req.write(sep.join([col for col in cols]) + CRLF) 
     621        content.write(sep.join([col for col in cols]) + CRLF) 
    605622 
    606623        results = query.execute(self.env.get_db_cnx()) 
    607624        for result in results: 
    608             req.write(sep.join([unicode(result[col]).replace(sep, '_') 
    609                                                     .replace('\n', ' ') 
    610                                                     .replace('\r', ' ') 
    611                                 for col in cols]) + CRLF) 
     625            content.write(sep.join([unicode(result[col]).replace(sep, '_') 
     626                                                        .replace('\n', ' ') 
     627                                                        .replace('\r', ' ') 
     628                                    for col in cols]) + CRLF) 
     629        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
    612630 
    613     def display_rss(self, req, query): 
     631    def export_rss(self, req, query): 
    614632        query.verbose = True 
    615633        db = self.env.get_db_cnx() 
    616634        results = query.execute(db) 
     
    630648                groupdesc=query.groupdesc and 1 or None, 
    631649                verbose=query.verbose and 1 or None, 
    632650                **query.constraints) 
     651        return (req.hdf.render('query_rss.cs'), 'application/rss+xml') 
    633652 
    634653    # IWikiSyntaxProvider methods 
    635654     
  • trac/wiki/web_ui.py

     
    3333from trac.wiki.api import IWikiPageManipulator 
    3434from trac.wiki.model import WikiPage 
    3535from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner 
     36from trac.mimeview.api import Mimeview, IContentConverter 
    3637 
    3738 
    3839class WikiModule(Component): 
    3940 
    4041    implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 
    41                ITimelineEventProvider, ISearchSource) 
     42               ITimelineEventProvider, ISearchSource, IContentConverter) 
    4243 
    4344    page_manipulators = ExtensionPoint(IWikiPageManipulator) 
    4445 
     46    # IContentConverter methods 
     47    def get_conversions(self): 
     48        yield ('txt', 'Plain Text', 'txt', 'text/x-trac-wiki', 'text/plain', 9) 
     49 
     50    def convert_content(self, req, mimetype, content, key): 
     51        return (content, 'text/plain;charset=utf-8') 
     52 
    4553    # INavigationContributor methods 
    4654 
    4755    def get_active_navigation_item(self, req): 
     
    111119        elif action == 'history': 
    112120            self._render_history(req, db, page) 
    113121        else: 
    114             if req.args.get('format') == 'txt': 
     122            format = req.args.get('format') 
     123            if format: 
     124                content, output_type, ext = Mimeview(self.env).convert_content( 
     125                                            req, 'text/x-trac-wiki', page.text, 
     126                                            format) 
    115127                req.send_response(200) 
    116                 req.send_header('Content-Type', 'text/plain;charset=utf-8') 
     128                req.send_header('Content-Type', output_type) 
     129                req.send_header('Content-Disposition', 
     130                                'filename=%s.%s' % (page.name, ext)) 
    117131                req.end_headers() 
    118                 req.write(page.text) 
     132                req.write(content) 
    119133                return 
     134 
    120135            self._render_view(req, db, page) 
    121136 
    122137        req.hdf['wiki.action'] = action 
     
    362377            # Ask web spiders to not index old versions 
    363378            req.hdf['html.norobots'] = 1 
    364379 
    365         txt_href = req.href.wiki(page.name, version=version, format='txt') 
    366         add_link(req, 'alternate', txt_href, 'Plain Text', 'text/plain') 
     380        # Add registered converters 
     381        for conversion in Mimeview(self.env).get_supported_conversions( 
     382                                             'text/x-trac-wiki'): 
     383            conversion_href = req.href.wiki(page.name, version=version, 
     384                                            format=conversion[0]) 
     385            add_link(req, 'alternate', conversion_href, conversion[1], 
     386                     conversion[3]) 
    367387 
    368388        req.hdf['wiki'] = {'page_name': page.name, 'exists': page.exists, 
    369389                           'version': page.version, 'readonly': page.readonly} 
  • templates/ticket_rss.cs

     
     1<?xml version="1.0"?> 
     2<!-- RSS generated by Trac v<?cs var:trac.version ?> on <?cs var:trac.time ?> --> 
     3<rss version="2.0"> 
     4 <channel><?cs  
     5  if:project.name_encoded ?> 
     6   <title><?cs var:project.name_encoded ?>: Ticket <?cs var:title ?></title><?cs  
     7  else ?> 
     8   <title>Ticket <?cs var:title ?></title><?cs  
     9  /if ?> 
     10  <link><?cs var:base_host ?><?cs var:ticket.href ?></link> 
     11  <description><?cs var:ticket.description.formatted ?></description> 
     12  <language>en-us</language> 
     13  <generator>Trac v<?cs var:trac.version ?></generator><?cs  
     14  each:change = ticket.changes ?> 
     15   <item><?cs 
     16    if:change.author ?><author><?cs var:change.author ?></author><?cs 
     17    /if ?> 
     18    <pubDate><?cs var:change.date ?></pubDate> 
     19    <title><?cs var:change.title ?></title> 
     20    <link><?cs var:base_host ?><?cs var:ticket.href ?></link> 
     21    <description> 
     22    <?cs if:len(change.fields) ?> 
     23    &lt;ul&gt;<?cs 
     24    each:field = change.fields ?> 
     25    &lt;li&gt;&lt;strong&gt;<?cs name:field ?>&lt;/strong&gt; <?cs 
     26     if:!field.old ?>set to &lt;em&gt;<?cs 
     27      var:field.new ?>&lt;/em&gt;<?cs 
     28     elif:field.new ?>changed from &lt;em&gt;<?cs var:field.old 
     29      ?>&lt;/em&gt; to &lt;em&gt;<?cs 
     30      var:field.new ?>&lt;/em&gt;.<?cs 
     31     else ?>deleted<?cs 
     32     /if ?>&lt;/li&gt;<?cs 
     33    /each ?> 
     34    &lt;/ul&gt; 
     35    <?cs /if ?> 
     36    <?cs var:change.comment ?> 
     37    </description> 
     38    <category>Ticket</category> 
     39   </item><?cs  
     40  /each ?> 
     41 </channel> 
     42</rss>