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