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