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