Fixed conversion errors in inn_to_gasthausbox.
[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         v = '' if value is None else value
116         return GASTHAUSBOX_DICT[key].from_str(v)
117     value = collections.OrderedDict()
118     value['Position'] = LonLat(inn.position_longitude, inn.position_latitude)
119     value['Höhe'] = inn.position_elevation
120     value['Betreiber'] = inn.operator
121     value['Sitzplätze'] = inn.seats
122     value['Übernachtung'] = (inn.overnight, inn.overnight_comment)
123     value['Rauchfrei'] = {(False, True): 0.0, (True, True): 0.5, (True, False): 1.0}.get((inn.nonsmoker_area, inn.smoker_area), None)
124     value['Rodelverleih'] = (inn.sled_rental, inn.sled_rental_comment)
125     value['Handyempfang'] = convfromdb(inn.mobile_provider, 'Handyempfang')
126     value['Homepage'] = convfromdb(inn.homepage, 'Homepage')
127     value['E-Mail'] = convfromdb(inn.email_list, 'E-Mail')
128     value['Telefon'] = convfromdb(inn.phone_list, 'Telefon')
129     value['Bild'] = inn.image
130     value['Rodelbahnen'] = convfromdb(inn.sledding_list, 'Rodelbahnen')
131     return value
132
133
134 def lonlat_ele_from_template(template):
135     """Template is a mwparserfromhell.nodes.template.Template instance. Returns (lonlat, ele)."""
136     lonlat = opt_lonlat_from_str(template.params[0].strip())
137     ele = opt_uint_from_str(template.params[1].strip())
138     return lonlat, ele
139
140
141 def latlon_ele_to_template(lonlat_ele, name):
142     lonlat, ele = lonlat_ele
143     template = mwparserfromhell.nodes.template.Template(name)
144     template.add(1, opt_lonlat_to_str(lonlat))
145     template.add(2, opt_uint_to_str(ele))
146     wrpylib.mwmarkup.format_template_oneline(template)
147     return template
148
149
150 class ParseError(RuntimeError):
151     """Exception used by some of the functions"""
152     pass
153
154
155
156 def find_template_PositionOben(wikitext):
157     """Same as find_template_latlon_ele with template '{{Position oben|47.076207 N 11.453553 E|1890}}'"""
158     return find_template_latlon_ele(wikitext, 'Position oben')
159
160
161 def create_template_PositionOben(lat, lon, ele):
162     return create_template_latlon_ele('Position, oben', lat, lon, ele)
163
164
165 def find_template_PositionUnten(wikitext):
166     """Same as find_template_latlon_ele with template '{{Position unten|47.076207 N 11.453553 E|1890}}'"""
167     return find_template_latlon_ele(wikitext, 'Position unten')
168
169
170 def find_template_unsigned(wikitext, template_title):
171     """Finds the first occurance of the '{{template_title|1890}}' template
172     and returns the tuple (start, end, unsigned_value) or (None, None, None) if the
173     template was not found. If the template has no valid format, an exception is thrown."""
174     start, end = wrpylib.mwmarkup.find_template(wikitext, template_title)
175     if start is None: return (None,) * 3
176     title, params = wrpylib.mwmarkup.split_template(wikitext[start:end])
177     unsigned_value = wrpylib.wrvalidators.UnsignedNone().to_python(params['1'].strip())
178     return start, end, unsigned_value
179
180
181 def create_template_unsigned(template_title, unsigned):
182     unsigned = wrpylib.wrvalidators.UnsignedNone().from_python(unsigned)
183     if len(unsigned) == 0: unsigned = ' '
184     return wrpylib.mwmarkup.create_template(template_title, [unsigned])
185
186
187 def find_template_Hoehenunterschied(wikitext):
188     """Same as find_template_unsigned with template '{{Höhenunterschied|350}}'"""
189     return find_template_unsigned(wikitext, 'Höhenunterschied')
190
191
192 def create_template_Hoehenunterschied(ele_diff):
193     return create_template_unsigned('Höhenunterschied', ele_diff)
194
195
196 def find_template_Bahnlaenge(wikitext):
197     """Same as find_template_unsigned with template '{{Bahnlänge|4500}}'"""
198     return find_template_unsigned(wikitext, 'Bahnlänge')
199
200
201 def create_template_Bahnlaenge(length):
202     return create_template_unsigned('Bahnlänge', length)
203
204
205 def find_template_Gehzeit(wikitext):
206     """Same as find_template_unsigned with template '{{Gehzeit|60}}'"""
207     return find_template_unsigned(wikitext, 'Gehzeit')
208
209
210 def create_template_Gehzeit(walkup_time):
211     return create_template_unsigned('Gehzeit', walkup_time)
212
213
214 def find_template_Forumlink(wikitext):
215     """Same as find_template_unsigned with template '{{Forumlink|26}}'"""
216     start, end = wrpylib.mwmarkup.find_template(wikitext, 'Forumlink')
217     if start is None: return (None,) * 3
218     title, params = wrpylib.mwmarkup.split_template(wikitext[start:end])
219     forumid = params['1'].strip()
220     if forumid == '<nummer einfügen>': unsigned_value = None
221     else: unsigned_value = wrpylib.wrvalidators.UnsignedNone().to_python(forumid)
222     return start, end, unsigned_value
223     # return find_template_unsigned(wikitext, u'Forumlink')
224
225
226 def find_template_Parkplatz(wikitext):
227     """Same as find_template_latlon_ele with template '{{Parkplatz|47.076207 N 11.453553 E|1890}}'"""
228     return find_template_latlon_ele(wikitext, 'Parkplatz')
229
230
231 def find_template_Haltestelle(wikitext):
232     """Finds the first occurance of the '{{Haltestelle|Ortsname|Haltestellenname|47.076207 N 11.453553 E|1890}}' template
233     and returns the tuple (start, end, city, stop, lat, lon, ele) or (None, None, None, None, None, None, None) if the
234     template was not found. If the template has no valid format, an exception is thrown."""
235     start, end = wrpylib.mwmarkup.find_template(wikitext, 'Haltestelle')
236     if start is None: return (None,) * 7
237     title, params = wrpylib.mwmarkup.split_template(wikitext[start:end])
238     city = wrpylib.wrvalidators.UnicodeNone().to_python(params['1'].strip())
239     stop = wrpylib.wrvalidators.UnicodeNone().to_python(params['2'].strip())
240     lat, lon = wrpylib.wrvalidators.GeoNone().to_python(params['3'].strip())
241     ele = wrpylib.wrvalidators.UnsignedNone().to_python(params['4'].strip())
242     return start, end, city, stop, lat, lon, ele
243
244
245 def parse_wrmap_coordinates(coords):
246     """gets a string coordinates and returns an array of lon/lat coordinate pairs, e.g.
247     47.12 N 11.87 E
248     47.13 N 11.70 E
249     ->
250     [[11.87, 47.12], [11.70, 47.13]]"""
251     result = []
252     pos = 0
253     for match in re.finditer(r'\s*(\d+\.?\d*)\s*N?\s+(\d+\.?\d*)\s*E?\s*', coords):
254         if match.start() != pos:
255             break
256         result.append([float(match.groups()[1]), float(match.groups()[0])])
257         pos = match.end()
258     else:
259         if pos == len(coords):
260             return result
261     raise RuntimeError('Wrong coordinate format: {}'.format(coords))
262
263
264 WRMAP_POINT_TYPES = ['gasthaus', 'haltestelle', 'parkplatz', 'achtung', 'foto', 'verleih', 'punkt']
265 WRMAP_LINE_TYPES = ['rodelbahn', 'gehweg', 'alternative', 'lift', 'anfahrt', 'linie']
266
267
268 def parse_wrmap(wikitext):
269     """Parses the (unicode) u'<wrmap ...>content</wrmap>' of the Winterrodeln wrmap extension.
270     If wikitext does not contain the <wrmap> tag or if the <wrmap> tag contains 
271     invalid formatted lines, a ParseError is raised.
272     Use wrpylib.mwmarkup.find_tag(wikitext, 'wrmap') to find the wrmap tag within an arbitrary
273     wikitext before using this function.
274
275     :param wikitext: wikitext containing only the template. Example:
276
277     wikitext = u'''
278     <wrmap lat="47.2417134" lon="11.21408895" zoom="14" width="700" height="400">
279     <gasthaus name="Rosskogelhütte" wiki="Rosskogelhütte">47.240689 11.190454</gasthaus>
280     <parkplatz>47.245789 11.238971</parkplatz>
281     <haltestelle name="Oberperfuss Rangger Köpfl Lift">47.245711 11.238283</haltestelle>
282     <rodelbahn>
283         47.238587 11.203360
284         47.244951 11.230868
285         47.245470 11.237853
286     </rodelbahn>
287     </wrmap>
288     '''
289     :returns: GeoJSON as nested Python datatype
290     """
291     # parse XML
292     try:
293         wrmap_xml = xml.etree.ElementTree.fromstring(wikitext.encode('utf-8'))
294     except xml.etree.ElementTree.ParseError as e:
295         row, column = e.position
296         raise ParseError("XML parse error on row {}, column {}: {}".format(row, column, e))
297     if wrmap_xml.tag not in ['wrmap', 'wrgmap']:
298         raise ParseError('No valid tag name')
299
300     # convert XML to geojson (http://www.geojson.org/geojson-spec.html)
301     json_features = []
302     for feature in wrmap_xml:
303         # determine feature type
304         is_point = feature.tag in WRMAP_POINT_TYPES
305         is_line = feature.tag in WRMAP_LINE_TYPES
306         if (not is_point and not is_line):
307             raise ParseError('Unknown element <{}>.'.format(feature.tag))
308
309         # point
310         if is_point:
311             properties = {'type': feature.tag}
312             allowed_properties = {'name', 'wiki'}
313             wrong_properties = set(feature.attrib.keys()) - allowed_properties
314             if len(wrong_properties) > 0:
315                 raise ParseError("The attribute '{}' is not allowed at <{}>.".format(list(wrong_properties)[0], feature.tag))
316             properties.update(feature.attrib)
317             coordinates = parse_wrmap_coordinates(feature.text)
318             if len(coordinates) != 1:
319                 raise ParseError('The element <{}> has to have exactly one coordinate pair.'.format(feature.tag))
320             json_features.append({
321                 'type': 'Feature',
322                 'geometry': {'type': 'Point', 'coordinates': coordinates[0]},
323                 'properties': properties})
324
325         # line
326         if is_line:
327             properties = {'type': feature.tag}
328             allowed_properties = {'farbe', 'dicke'}
329             wrong_properties = set(feature.attrib.keys()) - allowed_properties
330             if len(wrong_properties) > 0:
331                 raise ParseError("The attribute '{}' is not allowed at <{}>.".format(list(wrong_properties)[0], feature.tag))
332             if 'farbe' in feature.attrib: 
333                 if not re.match('#[0-9a-fA-F]{6}$', feature.attrib['farbe']):
334                     raise ParseError('The attribute "farbe" has to have a format like "#a0bb43".')
335                 properties['strokeColor'] = feature.attrib['farbe'] # e.g. #a200b7
336             if 'dicke' in feature.attrib:
337                 try:
338                     properties['strokeWidth'] = int(feature.attrib['dicke']) # e.g. 6
339                 except ValueError:
340                     raise ParseError('The attribute "dicke" has to be an integer.')
341             json_features.append({
342                 'type': 'Feature',
343                 'geometry': {'type': 'LineString', 'coordinates': parse_wrmap_coordinates(feature.text)},
344                 'properties': properties})
345
346     # attributes
347     properties = {}
348     for k, v in wrmap_xml.attrib.items():
349         if k in ['lat', 'lon']:
350             try:
351                 properties[k] = float(v)
352             except ValueError:
353                 raise ParseError('Attribute "{}" has to be a float value.'.format(k))
354         elif k in ['zoom', 'width', 'height']:
355             try:
356                 properties[k] = int(v)
357             except ValueError:
358                 raise ParseError('Attribute "{}" has to be an integer value.'.format(k))
359         else:
360             raise ParseError('Unknown attribute "{}".'.format(k))
361
362     geojson = {
363         'type': 'FeatureCollection',
364         'features': json_features,
365         'properties': properties}
366
367     return geojson
368
369
370 def create_wrmap_coordinates(coords):
371     result = []
372     for coord in coords:
373         result.append('{:.6f} N {:.6f} E'.format(coord[1], coord[0]))
374     return '\n'.join(result)
375  
376
377 def create_wrmap(geojson):
378     """Creates a <wrmap> wikitext from geojson (as python types)."""
379     wrmap_xml = xml.etree.ElementTree.Element('wrmap')
380     wrmap_xml.text = '\n\n'
381     for k, v in geojson['properties'].items():
382         if k in ['lon', 'lat']:
383             wrmap_xml.attrib[k] = '{:.6f}'.format(v)
384         else:
385             wrmap_xml.attrib[k] = str(v)
386
387     assert geojson['type'] == 'FeatureCollection'
388     json_features = geojson['features']
389     last_json_feature = None
390     for json_feature in json_features:
391         feature_xml = xml.etree.ElementTree.SubElement(wrmap_xml, json_feature['properties']['type'])
392         geo = json_feature['geometry']
393         if geo['type'] == 'Point':
394             feature_xml.text = create_wrmap_coordinates([geo['coordinates']])
395             if last_json_feature is not None:
396                 last_json_feature.tail = '\n'
397         else:
398             if last_json_feature is not None:
399                 last_json_feature.tail = '\n\n'
400             feature_xml.text = '\n' + create_wrmap_coordinates(geo['coordinates']) + '\n'
401         last_json_feature = feature_xml
402         feature_xml.attrib = json_feature['properties']
403         del feature_xml.attrib['type']
404
405     if last_json_feature is not None:
406         last_json_feature.tail = '\n\n'
407     return xml.etree.ElementTree.tostring(wrmap_xml, encoding='utf-8').decode('utf-8')