Implemented some missing test cases.
[philipp/winterrodeln/wrpylib.git] / wrpylib / wrmwmarkup.py
1 #!/usr/bin/python3.4
2 # -*- coding: iso-8859-15 -*-
3 # $Id$
4 # $HeadURL$
5 """This module contains winterrodeln specific functions that are processing the MediaWiki markup.
6 """
7 import re
8 import xml.etree.ElementTree
9 import collections
10 import formencode
11 import wrpylib.wrvalidators
12 import wrpylib.mwmarkup
13
14 WRMAP_POINT_TYPES = ['gasthaus', 'haltestelle', 'parkplatz', 'achtung', 'foto', 'verleih', 'punkt']
15 WRMAP_LINE_TYPES = ['rodelbahn', 'gehweg', 'alternative', 'lift', 'anfahrt', 'linie']
16
17
18 class ParseError(RuntimeError):
19     """Exception used by some of the functions"""
20     pass
21
22
23 class RodelbahnboxDictConverter(formencode.Validator):
24     """Converts a dict with Rodelbahnbox properties to a Sledrun class. Does no validation."""
25
26     def to_python(self, value, state=None):
27         """value is a dict of properties. If state is an object with the attribute sledrun, this sledrun class will be populated or updated."""
28         props = value
29         if isinstance(state, object) and hasattr(state, 'sledrun'):
30             sledrun = state.sledrun
31         else:
32             class Sledrun(object):
33                 pass
34             sledrun = Sledrun()
35         for k, v in props.items():
36             if   k == 'Position': sledrun.position_latitude, sledrun.position_longitude = v
37             elif k == 'Position oben': sledrun.top_latitude, sledrun.top_longitude = v
38             elif k == 'Höhe oben': sledrun.top_elevation = v
39             elif k == 'Position unten': sledrun.bottom_latitude, sledrun.bottom_longitude = v
40             elif k == 'Höhe unten': sledrun.bottom_elevation = v
41             elif k == 'Länge': sledrun.length = v
42             elif k == 'Schwierigkeit': sledrun.difficulty = v
43             elif k == 'Lawinen': sledrun.avalanches = v
44             elif k == 'Betreiber': sledrun.operator = v
45             elif k == 'Öffentliche Anreise': sledrun.public_transport = v
46             elif k == 'Aufstieg möglich': sledrun.walkup_possible = v
47             elif k == 'Aufstieg getrennt': sledrun.walkup_separate, sledrun.walkup_separate_comment = v
48             elif k == 'Gehzeit': sledrun.walkup_time = v
49             elif k == 'Aufstiegshilfe': sledrun.lift, sledrun.lift_details = v
50             elif k == 'Beleuchtungsanlage': sledrun.night_light, sledrun.night_light_comment = v
51             elif k == 'Beleuchtungstage': sledrun.night_light_days, sledrun.night_light_days_comment = v
52             elif k == 'Rodelverleih': sledrun.sled_rental, sledrun.sled_rental_comment = v
53             elif k == 'Gütesiegel': sledrun.cachet = v
54             elif k == 'Webauskunft': sledrun.information_web = v
55             elif k == 'Telefonauskunft': sledrun.information_phone = v
56             elif k == 'Bild': sledrun.image = v
57             elif k == 'In Übersichtskarte': sledrun.show_in_overview = v
58             elif k == 'Forumid': sledrun.forum_id = v
59         return sledrun
60
61     def from_python(self, value, state=None):
62         """Converts a sledrun class to a dict of Rodelbahnbox properties. value is a sledrun instance."""
63         sledrun = value
64         r = collections.OrderedDict()
65         r['Position'] = (sledrun.position_latitude, sledrun.position_longitude)
66         r['Position oben'] = (sledrun.top_latitude, sledrun.top_longitude)
67         r['Höhe oben'] = sledrun.top_elevation
68         r['Position unten'] = (sledrun.bottom_latitude, sledrun.bottom_longitude)
69         r['Höhe unten'] = sledrun.bottom_elevation
70         r['Länge'] = sledrun.length
71         r['Schwierigkeit'] = sledrun.difficulty
72         r['Lawinen'] = sledrun.avalanches
73         r['Betreiber'] = sledrun.operator
74         r['Öffentliche Anreise'] = sledrun.public_transport
75         r['Aufstieg möglich'] = sledrun.walkup_possible
76         r['Aufstieg getrennt'] = (sledrun.walkup_separate, sledrun.walkup_separate_comment)
77         r['Gehzeit'] = sledrun.walkup_time
78         r['Aufstiegshilfe'] = (sledrun.lift, sledrun.lift_details)
79         r['Beleuchtungsanlage'] = (sledrun.night_light, sledrun.night_light_comment)
80         r['Beleuchtungstage'] = (sledrun.night_light_days, sledrun.night_light_days_comment)
81         r['Rodelverleih'] = (sledrun.sled_rental, sledrun.sled_rental_comment)
82         r['Gütesiegel'] = sledrun.cachet
83         r['Webauskunft'] = sledrun.information_web
84         r['Telefonauskunft'] = sledrun.information_phone
85         r['Bild'] = sledrun.image
86         r['In Übersichtskarte'] = sledrun.show_in_overview
87         r['Forumid'] = sledrun.forum_id
88         return r
89
90
91 class WinterrodelnTemplateDict(formencode.Validator):
92     """Private helper class for RodelbahnboxValidator or GasthausboxValidator"""
93     def __init__(self, template_title):
94         self.template_title = template_title
95
96     def to_python(self, value, state):
97         title, anonym_params, named_params = value
98         if title != self.template_title:
99             raise formencode.Invalid('Template title has to be "{}".'.format(self.template_title), value, state)
100         if len(anonym_params) > 0:
101             raise formencode.Invalid('No anonymous parameters are allowed in "{}".'.format(self.template_title), value, state)
102         return named_params
103
104     def from_python(self, value, state):
105         return self.template_title, [], value
106
107
108 class RodelbahnboxValidator(wrpylib.wrvalidators.RodelbahnboxDictValidator):
109     def __init__(self):
110         wrpylib.wrvalidators.RodelbahnboxDictValidator.__init__(self)
111         self.pre_validators=[wrpylib.mwmarkup.TemplateValidator(as_table=True, as_table_keylen=20), WinterrodelnTemplateDict('Rodelbahnbox')]
112         self.chained_validators = [RodelbahnboxDictConverter()]
113
114
115 def rodelbahnbox_to_sledrun(wikitext, sledrun=None):
116     """Converts a sledrun wiki page containing the {{Rodelbahnbox}}
117     to a sledrun. sledrun may be an instance of WrSledrunCache or an "empty" class (object()) (default).
118     Raises a formencode.Invalid exception if the format is not OK or the Rodelbahnbox is not found.
119     :return: (start, end, sledrun) tuple of the Rodelbahnbox."""
120     # find Rodelbahnbox
121     start, end = wrpylib.mwmarkup.find_template(wikitext, 'Rodelbahnbox')
122     if start is None: raise formencode.Invalid("Rodelbahnbox nicht gefunden", wikitext, None)
123
124     # convert to sledrun
125     if sledrun is None:
126         state = None
127     else:
128         class State(object):
129             pass
130         state = State()
131         state.sledrun = sledrun
132     return start, end, RodelbahnboxValidator().to_python(wikitext[start:end], state)
133
134
135 def sledrun_to_rodelbahnbox(sledrun, version=None):
136     """Converts a sledrun class to the {{Rodelbahnbox}} representation.
137     The sledrun class has to have properties like position_latitude, ...
138     See the table sledruncache for field (column) values.
139     :param sledrun: an arbitrary class that contains the right properties
140     :param version: a string specifying the version of the rodelbahnbox zu produce.
141         Version '1.4' is supported."""
142     assert version in [None, '1.4']
143     return RodelbahnboxValidator().from_python(sledrun)
144
145
146 class GasthausboxDictConverter(formencode.Validator):
147     """Converts a dict with Gasthausbox properties to a Inn class. Does no validation."""
148
149     def to_python(self, value, state=None):
150         """value is a dict of properties. If state is an object with the attribute inn, this inn class will be populated or updated."""
151         props = value
152         if isinstance(state, object) and hasattr(state, 'inn'):
153             inn = state.inn
154         else:
155             class Inn(object):
156                 pass
157             inn = Inn()
158         for k, v in props.items():
159             if   k == 'Position': inn.position_latitude, inn.position_longitude = v
160             elif k == 'Höhe': inn.position_elevation = v
161             elif k == 'Betreiber': inn.operator = v
162             elif k == 'Sitzplätze': inn.seats = v
163             elif k == 'Übernachtung': inn.overnight, inn.overnight_comment = v
164             elif k == 'Rauchfrei': inn.nonsmoker_area, inn.smoker_area = v
165             elif k == 'Rodelverleih': inn.sled_rental, inn.sled_rental_comment = v
166             elif k == 'Handyempfang': inn.mobile_provider = v
167             elif k == 'Homepage': inn.homepage = v
168             elif k == 'E-Mail': inn.email_list = v
169             elif k == 'Telefon': inn.phone_list = v
170             elif k == 'Bild': inn.image = v
171             elif k == 'Rodelbahnen': inn.sledding_list = v
172         return inn
173
174     def from_python(self, value, state=None):
175         """Converts an inn class to a dict of Gasthausbox properties. value is an Inn instance."""
176         inn = value
177         r = collections.OrderedDict()
178         r['Position'] = (inn.position_latitude, inn.position_longitude)
179         r['Höhe'] = inn.position_elevation
180         r['Betreiber'] = inn.operator
181         r['Sitzplätze'] = inn.seats
182         r['Übernachtung'] = (inn.overnight, inn.overnight_comment)
183         r['Rauchfrei'] = (inn.nonsmoker_area, inn.smoker_area)
184         r['Rodelverleih'] = (inn.sled_rental, inn.sled_rental_comment)
185         r['Handyempfang'] = inn.mobile_provider
186         r['Homepage'] = inn.homepage
187         r['E-Mail'] = inn.email_list
188         r['Telefon'] = inn.phone_list
189         r['Bild'] = inn.image
190         r['Rodelbahnen'] = inn.sledding_list
191         return r
192
193
194 class GasthausboxValidator(wrpylib.wrvalidators.GasthausboxDictValidator):
195     def __init__(self):
196         wrpylib.wrvalidators.GasthausboxDictValidator.__init__(self)
197         self.pre_validators=[wrpylib.mwmarkup.TemplateValidator(as_table=True, as_table_keylen=17), WinterrodelnTemplateDict('Gasthausbox')]
198         self.chained_validators = [GasthausboxDictConverter()]
199
200
201 def gasthausbox_to_inn(wikitext, inn=None):
202     """Converts a inn wiki page containing a {{Gasthausbox}} to an inn.
203     inn may be an instance of WrInnCache or an "empty" class (default).
204     raises a formencode.Invalid exception if the format is not OK or the Gasthausbox is not found.
205     :return: (start, end, inn) tuple."""
206     # find Gasthausbox
207     start, end = wrpylib.mwmarkup.find_template(wikitext, 'Gasthausbox')
208     if start is None: raise formencode.Invalid("No 'Gasthausbox' found", wikitext, None)
209
210     # convert to inn
211     if inn is None:
212         state = None
213     else:
214         class State(object):
215             pass
216         state = State()
217         state.inn = inn
218     return start, end, GasthausboxValidator().to_python(wikitext[start:end], state)
219
220
221 def inn_to_gasthausbox(inn):
222     """Converts the inn class to the {{Gasthausbox}} representation."""
223     return GasthausboxValidator().from_python(inn)
224
225
226 def split_template_latlon_ele(template):
227     """Template is a mwparserfromhell.nodes.template.Template instance. Returns (latlon, ele)."""
228     latlon = opt_geostr_to_lat_lon(template.params[1].strip())
229     ele = opt_intstr_to_int(template.params[2].strip())
230     return latlon, ele
231
232
233 def create_template_latlon_ele(template_name, latlon, ele):
234     geo = wrpylib.wrvalidators.GeoNone().from_python((latlon))
235     if len(geo) == 0: geo = ' '
236     ele = wrpylib.wrvalidators.UnsignedNone().from_python(ele)
237     if len(ele) == 0: ele = ' '
238     return wrpylib.mwmarkup.create_template(template_name, [geo, ele])
239
240
241 def find_template_PositionOben(wikitext):
242     """Same as find_template_latlon_ele with template '{{Position oben|47.076207 N 11.453553 E|1890}}'"""
243     return find_template_latlon_ele(wikitext, 'Position oben')
244
245
246 def create_template_PositionOben(lat, lon, ele):
247     return create_template_latlon_ele('Position, oben', lat, lon, ele)
248
249
250 def find_template_PositionUnten(wikitext):
251     """Same as find_template_latlon_ele with template '{{Position unten|47.076207 N 11.453553 E|1890}}'"""
252     return find_template_latlon_ele(wikitext, 'Position unten')
253
254
255 def find_template_unsigned(wikitext, template_title):
256     """Finds the first occurance of the '{{template_title|1890}}' template
257     and returns the tuple (start, end, unsigned_value) or (None, None, None) if the
258     template was not found. If the template has no valid format, an exception is thrown."""
259     start, end = wrpylib.mwmarkup.find_template(wikitext, template_title)
260     if start is None: return (None,) * 3
261     title, params = wrpylib.mwmarkup.split_template(wikitext[start:end])
262     unsigned_value = wrpylib.wrvalidators.UnsignedNone().to_python(params['1'].strip())
263     return start, end, unsigned_value
264
265
266 def create_template_unsigned(template_title, unsigned):
267     unsigned = wrpylib.wrvalidators.UnsignedNone().from_python(unsigned)
268     if len(unsigned) == 0: unsigned = ' '
269     return wrpylib.mwmarkup.create_template(template_title, [unsigned])
270
271
272 def find_template_Hoehenunterschied(wikitext):
273     """Same as find_template_unsigned with template '{{Höhenunterschied|350}}'"""
274     return find_template_unsigned(wikitext, 'Höhenunterschied')
275
276
277 def create_template_Hoehenunterschied(ele_diff):
278     return create_template_unsigned('Höhenunterschied', ele_diff)
279
280
281 def find_template_Bahnlaenge(wikitext):
282     """Same as find_template_unsigned with template '{{Bahnlänge|4500}}'"""
283     return find_template_unsigned(wikitext, 'Bahnlänge')
284
285
286 def create_template_Bahnlaenge(length):
287     return create_template_unsigned('Bahnlänge', length)
288
289
290 def find_template_Gehzeit(wikitext):
291     """Same as find_template_unsigned with template '{{Gehzeit|60}}'"""
292     return find_template_unsigned(wikitext, 'Gehzeit')
293
294
295 def create_template_Gehzeit(walkup_time):
296     return create_template_unsigned('Gehzeit', walkup_time)
297
298
299 def find_template_Forumlink(wikitext):
300     """Same as find_template_unsigned with template '{{Forumlink|26}}'"""
301     start, end = wrpylib.mwmarkup.find_template(wikitext, 'Forumlink')
302     if start is None: return (None,) * 3
303     title, params = wrpylib.mwmarkup.split_template(wikitext[start:end])
304     forumid = params['1'].strip()
305     if forumid == '<nummer einfügen>': unsigned_value = None
306     else: unsigned_value = wrpylib.wrvalidators.UnsignedNone().to_python(forumid)
307     return start, end, unsigned_value
308     # return find_template_unsigned(wikitext, u'Forumlink')
309
310
311 def find_template_Parkplatz(wikitext):
312     """Same as find_template_latlon_ele with template '{{Parkplatz|47.076207 N 11.453553 E|1890}}'"""
313     return find_template_latlon_ele(wikitext, 'Parkplatz')
314
315
316 def find_template_Haltestelle(wikitext):
317     """Finds the first occurance of the '{{Haltestelle|Ortsname|Haltestellenname|47.076207 N 11.453553 E|1890}}' template
318     and returns the tuple (start, end, city, stop, lat, lon, ele) or (None, None, None, None, None, None, None) if the
319     template was not found. If the template has no valid format, an exception is thrown."""
320     start, end = wrpylib.mwmarkup.find_template(wikitext, 'Haltestelle')
321     if start is None: return (None,) * 7
322     title, params = wrpylib.mwmarkup.split_template(wikitext[start:end])
323     city = wrpylib.wrvalidators.UnicodeNone().to_python(params['1'].strip())
324     stop = wrpylib.wrvalidators.UnicodeNone().to_python(params['2'].strip())
325     lat, lon = wrpylib.wrvalidators.GeoNone().to_python(params['3'].strip())
326     ele = wrpylib.wrvalidators.UnsignedNone().to_python(params['4'].strip())
327     return start, end, city, stop, lat, lon, ele
328
329
330 def parse_wrmap_coordinates(coords):
331     '''gets a string coordinates and returns an array of lon/lat coordinate pairs, e.g.
332     47.12 N 11.87 E
333     47.13 N 11.70 E
334     ->
335     [[11.87, 47.12], [11.70, 47.13]]'''
336     result = []
337     pos = 0
338     for match in re.finditer(r'\s*(\d+\.?\d*)\s*N?\s+(\d+\.?\d*)\s*E?\s*', coords):
339         if match.start() != pos:
340             break
341         result.append([float(match.groups()[1]), float(match.groups()[0])])
342         pos = match.end()
343     else:
344         if pos == len(coords):
345             return result
346     raise RuntimeError('Wrong coordinate format: {}'.format(coords))
347
348
349 def parse_wrmap(wikitext):
350     """Parses the (unicode) u'<wrmap ...>content</wrmap>' of the Winterrodeln wrmap extension.
351     If wikitext does not contain the <wrmap> tag or if the <wrmap> tag contains 
352     invalid formatted lines, a ParseError is raised.
353     Use wrpylib.mwmarkup.find_tag(wikitext, 'wrmap') to find the wrmap tag within an arbitrary
354     wikitext before using this function.
355
356     :param wikitext: wikitext containing only the template. Example:
357
358     wikitext = u'''
359     <wrmap lat="47.2417134" lon="11.21408895" zoom="14" width="700" height="400">
360     <gasthaus name="Rosskogelhütte" wiki="Rosskogelhütte">47.240689 11.190454</gasthaus>
361     <parkplatz>47.245789 11.238971</parkplatz>
362     <haltestelle name="Oberperfuss Rangger Köpfl Lift">47.245711 11.238283</haltestelle>
363     <rodelbahn>
364         47.238587 11.203360
365         47.244951 11.230868
366         47.245470 11.237853
367     </rodelbahn>
368     </wrmap>
369     '''
370     :returns: GeoJSON as nested Python datatype
371     """
372     # parse XML
373     try:
374         wrmap_xml = xml.etree.ElementTree.fromstring(wikitext.encode('utf-8'))
375     except xml.etree.ElementTree.ParseError as e:
376         row, column = e.position
377         raise ParseError("XML parse error on row {}, column {}: {}".format(row, column, e))
378     if wrmap_xml.tag not in ['wrmap', 'wrgmap']:
379         raise ParseError('No valid tag name')
380
381     # convert XML to geojson (http://www.geojson.org/geojson-spec.html)
382     json_features = []
383     for feature in wrmap_xml:
384         # determine feature type
385         is_point = feature.tag in WRMAP_POINT_TYPES
386         is_line = feature.tag in WRMAP_LINE_TYPES
387         if (not is_point and not is_line):
388             raise ParseError('Unknown element <{}>.'.format(feature.tag))
389
390         # point
391         if is_point:
392             properties = {'type': feature.tag}
393             allowed_properties = {'name', 'wiki'}
394             wrong_properties = set(feature.attrib.keys()) - allowed_properties
395             if len(wrong_properties) > 0:
396                 raise ParseError("The attribute '{}' is not allowed at <{}>.".format(list(wrong_properties)[0], feature.tag))
397             properties.update(feature.attrib)
398             coordinates = parse_wrmap_coordinates(feature.text)
399             if len(coordinates) != 1:
400                 raise ParseError('The element <{}> has to have exactly one coordinate pair.'.format(feature.tag))
401             json_features.append({
402                 'type': 'Feature',
403                 'geometry': {'type': 'Point', 'coordinates': coordinates[0]},
404                 'properties': properties})
405
406         # line
407         if is_line:
408             properties = {'type': feature.tag}
409             allowed_properties = {'farbe', 'dicke'}
410             wrong_properties = set(feature.attrib.keys()) - allowed_properties
411             if len(wrong_properties) > 0:
412                 raise ParseError("The attribute '{}' is not allowed at <{}>.".format(list(wrong_properties)[0], feature.tag))
413             if 'farbe' in feature.attrib: 
414                 if not re.match('#[0-9a-fA-F]{6}$', feature.attrib['farbe']):
415                     raise ParseError('The attribute "farbe" has to have a format like "#a0bb43".')
416                 properties['strokeColor'] = feature.attrib['farbe'] # e.g. #a200b7
417             if 'dicke' in feature.attrib:
418                 try:
419                     properties['strokeWidth'] = int(feature.attrib['dicke']) # e.g. 6
420                 except ValueError:
421                     raise ParseError('The attribute "dicke" has to be an integer.')
422             json_features.append({
423                 'type': 'Feature',
424                 'geometry': {'type': 'LineString', 'coordinates': parse_wrmap_coordinates(feature.text)},
425                 'properties': properties})
426
427     # attributes
428     properties = {}
429     for k, v in wrmap_xml.attrib.items():
430         if k in ['lat', 'lon']:
431             try:
432                 properties[k] = float(v)
433             except ValueError:
434                 raise ParseError('Attribute "{}" has to be a float value.'.format(k))
435         elif k in ['zoom', 'width', 'height']:
436             try:
437                 properties[k] = int(v)
438             except ValueError:
439                 raise ParseError('Attribute "{}" has to be an integer value.'.format(k))
440         else:
441             raise ParseError('Unknown attribute "{}".'.format(k))
442
443     geojson = {
444         'type': 'FeatureCollection',
445         'features': json_features,
446         'properties': properties}
447
448     return geojson
449
450
451 def create_wrmap_coordinates(coords):
452     result = []
453     for coord in coords:
454         result.append('{:.6f} N {:.6f} E'.format(coord[1], coord[0]))
455     return '\n'.join(result)
456  
457
458 def create_wrmap(geojson):
459     """Creates a <wrmap> wikitext from geojson (as python types)."""
460     wrmap_xml = xml.etree.ElementTree.Element('wrmap')
461     wrmap_xml.text = '\n\n'
462     for k, v in geojson['properties'].items():
463         if k in ['lon', 'lat']:
464             wrmap_xml.attrib[k] = '{:.6f}'.format(v)
465         else:
466             wrmap_xml.attrib[k] = str(v)
467
468     assert geojson['type'] == 'FeatureCollection'
469     json_features = geojson['features']
470     last_json_feature = None
471     for json_feature in json_features:
472         feature_xml = xml.etree.ElementTree.SubElement(wrmap_xml, json_feature['properties']['type'])
473         geo = json_feature['geometry']
474         if geo['type'] == 'Point':
475             feature_xml.text = create_wrmap_coordinates([geo['coordinates']])
476             if last_json_feature is not None:
477                 last_json_feature.tail = '\n'
478         else:
479             if last_json_feature is not None:
480                 last_json_feature.tail = '\n\n'
481             feature_xml.text = '\n' + create_wrmap_coordinates(geo['coordinates']) + '\n'
482         last_json_feature = feature_xml
483         feature_xml.attrib = json_feature['properties']
484         del feature_xml.attrib['type']
485
486     if last_json_feature is not None:
487         last_json_feature.tail = '\n\n'
488     return xml.etree.ElementTree.tostring(wrmap_xml, encoding='utf-8').decode('utf-8')
489