8bf6ae93b6034d87197702145e965460ca0d866a
[ikiwiki.git] / IkiWiki / Plugin / osm.pm
1 #!/usr/bin/perl
2 # Copyright 2011 Blars Blarson
3 # Released under GPL version 2
4
5 package IkiWiki::Plugin::osm;
6 use utf8;
7 use strict;
8 use warnings;
9 use IkiWiki 3.0;
10
11 sub import {
12         add_underlay("osm");
13         hook(type => "getsetup", id => "osm", call => \&getsetup);
14         hook(type => "format", id => "osm", call => \&format);
15         hook(type => "preprocess", id => "osm", call => \&preprocess);
16         hook(type => "preprocess", id => "waypoint", call => \&process_waypoint);
17         hook(type => "savestate", id => "waypoint", call => \&savestate);
18         hook(type => "cgi", id => "osm", call => \&cgi);
19 }
20
21 sub getsetup () {
22         return
23                 plugin => {
24                         safe => 1,
25                         rebuild => 1,
26                         section => "special-purpose",
27                 },
28                 osm_default_zoom => {
29                         type => "integer",
30                         example => "15",
31                         description => "the default zoom when you click on the map link",
32                         safe => 1,
33                         rebuild => 1,
34                 },
35                 osm_default_icon => {
36                         type => "string",
37                         example => "ikiwiki/images/osm.png",
38                         description => "the icon shown on links and on the main map",
39                         safe => 0,
40                         rebuild => 1,
41                 },
42                 osm_alt => {
43                         type => "string",
44                         example => "",
45                         description => "the alt tag of links, defaults to empty",
46                         safe => 0,
47                         rebuild => 1,
48                 },
49                 osm_format => {
50                         type => "string",
51                         example => "KML",
52                         description => "the output format for waypoints, can be KML, GeoJSON or CSV (one or many, comma-separated)",
53                         safe => 1,
54                         rebuild => 1,
55                 },
56                 osm_tag_default_icon => {
57                         type => "string",
58                         example => "icon.png",
59                         description => "the icon attached to a tag, displayed on the map for tagged pages",
60                         safe => 0,
61                         rebuild => 1,
62                 },
63 }
64
65 sub preprocess {
66         my %params=@_;
67         my $page = $params{page};
68         my $dest = $params{destpage};
69         my $loc = $params{loc}; # sanitized below
70         my $lat = $params{lat}; # sanitized below
71         my $lon = $params{lon}; # sanitized below
72         my $href = $params{href};
73
74         my ($width, $height, $float);
75         $height = scrub($params{'height'} || "300px", $page, $dest); # sanitized here
76         $width = scrub($params{'width'} || "500px", $page, $dest); # sanitized here
77         $float = (defined($params{'right'}) && 'right') || (defined($params{'left'}) && 'left'); # sanitized here
78         
79         my $zoom = scrub($params{'zoom'} // $config{'osm_default_zoom'} // 15, $page, $dest); # sanitized below
80         my $map;
81         $map = $params{'map'} || 'map';
82         
83         $map = scrub($map, $page, $dest); # sanitized here
84         my $name = scrub($params{'name'} || $map, $page, $dest);
85
86         if (defined($lon) || defined($lat) || defined($loc)) {
87                 ($lon, $lat) = scrub_lonlat($loc, $lon, $lat);
88         }
89
90         if ($zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18) {
91                 error("Bad zoom");
92         }
93
94         if (! defined $href || ! length $href) {
95                 $href=IkiWiki::cgiurl(
96                         do => "osm",
97                         map => $map,
98                 );
99         }
100
101         $pagestate{$page}{'osm'}{$map}{'displays'}{$name} = {
102                 height => $height,
103                 width => $width,
104                 float => $float,
105                 zoom => $zoom,
106                 fullscreen => 0,
107                 editable => defined($params{'editable'}),
108                 lat => $lat,
109                 lon => $lon,
110                 href => $href,
111         };
112         return "<div id=\"mapdiv-$name\"></div>";
113 }
114
115 sub process_waypoint {
116         my %params=@_;
117         my $loc = $params{'loc'}; # sanitized below
118         my $lat = $params{'lat'}; # sanitized below
119         my $lon = $params{'lon'}; # sanitized below
120         my $page = $params{'page'}; # not sanitized?
121         my $dest = $params{'destpage'}; # not sanitized?
122         my $hidden = defined($params{'hidden'}); # sanitized here
123         my ($p) = $page =~ /(?:^|\/)([^\/]+)\/?$/; # shorter page name
124         my $name = scrub($params{'name'} || $p, $page, $dest); # sanitized here
125         my $desc = scrub($params{'desc'} || '', $page, $dest); # sanitized here
126         my $zoom = scrub($params{'zoom'} // $config{'osm_default_zoom'} // 15, $page, $dest); # sanitized below
127         my $icon = $config{'osm_default_icon'} || "ikiwiki/images/osm.png"; # sanitized: we trust $config
128         my $map = scrub($params{'map'} || 'map', $page, $dest); # sanitized here
129         my $alt = $config{'osm_alt'} ? "alt=\"$config{'osm_alt'}\"" : ''; # sanitized: we trust $config
130         if ($zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18) {
131                 error("Bad zoom");
132         }
133
134         ($lon, $lat) = scrub_lonlat($loc, $lon, $lat);
135         if (!defined($lat) || !defined($lon)) {
136                 error("Must specify lat and lon");
137         }
138
139         my $tag = $params{'tag'};
140         foreach my $t (keys %{$typedlinks{$page}{'tag'}}) {
141                 if ($icon = get_tag_icon($t)) {
142                         $tag = $t;
143                         last;
144                 }
145                 $t =~ s!/$config{'tagbase'}/!!;
146                 if ($icon = get_tag_icon($t)) {
147                         $tag = $t;
148                         last;
149                 }
150         }
151         $icon = urlto($icon, $dest, 1);
152         $tag = '' unless $tag;
153         if ($page eq $dest) {
154                 my %formats = get_formats();
155                 if ($formats{'GeoJSON'}) {
156                         will_render($page, "$map/pois.json");
157                 }
158                 if ($formats{'CSV'}) {
159                         will_render($page, "$map/pois.txt");
160                 }
161                 if ($formats{'KML'}) {
162                         will_render($page, "$map/pois.kml");
163                 }
164         }
165         $pagestate{$page}{'osm'}{$map}{'waypoints'}{$name} = {
166                 page => $page,
167                 desc => $desc,
168                 icon => $icon,
169                 tag => $tag,
170                 lat => $lat,
171                 lon => $lon,
172                 # How to link back to the page from the map, not to be
173                 # confused with the URL of the map itself sent to the
174                 # embeded map below. Note: used in generated KML etc file,
175                 # so must be absolute.
176                 href => urlto($page),
177         };
178
179         my $mapurl = IkiWiki::cgiurl(
180                 do => "osm",
181                 map => $map,
182                 lat => $lat,
183                 lon => $lon,
184                 zoom => $zoom,
185         );
186         my $output = '';
187         if (defined($params{'embed'})) {
188                 $output .= preprocess(%params,
189                         href => $mapurl,
190                 );
191         }
192         if (!$hidden) {
193                 $output .= "<a href=\"$mapurl\"><img class=\"img\" src=\"$icon\" $alt /></a>";
194         }
195         return $output;
196 }
197
198 # get the icon from the given tag
199 sub get_tag_icon($) {
200         my $tag = shift;
201         # look for an icon attached to the tag
202         my $attached = $tag . '/' . $config{'osm_tag_default_icon'};
203         if (srcfile($attached)) {
204                 return $attached;
205         }
206         else {
207                 return undef;
208         }
209 }
210
211 sub scrub_lonlat($$$) {
212         my ($loc, $lon, $lat) = @_;
213         if ($loc) {
214                 if ($loc =~ /^\s*(\-?\d+(?:\.\d*°?|(?:°?|\s)\s*\d+(?:\.\d*\'?|(?:\'|\s)\s*\d+(?:\.\d*)?\"?|\'?)°?)[NS]?)\s*\,?\;?\s*(\-?\d+(?:\.\d*°?|(?:°?|\s)\s*\d+(?:\.\d*\'?|(?:\'|\s)\s*\d+(?:\.\d*)?\"?|\'?)°?)[EW]?)\s*$/) {
215                         $lat = $1;
216                         $lon = $2;
217                 }
218                 else {
219                         error("Bad loc");
220                 }
221         }
222         if (defined($lat)) {
223                 if ($lat =~ /^(\-?)(\d+)(?:(\.\d*)°?|(?:°|\s)\s*(\d+)(?:(\.\d*)\'?|(?:\'|\s)\s*(\d+(?:\.\d*)?\"?)|\'?)|°?)\s*([NS])?\s*$/) {
224                         $lat = $2 + ($3//0) + ((($4//0) + (($5//0) + (($6//0)/60.)))/60.);
225                         if (($1 eq '-') || (($7//'') eq 'S')) {
226                                 $lat = - $lat;
227                         }
228                 }
229                 else {
230                         error("Bad lat");
231                 }
232         }
233         if (defined($lon)) {
234                 if ($lon =~ /^(\-?)(\d+)(?:(\.\d*)°?|(?:°|\s)\s*(\d+)(?:(\.\d*)\'?|(?:\'|\s)\s*(\d+(?:\.\d*)?\"?)|\'?)|°?)\s*([EW])?$/) {
235                         $lon = $2 + ($3//0) + ((($4//0) + (($5//0) + (($6//0)/60.)))/60.);
236                         if (($1 eq '-') || (($7//'') eq 'W')) {
237                                 $lon = - $lon;
238                         }
239                 }
240                 else {
241                         error("Bad lon");
242                 }
243         }
244         if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
245                 error("Location out of range");
246         }
247         return ($lon, $lat);
248 }
249
250 sub savestate {
251         my %waypoints = ();
252         my %linestrings = ();
253
254         foreach my $page (keys %pagestate) {
255                 if (exists $pagestate{$page}{'osm'}) {
256                         foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
257                                 foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'waypoints'}}) {
258                                         debug("found waypoint $name");
259                                         $waypoints{$map}{$name} = $pagestate{$page}{'osm'}{$map}{'waypoints'}{$name};
260                                 }
261                         }
262                 }
263         }
264
265         foreach my $page (keys %pagestate) {
266                 if (exists $pagestate{$page}{'osm'}) {
267                         foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
268                                 # examine the links on this page
269                                 foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'waypoints'}}) {
270                                         if (exists $links{$page}) {
271                                                 foreach my $otherpage (@{$links{$page}}) {
272                                                         if (exists $waypoints{$map}{$otherpage}) {
273                                                                 push(@{$linestrings{$map}}, [
274                                                                         [ $waypoints{$map}{$name}{'lon'}, $waypoints{$map}{$name}{'lat'} ],
275                                                                         [ $waypoints{$map}{$otherpage}{'lon'}, $waypoints{$map}{$otherpage}{'lat'} ]
276                                                                 ]);
277                                                         }
278                                                 }
279                                         }
280                                 }
281                         }
282                         # clear the state, it will be regenerated on the next parse
283                         # the idea here is to clear up removed waypoints...
284                         $pagestate{$page}{'osm'} = ();
285                 }
286         }
287
288         my %formats = get_formats();
289         if ($formats{'GeoJSON'}) {
290                 writejson(\%waypoints, \%linestrings);
291         }
292         if ($formats{'CSV'}) {
293                 writecsvs(\%waypoints, \%linestrings);
294         }
295         if ($formats{'KML'}) {
296                 writekml(\%waypoints, \%linestrings);
297         }
298 }
299
300 sub writejson($;$) {
301         my %waypoints = %{$_[0]};
302         my %linestrings = %{$_[1]};
303         eval q{use JSON};
304         error $@ if $@;
305         foreach my $map (keys %waypoints) {
306                 my %geojson = ( "type" => "FeatureCollection", "features" => []);
307                 foreach my $name (keys %{$waypoints{$map}}) {
308                         my %marker = ( "type" => "Feature",
309                                 "geometry" => { "type" => "Point", "coordinates" => [ $waypoints{$map}{$name}{'lon'}, $waypoints{$map}{$name}{'lat'} ] },
310                                 "properties" => $waypoints{$map}{$name} );
311                         push @{$geojson{'features'}}, \%marker;
312                 }
313                 foreach my $linestring (@{$linestrings{$map}}) {
314                         my %json  = ( "type" => "Feature",
315                                 "geometry" => { "type" => "LineString", "coordinates" => $linestring });
316                         push @{$geojson{'features'}}, \%json;
317                 }
318                 writefile("pois.json", $config{destdir} . "/$map", to_json(\%geojson));
319         }
320 }
321
322 sub writekml($;$) {
323         my %waypoints = %{$_[0]};
324         my %linestrings = %{$_[1]};
325         eval q{use XML::Writer};
326         error $@ if $@;
327         foreach my $map (keys %waypoints) {
328
329 =pod
330 Sample placemark:
331
332 <?xml version="1.0" encoding="UTF-8"?>
333 <kml xmlns="http://www.opengis.net/kml/2.2">
334   <Placemark>
335     <name>Simple placemark</name>
336     <description>Attached to the ground. Intelligently places itself 
337        at the height of the underlying terrain.</description>
338     <Point>
339       <coordinates>-122.0822035425683,37.42228990140251,0</coordinates>
340     </Point>
341   </Placemark>
342 </kml>
343
344 Sample style:
345
346
347         <Style id="sh_sunny_copy69">
348                 <IconStyle>
349                         <scale>1.4</scale>
350                         <Icon>
351                                 <href>http://waypoints.google.com/mapfiles/kml/shapes/sunny.png</href>
352                         </Icon>
353                         <hotSpot x="0.5" y="0.5" xunits="fraction" yunits="fraction"/>
354                 </IconStyle>
355                 <LabelStyle>
356                         <color>ff00aaff</color>
357                 </LabelStyle>
358         </Style>
359
360
361 =cut
362
363                 my $output;
364                 my $writer = XML::Writer->new( OUTPUT => \$output,
365                         DATA_MODE => 1, ENCODING => 'UTF-8');
366                 $writer->xmlDecl();
367                 $writer->startTag("kml", "xmlns" => "http://www.opengis.net/kml/2.2");
368
369                 # first pass: get the icons
370                 foreach my $name (keys %{$waypoints{$map}}) {
371                         my %options = %{$waypoints{$map}{$name}};
372                         $writer->startTag("Style", id => $options{tag});
373                         $writer->startTag("IconStyle");
374                         $writer->startTag("Icon");
375                         $writer->startTag("href");
376                         $writer->characters($options{icon});
377                         $writer->endTag();
378                         $writer->endTag();
379                         $writer->endTag();
380                         $writer->endTag();
381                 }
382         
383                 foreach my $name (keys %{$waypoints{$map}}) {
384                         my %options = %{$waypoints{$map}{$name}};
385                         $writer->startTag("Placemark");
386                         $writer->startTag("name");
387                         $writer->characters($name);
388                         $writer->endTag();
389                         $writer->startTag("styleUrl");
390                         $writer->characters('#' . $options{tag});
391                         $writer->endTag();
392                         #$writer->emptyTag('atom:link', href => $options{href});
393                         # to make it easier for us as the atom:link parameter is
394                         # hard to access from javascript
395                         $writer->startTag('href');
396                         $writer->characters($options{href});
397                         $writer->endTag();
398                         $writer->startTag("description");
399                         $writer->characters($options{desc});
400                         $writer->endTag();
401                         $writer->startTag("Point");
402                         $writer->startTag("coordinates");
403                         $writer->characters($options{lon} . "," . $options{lat});
404                         $writer->endTag();
405                         $writer->endTag();
406                         $writer->endTag();
407                 }
408                 
409                 my $i = 0;
410                 foreach my $linestring (@{$linestrings{$map}}) {
411                         $writer->startTag("Placemark");
412                         $writer->startTag("name");
413                         $writer->characters("linestring " . $i++);
414                         $writer->endTag();
415                         $writer->startTag("LineString");
416                         $writer->startTag("coordinates");
417                         my $str = '';
418                         foreach my $coord (@{$linestring}) {
419                                 $str .= join(',', @{$coord}) . " \n";
420                         }
421                         $writer->characters($str);
422                         $writer->endTag();
423                         $writer->endTag();
424                         $writer->endTag();
425                 }
426                 $writer->endTag();
427                 $writer->end();
428
429                 writefile("pois.kml", $config{destdir} . "/$map", $output);
430         }
431 }
432
433 sub writecsvs($;$) {
434         my %waypoints = %{$_[0]};
435         foreach my $map (keys %waypoints) {
436                 my $poisf = "lat\tlon\ttitle\tdescription\ticon\ticonSize\ticonOffset\n";
437                 foreach my $name (keys %{$waypoints{$map}}) {
438                         my %options = %{$waypoints{$map}{$name}};
439                         my $line = 
440                                 $options{'lat'} . "\t" .
441                                 $options{'lon'} . "\t" .
442                                 $name . "\t" .
443                                 $options{'desc'} . '<br /><a href="' . $options{'page'} . '">' . $name . "</a>\t" .
444                                 $options{'icon'} . "\n";
445                         $poisf .= $line;
446                 }
447                 writefile("pois.txt", $config{destdir} . "/$map", $poisf);
448         }
449 }
450
451 # pipe some data through the HTML scrubber
452 #
453 # code taken from the meta.pm plugin
454 sub scrub($$$) {
455         if (IkiWiki::Plugin::htmlscrubber->can("sanitize")) {
456                 return IkiWiki::Plugin::htmlscrubber::sanitize(
457                         content => shift, page => shift, destpage => shift);
458         }
459         else {
460                 return shift;
461         }
462 }
463
464 # taken from toggle.pm
465 sub format (@) {
466         my %params=@_;
467
468         if ($params{content}=~m!<div[^>]*id="mapdiv-[^"]*"[^>]*>!g) {
469                 if (! ($params{content}=~s!</body>!include_javascript($params{page})."</body>"!em)) {
470                         # no <body> tag, probably in preview mode
471                         $params{content}=$params{content} . include_javascript($params{page});
472                 }
473         }
474         return $params{content};
475 }
476
477 sub preferred_format() {
478         if (!defined($config{'osm_format'}) || !$config{'osm_format'}) {
479                 $config{'osm_format'} = 'KML';
480         }
481         my @spl = split(/, */, $config{'osm_format'});
482         return shift @spl;
483 }
484
485 sub get_formats() {
486         if (!defined($config{'osm_format'}) || !$config{'osm_format'}) {
487                 $config{'osm_format'} = 'KML';
488         }
489         map { $_ => 1 } split(/, */, $config{'osm_format'});
490 }
491
492 sub include_javascript ($) {
493         my $page=shift;
494         my $loader;
495
496         if (exists $pagestate{$page}{'osm'}) {
497                 foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
498                         foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'displays'}}) {
499                                 $loader .= map_setup_code($map, $name, %{$pagestate{$page}{'osm'}{$map}{'displays'}{$name}});
500                         }
501                 }
502         }
503         if ($loader) {
504                 return embed_map_code($page) . "<script type=\"text/javascript\" charset=\"utf-8\">$loader</script>";
505         }
506         else {
507                 return '';
508         }
509 }
510
511 sub cgi($) {
512         my $cgi=shift;
513
514         return unless defined $cgi->param('do') &&
515                 $cgi->param("do") eq "osm";
516         
517         IkiWiki::loadindex();
518
519         IkiWiki::decode_cgi_utf8($cgi);
520
521         my $map = $cgi->param('map');
522         if (!defined $map || $map !~ /^[a-z]*$/) {
523                 error("invalid map parameter");
524         }
525
526         print "Content-Type: text/html\r\n";
527         print ("\r\n");
528         print "<html><body>";
529         print "<div id=\"mapdiv-$map\"></div>";
530         print embed_map_code();
531         print "<script type=\"text/javascript\" charset=\"utf-8\">";
532         print map_setup_code($map, $map,
533                 lat => "urlParams['lat']",
534                 lon => "urlParams['lon']",
535                 zoom => "urlParams['zoom']",
536                 fullscreen => 1,
537                 editable => 1,
538         );
539         print "</script>";
540         print "</body></html>";
541
542         exit 0;
543 }
544
545 sub embed_map_code(;$) {
546         my $page=shift;
547         return '<script src="http://www.openlayers.org/api/OpenLayers.js" type="text/javascript" charset="utf-8"></script>'.
548                 '<script src="'.urlto("ikiwiki/osm.js", $page).
549                 '" type="text/javascript" charset="utf-8"></script>'."\n";
550 }
551
552 sub map_setup_code($;@) {
553         my $map=shift;
554         my $name=shift;
555         my %options=@_;
556
557         eval q{use JSON};
558         error $@ if $@;
559                                 
560         $options{'format'} = preferred_format();
561
562         my %formats = get_formats();
563         if ($formats{'GeoJSON'}) {
564                 $options{'jsonurl'} = urlto($map."/pois.json");
565         }
566         if ($formats{'CSV'}) {
567                 $options{'csvurl'} = urlto($map."/pois.txt");
568         }
569         if ($formats{'KML'}) {
570                 $options{'kmlurl'} = urlto($map."/pois.kml");
571         }
572
573         return "mapsetup('mapdiv-$name', " . to_json(\%options) . ");";
574 }
575
576 1;