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