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