chiark / gitweb /
Use ordered dicts for defaults in apps and builds
[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
653     c = 0
654     for line in metafile:
655         c += 1
656         linedesc = "%s:%d" % (metafile.name, c)
657         line = line.rstrip('\r\n')
658         if mode == 3:
659             if not any(line.startswith(s) for s in (' ', '\t')):
660                 if 'commit' not in curbuild and 'disable' not in curbuild:
661                     raise MetaDataException("No commit specified for {0} in {1}"
662                                             .format(curbuild['version'], linedesc))
663
664                 thisinfo['builds'].append(curbuild)
665                 add_comments('build:' + curbuild['version'])
666                 mode = 0
667             else:
668                 if line.endswith('\\'):
669                     buildlines.append(line[:-1].lstrip())
670                 else:
671                     buildlines.append(line.lstrip())
672                     bl = ''.join(buildlines)
673                     add_buildflag(bl, curbuild)
674                     buildlines = []
675
676         if mode == 0:
677             if not line:
678                 continue
679             if line.startswith("#"):
680                 curcomments.append(line)
681                 continue
682             try:
683                 field, value = line.split(':', 1)
684             except ValueError:
685                 raise MetaDataException("Invalid metadata in " + linedesc)
686             if field != field.strip() or value != value.strip():
687                 raise MetaDataException("Extra spacing found in " + linedesc)
688
689             # Translate obsolete fields...
690             if field == 'Market Version':
691                 field = 'Current Version'
692             if field == 'Market Version Code':
693                 field = 'Current Version Code'
694
695             fieldtype = metafieldtype(field)
696             if fieldtype not in ['build', 'buildv2']:
697                 add_comments(field)
698             if fieldtype == 'multiline':
699                 mode = 1
700                 thisinfo[field] = []
701                 if value:
702                     raise MetaDataException("Unexpected text on same line as " + field + " in " + linedesc)
703             elif fieldtype == 'string':
704                 thisinfo[field] = value
705             elif fieldtype == 'list':
706                 thisinfo[field] = [v.strip() for v in value.replace(';', ',').split(',')]
707             elif fieldtype == 'build':
708                 if value.endswith("\\"):
709                     mode = 2
710                     buildlines = [value[:-1]]
711                 else:
712                     curbuild = parse_buildline([value])
713                     add_comments('build:' + thisinfo['builds'][-1]['version'])
714             elif fieldtype == 'buildv2':
715                 curbuild = {}
716                 vv = value.split(',')
717                 if len(vv) != 2:
718                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
719                                             .format(value, linedesc))
720                 curbuild['version'] = vv[0]
721                 curbuild['vercode'] = vv[1]
722                 buildlines = []
723                 mode = 3
724             elif fieldtype == 'obsolete':
725                 pass        # Just throw it away!
726             else:
727                 raise MetaDataException("Unrecognised field type for " + field + " in " + linedesc)
728         elif mode == 1:     # Multiline field
729             if line == '.':
730                 mode = 0
731             else:
732                 thisinfo[field].append(line)
733         elif mode == 2:     # Line continuation mode in Build Version
734             if line.endswith("\\"):
735                 buildlines.append(line[:-1])
736             else:
737                 buildlines.append(line)
738                 curbuild = parse_buildline(buildlines)
739                 thisinfo['builds'].append(curbuild)
740                 add_comments('build:' + thisinfo['builds'][-1]['version'])
741                 mode = 0
742     add_comments(None)
743
744     # Mode at end of file should always be 0...
745     if mode == 1:
746         raise MetaDataException(field + " not terminated in " + metafile.name)
747     elif mode == 2:
748         raise MetaDataException("Unterminated continuation in " + metafile.name)
749     elif mode == 3:
750         raise MetaDataException("Unterminated build in " + metafile.name)
751
752     if not thisinfo['Description']:
753         thisinfo['Description'].append('No description available')
754
755     for build in thisinfo['builds']:
756         for flag, value in flag_defaults.iteritems():
757             if flag in build:
758                 continue
759             build[flag] = value
760         build['type'] = get_build_type(build)
761
762     return thisinfo
763
764
765 # Write a metadata file.
766 #
767 # 'dest'    - The path to the output file
768 # 'app'     - The app data
769 def write_metadata(dest, app):
770
771     def writecomments(key):
772         written = 0
773         for pf, comment in app['comments']:
774             if pf == key:
775                 mf.write("%s\n" % comment)
776                 written += 1
777         if written > 0:
778             logging.debug("...writing comments for " + (key if key else 'EOF'))
779
780     def writefield(field, value=None):
781         writecomments(field)
782         if value is None:
783             value = app[field]
784         t = metafieldtype(field)
785         if t == 'list':
786             value = ','.join(value)
787         mf.write("%s:%s\n" % (field, value))
788
789     mf = open(dest, 'w')
790     if app['Disabled']:
791         writefield('Disabled')
792     if app['AntiFeatures']:
793         writefield('AntiFeatures')
794     if app['Provides']:
795         writefield('Provides')
796     writefield('Categories')
797     writefield('License')
798     writefield('Web Site')
799     writefield('Source Code')
800     writefield('Issue Tracker')
801     if app['Donate']:
802         writefield('Donate')
803     if app['FlattrID']:
804         writefield('FlattrID')
805     if app['Bitcoin']:
806         writefield('Bitcoin')
807     if app['Litecoin']:
808         writefield('Litecoin')
809     if app['Dogecoin']:
810         writefield('Dogecoin')
811     mf.write('\n')
812     if app['Name']:
813         writefield('Name')
814     if app['Auto Name']:
815         writefield('Auto Name')
816     writefield('Summary')
817     writefield('Description', '')
818     for line in app['Description']:
819         mf.write("%s\n" % line)
820     mf.write('.\n')
821     mf.write('\n')
822     if app['Requires Root']:
823         writefield('Requires Root', 'Yes')
824         mf.write('\n')
825     if app['Repo Type']:
826         writefield('Repo Type')
827         writefield('Repo')
828         mf.write('\n')
829     for build in app['builds']:
830         writecomments('build:' + build['version'])
831         mf.write("Build:%s,%s\n" % (build['version'], build['vercode']))
832
833         def write_builditem(key, value):
834
835             if key in ['version', 'vercode', 'origlines', 'type']:
836                 return
837
838             if value == flag_defaults[key]:
839                 return
840
841             t = flagtype(key)
842
843             logging.debug("...writing {0} : {1}".format(key, value))
844             outline = '    %s=' % key
845
846             if t == 'string':
847                 outline += value
848             if t == 'bool':
849                 outline += 'yes'
850             elif t == 'script':
851                 outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
852             elif t == 'list':
853                 outline += ','.join(value) if type(value) == list else value
854
855             outline += '\n'
856             mf.write(outline)
857
858         for flag in flag_defaults:
859             value = build[flag]
860             if value:
861                 write_builditem(flag, value)
862         mf.write('\n')
863
864     if app['Maintainer Notes']:
865         writefield('Maintainer Notes', '')
866         for line in app['Maintainer Notes']:
867             mf.write("%s\n" % line)
868         mf.write('.\n')
869         mf.write('\n')
870
871     if app['Archive Policy']:
872         writefield('Archive Policy')
873     writefield('Auto Update Mode')
874     writefield('Update Check Mode')
875     if app['Update Check Ignore']:
876         writefield('Update Check Ignore')
877     if app['Vercode Operation']:
878         writefield('Vercode Operation')
879     if app['Update Check Name']:
880         writefield('Update Check Name')
881     if app['Update Check Data']:
882         writefield('Update Check Data')
883     if app['Current Version']:
884         writefield('Current Version')
885         writefield('Current Version Code')
886     mf.write('\n')
887     if app['No Source Since']:
888         writefield('No Source Since')
889         mf.write('\n')
890     writecomments(None)
891     mf.close()