chiark / gitweb /
Simplify check_metadata
[fdroidserver.git] / fdroidserver / metadata.py
1 # -*- coding: utf-8 -*-
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import os
21 import re
22 import glob
23 import cgi
24 import logging
25
26 from collections import OrderedDict
27
28 srclibs = {}
29
30
31 class MetaDataException(Exception):
32     def __init__(self, value):
33         self.value = value
34
35     def __str__(self):
36         return self.value
37
38 # In the order in which they are laid out on files
39 app_defaults = OrderedDict([
40     ('Disabled', None),
41     ('AntiFeatures', None),
42     ('Provides', None),
43     ('Categories', ['None']),
44     ('License', 'Unknown'),
45     ('Web Site', ''),
46     ('Source Code', ''),
47     ('Issue Tracker', ''),
48     ('Donate', None),
49     ('FlattrID', None),
50     ('Bitcoin', None),
51     ('Litecoin', None),
52     ('Dogecoin', None),
53     ('Name', None),
54     ('Auto Name', ''),
55     ('Summary', ''),
56     ('Description', []),
57     ('Requires Root', False),
58     ('Repo Type', ''),
59     ('Repo', ''),
60     ('Maintainer Notes', []),
61     ('Archive Policy', None),
62     ('Auto Update Mode', 'None'),
63     ('Update Check Mode', 'None'),
64     ('Update Check Ignore', None),
65     ('Vercode Operation', None),
66     ('Update Check Name', None),
67     ('Update Check Data', None),
68     ('Current Version', ''),
69     ('Current Version Code', '0'),
70     ('No Source Since', ''),
71     ])
72
73
74 # In the order in which they are laid out on files
75 # Sorted by their action and their place in the build timeline
76 flag_defaults = OrderedDict([
77     ('disable', False),
78     ('commit', None),
79     ('subdir', None),
80     ('submodules', False),
81     ('init', ''),
82     ('patch', []),
83     ('gradle', False),
84     ('maven', False),
85     ('kivy', False),
86     ('output', None),
87     ('srclibs', []),
88     ('oldsdkloc', False),
89     ('encoding', None),
90     ('forceversion', False),
91     ('forcevercode', False),
92     ('rm', []),
93     ('extlibs', []),
94     ('prebuild', ''),
95     ('update', ['auto']),
96     ('target', None),
97     ('scanignore', []),
98     ('scandelete', []),
99     ('build', ''),
100     ('buildjni', []),
101     ('preassemble', []),
102     ('antcommand', None),
103     ('novcheck', False),
104     ])
105
106
107 # Designates a metadata field type and checks that it matches
108 #
109 # 'name'     - The long name of the field type
110 # 'matching' - List of possible values or regex expression
111 # 'sep'      - Separator to use if value may be a list
112 # 'fields'   - Metadata fields (Field:Value) of this type
113 # 'attrs'    - Build attributes (attr=value) of this type
114 #
115 class FieldValidator():
116
117     def __init__(self, name, matching, sep, fields, attrs):
118         self.name = name
119         self.matching = matching
120         if type(matching) is str:
121             self.compiled = re.compile(matching)
122         self.sep = sep
123         self.fields = fields
124         self.attrs = attrs
125
126     def _assert_regex(self, values, appid):
127         for v in values:
128             if not self.compiled.match(v):
129                 raise MetaDataException("'%s' is not a valid %s in %s. "
130                                         % (v, self.name, appid) +
131                                         "Regex pattern: %s" % (self.matching))
132
133     def _assert_list(self, values, appid):
134         for v in values:
135             if v not in self.matching:
136                 raise MetaDataException("'%s' is not a valid %s in %s. "
137                                         % (v, self.name, appid) +
138                                         "Possible values: %s" % (", ".join(self.matching)))
139
140     def check(self, value, appid):
141         if type(value) is not str or not value:
142             return
143         if self.sep is not None:
144             values = value.split(self.sep)
145         else:
146             values = [value]
147         if type(self.matching) is list:
148             self._assert_list(values, appid)
149         else:
150             self._assert_regex(values, appid)
151
152
153 # Generic value types
154 valuetypes = {
155     FieldValidator("Integer",
156                    r'^[1-9][0-9]*$', None,
157                    ['FlattrID'],
158                    ['vercode']),
159
160     FieldValidator("HTTP link",
161                    r'^http[s]?://', None,
162                    ["Web Site", "Source Code", "Issue Tracker", "Donate"], []),
163
164     FieldValidator("Bitcoin address",
165                    r'^[a-zA-Z0-9]{27,34}$', None,
166                    ["Bitcoin"],
167                    []),
168
169     FieldValidator("Litecoin address",
170                    r'^L[a-zA-Z0-9]{33}$', None,
171                    ["Litecoin"],
172                    []),
173
174     FieldValidator("Dogecoin address",
175                    r'^D[a-zA-Z0-9]{33}$', None,
176                    ["Dogecoin"],
177                    []),
178
179     FieldValidator("Boolean",
180                    ['Yes', 'No'], None,
181                    ["Requires Root"],
182                    []),
183
184     FieldValidator("bool",
185                    ['yes', 'no'], None,
186                    [],
187                    ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
188                     'novcheck']),
189
190     FieldValidator("Repo Type",
191                    ['git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib'], None,
192                    ["Repo Type"],
193                    []),
194
195     FieldValidator("Archive Policy",
196                    r'^[0-9]+ versions$', None,
197                    ["Archive Policy"],
198                    []),
199
200     FieldValidator("Anti-Feature",
201                    ["Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree"], ',',
202                    ["AntiFeatures"],
203                    []),
204
205     FieldValidator("Auto Update Mode",
206                    r"^(Version .+|None)$", None,
207                    ["Auto Update Mode"],
208                    []),
209
210     FieldValidator("Update Check Mode",
211                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
212                    ["Update Check Mode"],
213                    [])
214     }
215
216
217 # Check an app's metadata information for integrity errors
218 def check_metadata(info):
219     for v in valuetypes:
220         for field in v.fields:
221             v.check(info[field], info['id'])
222         for build in info['builds']:
223             for attr in v.attrs:
224                 v.check(build[attr], info['id'])
225
226
227 # Formatter for descriptions. Create an instance, and call parseline() with
228 # each line of the description source from the metadata. At the end, call
229 # end() and then text_plain, text_wiki and text_html will contain the result.
230 class DescriptionFormatter:
231     stNONE = 0
232     stPARA = 1
233     stUL = 2
234     stOL = 3
235     bold = False
236     ital = False
237     state = stNONE
238     text_plain = ''
239     text_wiki = ''
240     text_html = ''
241     linkResolver = None
242
243     def __init__(self, linkres):
244         self.linkResolver = linkres
245
246     def endcur(self, notstates=None):
247         if notstates and self.state in notstates:
248             return
249         if self.state == self.stPARA:
250             self.endpara()
251         elif self.state == self.stUL:
252             self.endul()
253         elif self.state == self.stOL:
254             self.endol()
255
256     def endpara(self):
257         self.text_plain += '\n'
258         self.text_html += '</p>'
259         self.state = self.stNONE
260
261     def endul(self):
262         self.text_html += '</ul>'
263         self.state = self.stNONE
264
265     def endol(self):
266         self.text_html += '</ol>'
267         self.state = self.stNONE
268
269     def formatted(self, txt, html):
270         formatted = ''
271         if html:
272             txt = cgi.escape(txt)
273         while True:
274             index = txt.find("''")
275             if index == -1:
276                 return formatted + txt
277             formatted += txt[:index]
278             txt = txt[index:]
279             if txt.startswith("'''"):
280                 if html:
281                     if self.bold:
282                         formatted += '</b>'
283                     else:
284                         formatted += '<b>'
285                 self.bold = not self.bold
286                 txt = txt[3:]
287             else:
288                 if html:
289                     if self.ital:
290                         formatted += '</i>'
291                     else:
292                         formatted += '<i>'
293                 self.ital = not self.ital
294                 txt = txt[2:]
295
296     def linkify(self, txt):
297         linkified_plain = ''
298         linkified_html = ''
299         while True:
300             index = txt.find("[")
301             if index == -1:
302                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
303             linkified_plain += self.formatted(txt[:index], False)
304             linkified_html += self.formatted(txt[:index], True)
305             txt = txt[index:]
306             if txt.startswith("[["):
307                 index = txt.find("]]")
308                 if index == -1:
309                     raise MetaDataException("Unterminated ]]")
310                 url = txt[2:index]
311                 if self.linkResolver:
312                     url, urltext = self.linkResolver(url)
313                 else:
314                     urltext = url
315                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
316                 linkified_plain += urltext
317                 txt = txt[index + 2:]
318             else:
319                 index = txt.find("]")
320                 if index == -1:
321                     raise MetaDataException("Unterminated ]")
322                 url = txt[1:index]
323                 index2 = url.find(' ')
324                 if index2 == -1:
325                     urltxt = url
326                 else:
327                     urltxt = url[index2 + 1:]
328                     url = url[:index2]
329                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
330                 linkified_plain += urltxt
331                 if urltxt != url:
332                     linkified_plain += ' (' + url + ')'
333                 txt = txt[index + 1:]
334
335     def addtext(self, txt):
336         p, h = self.linkify(txt)
337         self.text_plain += p
338         self.text_html += h
339
340     def parseline(self, line):
341         self.text_wiki += "%s\n" % line
342         if not line:
343             self.endcur()
344         elif line.startswith('* '):
345             self.endcur([self.stUL])
346             if self.state != self.stUL:
347                 self.text_html += '<ul>'
348                 self.state = self.stUL
349             self.text_html += '<li>'
350             self.text_plain += '* '
351             self.addtext(line[1:])
352             self.text_html += '</li>'
353         elif line.startswith('# '):
354             self.endcur([self.stOL])
355             if self.state != self.stOL:
356                 self.text_html += '<ol>'
357                 self.state = self.stOL
358             self.text_html += '<li>'
359             self.text_plain += '* '  # TODO: lazy - put the numbers in!
360             self.addtext(line[1:])
361             self.text_html += '</li>'
362         else:
363             self.endcur([self.stPARA])
364             if self.state == self.stNONE:
365                 self.text_html += '<p>'
366                 self.state = self.stPARA
367             elif self.state == self.stPARA:
368                 self.text_html += ' '
369                 self.text_plain += ' '
370             self.addtext(line)
371
372     def end(self):
373         self.endcur()
374
375
376 # Parse multiple lines of description as written in a metadata file, returning
377 # a single string in plain text format.
378 def description_plain(lines, linkres):
379     ps = DescriptionFormatter(linkres)
380     for line in lines:
381         ps.parseline(line)
382     ps.end()
383     return ps.text_plain
384
385
386 # Parse multiple lines of description as written in a metadata file, returning
387 # a single string in wiki format. Used for the Maintainer Notes field as well,
388 # because it's the same format.
389 def description_wiki(lines):
390     ps = DescriptionFormatter(None)
391     for line in lines:
392         ps.parseline(line)
393     ps.end()
394     return ps.text_wiki
395
396
397 # Parse multiple lines of description as written in a metadata file, returning
398 # a single string in HTML format.
399 def description_html(lines, linkres):
400     ps = DescriptionFormatter(linkres)
401     for line in lines:
402         ps.parseline(line)
403     ps.end()
404     return ps.text_html
405
406
407 def parse_srclib(metafile):
408
409     thisinfo = {}
410     if metafile and not isinstance(metafile, file):
411         metafile = open(metafile, "r")
412
413     # Defaults for fields that come from metadata
414     thisinfo['Repo Type'] = ''
415     thisinfo['Repo'] = ''
416     thisinfo['Subdir'] = None
417     thisinfo['Prepare'] = None
418     thisinfo['Srclibs'] = None
419
420     if metafile is None:
421         return thisinfo
422
423     n = 0
424     for line in metafile:
425         n += 1
426         line = line.rstrip('\r\n')
427         if not line or line.startswith("#"):
428             continue
429
430         try:
431             field, value = line.split(':', 1)
432         except ValueError:
433             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
434
435         if field == "Subdir":
436             thisinfo[field] = value.split(',')
437         else:
438             thisinfo[field] = value
439
440     return thisinfo
441
442
443 def read_srclibs():
444     """Read all srclib metadata.
445
446     The information read will be accessible as metadata.srclibs, which is a
447     dictionary, keyed on srclib name, with the values each being a dictionary
448     in the same format as that returned by the parse_srclib function.
449
450     A MetaDataException is raised if there are any problems with the srclib
451     metadata.
452     """
453     global srclibs
454     srclibs = {}
455
456     srcdir = 'srclibs'
457     if not os.path.exists(srcdir):
458         os.makedirs(srcdir)
459
460     for metafile in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
461         srclibname = os.path.basename(metafile[:-4])
462         srclibs[srclibname] = parse_srclib(metafile)
463
464
465 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
466 # returned by the parse_metadata function.
467 def read_metadata(xref=True):
468     apps = []
469
470     for basedir in ('metadata', 'tmp'):
471         if not os.path.exists(basedir):
472             os.makedirs(basedir)
473
474     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
475         appinfo = parse_metadata(metafile)
476         check_metadata(appinfo)
477         apps.append(appinfo)
478
479     if xref:
480         # Parse all descriptions at load time, just to ensure cross-referencing
481         # errors are caught early rather than when they hit the build server.
482         def linkres(link):
483             for app in apps:
484                 if app['id'] == link:
485                     return ("fdroid.app:" + link, "Dummy name - don't know yet")
486             raise MetaDataException("Cannot resolve app id " + link)
487         for app in apps:
488             try:
489                 description_html(app['Description'], linkres)
490             except Exception, e:
491                 raise MetaDataException("Problem with description of " + app['id'] +
492                                         " - " + str(e))
493
494     return apps
495
496
497 # Get the type expected for a given metadata field.
498 def metafieldtype(name):
499     if name in ['Description', 'Maintainer Notes']:
500         return 'multiline'
501     if name in ['Categories']:
502         return 'list'
503     if name == 'Build Version':
504         return 'build'
505     if name == 'Build':
506         return 'buildv2'
507     if name == 'Use Built':
508         return 'obsolete'
509     if name not in app_defaults:
510         return 'unknown'
511     return 'string'
512
513
514 def flagtype(name):
515     if name in ['extlibs', 'srclibs', 'patch', 'rm', 'buildjni',
516                 'update', 'scanignore', 'scandelete']:
517         return 'list'
518     if name in ['init', 'prebuild', 'build']:
519         return 'script'
520     if name in ['submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
521                 'novcheck']:
522         return 'bool'
523     return 'string'
524
525
526 # Parse metadata for a single application.
527 #
528 #  'metafile' - the filename to read. The package id for the application comes
529 #               from this filename. Pass None to get a blank entry.
530 #
531 # Returns a dictionary containing all the details of the application. There are
532 # two major kinds of information in the dictionary. Keys beginning with capital
533 # letters correspond directory to identically named keys in the metadata file.
534 # Keys beginning with lower case letters are generated in one way or another,
535 # and are not found verbatim in the metadata.
536 #
537 # Known keys not originating from the metadata are:
538 #
539 #  'id'               - the application's package ID
540 #  'builds'           - a list of dictionaries containing build information
541 #                       for each defined build
542 #  'comments'         - a list of comments from the metadata file. Each is
543 #                       a tuple of the form (field, comment) where field is
544 #                       the name of the field it preceded in the metadata
545 #                       file. Where field is None, the comment goes at the
546 #                       end of the file. Alternatively, 'build:version' is
547 #                       for a comment before a particular build version.
548 #  'descriptionlines' - original lines of description as formatted in the
549 #                       metadata file.
550 #
551 def parse_metadata(metafile):
552
553     linedesc = None
554
555     def add_buildflag(p, thisbuild):
556         bv = p.split('=', 1)
557         if len(bv) != 2:
558             raise MetaDataException("Invalid build flag at {0} in {1}"
559                                     .format(buildlines[0], linedesc))
560         pk, pv = bv
561         if pk in thisbuild:
562             raise MetaDataException("Duplicate definition on {0} in version {1} of {2}"
563                                     .format(pk, thisbuild['version'], linedesc))
564
565         pk = pk.lstrip()
566         if pk not in flag_defaults:
567             raise MetaDataException("Unrecognised build flag at {0} in {1}"
568                                     .format(p, linedesc))
569         t = flagtype(pk)
570         if t == 'list':
571             # Port legacy ';' separators
572             thisbuild[pk] = [v.strip() for v in pv.replace(';', ',').split(',')]
573         elif t == 'string' or t == 'script':
574             thisbuild[pk] = pv
575         elif t == 'bool':
576             value = pv == 'yes'
577             if value:
578                 thisbuild[pk] = True
579             else:
580                 logging.debug("...ignoring bool flag %s" % p)
581
582         else:
583             raise MetaDataException("Unrecognised build flag type '%s' at %s in %s"
584                                     % (t, p, linedesc))
585
586     def parse_buildline(lines):
587         value = "".join(lines)
588         parts = [p.replace("\\,", ",")
589                  for p in re.split(r"(?<!\\),", value)]
590         if len(parts) < 3:
591             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
592         thisbuild = {}
593         thisbuild['origlines'] = lines
594         thisbuild['version'] = parts[0]
595         thisbuild['vercode'] = parts[1]
596         if parts[2].startswith('!'):
597             # For backwards compatibility, handle old-style disabling,
598             # including attempting to extract the commit from the message
599             thisbuild['disable'] = parts[2][1:]
600             commit = 'unknown - see disabled'
601             index = parts[2].rfind('at ')
602             if index != -1:
603                 commit = parts[2][index + 3:]
604                 if commit.endswith(')'):
605                     commit = commit[:-1]
606             thisbuild['commit'] = commit
607         else:
608             thisbuild['commit'] = parts[2]
609         for p in parts[3:]:
610             add_buildflag(p, thisbuild)
611
612         return thisbuild
613
614     def add_comments(key):
615         if not curcomments:
616             return
617         for comment in curcomments:
618             thisinfo['comments'].append((key, comment))
619         del curcomments[:]
620
621     def get_build_type(build):
622         for t in ['maven', 'gradle', 'kivy']:
623             if build[t]:
624                 return t
625         if build['output']:
626             return 'raw'
627         return 'ant'
628
629     thisinfo = {}
630     if metafile:
631         if not isinstance(metafile, file):
632             metafile = open(metafile, "r")
633         thisinfo['id'] = metafile.name[9:-4]
634     else:
635         thisinfo['id'] = None
636
637     thisinfo.update(app_defaults)
638
639     # General defaults...
640     thisinfo['builds'] = []
641     thisinfo['comments'] = []
642
643     if metafile is None:
644         return thisinfo
645
646     mode = 0
647     buildlines = []
648     curcomments = []
649     curbuild = None
650     vc_seen = {}
651
652     c = 0
653     for line in metafile:
654         c += 1
655         linedesc = "%s:%d" % (metafile.name, c)
656         line = line.rstrip('\r\n')
657         if mode == 3:
658             if not any(line.startswith(s) for s in (' ', '\t')):
659                 if 'commit' not in curbuild and 'disable' not in curbuild:
660                     raise MetaDataException("No commit specified for {0} in {1}"
661                                             .format(curbuild['version'], linedesc))
662
663                 thisinfo['builds'].append(curbuild)
664                 add_comments('build:' + curbuild['vercode'])
665                 mode = 0
666             else:
667                 if line.endswith('\\'):
668                     buildlines.append(line[:-1].lstrip())
669                 else:
670                     buildlines.append(line.lstrip())
671                     bl = ''.join(buildlines)
672                     add_buildflag(bl, curbuild)
673                     buildlines = []
674
675         if mode == 0:
676             if not line:
677                 continue
678             if line.startswith("#"):
679                 curcomments.append(line)
680                 continue
681             try:
682                 field, value = line.split(':', 1)
683             except ValueError:
684                 raise MetaDataException("Invalid metadata in " + linedesc)
685             if field != field.strip() or value != value.strip():
686                 raise MetaDataException("Extra spacing found in " + linedesc)
687
688             # Translate obsolete fields...
689             if field == 'Market Version':
690                 field = 'Current Version'
691             if field == 'Market Version Code':
692                 field = 'Current Version Code'
693
694             fieldtype = metafieldtype(field)
695             if fieldtype not in ['build', 'buildv2']:
696                 add_comments(field)
697             if fieldtype == 'multiline':
698                 mode = 1
699                 thisinfo[field] = []
700                 if value:
701                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
702             elif fieldtype == 'string':
703                 thisinfo[field] = value
704             elif fieldtype == 'list':
705                 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
706             elif fieldtype == 'build':
707                 if value.endswith("\\"):
708                     mode = 2
709                     buildlines = [value[:-1]]
710                 else:
711                     curbuild = parse_buildline([value])
712                     thisinfo['builds'].append(curbuild)
713                     add_comments('build:' + thisinfo['builds'][-1]['vercode'])
714             elif fieldtype == 'buildv2':
715                 curbuild = {}
716                 vv = value.split(',')
717                 if len(vv) != 2:
718                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
719                                             .format(value, linedesc))
720                 curbuild['version'] = vv[0]
721                 curbuild['vercode'] = vv[1]
722                 if curbuild['vercode'] in vc_seen:
723                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
724                                             curbuild['vercode'], linedesc))
725                 vc_seen[curbuild['vercode']] = True
726                 buildlines = []
727                 mode = 3
728             elif fieldtype == 'obsolete':
729                 pass        # Just throw it away!
730             else:
731                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
732         elif mode == 1:     # Multiline field
733             if line == '.':
734                 mode = 0
735             else:
736                 thisinfo[field].append(line)
737         elif mode == 2:     # Line continuation mode in Build Version
738             if line.endswith("\\"):
739                 buildlines.append(line[:-1])
740             else:
741                 buildlines.append(line)
742                 curbuild = parse_buildline(buildlines)
743                 thisinfo['builds'].append(curbuild)
744                 add_comments('build:' + thisinfo['builds'][-1]['vercode'])
745                 mode = 0
746     add_comments(None)
747
748     # Mode at end of file should always be 0...
749     if mode == 1:
750         raise MetaDataException(field + " not terminated in " + metafile.name)
751     elif mode == 2:
752         raise MetaDataException("Unterminated continuation in " + metafile.name)
753     elif mode == 3:
754         raise MetaDataException("Unterminated build in " + metafile.name)
755
756     if not thisinfo['Description']:
757         thisinfo['Description'].append('No description available')
758
759     for build in thisinfo['builds']:
760         for flag, value in flag_defaults.iteritems():
761             if flag in build:
762                 continue
763             build[flag] = value
764         build['type'] = get_build_type(build)
765
766     return thisinfo
767
768
769 # Write a metadata file.
770 #
771 # 'dest'    - The path to the output file
772 # 'app'     - The app data
773 def write_metadata(dest, app):
774
775     def writecomments(key):
776         written = 0
777         for pf, comment in app['comments']:
778             if pf == key:
779                 mf.write("%s\n" % comment)
780                 written += 1
781         if written > 0:
782             logging.debug("...writing comments for " + (key if key else 'EOF'))
783
784     def writefield(field, value=None):
785         writecomments(field)
786         if value is None:
787             value = app[field]
788         t = metafieldtype(field)
789         if t == 'list':
790             value = ','.join(value)
791         mf.write("%s:%s\n" % (field, value))
792
793     mf = open(dest, 'w')
794     if app['Disabled']:
795         writefield('Disabled')
796     if app['AntiFeatures']:
797         writefield('AntiFeatures')
798     if app['Provides']:
799         writefield('Provides')
800     writefield('Categories')
801     writefield('License')
802     writefield('Web Site')
803     writefield('Source Code')
804     writefield('Issue Tracker')
805     if app['Donate']:
806         writefield('Donate')
807     if app['FlattrID']:
808         writefield('FlattrID')
809     if app['Bitcoin']:
810         writefield('Bitcoin')
811     if app['Litecoin']:
812         writefield('Litecoin')
813     if app['Dogecoin']:
814         writefield('Dogecoin')
815     mf.write('\n')
816     if app['Name']:
817         writefield('Name')
818     if app['Auto Name']:
819         writefield('Auto Name')
820     writefield('Summary')
821     writefield('Description', '')
822     for line in app['Description']:
823         mf.write("%s\n" % line)
824     mf.write('.\n')
825     mf.write('\n')
826     if app['Requires Root']:
827         writefield('Requires Root', 'Yes')
828         mf.write('\n')
829     if app['Repo Type']:
830         writefield('Repo Type')
831         writefield('Repo')
832         mf.write('\n')
833     for build in app['builds']:
834         writecomments('build:' + build['vercode'])
835         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
836
837         def write_builditem(key, value):
838
839             if key in ['version', 'vercode']:
840                 return
841
842             if value == flag_defaults[key]:
843                 return
844
845             t = flagtype(key)
846
847             logging.debug("...writing {0} : {1}".format(key, value))
848             outline = '    %s=' % key
849
850             if t == 'string':
851                 outline += value
852             if t == 'bool':
853                 outline += 'yes'
854             elif t == 'script':
855                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
856             elif t == 'list':
857                 outline += ','.join(value) if type(value) == list else value
858
859             outline += '\n'
860             mf.write(outline)
861
862         for flag in flag_defaults:
863             value = build[flag]
864             if value:
865                 write_builditem(flag, value)
866         mf.write('\n')
867
868     if app['Maintainer Notes']:
869         writefield('Maintainer Notes', '')
870         for line in app['Maintainer Notes']:
871             mf.write("%s\n" % line)
872         mf.write('.\n')
873         mf.write('\n')
874
875     if app['Archive Policy']:
876         writefield('Archive Policy')
877     writefield('Auto Update Mode')
878     writefield('Update Check Mode')
879     if app['Update Check Ignore']:
880         writefield('Update Check Ignore')
881     if app['Vercode Operation']:
882         writefield('Vercode Operation')
883     if app['Update Check Name']:
884         writefield('Update Check Name')
885     if app['Update Check Data']:
886         writefield('Update Check Data')
887     if app['Current Version']:
888         writefield('Current Version')
889         writefield('Current Version Code')
890     mf.write('\n')
891     if app['No Source Since']:
892         writefield('No Source Since')
893         mf.write('\n')
894     writecomments(None)
895     mf.close()