summaryrefslogtreecommitdiff
path: root/bin/rdf2hours
blob: 0d28d79d5850f21114b82b4675b4c8e24a6d845a (plain)
  1. #!/usr/bin/perl
  2. #
  3. # Copyright © 2013 Jonas Smedegaard <dr@jones.dk>
  4. # Description: render opening hours webpage from RDF data
  5. #
  6. # This program is free software; you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation; either version 3, or (at your option)
  9. # any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful, but
  12. # WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. # General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. use 5.010;
  19. use strictures 1;
  20. use utf8::all;
  21. #use DDP alias => 'Dumper';
  22. use DateTimeX::Easy;
  23. use DateTime::Format::W3CDTF;
  24. use Carp;
  25. use Try::Tiny;
  26. use RDF::Query;
  27. use RDF::TrineX::Functions qw[curie model parse];
  28. use HTML::HTML5::Builder qw[:standard];
  29. use HTML::HTML5::Writer;
  30. # TODO: add options --verbose and --debug
  31. # TODO: add override options --theme and (multivalued) --category
  32. my @category = <<LIST =~ m/(\S.*\S)/g;
  33. full-meal food
  34. non-meal food
  35. <http://schema.org/GroceryStore>
  36. non-grocery store
  37. attraction
  38. transport
  39. public service
  40. LIST
  41. # TODO: add override options --from, --through and --theme
  42. #my $globalfrom = DateTimeX::Easy->new('now');
  43. # TODO: teach DTX::Easy / DT::F::Natural DT::F::Flexible words "current" and "present" (as alternatives to this)
  44. my $globalfrom = DateTimeX::Easy->new('first day of this month');
  45. # TODO: teach DTX::Easy / DT::F::Natural DT::F::Flexible these:
  46. # last day of month three months from now
  47. # last day of month in three months
  48. # last day of (the) month three months ahead
  49. # ... in the future
  50. # ... into the future
  51. # ... from now
  52. # ... later <- this last one for changing object, not create new
  53. #my $globalthrough = DateTimeX::Easy->new('last day of month in 3 months');
  54. my $globalthrough = DateTimeX::Easy->new($globalfrom);
  55. $globalthrough->add( months => 4 )->subtract( days => 1 );
  56. # TODO: add Getopt option --preset as shortcut to --category, --from and --through
  57. my $parser = RDF::Trine::Parser::Turtle->new;
  58. my $model = model();
  59. # TODO: throw sensible error if no (non-opt) args provided
  60. while (my $data = shift @ARGV) {
  61. try {
  62. parse($data, using => $parser, into => $model);
  63. } catch {
  64. say STDERR "Failed to parse file \"$data\": $_";
  65. die;
  66. }
  67. }
  68. # compose query for hourspec data at least partly within a "window" of time
  69. # (yes, comparing global dates against opposite spec dates is deliberate)
  70. # TODO: support transit:Schedule (as alternative to gr:hasOpeningHourSpecification)
  71. my $w3c = DateTime::Format::W3CDTF->new;
  72. my $query = qurie( sprintf(<<'SPARQL', $w3c->format_datetime($globalthrough), $w3c->format_datetime($globalfrom) ));
  73. SELECT *
  74. WHERE {
  75. {
  76. ?location
  77. # a schema:Place ;
  78. gr:category ?category ;
  79. gr:name ?name ;
  80. gr:hasOpeningHourSpecification ?spec .
  81. ?spec
  82. gr:opens ?opens ;
  83. gr:closes ?closes ;
  84. rdfs:label ?speclabel ;
  85. gr:validFrom ?specfrom ;
  86. gr:validThrough ?specthrough .
  87. OPTIONAL { ?spec rdfs:comment ?speccomment } .
  88. OPTIONAL { ?location gr:description ?locationcomment } .
  89. FILTER ( ?specfrom <= "%s"^^xsd:dateTime ) .
  90. FILTER ( ?specthrough > "%s"^^xsd:dateTime )
  91. } UNION {
  92. ?location
  93. gr:category ?category ;
  94. gr:name ?name ;
  95. gr:hasOpeningHourSpecification ?spec .
  96. ?spec
  97. gr:opens ?opens ;
  98. gr:closes ?closes .
  99. OPTIONAL {
  100. ?spec
  101. rdfs:label ?speclabel ;
  102. gr:validFrom ?specfrom ;
  103. gr:validThrough ?specthrough .
  104. } .
  105. OPTIONAL { ?spec rdfs:comment ?speccomment } .
  106. OPTIONAL { ?location gr:description ?locationcomment } .
  107. FILTER (!bound(?specfrom)) .
  108. FILTER (!bound(?specthrough))
  109. }
  110. }
  111. SPARQL
  112. # TODO: sort using SPARQL instead of perl
  113. # ORDER BY ?category ?name ?specfrom ?specthrough ?opens ?closes
  114. my %data;
  115. my $iterator = $query->execute($model);
  116. while ( my $row = $iterator->next ) {
  117. my $category = $row->{category}->as_string;
  118. my $name = $row->{name}->literal_value;
  119. my $locationcomment = $row->{locationcomment} ? $row->{locationcomment}->literal_value : '';
  120. my $speclabel = $row->{speclabel} ? $row->{speclabel}->literal_value : '';
  121. # Strip surrounding quotes
  122. $category =~ s/^"(.*)"$/$1/;
  123. # merge identically named locations, tying varying descriptions to spec instead
  124. # my $name_key = titledescribe( $name, $locationcomment );
  125. # my $specbundle_key = $speclabel;
  126. my $name_key = $name;
  127. my $specbundle_key = titledescribe( $speclabel, $locationcomment );
  128. my $specfrom = $row->{specfrom} ? $row->{specfrom}->datetime : '';
  129. my $specthrough = $row->{specthrough} ? $row->{specthrough}->datetime : '';
  130. my $speccomment = $row->{speccomment} ? $row->{speccomment}->literal_value : '';
  131. # TODO: use DateTime objects instead.
  132. my $opens = $row->{opens}->literal_value;
  133. $opens =~ s/:\d\d$//;
  134. my $closes = $row->{closes}->literal_value;
  135. $closes =~ s/:\d\d$//;
  136. $closes =~ s/^23:59/24/;
  137. my @weekdays;
  138. my $iterator = $model->get_statements($row->{spec}, curie('gr_hasOpeningHoursDayOfWeek'), undef);
  139. while (my $statement = $iterator->next) {
  140. my $label = $statement->object;
  141. given ($label->as_string) {
  142. when (/Monday/) { push @weekdays, 1 };
  143. when (/Tuesday/) { push @weekdays, 2 };
  144. when (/Wednesday/) { push @weekdays, 3 };
  145. when (/Thursday/) { push @weekdays, 4 };
  146. when (/Friday/) { push @weekdays, 5 };
  147. when (/Saturday/) { push @weekdays, 6 };
  148. when (/Sunday/) { push @weekdays, 7 };
  149. when (/PublicHolidays/) { push @weekdays, 8 };
  150. default { die "failed to parse weekday: ", $label->as_string };
  151. }
  152. }
  153. my $weekdays = join ', ', sort @weekdays;
  154. $weekdays = 0 unless ($weekdays);
  155. my $weekdays_key = titledescribe( $weekdays, $speccomment );
  156. $data{$category}{$name_key}{name} = $name;
  157. $data{$category}{$name_key}{specbundle}{$specbundle_key}{locationcomment} = $locationcomment;
  158. $data{$category}{$name_key}{specbundle}{$specbundle_key}{speclabel} = $speclabel;
  159. $data{$category}{$name_key}{specbundle}{$specbundle_key}{specfrom} = $specfrom;
  160. $data{$category}{$name_key}{specbundle}{$specbundle_key}{specthrough} = $specthrough;
  161. $data{$category}{$name_key}{specbundle}{$specbundle_key}{spec}{$weekdays_key}{weekdays} = $weekdays;
  162. # TODO: extend SPARQL to cover specs without opens/closes, or drop below check
  163. if ($opens and $closes) {
  164. my $hours_key = $opens;
  165. $hours_key =~ s/\b(\d)\b/0$1/g;
  166. my $hourrange = "$opens - $closes";
  167. $data{$category}{$name_key}{specbundle}{$specbundle_key}{spec}{$weekdays_key}{hours}{$hours_key} = $hourrange;
  168. $data{$category}{$name_key}{specbundle}{$specbundle_key}{spec}{$weekdays_key}{speccomment} = $speccomment;
  169. }
  170. }
  171. my $title = 'Åbningstider';
  172. my $intro = sprintf(
  173. 'Åbningstider på Orø indenfor udvalgte kategorier, dækkende perioden %s.',
  174. daterangedescribe($globalfrom, $globalthrough),
  175. );
  176. # TODO: make simplified html (preferred for Scribus) optional
  177. #push my @content, h1($title), "\n", p($intro);
  178. push my @content, h1($title), "\n", $intro;
  179. # TODO: optionally merge items appearing in multiple categories
  180. for my $category ( @category ) {
  181. push @content, "\n", h2( categorydescribe($category) ), "\n";
  182. for my $name ( sort keys %{ $data{$category} } ) {
  183. push @content, "\n", h3($name);
  184. my @bundle = keys %{ $data{$category}{$name}{specbundle} };
  185. # FIXME: respect locale when sorting (Galleri before Gaardstronomi)
  186. for my $specbundle ( sort {
  187. $data{$category}{$name}{specbundle}{$a}{specfrom}
  188. <=> $data{$category}{$name}{specbundle}{$b}{specfrom}
  189. || $a cmp $b
  190. } @bundle ) {
  191. # TODO: when global limits shown, show only speclabel plural spec bundles
  192. my $speclabel = speclabeldescribe(
  193. $data{$category}{$name}{specbundle}{$specbundle}{speclabel},
  194. $data{$category}{$name}{specbundle}{$specbundle}{locationcomment},
  195. $data{$category}{$name}{specbundle}{$specbundle}{specfrom},
  196. $data{$category}{$name}{specbundle}{$specbundle}{specthrough},
  197. scalar @bundle,
  198. );
  199. push @content, "\n", h4($speclabel) if ($speclabel);
  200. my @specbundle;
  201. for my $weekdays ( sort keys %{ $data{$category}{$name}{specbundle}{$specbundle}{spec} } ) {
  202. my $specdescription = specdescribe(
  203. $data{$category}{$name}{specbundle}{$specbundle}{spec}{$weekdays}{weekdays},
  204. $data{$category}{$name}{specbundle}{$specbundle}{spec}{$weekdays}{speccomment},
  205. );
  206. push @specbundle, br if (@specbundle);
  207. my @hours;
  208. for my $hours ( sort keys %{ $data{$category}{$name}{specbundle}{$specbundle}{spec}{$weekdays}{hours} } ) {
  209. # TODO: make simplified html (preferred for Scribus) optional
  210. # push @hours, br, "\n" if (@hours);
  211. push @hours, br if (@hours);
  212. push @hours, "\t", span(
  213. $data{$category}{$name}{specbundle}{$specbundle}{spec}{$weekdays}{hours}{$hours}
  214. );
  215. }
  216. push @specbundle, "\n", span(
  217. span($specdescription, ":"), @hours,
  218. );
  219. }
  220. # TODO: make simplified html (preferred for Scribus) optional
  221. # push @content, p("\n", @specbundle) if (@specbundle);
  222. push @content, @specbundle if (@specbundle);
  223. }
  224. }
  225. }
  226. #Dumper %data;
  227. #die;
  228. my $writer = HTML::HTML5::Writer->new;
  229. say $writer->document( html(
  230. -lang => 'da',
  231. head(
  232. title($title),
  233. Meta(-charset => 'utf-8'),
  234. ),
  235. body(
  236. "\n", @content
  237. ),
  238. ));
  239. # create query object from curied SPARQL string
  240. sub qurie {
  241. my $sparql = shift;
  242. $sparql =~ s/(?<=\s|\^)([a-z]+):([a-zA-Z]+)(?=\s)/curie("$1_$2")/eg;
  243. my $query = RDF::Query->new( $sparql );
  244. if (!$query) {
  245. say STDERR $sparql;
  246. say STDERR RDF::Query->error;
  247. croak "failed to prepare SPARQL query";
  248. }
  249. return $query;
  250. }
  251. sub daterangedescribe {
  252. my ($from, $through) = @_;
  253. return sprintf( '%s - %s',
  254. $from->format_cldr('d/M'),
  255. $through->format_cldr('d/M'),
  256. );
  257. }
  258. sub categorydescribe {
  259. my $cat = shift;
  260. $cat =~ s!full-meal food!spisesteder!;
  261. $cat =~ s!non-meal food!caféer, slikbutikker o.l.!;
  262. $cat =~ s!<http://schema.org/GroceryStore>!dagligvarebutikker!;
  263. $cat =~ s!non-grocery store!øvrige butikker!;
  264. $cat =~ s!attraction!attraktioner og museer!;
  265. $cat =~ s!public service!offentlige services!;
  266. return ucfirst($cat);
  267. }
  268. sub titledescribe {
  269. my ($title, $comment) = @_;
  270. return ($comment) ? "$title ($comment)" : $title;
  271. }
  272. sub speclabeldescribe {
  273. my ($label, $comment, $from, $through, $size) = @_;
  274. # TODO: support optionally enabling descriptive label
  275. my $compact = 1;
  276. return '' if ( ($compact) and ( $size == 1 ) );
  277. my $daterange = daterangedescribe($from, $through);
  278. if (($from) and ($through)) {
  279. return titledescribe( $daterange, $comment )
  280. if ($compact);
  281. return $label, ' (', $daterange, ' - ', $comment, ')'
  282. if ($comment);
  283. return $label, ' (', $daterange, ')';
  284. }
  285. warn "gr:validFrom and gr:validThrough missing for label \"$label\"";
  286. return ($compact) ? '' : titledescribe( $label, $comment );
  287. }
  288. sub specdescribe {
  289. my ($weekdays, $comment) = @_;
  290. # TODO: translate properly
  291. $weekdays =~ s/1, 2, 3, 4, 5, 6, 7/alle ugedage/;
  292. $weekdays =~ s/1, 2, 3, 4, 5/mandag-fredag/;
  293. $weekdays =~ s/1, 2, 3, 4/mandag-torsdag/;
  294. $weekdays =~ s/2, 3, 4, 5, 6, 7/tirsdag-søndag/;
  295. $weekdays =~ s/3, 4, 5/onsdag-fredag/;
  296. $weekdays =~ s/^2, 3, 4(|, [^5].*)$/tirsdag-torsdag$1/;
  297. $weekdays =~ s/^(|.*?[\d^5], |.*?(?!fredag, ))6, 7(.*)$/$1weekend$2/;
  298. $weekdays =~ s/1/mandag/;
  299. $weekdays =~ s/2/tirsdag/;
  300. $weekdays =~ s/3/onsdag/;
  301. $weekdays =~ s/4/torsdag/;
  302. $weekdays =~ s/5/fredag/;
  303. $weekdays =~ s/6/lørdag/;
  304. $weekdays =~ s/7/søndag/;
  305. $weekdays =~ s/8/helligdage/;
  306. if ( $weekdays eq "0" and ($comment) ) {
  307. return $comment;
  308. }
  309. return titledescribe( $weekdays, $comment );
  310. }
  311. 1;