Back to documentation
package Statocles::Store;
our $VERSION = '0.094';
# ABSTRACT: The source for data documents and files

use Statocles::Base 'Class';
use Scalar::Util qw( weaken blessed );
use Statocles::Util qw( derp );
use Statocles::Document;
use Statocles::File;

# A hash of PATH => COUNT for all the open store paths. Stores are not allowed to
# discover the files or documents of other stores (unless the two stores have the same
# path)
my %FILE_STORES = ();

=attr path

The path to the directory containing the L<documents|Statocles::Document>.

=cut

has path => (
    is => 'ro',
    isa => AbsPath,
    coerce => AbsPath->coercion,
    required => 1,
);

=attr document_extensions

An array of file extensions that should be considered documents. Defaults to
"markdown" and "md".

=cut

has document_extensions => (
    is => 'ro',
    isa => ArrayRef[Str],
    default => sub { [qw( markdown md )] },
    coerce => sub {
        my ( $ext ) = @_;
        if ( !ref $ext ) {
            return [ split /[, ]/, $ext ];
        }
        return $ext;
    },
);

# Cache our realpath in case it disappears before we get demolished
has _realpath => (
    is => 'ro',
    isa => Path,
    lazy => 1,
    default => sub { $_[0]->path->realpath },
);

# If true, we've already checked if this store's path exists. We need to
# check this lazily to ensure the site is created and the logger is
# ready to go.
#
# XXX: Making sure the logger is ready before the thing that needs it is
# the entire reason that dependency injection exists. We should use the
# container to make sure the logger is wired up with every object that
# needs it...
has _check_exists => (
    is => 'rw',
    isa => Bool,
    lazy => 1,
    default => sub {
        my ( $self ) = @_;
        if ( !$self->path->exists ) {
            site->log->warn( sprintf qq{Store path "%s" does not exist}, $self->path );
        }
        return 1;
    },
);

sub BUILD {
    my ( $self ) = @_;
    $FILE_STORES{ $self->_realpath }++;
}

sub DEMOLISH {
    my ( $self, $in_global_destruction ) = @_;
    return if $in_global_destruction; # We're ending, we don't need to care anymore
    if ( --$FILE_STORES{ $self->_realpath } <= 0 ) {
        delete $FILE_STORES{ $self->_realpath };
    }
}

sub _is_owned_path {
    my ( $self, $path ) = @_;
    my $self_path = $self->_realpath;
    $path = $path->realpath;
    my $dir = $path->parent;
    for my $store_path ( keys %FILE_STORES ) {
        # This is us!
        next if $store_path eq $self_path;
        # If our store is contained inside this store's path, we win
        next if $self_path =~ /^\Q$store_path/;
        return 0 if $path =~ /^\Q$store_path/;
    }
    return 1;
}

=method is_document

    my $bool = $store->is_document( $path );

Returns true if the path looks like a document path (matches the L</document_extensions>).

=cut

sub is_document {
    my ( $self, $path ) = @_;
    my $match = join "|", @{ $self->document_extensions };
    return $path =~ /[.](?:$match)$/;
}

=method has_file

    my $bool = $store->has_file( $path )

Returns true if a file exists with the given C<path>.

=cut

sub has_file {
    my ( $self, $path ) = @_;
    return $self->path->child( $path )->is_file;
}

=method write_file

    $store->write_file( $path, $content );

Write the given C<content> to the given C<path>. This is mostly used to write
out L<page objects|Statocles::Page>.

C<content> may be a simple string or a filehandle. If given a string, will
write the string using UTF-8 characters. If given a filehandle, will write out
the raw bytes read from it with no special encoding.

=cut

sub write_file {
    my ( $self, $path, $content ) = @_;
    site->log->debug( "Write file: " . $path );
    my $full_path = $self->path->child( $path );

    #; say "Writing full path: " . $full_path;

    if ( ref $content eq 'GLOB' ) {
        my $fh = $full_path->touchpath->openw_raw;
        while ( my $line = <$content> ) {
            $fh->print( $line );
        }
    }
    elsif ( blessed $content && $content->isa( 'Path::Tiny' ) ) {
        $content->copy( $full_path->touchpath );
    }
    elsif ( blessed $content && $content->isa( 'Statocles::Document' ) ) {
        $full_path->touchpath->spew_utf8( $content->deparse_content );
    }
    elsif ( blessed $content && $content->isa( 'Statocles::File' ) ) {
        $content->path->copy( $full_path->touchpath );
    }
    else {
        $full_path->touchpath->spew_utf8( $content );
    }

    return;
}

=method remove

    $store->remove( $path )

Remove the given path from the store. If the path is a directory, the entire
directory is removed.

=cut

sub remove {
    my ( $self, $path ) = @_;
    $self->path->child( $path )->remove_tree;
    return;
}

=method iterator

    my $iter = $store->iterator;

Iterate over all the objects in this store. Returns an iterator that
will yield a L<Statocles::Document> object or a L<Statocles::File>
object.

Hidden files and folders are automatically ignored by this method.

    my $iter = $store->iterator;
    while ( my $obj = $iter->() ) {
        if ( $obj->isa( 'Statocles::Document' ) ) {
            ...;
        }
        else {
            ...;
        }
    }

=cut

sub iterator {
    my ( $self ) = @_;
    $self->_check_exists;
    my $iter = $self->path->iterator({ recurse => 1 });
    return sub {
        PATH:
        while ( my $path = $iter->() ) {
            next if $path->is_dir;
            next unless $self->_is_owned_path( $path );

            # Check for hidden files and folders
            next if $path->basename =~ /^[.]/;
            my $parent = $path->realpath->parent;
            while ( $self->path->subsumes( $parent ) && !$parent->is_rootdir ) {
                last if !$parent->basename;
                next PATH if $parent->basename =~ /^[.]/;
                $parent = $parent->parent;
            }

            my $from = $path->relative( $self->path );
            if ( $self->is_document( $path ) ) {
                my $content = $path->slurp_utf8;
                my $obj = eval {
                    Statocles::Document->parse_content(
                        content => $content,
                        path => $from.'',
                        store => $self,
                    )
                };
                if ( $@ ) {
                    if ( ref $@ && $@->isa( 'Error::TypeTiny::Assertion' ) ) {
                        if ( $@->attribute_name eq 'date' ) {
                            die sprintf qq{Could not parse date "%s" in "%s": Does not match "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS"\n},
                                $@->value,
                                $from;
                        }

                        die sprintf qq{Error creating document in "%s": Value "%s" is not valid for attribute "%s" (expected "%s")\n},
                            $from,
                            $@->value,
                            $@->attribute_name,
                            $@->type;
                    }
                    else {
                        die sprintf qq{Error creating document in "%s": %s\n},
                            $from,
                            $@;
                    }
                }
                return $obj;
            }

            return Statocles::File->new(
                store => $self,
                path => $from.'',
            );
        }
        return undef;
    };
}

1;
__END__

=head1 DESCRIPTION

A Statocles::Store reads and writes L<documents|Statocles::Document> and
files (mostly L<pages|Statocles::Page>).

This class also handles the parsing and inflating of
L<"document objects"|Statocles::Document>.

=head2 Frontmatter Document Format

Documents are formatted with a YAML document on top, and Markdown content
on the bottom, like so:

    ---
    title: This is a title
    author: preaction
    ---
    # This is the markdown content
    
    This is a paragraph