diff --git a/site-generator b/site-generator index 1147b68..bef4d08 100755 --- a/site-generator +++ b/site-generator @@ -1,88 +1,92 @@ #!/usr/bin/perl +# {{{ POD documentation + =head1 NAME - site-generator - HPR Site Generator + site-generator - HPR Site Generator =head1 SYNOPSIS - site-generator [OPTION]... PAGE|PAGE=... + site-generator [OPTION]... PAGE|PAGE=... - -a, --all generate all pages defined in configuration file - -c, --configuration path to configuration file - -l, --list print list of configured pages - -p, --preview print generated pages to standard out - -q, --quiet suppress progress information while generating pages - -v, --verbose print extended progress information while generating pages - --help print this help message + -a, --all generate all pages defined in configuration file + -c, --configuration path to configuration file + -l, --list print list of configured pages + -p, --preview print generated pages to standard out + -q, --quiet suppress progress information while generating pages + -v, --verbose print extended progress information while generating pages + --help print this help message - Where I is a file name of a web page - or the special I (to generate all pages). + Where I is a file name of a web page + or the special I (to generate all pages). - Examples: + Examples: - Generate two specific pages: - site-generator index about + Generate two specific pages: + site-generator index about - Generate the whole site: - site-generator --all + Generate the whole site: + site-generator --all - Generate pages based on the same template: - site-generator correspondent=1,3,5..10 + Generate pages based on the same template: + site-generator correspondent=1,3,5..10 - Generate two specific pages with a different configuration: - site-generator --configuration=site_sqlite.cfg index about + Generate two specific pages with a different configuration: + site-generator --configuration=site_sqlite.cfg index about =head1 DESCRIPTION -This is a site generator for the Hacker Public Radio website based upon the Perl Templates Toolkit. +This is a site generator for the Hacker Public Radio website based upon the +Perl Template Toolkit. =head1 INSTALLATION - With SQLite - * Create the sqlite3 database from the hpr.sql MySQL dump file available on - hackerpublicradio.org. The default name for the database file is "hpr.db" - and should be located in the root of the project directory. The name and - location can be set in the site.cfg file. - * An "update-hpr.sh" helper script is available in the utils directory. This - script will download the hpr.sql file, convert it to the SQLite hpr.db file, - and regenerate the website using the site-generator. - 1. `cd` into the root of the project directory - 2. Run `./utils/update-hpr.sh` - * SQLite v3.8.3 or greater is recommended. CTE WITH clauses are used in some template queries. - Must convert WITH clauses to sub-queries when using earlier versions of SQLite. + With SQLite + * Create the sqlite3 database from the hpr.sql MySQL dump file available on + hackerpublicradio.org. The default name for the database file is "hpr.db" + and should be located in the root of the project directory. The name and + location can be set in the site.cfg file. + * An "update-hpr.sh" helper script is available in the utils directory. This + script will download the hpr.sql file, convert it to the SQLite hpr.db file, + and regenerate the website using the site-generator. + 1. `cd` into the root of the project directory + 2. Run `./utils/update-hpr.sh` + * SQLite v3.8.3 or greater is recommended. CTE WITH clauses are used in some template queries. + Must convert WITH clauses to sub-queries when using earlier versions of SQLite. - With MySQL - * Create database hpr_hpr in the MySQL server from HPR dump file. - - sudo mysql --host=localhost < hpr.sql - * Create a user that will be used by the site-generator. - - Suggested username: hpr-generator - - CREATE USER 'hpr-generator'@'localhost' IDENTIFIED BY ''; - * Limit the user's privileges to EXECUTE and SELECT - - GRANT SELECT ON hpr_hpr.* TO 'hpr-generator'@'localhost'; - - GRANT EXECUTE ON `hpr_hpr`.* TO 'hpr-generator'@'localhost'; + With MySQL + * Create database hpr_hpr in the MySQL server from HPR dump file. + - sudo mysql --host=localhost < hpr.sql + * Create a user that will be used by the site-generator. + - Suggested username: hpr-generator + - CREATE USER 'hpr-generator'@'localhost' IDENTIFIED BY ''; + * Limit the user's privileges to EXECUTE and SELECT + - GRANT SELECT ON hpr_hpr.* TO 'hpr-generator'@'localhost'; + - GRANT EXECUTE ON `hpr_hpr`.* TO 'hpr-generator'@'localhost'; - Install the needed Perl modules using preferred method (distribution packages, CPAN, etc.) - * GetOpt - * Pod::Usage - * Config::Std - * Template - * Template::Plugin::File - * Template::Plugin::DBI - * DBI - * Tie::DBI - * DBD::SQLite or DBD:mysql - * Date::Calc + Install the needed Perl modules using preferred method (distribution packages, CPAN, etc.) + * GetOpt + * Pod::Usage + * Config::Std + * Template + * Template::Plugin::File + * Template::Plugin::DBI + * DBI + * Tie::DBI + * DBD::SQLite or DBD:mysql + * Date::Calc + * Text::CSV_XS =head1 AUTHOR - Roan Horning + Roan Horning =head1 LICENSE - site-generator -- a static website generator for HPR - Copyright (C) 2022 Roan Horning + site-generator -- a static website generator for HPR + Copyright (C) 2022 Roan Horning This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by @@ -95,253 +99,323 @@ This is a site generator for the Hacker Public Radio website based upon the Perl GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . + along with this program. If not, see . =cut +# }}} + use strict; use warnings; use Getopt::Long qw(:config auto_help); use Pod::Usage; use Config::Std; +use Text::CSV_XS; +use HTML::Entities qw(encode_entities_numeric); use Template; use Data::Dumper; +binmode STDOUT, ":encoding(UTF-8)"; +binmode STDERR, ":encoding(UTF-8)"; + exit main(); sub main { - # Argument parsing - my $all; - my $configuration_path; - my $preview; - my $verbose; - my $quiet; - GetOptions( - 'all' => \$all, - 'configuration=s' => \$configuration_path, - 'list' => \&print_available_pages, - 'preview' => \$preview, - 'verbose' => \$verbose, - 'quiet' => \$quiet, - ) or pod2usage(1); - pod2usage(1) unless @ARGV || $all; - my (@page_args) = @ARGV; + # Argument parsing + my $all; + my $configuration_path; + my $preview; + my $verbose; + my $quiet; + GetOptions( + 'all' => \$all, + 'configuration=s' => \$configuration_path, + 'list' => \&print_available_pages, + 'preview' => \$preview, + 'verbose' => \$verbose, + 'quiet' => \$quiet, + ) or pod2usage(1); + pod2usage(1) unless @ARGV || $all; + my (@page_args) = @ARGV; - if ($quiet) { - $verbose = 'quiet'; - }; + if ($quiet) { + $verbose = 'quiet'; + }; - if (!$configuration_path) { - $configuration_path = "site.cfg"; - } + if (!$configuration_path) { + $configuration_path = "site.cfg"; + } - my %config; - if ( -f $configuration_path ) { - # Load config file - read_config $configuration_path => %config; - } - else { - print STDOUT "Could not read configuration file: $configuration_path\n"; - exit 1; - } + my %config; + if ( -f $configuration_path ) { + # Load config file + read_config $configuration_path => %config; + } + else { + print STDOUT "Could not read configuration file: $configuration_path\n"; + exit 1; + } - my $tt = get_template_html($config{DBI}, $config{app_paths}); + my $tt = get_template_html($config{DBI}, $config{app_paths}); - # If command line option all is set, parse configuration file - # for all pages - if ($all) { - @page_args = keys %config; + # + # Define a TTĀ² vmethod called 'csv_parse', it takes a scalar value and + # returns an arrayref. Also define a filter called 'xml_entity' which + # numerically encodes non-ASCII characters. + # + $tt->context->define_vmethod( 'scalar', 'csv_parse', \&parse_csv ); + $tt->context->define_filter( 'xml_entity', \&xml_entity ); - # Remove non page sections of the configuration file - # from the generated list of pages. - @page_args= grep { $_ ne 'DBI' } @page_args; - @page_args= grep { $_ ne 'root_template' } @page_args; - @page_args= grep { $_ ne 'app_paths' } @page_args; + # If command line option all is set, parse configuration file + # for all pages + if ($all) { + @page_args = keys %config; - }; - foreach my $page_arg (@page_args) { - my %parsed_arg = parse_page_arg($page_arg); - if (exists($config{$parsed_arg{'page'}})) { - my $page_config = $config{$parsed_arg{'page'}}; - $page_config->{'page'} = $parsed_arg{'page'}; + # Remove non page sections of the configuration file + # from the generated list of pages. + @page_args= grep { $_ ne 'DBI' } @page_args; + @page_args= grep { $_ ne 'root_template' } @page_args; + @page_args= grep { $_ ne 'app_paths' } @page_args; - # Set page's root_template to the default root_template if the - # page root_template property is not set in the configuration file. - if (exists $page_config->{'root_template'} == 0) { - $page_config->{'root_template'} = $config{root_template}{content}; - } + }; + foreach my $page_arg (@page_args) { + my %parsed_arg = parse_page_arg($page_arg); + if (exists($config{$parsed_arg{'page'}})) { + my $page_config = $config{$parsed_arg{'page'}}; + $page_config->{'page'} = $parsed_arg{'page'}; - # Set all config root_template properties as default page config properties - # except the previously set root_template content property - my @root_args = grep { $_ ne 'content' } keys %{$config{root_template}}; - foreach my $root_arg (@root_args) { - if (exists $page_config->{$root_arg} == 0) { - $page_config->{$root_arg} = $config{root_template}{$root_arg}; - } - } + # Set page's root_template to the default root_template if the + # page root_template property is not set in the configuration file. + if (exists $page_config->{'root_template'} == 0) { + $page_config->{'root_template'} = $config{root_template}{content}; + } - if ($page_config->{'multipage'} && $page_config->{'multipage'} eq 'true') { - # Empty arrayref bug fixed, so count is reduced by 1 -# if (scalar @{$parsed_arg{'ids'}} == 1) { - if (scalar @{$parsed_arg{'ids'}} == 0) { - @{$parsed_arg{'ids'}} = get_ids_from_db($tt, \$page_config); - } - foreach my $id (@{$parsed_arg{'ids'}}) { - $page_config->{'id'} = $id; - verbose ($verbose, "Generating page: $page_config->{'page'} with id: $id"); - generate_page($tt, \$page_config, $preview); - } - } - else { - verbose ($verbose, "Generating page: $page_config->{'page'}"); - generate_page($tt, \$page_config, $preview); - } - } - else { - verbose (1, "\nWarning: Page $parsed_arg{'page'} is not defined in the configuration file."); - } - } + # Set all config root_template properties as default page config properties + # except the previously set root_template content property + my @root_args = grep { $_ ne 'content' } keys %{$config{root_template}}; + foreach my $root_arg (@root_args) { + if (exists $page_config->{$root_arg} == 0) { + $page_config->{$root_arg} = $config{root_template}{$root_arg}; + } + } + + if ($page_config->{'multipage'} && $page_config->{'multipage'} eq 'true') { + # Empty arrayref bug fixed, so count is reduced by 1 + # was: if (scalar @{$parsed_arg{'ids'}} == 1) { + if (scalar @{$parsed_arg{'ids'}} == 0) { + @{$parsed_arg{'ids'}} = get_ids_from_db($tt, \$page_config); + } + foreach my $id (@{$parsed_arg{'ids'}}) { + $page_config->{'id'} = $id; + verbose ($verbose, "Generating page: $page_config->{'page'} with id: $id"); + generate_page($tt, \$page_config, $preview); + } + } + else { + verbose ($verbose, "Generating page: $page_config->{'page'}"); + generate_page($tt, \$page_config, $preview); + } + } + else { + verbose (1, "\nWarning: Page $parsed_arg{'page'} is not defined in the configuration file."); + } + } verbose (1, "\nFinished processing the files."); - return 0; + return 0; } sub get_template_html (\%@) { - # For an HTML based Template file, define the - # template start and end tags to also function as - # HTML comments to make the template file valid HTML. - # - return Template->new({ - INCLUDE_PATH => $_[1]{templates_path}, - OUTPUT_PATH => $_[1]{output_path}, - EVAL_PERL => 1, - START_TAG => '', - PRE_CHOMP => 1, - POST_CHOMP => 1, - CONSTANTS => { - database => $_[0]{database}, - driver => $_[0]{driver}, - user => $_[0]{user}, - password => $_[0]{password}, - } - }) || die $Template::ERROR, "\n"; + # For an HTML based Template file, define the + # template start and end tags to also function as + # HTML comments to make the template file valid HTML. + # + return Template->new( + { INCLUDE_PATH => $_[1]{templates_path}, + OUTPUT_PATH => $_[1]{output_path}, + ENCODING => 'utf8', + EVAL_PERL => 1, + START_TAG => '', + PRE_CHOMP => 1, + POST_CHOMP => 1, + CONSTANTS => { + database => $_[0]{database}, + driver => $_[0]{driver}, + user => $_[0]{user}, + password => $_[0]{password}, + } + } + ) || die $Template::ERROR, "\n"; } -sub generate_page { - my ($tt, $config, $preview) = @_; - my $html; - if (!$preview) { - $html = get_filename($$config); - } - $tt->process($$config->{root_template}, $$config, $html) - || die $tt->error(), "\n"; +sub generate_page { + my ( $tt, $config, $preview ) = @_; + my $html; + if ( !$preview ) { + $html = get_filename($$config); + } + $tt->process( $$config->{root_template}, + $$config, $html, { binmode => ':utf8' } ) + || die $tt->error(), "\n"; } sub verbose { - my ($verbose, $message) = @_; - if ($verbose) { - if ($verbose ne 'quiet') { - print STDOUT "$message\n"; - } - } - else { - STDOUT->autoflush(1); - print STDOUT "."; - }; + my ($verbose, $message) = @_; + if ($verbose) { + if ($verbose ne 'quiet') { + print STDOUT "$message\n"; + } + } + else { + STDOUT->autoflush(1); + print STDOUT "."; + }; } sub parse_page_arg { - my ($page_arg) = @_; - # Split page name from page ids if available. - my ($page, $ids) = split(/=/, $page_arg); - #my @ids = []; - my @ids; + my ($page_arg) = @_; + # Split page name from page ids if available. + my ($page, $ids) = split(/=/, $page_arg); + my @ids; - if(!$ids) { - $ids = ""; - } - else { - # Parse the page ids and push them onto @ids array - my @ids_by_comma = split(/\,/, $ids); - foreach my $id_by_comma (@ids_by_comma) { - my @ids_for_range = split(/\.\./, $id_by_comma); - if ((scalar @ids_for_range) == 2) { - push @ids, $ids_for_range[0]..$ids_for_range[1]; - } - elsif ((scalar @ids_for_range) == 1) { - push @ids, $ids_for_range[0]; - } - else { - verbose (1, "\nWarning: Page $page id range $id_by_comma could not be parsed."); - } - } - } - return ('page' => $page, 'ids' => [@ids]); + if(!$ids) { + $ids = ""; + } + else { + # Parse the page ids and push them onto @ids array + my @ids_by_comma = split(/\,/, $ids); + foreach my $id_by_comma (@ids_by_comma) { + my @ids_for_range = split(/\.\./, $id_by_comma); + if ((scalar @ids_for_range) == 2) { + push @ids, $ids_for_range[0]..$ids_for_range[1]; + } + elsif ((scalar @ids_for_range) == 1) { + push @ids, $ids_for_range[0]; + } + else { + verbose (1, "\nWarning: Page $page id range $id_by_comma could not be parsed."); + } + } + } + return ('page' => $page, 'ids' => [@ids]); } sub get_ids_from_db { - # Use a template to generate a string of page identifiers. - # The template should return the string in the form of - # ... - # - my ($tt, $config) = @_; - my $selected_ids = ""; - my $id_template = "ids-$$config->{'page'}.tpl.html"; + # Use a template to generate a string of page identifiers. + # The template should return the string in the form of + # ... + # + my ($tt, $config) = @_; + my $selected_ids = ""; + my $id_template = "ids-$$config->{'page'}.tpl.html"; - $tt->process($id_template, $$config, \$selected_ids) - || die $tt->error(), "\n"; + $tt->process($id_template, $$config, \$selected_ids) + || die $tt->error(), "\n"; - # Starts with a newline and comma - return split(/,/, substr($selected_ids, 2)); + # Starts with a newline and comma + return split(/,/, substr($selected_ids, 2)); } sub get_filename { - my ($config) = @_; - my $filename = "output.html"; - my $base_path = ""; + my ($config) = @_; + my $filename = "output.html"; + my $base_path = ""; - if ($$config{'filename'}) { - if (substr($$config{'filename'}, -1) eq '/') { - $base_path = $$config{'filename'}; - } - else { - $filename = $$config{'filename'}; - my $padded_index = ""; - if (exists $$config{'id'} && $$config{'id'} ne "") { - $padded_index = sprintf("%04d", $$config{'id'}); - } - $filename =~ s/\[id\]/$padded_index/; - return $filename; - } - } - # Default naming if full filename configuration is not supplied. - if ($$config{'multipage'} && $$config{'multipage'} eq 'true') { - my $padded_index = sprintf("%04d", $$config{'id'}); - $filename = "$base_path$$config{'page'}${padded_index}.html"; - } - else { - $filename = "$base_path$$config{'page'}.html"; - } - return $filename; + if ($$config{'filename'}) { + if (substr($$config{'filename'}, -1) eq '/') { + $base_path = $$config{'filename'}; + } + else { + $filename = $$config{'filename'}; + my $padded_index = ""; + if (exists $$config{'id'} && $$config{'id'} ne "") { + $padded_index = sprintf("%04d", $$config{'id'}); + } + $filename =~ s/\[id\]/$padded_index/; + return $filename; + } + } + # Default naming if full filename configuration is not supplied. + if ($$config{'multipage'} && $$config{'multipage'} eq 'true') { + my $padded_index = sprintf("%04d", $$config{'id'}); + $filename = "$base_path$$config{'page'}${padded_index}.html"; + } + else { + $filename = "$base_path$$config{'page'}.html"; + } + return $filename; } sub print_available_pages { - # Load config file - read_config "site.cfg" => my %config; + # Load config file + read_config "site.cfg" => my %config; - my @page_args = sort (keys %config); + my @page_args = sort ( keys %config ); - # Remove non page sections of the configuration file - # from the generated list of pages. - @page_args= grep { $_ ne 'DBI' } @page_args; - @page_args= grep { $_ ne 'root_template' } @page_args; + # Remove non page sections of the configuration file + # from the generated list of pages. + @page_args = grep { $_ ne 'DBI' } @page_args; + @page_args = grep { $_ ne 'root_template' } @page_args; - foreach my $page_arg (@page_args) { - print "$page_arg\n"; - } - exit; + foreach my $page_arg (@page_args) { + print "$page_arg\n"; + } + exit; } + +#=== FUNCTION ================================================================ +# NAME: parse_csv +# PURPOSE: Parses a simple string containing CSV data +# PARAMETERS: $csv_in CSV string +# RETURNS: An arrayref containing the parsed CSV elements +# DESCRIPTION: The Text::CSV_XS module instance is created with the option +# 'allow_whitespace' to be forgiving of any spaces around the +# CSV elements and to strip them. Also, 'allow_loose_quotes' is +# forgiving of really messed up CSV. +# THROWS: No exceptions +# COMMENTS: None +# SEE ALSO: N/A +#=============================================================================== +sub parse_csv { + my ($csv_in) = @_; + + my $csv = Text::CSV_XS->new( + { binary => 1, + auto_diag => 1, + allow_whitespace => 1, + allow_loose_quotes => 1 + } + ); + my $status = $csv->parse($csv_in); + unless ( $status ) { + warn "Failed to parse '$csv_in'\n" ; + return; + } + my @fields = $csv->fields(); + + return \@fields; +} + +#=== FUNCTION ================================================================ +# NAME: xml_entity +# PURPOSE: Static filter to encode Unicode for XML +# PARAMETERS: $text String to be processed +# RETURNS: Processed text +# DESCRIPTION: +# THROWS: No exceptions +# COMMENTS: None +# SEE ALSO: N/A +#=============================================================================== +sub xml_entity { + my ($text) = @_; + + encode_entities_numeric( $text ); + + return $text; +} + +# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker diff --git a/templates/queries-episodes-sqlite.tpl.html b/templates/queries-episodes-sqlite.tpl.html index ce07bf6..d2f846b 100644 --- a/templates/queries-episodes-sqlite.tpl.html +++ b/templates/queries-episodes-sqlite.tpl.html @@ -1,15 +1,15 @@ - diff --git a/templates/rss-query-hpr-mysql.tpl.xml b/templates/rss-query-hpr-mysql.tpl.xml index 85443c8..1b284a7 100644 --- a/templates/rss-query-hpr-mysql.tpl.xml +++ b/templates/rss-query-hpr-mysql.tpl.xml @@ -1,23 +1,26 @@ - + diff --git a/templates/rss-query-hpr-sqlite.tpl.xml b/templates/rss-query-hpr-sqlite.tpl.xml index 53e32c2..3466471 100644 --- a/templates/rss-query-hpr-sqlite.tpl.xml +++ b/templates/rss-query-hpr-sqlite.tpl.xml @@ -1,23 +1,26 @@ - + diff --git a/templates/rss-query-hpr_total-mysql.tpl.xml b/templates/rss-query-hpr_total-mysql.tpl.xml index 4846f16..f8c775a 100644 --- a/templates/rss-query-hpr_total-mysql.tpl.xml +++ b/templates/rss-query-hpr_total-mysql.tpl.xml @@ -1,22 +1,25 @@ - + diff --git a/templates/rss-query-hpr_total-sqlite.tpl.xml b/templates/rss-query-hpr_total-sqlite.tpl.xml index 1465690..fd02ab6 100644 --- a/templates/rss-query-hpr_total-sqlite.tpl.xml +++ b/templates/rss-query-hpr_total-sqlite.tpl.xml @@ -1,22 +1,25 @@ - + diff --git a/templates/rss.tpl.xml b/templates/rss.tpl.xml index 40d13b8..3471eb4 100644 --- a/templates/rss.tpl.xml +++ b/templates/rss.tpl.xml @@ -1,9 +1,9 @@ - Hacker Public Radio @@ -22,14 +22,14 @@ Hacker Public Radio Community Radio, Tech Interviews, Linux, Open, Hobby, Software Freedom Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) License - feedback@NOSPAM-hackerpublicradio.org (HPR Feedback) + feedback@NOSPAM-hackerpublicradio.org (HPR Feedback) - + HPR Volunteer admin@hackerpublicradio.org - admin@hackerpublicradio.org (HPR Volunteer) - site-generator + admin@hackerpublicradio.org (HPR Volunteer) + site-generator http://www.rssboard.org/rss-specification 43200 @@ -39,7 +39,7 @@ https://www.hackerpublicradio.org/images/hpr_feed_small.png Hacker Public Radio - https://www.hackerpublicradio.org/about.php + https://www.hackerpublicradio.org/about.html The Hacker Public Radio Old Microphone Logo 164 144 @@ -51,7 +51,7 @@ - + diff --git a/templates/shared-episode-summary.tpl.html b/templates/shared-episode-summary.tpl.html index 3ac1b96..ea6f1c1 100644 --- a/templates/shared-episode-summary.tpl.html +++ b/templates/shared-episode-summary.tpl.html @@ -12,7 +12,11 @@ from the series tags.html#">., + + diff --git a/templates/shared-item.tpl.xml b/templates/shared-item.tpl.xml index a77b02d..387c7aa 100644 --- a/templates/shared-item.tpl.xml +++ b/templates/shared-item.tpl.xml @@ -1,23 +1,24 @@ + - + - HPR<!--% zero_pad_left(episode.id) %-->: <!--% episode.title %--> + HPR<!--% zero_pad_left(episode.id) %-->: <!--% episode.title %--> () () () - https://www.hackerpublicradio.org/eps/hpr//index.html + https://www.hackerpublicradio.org/eps/hpr/index.html ]]> - ]]> + ]]> - - http://hackerpublicradio.org/eps/hpr. + + http://hackerpublicradio.org/eps/hpr. diff --git a/templates/shared-utils.tpl.html b/templates/shared-utils.tpl.html index c544bcd..abe1404 100644 --- a/templates/shared-utils.tpl.html +++ b/templates/shared-utils.tpl.html @@ -1,11 +1,11 @@ - - - - - - - + + + + + + + @@ -17,7 +17,7 @@ - + @@ -57,23 +57,23 @@ - - - - - - - - - - - - - - - - - . + + + + + + + + + + + + + + + + + . @@ -93,7 +93,3 @@ Latest >> - -