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