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