# Parse data from a spin .versions file. # # The top of a web site source tree for use with spin may contain a .versions # file, which is a database of software release versions and dates that # support various rendering features for software pages. This module parses # that file into an internal data structure and answers questions about its # contents. # # SPDX-License-Identifier: MIT ############################################################################## # Modules and declarations ############################################################################## package App::DocKnot::Spin::Versions 7.01; use 5.024; use autodie; use warnings; use Path::Tiny qw(path); use POSIX qw(mktime strftime); ############################################################################## # File parsing ############################################################################## # Parse a date/time in YYYY-mm-dd HH:MM:SS format in local time into seconds # since epoch. This duplicates Date::Parse, which is already a dependency, # but this gives us more control over the format and better error reporting. # # $date - The date component # $time - The time component # $path - Path of file being parsed, for error reporting # # Returns: The time in seconds since epoch # Raises: Text exception if the date is invalid sub _datetime_to_seconds { my ($date, $time, $path) = @_; # Check the data for validity. if ($date !~ m{ \A \d{4}-\d\d-\d\d \z }xms) { die qq(invalid date "$date" in $path\n); } if ($time !~ m{ \A \d\d:\d\d:\d\d \z }xms) { die qq(invalid time "$time" in $path\n); } # Parse and convert the date/time. my @datetime = reverse(split(m{ : }xms, $time)); push(@datetime, reverse(split(m{ - }xms, $date))); $datetime[4]--; $datetime[5] -= 1900; $datetime[6] = 0; $datetime[7] = 0; $datetime[8] = -1; return mktime(@datetime); } # Parse the .versions file and populate the App::DocKnot::Spin::Versions # object. # # Raises: autodie exception on file read errors # Text exception on file parsing errors sub _read_data { my ($self) = @_; $self->{depends} = {}; $self->{versions} = {}; my $timestamp; my $lineno = 0; for my $line ($self->{path}->lines_utf8()) { $lineno++; next if $line =~ m{ \A \s* \z }xms; next if $line =~ m{ \A \s* \# }xms; # The list of files may be continued from a previous line. my @depends; if ($line =~ m{ \A \s }xms) { if (!defined($timestamp)) { die "continuation without previous entry in $self->{path}\n"; } @depends = split(qr{ \s+ }xms, $line); } else { my @line = split(qr{ \s+ }xms, $line); my ($package, $version, $date, $time, @files) = @line; if (!defined($time)) { die "invalid line $lineno in $self->{path}\n"; } @depends = @files; $timestamp = _datetime_to_seconds($date, $time, $self->{path}); $date = strftime('%Y-%m-%d', gmtime($timestamp)); $self->{versions}{$package} = [$version, $date]; } # We now have the previous release time as a timestamp in $timestamp # and some set of files affected by that release in @depends. Record # that as dependency information. for my $file (@depends) { $self->{depends}{$file} //= $timestamp; if ($self->{depends}{$file} < $timestamp) { $self->{depends}{$file} = $timestamp; } } } return; } ############################################################################## # Public interface ############################################################################## # Parse a .versions file into a new App::DocKnot::Spin::Versions object. # # $path - Path to the .versions file # # Returns: Newly created object # Throws: Text exception on failure to parse the file # autodie exception on failure to read the file sub new { my ($class, $path) = @_; # Create an empty object. my $self = { path => path($path) }; bless($self, $class); # Parse the file into the newly-created object. $self->_read_data(); # Return the populated object. return $self; } # Return the timestamp of the latest release affecting a different page. # # $file - File path that may be listed as an affected file for a release # # Returns: The timestamp in seconds since epoch of the latest release # affecting that file, or 0 if there are none sub latest_release { my ($self, $file) = @_; return $self->{depends}{"$file"} // 0; } # Return the release date for a given package. # # $package - Name of the package # # Returns: Release date as a string in UTC, or undef if not known sub release_date { my ($self, $package) = @_; my $version = $self->{versions}{$package}; return defined($version) ? $version->[1] : undef; } # Update the version and release date for a package. Add the change to Git if # the .versions file is at the top of a Git repository. # # $package - Name of the package # $version - New version # $timestamp - New release date as seconds since epoch # # Throws: Text exception on failure sub update_version { my ($self, $package, $version, $timestamp) = @_; my $date = strftime('%Y-%m-%d', localtime($timestamp)); my $time = strftime('%H:%M:%S', localtime($timestamp)); # Edits the line for the package to replace the version and release date. my $edit = sub { my ($product, $old_version, $old_date, $old_time) = split(q{ }); return if $product ne $package; # We're going to replace the old version with the new one, but we need # to space-pad one or the other if they're not the same length. my $version_string = $version; while (length($old_version) > length($version_string)) { $version_string .= q{ }; } while (length($old_version) < length($version_string)) { $old_version .= q{ }; } # Make the replacement. my $line = $_; $line =~ s{ \Q$old_version\E }{$version_string}xms; $line =~ s{ \Q$old_date\E }{$date}xms; $line =~ s{ \Q$old_time\E }{$time}xms; $_ = $line; }; # Apply that change to our versions file, and then re-read the contents to # update the internal data structure. $self->{path}->edit_lines_utf8($edit); $self->_read_data(); return; } # Return the latest version for a given package. # # $package - Name of the package # # Returns: Latest version for package as a string, or undef if not known sub version { my ($self, $package) = @_; my $version = $self->{versions}{$package}; return defined($version) ? $version->[0] : undef; } ############################################################################## # Module return value and documentation ############################################################################## 1; __END__ =for stopwords Allbery DocKnot MERCHANTABILITY NONINFRINGEMENT sublicense YYYY-MM-DD =head1 NAME App::DocKnot::Spin::Versions - Parse package release information for spin =head1 SYNOPSIS use App::DocKnot::Spin::Versions; my $versions = App::DocKnot::Spin::Versions('/path/to/.versions'); my $version = $versions->version('some-package'); my $release_date = $versions->release_date('some-package'); my $timestamp = $versions->latest_release('some/file/index.th'); =head1 REQUIREMENTS Perl 5.24 or later and the Path::Tiny module, available from CPAN. =head1 DESCRIPTION App::DocKnot::Spin supports a database of release information for packages that may be referenced in the generated web site. This is stored as the file named F<.versions> at the top of the source tree. This module parses that file and provides an API to the information it contains. The file should consist of lines (except for continuation lines, see below) in the form: