1 | # -*- coding: utf-8 -*- |
---|
2 | |
---|
3 | import inspect |
---|
4 | import re |
---|
5 | import types |
---|
6 | from datetime import datetime |
---|
7 | from itertools import chain, groupby |
---|
8 | |
---|
9 | from trac.core import Component, implements |
---|
10 | from trac.env import Environment |
---|
11 | from trac.mimeview.api import Context, IContentConverter |
---|
12 | from trac.resource import Resource, get_resource_url |
---|
13 | from trac.ticket.api import TicketSystem |
---|
14 | from trac.ticket.model import Ticket |
---|
15 | from trac.ticket.query import Query |
---|
16 | from trac.ticket.web_ui import TicketModule |
---|
17 | from trac.util import Ranges |
---|
18 | from trac.util.text import empty, unicode_urlencode |
---|
19 | from trac.web.api import IRequestFilter, RequestDone |
---|
20 | from trac.web.chrome import Chrome, add_link |
---|
21 | try: |
---|
22 | from trac.util.datefmt import from_utimestamp |
---|
23 | except ImportError: |
---|
24 | from datetime import timedelta |
---|
25 | from trac.util.datefmt import utc |
---|
26 | _epoc = datetime(1970, 1, 1, tzinfo=utc) |
---|
27 | from_utimestamp = lambda ts: _epoc + timedelta(seconds=ts or 0) |
---|
28 | |
---|
29 | from tracexceldownload.api import (get_excel_format, get_excel_mimetype, |
---|
30 | get_workbook_writer) |
---|
31 | from tracexceldownload.translation import _, dgettext, dngettext |
---|
32 | |
---|
33 | |
---|
34 | if hasattr(Environment, 'get_read_db'): |
---|
35 | _get_db = lambda env: env.get_read_db() |
---|
36 | else: |
---|
37 | _get_db = lambda env: env.get_db_cnx() |
---|
38 | |
---|
39 | |
---|
40 | def _tkt_id_conditions(column, tkt_ids): |
---|
41 | ranges = Ranges() |
---|
42 | ranges.appendrange(','.join(map(str, sorted(tkt_ids)))) |
---|
43 | condition = [] |
---|
44 | tkt_ids = [] |
---|
45 | for a, b in ranges.pairs: |
---|
46 | if a == b: |
---|
47 | tkt_ids.append(a) |
---|
48 | elif a + 1 == b: |
---|
49 | tkt_ids.extend((a, b)) |
---|
50 | else: |
---|
51 | condition.append('%s BETWEEN %d AND %d' % (column, a, b)) |
---|
52 | if tkt_ids: |
---|
53 | condition.append('%s IN (%s)' % (column, ','.join(map(str, tkt_ids)))) |
---|
54 | return ' OR '.join(condition) |
---|
55 | |
---|
56 | |
---|
57 | class BulkFetchTicket(Ticket): |
---|
58 | |
---|
59 | @classmethod |
---|
60 | def select(cls, env, tkt_ids): |
---|
61 | if not tkt_ids: |
---|
62 | return {} |
---|
63 | |
---|
64 | db = _get_db(env) |
---|
65 | fields = TicketSystem(env).get_ticket_fields() |
---|
66 | std_fields = [f['name'] for f in fields if not f.get('custom')] |
---|
67 | time_fields = [f['name'] for f in fields if f['type'] == 'time'] |
---|
68 | custom_fields = set(f['name'] for f in fields if f.get('custom')) |
---|
69 | cursor = db.cursor() |
---|
70 | tickets = {} |
---|
71 | |
---|
72 | cursor.execute('SELECT %s,id FROM ticket WHERE %s' % |
---|
73 | (','.join(std_fields), |
---|
74 | _tkt_id_conditions('id', tkt_ids))) |
---|
75 | for row in cursor: |
---|
76 | id = row[-1] |
---|
77 | values = {} |
---|
78 | for idx, field in enumerate(std_fields): |
---|
79 | value = row[idx] |
---|
80 | if field in time_fields: |
---|
81 | value = from_utimestamp(value) |
---|
82 | elif value is None: |
---|
83 | value = empty |
---|
84 | values[field] = value |
---|
85 | tickets[id] = (values, []) # values, changelog |
---|
86 | |
---|
87 | cursor.execute('SELECT ticket,name,value FROM ticket_custom ' |
---|
88 | 'WHERE %s ORDER BY ticket' % |
---|
89 | _tkt_id_conditions('ticket', tkt_ids)) |
---|
90 | for id, rows in groupby(cursor, lambda row: row[0]): |
---|
91 | if id not in tickets: |
---|
92 | continue |
---|
93 | values = {} |
---|
94 | for id, name, value in rows: |
---|
95 | if name in custom_fields: |
---|
96 | if value is None: |
---|
97 | value = empty |
---|
98 | values[name] = value |
---|
99 | tickets[id][0].update(values) |
---|
100 | |
---|
101 | cursor.execute('SELECT ticket,time,author,field,oldvalue,newvalue ' |
---|
102 | 'FROM ticket_change WHERE %s ORDER BY ticket,time' % |
---|
103 | _tkt_id_conditions('ticket', tkt_ids)) |
---|
104 | for id, rows in groupby(cursor, lambda row: row[0]): |
---|
105 | if id not in tickets: |
---|
106 | continue |
---|
107 | tickets[id][1].extend( |
---|
108 | (from_utimestamp(t), author, field, oldvalue or '', |
---|
109 | newvalue or '', 1) |
---|
110 | for id, t, author, field, oldvalue, newvalue in rows) |
---|
111 | |
---|
112 | return dict((id, cls(env, id, values=values, changelog=changelog, |
---|
113 | fields=fields, time_fields=time_fields)) |
---|
114 | for id, (values, changelog) in tickets.iteritems()) |
---|
115 | |
---|
116 | def __init__(self, env, tkt_id=None, db=None, version=None, values=None, |
---|
117 | changelog=None, fields=None, time_fields=None): |
---|
118 | self.env = env |
---|
119 | if tkt_id is not None: |
---|
120 | tkt_id = int(tkt_id) |
---|
121 | self.fields = fields |
---|
122 | self.time_fields = time_fields |
---|
123 | self.id = tkt_id |
---|
124 | self.version = version |
---|
125 | self._values = values |
---|
126 | self.values = values.copy() |
---|
127 | self._changelog = changelog |
---|
128 | self._old = {} |
---|
129 | |
---|
130 | @property |
---|
131 | def resource(self): |
---|
132 | return Resource('ticket', self.id, self.version) |
---|
133 | |
---|
134 | def _fetch_ticket(self, tkt_id, db=None): |
---|
135 | self.values = self._values.copy() |
---|
136 | |
---|
137 | def get_changelog(self, when=None, db=None): |
---|
138 | return self._changelog[:] |
---|
139 | |
---|
140 | |
---|
141 | class ExcelTicketModule(Component): |
---|
142 | |
---|
143 | implements(IContentConverter) |
---|
144 | |
---|
145 | def get_supported_conversions(self): |
---|
146 | format = get_excel_format(self.env) |
---|
147 | mimetype = get_excel_mimetype(format) |
---|
148 | yield ('excel', _("Excel"), format, |
---|
149 | 'trac.ticket.Query', mimetype, 8) |
---|
150 | yield ('excel-history', _("Excel including history"), format, |
---|
151 | 'trac.ticket.Query', mimetype, 8) |
---|
152 | yield ('excel-history', _("Excel including history"), format, |
---|
153 | 'trac.ticket.Ticket', mimetype, 8) |
---|
154 | |
---|
155 | def convert_content(self, req, mimetype, content, key): |
---|
156 | if key == 'excel': |
---|
157 | return self._convert_query(req, content) |
---|
158 | if key == 'excel-history': |
---|
159 | kwargs = {} |
---|
160 | if isinstance(content, Ticket): |
---|
161 | content = Query.from_string(self.env, 'id=%d' % content.id) |
---|
162 | kwargs['sheet_query'] = False |
---|
163 | kwargs['sheet_history'] = True |
---|
164 | else: |
---|
165 | kwargs['sheet_query'] = True |
---|
166 | kwargs['sheet_history'] = True |
---|
167 | return self._convert_query(req, content, **kwargs) |
---|
168 | |
---|
169 | def _convert_query(self, req, query, sheet_query=True, |
---|
170 | sheet_history=False): |
---|
171 | book = get_workbook_writer(self.env, req) |
---|
172 | |
---|
173 | # no paginator |
---|
174 | query.max = 0 |
---|
175 | query.has_more_pages = False |
---|
176 | query.offset = 0 |
---|
177 | db = _get_db(self.env) |
---|
178 | |
---|
179 | # extract all fields except custom fields |
---|
180 | custom_fields = [f['name'] for f in query.fields if f.get('custom')] |
---|
181 | cols = ['id'] |
---|
182 | cols.extend(f['name'] for f in query.fields |
---|
183 | if f['name'] not in custom_fields) |
---|
184 | cols.extend(name for name in ('time', 'changetime') |
---|
185 | if name not in cols) |
---|
186 | query.cols = cols |
---|
187 | |
---|
188 | # prevent "SELECT COUNT(*)" query |
---|
189 | saved_count_prop = query._count |
---|
190 | try: |
---|
191 | query._count = types.MethodType(lambda self, sql, args, db=None: 0, |
---|
192 | query, query.__class__) |
---|
193 | if 'db' in inspect.getargspec(query.execute)[0]: |
---|
194 | tickets = query.execute(req, db) |
---|
195 | else: |
---|
196 | tickets = query.execute(req) |
---|
197 | query.num_items = len(tickets) |
---|
198 | finally: |
---|
199 | query._count = saved_count_prop |
---|
200 | |
---|
201 | # add custom fields to avoid error to join many tables |
---|
202 | self._fill_custom_fields(tickets, query.fields, custom_fields, db) |
---|
203 | |
---|
204 | context = Context.from_request(req, 'query', absurls=True) |
---|
205 | cols.extend([name for name in custom_fields if name not in cols]) |
---|
206 | data = query.template_data(context, tickets) |
---|
207 | |
---|
208 | if sheet_query: |
---|
209 | self._create_sheet_query(req, context, data, book) |
---|
210 | if sheet_history: |
---|
211 | self._create_sheet_history(req, context, data, book) |
---|
212 | return book.dumps(), book.mimetype |
---|
213 | |
---|
214 | def _fill_custom_fields(self, tickets, fields, custom_fields, db): |
---|
215 | if not tickets or not custom_fields: |
---|
216 | return |
---|
217 | fields = dict((f['name'], f) for f in fields) |
---|
218 | tickets = dict((int(ticket['id']), ticket) for ticket in tickets) |
---|
219 | query = "SELECT ticket,name,value " \ |
---|
220 | "FROM ticket_custom WHERE %s ORDER BY ticket" % \ |
---|
221 | _tkt_id_conditions('ticket', tickets) |
---|
222 | |
---|
223 | cursor = db.cursor() |
---|
224 | cursor.execute(query) |
---|
225 | for id, name, value in cursor: |
---|
226 | if id not in tickets: |
---|
227 | continue |
---|
228 | f = fields.get(name) |
---|
229 | if f and f['type'] == 'checkbox': |
---|
230 | try: |
---|
231 | value = bool(int(value)) |
---|
232 | except (TypeError, ValueError): |
---|
233 | value = False |
---|
234 | tickets[id][name] = value |
---|
235 | |
---|
236 | def _create_sheet_query(self, req, context, data, book): |
---|
237 | def write_headers(writer, query): |
---|
238 | writer.write_row([( |
---|
239 | u'%s (%s)' % (dgettext('messages', 'Custom Query'), |
---|
240 | dngettext('messages', '%(num)s match', |
---|
241 | '%(num)s matches', query.num_items)), |
---|
242 | 'header', -1, -1)]) |
---|
243 | |
---|
244 | query = data['query'] |
---|
245 | groups = data['groups'] |
---|
246 | fields = data['fields'] |
---|
247 | headers = data['headers'] |
---|
248 | |
---|
249 | sheet_count = 1 |
---|
250 | sheet_name = dgettext("messages", "Custom Query") |
---|
251 | writer = book.create_sheet(sheet_name) |
---|
252 | write_headers(writer, query) |
---|
253 | |
---|
254 | for groupname, results in groups: |
---|
255 | results = [result for result in results |
---|
256 | if 'TICKET_VIEW' in req.perm( |
---|
257 | context('ticket', result['id']).resource)] |
---|
258 | if not results: |
---|
259 | continue |
---|
260 | |
---|
261 | if writer.row_idx + len(results) + 3 > writer.MAX_ROWS: |
---|
262 | sheet_count += 1 |
---|
263 | writer = book.create_sheet('%s (%d)' % (sheet_name, |
---|
264 | sheet_count)) |
---|
265 | write_headers(writer, query) |
---|
266 | |
---|
267 | if groupname: |
---|
268 | writer.move_row() |
---|
269 | cell = fields[query.group]['label'] + ' ' |
---|
270 | if query.group in ('owner', 'reporter'): |
---|
271 | cell += Chrome(self.env).format_author(req, groupname) |
---|
272 | else: |
---|
273 | cell += groupname |
---|
274 | cell += ' (%s)' % dngettext('messages', '%(num)s match', |
---|
275 | '%(num)s matches', len(results)) |
---|
276 | writer.write_row([(cell, 'header2', -1, -1)]) |
---|
277 | |
---|
278 | writer.write_row((header['label'], 'thead', None, None) |
---|
279 | for idx, header in enumerate(headers)) |
---|
280 | |
---|
281 | for result in results: |
---|
282 | ticket_context = context('ticket', result['id']) |
---|
283 | cells = [] |
---|
284 | for idx, header in enumerate(headers): |
---|
285 | name = header['name'] |
---|
286 | value, style, width, line = self._get_cell_data( |
---|
287 | name, result.get(name), req, ticket_context, writer) |
---|
288 | cells.append((value, style, width, line)) |
---|
289 | writer.write_row(cells) |
---|
290 | |
---|
291 | writer.set_col_widths() |
---|
292 | |
---|
293 | def _create_sheet_history(self, req, context, data, book): |
---|
294 | def write_headers(writer, headers): |
---|
295 | writer.write_row((header['label'], 'thead', None, None) |
---|
296 | for idx, header in enumerate(headers)) |
---|
297 | |
---|
298 | groups = data['groups'] |
---|
299 | headers = [header for header in data['headers'] |
---|
300 | if header['name'] not in ('id', 'time', 'changetime')] |
---|
301 | headers[0:0] = [ |
---|
302 | {'name': 'id', 'label': dgettext("messages", "Ticket")}, |
---|
303 | {'name': 'time', 'label': dgettext("messages", "Time")}, |
---|
304 | {'name': 'author', 'label': dgettext("messages", "Author")}, |
---|
305 | {'name': 'comment', 'label': dgettext("messages", "Comment")}, |
---|
306 | ] |
---|
307 | |
---|
308 | sheet_name = dgettext("messages", "Change History") |
---|
309 | sheet_count = 1 |
---|
310 | writer = book.create_sheet(sheet_name) |
---|
311 | write_headers(writer, headers) |
---|
312 | |
---|
313 | tkt_ids = [result['id'] |
---|
314 | for result in chain(*[results for groupname, results |
---|
315 | in groups])] |
---|
316 | tickets = BulkFetchTicket.select(self.env, tkt_ids) |
---|
317 | |
---|
318 | mod = TicketModule(self.env) |
---|
319 | for result in chain(*[results for groupname, results in groups]): |
---|
320 | id = result['id'] |
---|
321 | ticket = tickets[id] |
---|
322 | ticket_context = context('ticket', id) |
---|
323 | if 'TICKET_VIEW' not in req.perm(ticket_context.resource): |
---|
324 | continue |
---|
325 | values = ticket.values.copy() |
---|
326 | changes = [] |
---|
327 | |
---|
328 | for change in mod.grouped_changelog_entries(ticket, None): |
---|
329 | if change['permanent']: |
---|
330 | changes.append(change) |
---|
331 | for change in reversed(changes): |
---|
332 | change['values'] = values |
---|
333 | values = values.copy() |
---|
334 | for name, field in change['fields'].iteritems(): |
---|
335 | if name in values: |
---|
336 | values[name] = field['old'] |
---|
337 | changes[0:0] = [{'date': ticket.time_created, 'fields': {}, |
---|
338 | 'values': values, 'cnum': None, |
---|
339 | 'comment': '', 'author': ticket['reporter']}] |
---|
340 | |
---|
341 | if writer.row_idx + len(changes) >= writer.MAX_ROWS: |
---|
342 | sheet_count += 1 |
---|
343 | writer = book.create_sheet('%s (%d)' % (sheet_name, |
---|
344 | sheet_count)) |
---|
345 | write_headers(writer, headers) |
---|
346 | |
---|
347 | for change in changes: |
---|
348 | cells = [] |
---|
349 | for idx, header in enumerate(headers): |
---|
350 | name = header['name'] |
---|
351 | if name == 'id': |
---|
352 | value = id |
---|
353 | elif name == 'time': |
---|
354 | value = change.get('date', '') |
---|
355 | elif name == 'comment': |
---|
356 | value = change.get('comment', '') |
---|
357 | elif name == 'author': |
---|
358 | value = change.get('author', '') |
---|
359 | value = Chrome(self.env).format_author(req, value) |
---|
360 | else: |
---|
361 | value = change['values'].get(name, '') |
---|
362 | value, style, width, line = \ |
---|
363 | self._get_cell_data(name, value, req, |
---|
364 | ticket_context, writer) |
---|
365 | if name in change['fields']: |
---|
366 | style = '%s:change' % style |
---|
367 | cells.append((value, style, width, line)) |
---|
368 | writer.write_row(cells) |
---|
369 | |
---|
370 | writer.set_col_widths() |
---|
371 | |
---|
372 | def _get_cell_data(self, name, value, req, context, writer): |
---|
373 | if name == 'id': |
---|
374 | url = self.env.abs_href.ticket(value) |
---|
375 | value = '#%d' % value |
---|
376 | width = len(value) |
---|
377 | return value, 'id', width, 1 |
---|
378 | |
---|
379 | if isinstance(value, datetime): |
---|
380 | return value, '[datetime]', None, None |
---|
381 | |
---|
382 | if value and name in ('reporter', 'owner'): |
---|
383 | value = Chrome(self.env).format_author(req, value) |
---|
384 | return value, name, None, None |
---|
385 | |
---|
386 | if name == 'cc': |
---|
387 | value = Chrome(self.env).format_emails(context, value) |
---|
388 | return value, name, None, None |
---|
389 | |
---|
390 | if name == 'milestone': |
---|
391 | if value: |
---|
392 | url = self.env.abs_href.milestone(value) |
---|
393 | width, line = writer.get_metrics(value) |
---|
394 | return value, name, width, line |
---|
395 | else: |
---|
396 | return '', name, None, None |
---|
397 | |
---|
398 | return value, name, None, None |
---|
399 | |
---|
400 | |
---|
401 | class ExcelReportModule(Component): |
---|
402 | |
---|
403 | implements(IRequestFilter) |
---|
404 | |
---|
405 | _PATH_INFO_MATCH = re.compile(r'/report/[0-9]+').match |
---|
406 | |
---|
407 | def pre_process_request(self, req, handler): |
---|
408 | if self._PATH_INFO_MATCH(req.path_info) \ |
---|
409 | and req.args.get('format') in ('xlsx', 'xls') \ |
---|
410 | and handler.__class__.__name__ == 'ReportModule': |
---|
411 | req.args['max'] = 0 |
---|
412 | return handler |
---|
413 | |
---|
414 | def post_process_request(self, req, template, data, content_type): |
---|
415 | if template == 'report_view.html' and req.args.get('id'): |
---|
416 | format = req.args.getfirst('format') |
---|
417 | if format in ('xlsx', 'xls'): |
---|
418 | resource = Resource('report', req.args['id']) |
---|
419 | data['context'] = Context.from_request(req, resource, |
---|
420 | absurls=True) |
---|
421 | self._convert_report(format, req, data) |
---|
422 | elif not format: |
---|
423 | self._add_alternate_links(req) |
---|
424 | return template, data, content_type |
---|
425 | |
---|
426 | def _convert_report(self, format, req, data): |
---|
427 | book = get_workbook_writer(self.env, req) |
---|
428 | writer = book.create_sheet(dgettext('messages', 'Report')) |
---|
429 | |
---|
430 | writer.write_row([( |
---|
431 | '%s (%s)' % (data['title'], |
---|
432 | dngettext('messages', '%(num)s match', |
---|
433 | '%(num)s matches', data['numrows'])), |
---|
434 | 'header', -1, -1)]) |
---|
435 | |
---|
436 | for value_for_group, row_group in data['row_groups']: |
---|
437 | writer.move_row() |
---|
438 | |
---|
439 | if value_for_group and len(row_group): |
---|
440 | writer.write_row([( |
---|
441 | '%s (%s)' % (value_for_group, |
---|
442 | dngettext('messages', '%(num)s match', |
---|
443 | '%(num)s matches', len(row_group))), |
---|
444 | 'header2', -1, -1)]) |
---|
445 | for header_group in data['header_groups']: |
---|
446 | writer.write_row([ |
---|
447 | (header['title'], 'thead', None, None) |
---|
448 | for header in header_group |
---|
449 | if not header['hidden']]) |
---|
450 | |
---|
451 | for row in row_group: |
---|
452 | for cell_group in row['cell_groups']: |
---|
453 | cells = [] |
---|
454 | for cell in cell_group: |
---|
455 | cell_header = cell['header'] |
---|
456 | if cell_header['hidden']: |
---|
457 | continue |
---|
458 | col = cell_header['col'].strip('_').lower() |
---|
459 | value, style, width, line = \ |
---|
460 | self._get_cell_data(req, col, cell, row, writer) |
---|
461 | cells.append((value, style, width, line)) |
---|
462 | writer.write_row(cells) |
---|
463 | |
---|
464 | writer.set_col_widths() |
---|
465 | |
---|
466 | content = book.dumps() |
---|
467 | req.send_response(200) |
---|
468 | req.send_header('Content-Type', book.mimetype) |
---|
469 | req.send_header('Content-Length', len(content)) |
---|
470 | req.send_header('Content-Disposition', |
---|
471 | 'filename=report_%s.%s' % (req.args['id'], format)) |
---|
472 | req.end_headers() |
---|
473 | req.write(content) |
---|
474 | raise RequestDone |
---|
475 | |
---|
476 | def _get_cell_data(self, req, col, cell, row, writer): |
---|
477 | value = cell['value'] |
---|
478 | |
---|
479 | if col == 'report': |
---|
480 | url = self.env.abs_href.report(value) |
---|
481 | width, line = writer.get_metrics(value) |
---|
482 | return value, col, width, line |
---|
483 | |
---|
484 | if col in ('ticket', 'id'): |
---|
485 | id_value = cell['value'] |
---|
486 | value = '#%s' % id_value |
---|
487 | url = get_resource_url(self.env, row['resource'], self.env.abs_href) |
---|
488 | width = len(value) |
---|
489 | return id_value, 'id', width, 1 |
---|
490 | |
---|
491 | if col == 'milestone': |
---|
492 | url = self.env.abs_href.milestone(value) |
---|
493 | width, line = writer.get_metrics(value) |
---|
494 | return value, col, width, line |
---|
495 | |
---|
496 | if col == 'time': |
---|
497 | if isinstance(value, basestring) and value.isdigit(): |
---|
498 | value = from_utimestamp(long(value)) |
---|
499 | return value, '[time]', None, None |
---|
500 | elif col in ('date', 'created', 'modified'): |
---|
501 | if isinstance(value, basestring) and value.isdigit(): |
---|
502 | value = from_utimestamp(long(value)) |
---|
503 | return value, '[date]', None, None |
---|
504 | elif col == 'datetime': |
---|
505 | if isinstance(value, basestring) and value.isdigit(): |
---|
506 | value = from_utimestamp(long(value)) |
---|
507 | return value, '[datetime]', None, None |
---|
508 | |
---|
509 | width, line = writer.get_metrics(value) |
---|
510 | return value, col, width, line |
---|
511 | |
---|
512 | def _add_alternate_links(self, req): |
---|
513 | params = {} |
---|
514 | for arg in req.args.keys(): |
---|
515 | if not arg.isupper(): |
---|
516 | continue |
---|
517 | params[arg] = req.args.get(arg) |
---|
518 | if 'USER' not in params: |
---|
519 | params['USER'] = req.authname |
---|
520 | if 'sort' in req.args: |
---|
521 | params['sort'] = req.args['sort'] |
---|
522 | if 'asc' in req.args: |
---|
523 | params['asc'] = req.args['asc'] |
---|
524 | href = '' |
---|
525 | if params: |
---|
526 | href = '&' + unicode_urlencode(params) |
---|
527 | format = get_excel_format(self.env) |
---|
528 | mimetype = get_excel_mimetype(format) |
---|
529 | add_link(req, 'alternate', '?format=' + format + href, |
---|
530 | _("Excel"), mimetype) |
---|