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