File Coverage: lib/Yukki/Model/File.pm

# File Coverage

File: | lib/Yukki/Model/File.pm
Coverage: | 43.7%

line | stmt | bran | cond | sub | pod | time | code
--- | --- | --- | --- | --- | --- | --- | ---
1 |  |  |  |  |  |  | package Yukki::Model::File;
2 |
3 | 2 2 |  |  | 2 |  | 13 7 | use v5.24;
4 | 2 2 2 |  |  | 2 |  | 7 2 10 | use utf8;
5 | 2 2 2 |  |  | 2 |  | 26 3 5 | use Moo;
6 |
7 |  |  |  |  |  |  | extends 'Yukki::Model';
8 |
9 | 2 2 2 |  |  | 2 |  | 366 3 75 | use Class::Load;
10 | 2 2 2 |  |  | 2 |  | 185 476 80 | use Digest::SHA1 qw( sha1_hex );
11 | 2 2 2 |  |  | 2 |  | 457 2299 68 | use Number::Bytes::Human qw( format_bytes );
12 | 2 2 2 |  |  | 2 |  | 199 8794 56 | use LWP::MediaTypes qw( guess_media_type );
13 | 2 2 2 |  |  | 2 |  | 7 5 19 | use Type::Utils;
14 | 2 2 2 |  |  | 2 |  | 1863 3 13 | use Types::Standard qw( Maybe Str );
15 | 2 2 2 |  |  | 2 |  | 1048 3 11 | use Yukki::Error qw( http_throw );
16 |
17 | 2 2 2 |  |  | 2 |  | 332 3 14 | use namespace::clean;
18 |
19 |  |  |  |  |  |  | # ABSTRACT: the model for loading and saving files in the wiki
20 |
21 - 43 |  |
    =head1 SYNOPSIS

      my $repository = $app->model('Repository', { repository => 'main' });
      my $file = $repository->file({
          path     => 'foobar',
          filetype => 'yukki',
      });

    =head1 DESCRIPTION

    Tools for fetching files from the git repository and storing them there.

    =head1 EXTENDS

    L<Yukki::Model>

    =head1 ATTRIBUTES

    =head2 path

    This is the path to the file in the repository, but without the file suffix.

    =cut
44 |
45 |  |  |  |  |  |  | has path => (
46 |  |  |  |  |  |  | is => 'ro',
47 |  |  |  |  |  |  | isa => Str,
48 |  |  |  |  |  |  | required => 1,
49 |  |  |  |  |  |  | );
50 |
51 - 55 |  |
    =head2 filetype

    The suffix of the file. Defaults to "yukki".

    =cut
56 |
57 |  |  |  |  |  |  | has filetype => (
58 |  |  |  |  |  |  | is => 'ro',
59 |  |  |  |  |  |  | isa => Maybe[Str],
60 |  |  |  |  |  |  | required => 1,
61 |  |  |  |  |  |  | default => 'yukki',
62 |  |  |  |  |  |  | );
63 |
64 - 69 |  |
    =head2 repository

    This is the the L<Yukki::Model::Repository> the file will be fetched from or
    stored into.

    =cut
70 |
71 |  |  |  |  |  |  | has repository => (
72 |  |  |  |  |  |  | is => 'ro',
73 |  |  |  |  |  |  | isa => class_type('Yukki::Model::Repository'),
74 |  |  |  |  |  |  | required => 1,
75 |  |  |  |  |  |  | handles => {
76 |  |  |  |  |  |  | 'make_blob' => 'make_blob',
77 |  |  |  |  |  |  | 'make_blob_from_file' => 'make_blob_from_file',
78 |  |  |  |  |  |  | 'find_root' => 'find_root',
79 |  |  |  |  |  |  | 'branch' => 'branch',
80 |  |  |  |  |  |  | 'show' => 'show',
81 |  |  |  |  |  |  | 'make_tree' => 'make_tree',
82 |  |  |  |  |  |  | 'commit_tree' => 'commit_tree',
83 |  |  |  |  |  |  | 'update_root' => 'update_root',
84 |  |  |  |  |  |  | 'find_path' => 'find_path',
85 |  |  |  |  |  |  | 'fetch_size' => 'fetch_size',
86 |  |  |  |  |  |  | 'repository_name' => 'name',
87 |  |  |  |  |  |  | 'author_name' => 'author_name',
88 |  |  |  |  |  |  | 'author_email' => 'author_email',
89 |  |  |  |  |  |  | 'log' => 'log',
90 |  |  |  |  |  |  | 'diff_blobs' => 'diff_blobs',
91 |  |  |  |  |  |  | },
92 |  |  |  |  |  |  | );
93 |
94 - 100 |  |
    =head1 METHODS

    =head2 BUILDARGS

    Allows C<full_path> to be given instead of C<path> and C<filetype>.

    =cut
101 |
102 |  |  |  |  |  |  | sub BUILDARGS {
103 | 1 |  |  | 1 | 1 | 1680 | my $class = shift;
104 |
105 | 1 |  |  |  |  | 2 | my %args;
106 | 1 0 0 | 50 |  |  |  | 3 0 0 | if (@_ == 1) { %args = %{ $_[0] }; }
107 | 1 |  |  |  |  | 4 | else { %args = @_; }
108 |
109 | 1 | 50 |  |  |  | 3 | if ($args{full_path}) {
110 | 0 |  |  |  |  | 0 | my $full_path = delete $args{full_path};
111 |
112 | 0 |  |  |  |  | 0 | my ($path, $filetype) = $full_path =~ m{^(.*)(?:\.(\w+))?$};
113 |
114 | 0 |  |  |  |  | 0 | $args{path} = $path;
115 | 0 |  |  |  |  | 0 | $args{filetype} = $filetype;
116 |  |  |  |  |  |  | }
117 |
118 | 1 |  |  |  |  | 12 | return \%args;
119 |  |  |  |  |  |  | }
120 |
121 - 126 |  |
    =head2 full_path

    This is the complete path to the file in the repository with the L</filetype>
    tacked onto the end.

    =cut
127 |
128 |  |  |  |  |  |  | sub full_path {
129 | 6 |  |  | 6 | 1 | 9 | my $self = shift;
130 |
131 | 6 |  |  |  |  | 9 | my $full_path;
132 | 6 | 50 |  |  |  | 20 | if (defined $self->filetype) {
133 | 6 |  |  |  |  | 28 | $full_path = join '.', $self->path, $self->filetype;
134 |  |  |  |  |  |  | }
135 |  |  |  |  |  |  | else {
136 | 0 |  |  |  |  | 0 | $full_path = $self->path;
137 |  |  |  |  |  |  | }
138 |
139 | 6 |  |  |  |  | 111 | return $full_path;
140 |  |  |  |  |  |  | }
141 |
142 - 146 |  |
    =head2 file_name

    This is the base name of the file.

    =cut
147 |
148 |  |  |  |  |  |  | sub file_name {
149 | 0 |  |  | 0 | 1 | 0 | my $self = shift;
150 | 0 |  |  |  |  | 0 | my $full_path = $self->full_path;
151 | 0 |  |  |  |  | 0 | my ($file_name) = $full_path =~ m{([^/]+)$};
152 | 0 |  |  |  |  | 0 | return $file_name;
153 |  |  |  |  |  |  | }
154 |
155 - 159 |  |
    =head2 file_id

    This is a SHA-1 of the file name in hex.

    =cut
160 |
161 |  |  |  |  |  |  | sub file_id {
162 | 0 |  |  | 0 | 1 | 0 | my $self = shift;
163 | 0 |  |  |  |  | 0 | return sha1_hex($self->file_name);
164 |  |  |  |  |  |  | }
165 |
166 - 170 |  |
    =head2 object_id

    This is the git object ID of the file blob.

    =cut
171 |
172 |  |  |  |  |  |  | sub object_id {
173 | 0 |  |  | 0 | 1 | 0 | my $self = shift;
174 | 0 |  |  |  |  | 0 | return $self->find_path($self->full_path);
175 |  |  |  |  |  |  | }
176 |
177 - 181 |  |
    =head2 title

    This is the title for the file. For most files this is the file name. For files with the "yukki" L</filetype>, the title metadata or first heading found in the file is used.

    =cut
182 |
183 |  |  |  |  |  |  | sub title {
184 | 1 |  |  | 1 | 1 | 72 | my $self = shift;
185 |
186 | 1 | 50 |  |  |  | 7 | if ($self->filetype eq 'yukki') {
187 | 1 |  |  |  |  | 6 | LINE: for my $line ($self->fetch) {
188 | 1 | 50 0 |  |  |  | 14342 | if ($line =~ /^#\s*(.*)$/) {
189 | 1 |  |  |  |  | 493 | return $1;
190 |  |  |  |  |  |  | }
191 |  |  |  |  |  |  | elsif ($line =~ /:/) {
192 | 0 |  |  |  |  | 0 | my ($name, $value) = split m{\s*:\s*}, $line, 2;
193 | 0 | 0 |  |  |  | 0 | return $value if lc($name) eq 'title';
194 |  |  |  |  |  |  | }
195 |  |  |  |  |  |  | else {
196 | 0 |  |  |  |  | 0 | last LINE;
197 |  |  |  |  |  |  | }
198 |  |  |  |  |  |  | }
199 |  |  |  |  |  |  | }
200 |
201 | 0 |  |  |  |  | 0 | my $title = $self->file_name;
202 | 0 |  |  |  |  | 0 | $title =~ s/\.(\w+)$//g;
203 | 0 |  |  |  |  | 0 | return $title;
204 |  |  |  |  |  |  | }
205 |
206 - 210 |  |
    =head2 file_size

    This is the size of the file in bytes.

    =cut
211 |
212 |  |  |  |  |  |  | sub file_size {
213 | 0 |  |  | 0 | 1 | 0 | my $self = shift;
214 | 0 |  |  |  |  | 0 | return $self->fetch_size($self->full_path);
215 |  |  |  |  |  |  | }
216 |
217 - 221 |  |
    =head2 formatted_file_size

    This returns a human-readable version of the file size.

    =cut
222 |
223 |  |  |  |  |  |  | sub formatted_file_size {
224 | 0 |  |  | 0 | 1 | 0 | my $self = shift;
225 | 0 |  |  |  |  | 0 | return format_bytes($self->file_size);
226 |  |  |  |  |  |  | }
227 |
228 - 232 |  |
    =head2 media_type

    This is the MIME type detected for the file.

    =cut
233 |
234 |  |  |  |  |  |  | sub media_type {
235 | 2 |  |  | 2 | 1 | 6 | my $self = shift;
236 | 2 |  |  |  |  | 8 | return guess_media_type($self->full_path);
237 |  |  |  |  |  |  | }
238 |
239 - 256 |  |
    =head2 store

      $file->store({
          content => 'text to put in file...',
          comment => 'comment describing the change',
      });

      # OR

      $file->store({
          filename => 'file.pdf',
          comment  => 'comment describing the change',
      });

    This stores a new version of the file, either from the given content string or a
    named local file.

    =cut
257 |
258 |  |  |  |  |  |  | sub store {
259 | 0 |  |  | 0 | 1 | 0 | my ($self, $params) = @_;
260 | 0 |  |  |  |  | 0 | my $path = $self->full_path;
261 |
262 | 0 |  |  |  |  | 0 | my (@parts) = split m{/}, $path;
263 | 0 |  |  |  |  | 0 | my $blob_name = $parts[-1];
264 |
265 | 0 |  |  |  |  | 0 | my $object_id;
266 | 0 | 0 0 |  |  |  | 0 | if ($params->{content}) {
267 | 0 |  |  |  |  | 0 | $object_id = $self->make_blob($blob_name, $params->{content});
268 |  |  |  |  |  |  | }
269 |  |  |  |  |  |  | elsif ($params->{filename}) {
270 | 0 |  |  |  |  | 0 | $object_id = $self->make_blob_from_file($blob_name, $params->{filename});
271 |  |  |  |  |  |  | }
272 | 0 | 0 |  |  |  | 0 | http_throw("unable to create blob for $path") unless $object_id;
273 |
274 | 0 |  |  |  |  | 0 | my $old_tree_id = $self->find_root;
275 | 0 | 0 |  |  |  | 0 | http_throw("unable to locate original tree ID for ".$self->branch)
276 |  |  |  |  |  |  | unless $old_tree_id;
277 |
278 | 0 |  |  |  |  | 0 | my $new_tree_id = $self->make_tree($old_tree_id, \@parts, $object_id);
279 | 0 | 0 |  |  |  | 0 | http_throw("unable to create the new tree containing $path\n")
280 |  |  |  |  |  |  | unless $new_tree_id;
281 |
282 | 0 |  |  |  |  | 0 | my $commit_id = $self->commit_tree($old_tree_id, $new_tree_id, $params->{comment});
283 | 0 | 0 |  |  |  | 0 | http_throw("unable to commit the new tree containing $path\n")
284 |  |  |  |  |  |  | unless $commit_id;
285 |
286 | 0 |  |  |  |  | 0 | $self->update_root($old_tree_id, $commit_id);
287 |  |  |  |  |  |  | }
288 |
289 - 298 |  |
    =head2 rename

      my $new_file = $file->rename({
          full_path => 'renamed/to/path.yukki',
          comment   => 'renamed the file',
      });

    Renames the file within the repository. When complete, this method returns a reference to the L<Yukki::Model::File> object representing the new path.

    =cut
299 |
300 |  |  |  |  |  |  | sub rename {
301 | 0 |  |  | 0 | 1 | 0 | my ($self, $params) = @_;
302 | 0 |  |  |  |  | 0 | my $old_path = $self->full_path;
303 |
304 | 0 |  |  |  |  | 0 | my (@new_parts) = split m{/}, $params->{full_path};
305 | 0 |  |  |  |  | 0 | my (@old_parts) = split m{/}, $old_path;
306 | 0 |  |  |  |  | 0 | my $blob_name = $old_parts[-1];
307 |
308 | 0 |  |  |  |  | 0 | my $object_id = $self->object_id;
309 |
310 | 0 |  |  |  |  | 0 | my $old_tree_id = $self->find_root;
311 | 0 | 0 |  |  |  | 0 | http_throw("unable to locate original tree ID for ".$self->branch)
312 |  |  |  |  |  |  | unless $old_tree_id;
313 |
314 | 0 |  |  |  |  | 0 | my $new_tree_id = $self->make_tree(
315 |  |  |  |  |  |  | $old_tree_id, \@old_parts, \@new_parts, $object_id);
316 | 0 | 0 |  |  |  | 0 | http_throw("unable to create the new tree renaming $old_path to $params->{full_path}\n")
317 |  |  |  |  |  |  | unless $new_tree_id;
318 |
319 | 0 |  |  |  |  | 0 | my $commit_id = $self->commit_tree($old_tree_id, $new_tree_id, $params->{comment});
320 | 0 | 0 |  |  |  | 0 | http_throw("unable to commit the new tree renaming $old_path to $params->{full_path}\n")
321 |  |  |  |  |  |  | unless $commit_id;
322 |
323 | 0 |  |  |  |  | 0 | $self->update_root($old_tree_id, $commit_id);
324 |
325 |  |  |  |  |  |  | return Yukki::Model::File->new(
326 |  |  |  |  |  |  | app => $self->app,
327 |  |  |  |  |  |  | repository => $self->repository,
328 |  |  |  |  |  |  | full_path => $params->{full_path},
329 | 0 |  |  |  |  | 0 | );
330 |  |  |  |  |  |  | }
331 |
332 - 338 |  |
    =head2 remove

      $self->remove({ comment => 'removed the file' });

    Removes the file from the repostory. The file is not permanently deleted as it still exists in the version history. However, as of this writing, the API here does not provide any means for getting at a deleted file.

    =cut
339 |
340 |  |  |  |  |  |  | sub remove {
341 | 0 |  |  | 0 | 1 | 0 | my ($self, $params) = @_;
342 | 0 |  |  |  |  | 0 | my $old_path = $self->full_path;
343 |
344 | 0 |  |  |  |  | 0 | my (@old_parts) = split m{/}, $old_path;
345 |
346 | 0 |  |  |  |  | 0 | my $old_tree_id = $self->find_root;
347 | 0 | 0 |  |  |  | 0 | http_throw("unable to locate original tree ID for ".$self->branch)
348 |  |  |  |  |  |  | unless $old_tree_id;
349 |
350 | 0 |  |  |  |  | 0 | my $new_tree_id = $self->make_tree($old_tree_id, \@old_parts);
351 | 0 | 0 |  |  |  | 0 | http_throw("unable to create the new tree removing $old_path\n")
352 |  |  |  |  |  |  | unless $new_tree_id;
353 |
354 | 0 |  |  |  |  | 0 | my $commit_id = $self->commit_tree($old_tree_id, $new_tree_id, $params->{comment});
355 | 0 | 0 |  |  |  | 0 | http_throw("unable to commit the new tree removing $old_path\n")
356 |  |  |  |  |  |  | unless $commit_id;
357 |
358 | 0 |  |  |  |  | 0 | $self->update_root($old_tree_id, $commit_id);
359 |  |  |  |  |  |  | }
360 |
361 - 365 |  |
    =head2 exists

    Returns true if the file exists in the repository already.

    =cut
366 |
367 |  |  |  |  |  |  | sub exists {
368 | 1 |  |  | 1 | 1 | 1 | my $self = shift;
369 |
370 | 1 |  |  |  |  | 3 | my $path = $self->full_path;
371 | 1 |  |  |  |  | 13 | return $self->find_path($path);
372 |  |  |  |  |  |  | }
373 |
374 - 381 |  |
    =head2 fetch

      my $content = $self->fetch;
      my @lines   = $self->fetch;

    Returns the contents of the file.

    =cut
382 |
383 |  |  |  |  |  |  | sub fetch {
384 | 2 |  |  | 2 | 1 | 4 | my $self = shift;
385 |
386 | 2 |  |  |  |  | 7 | my $path = $self->full_path;
387 | 2 |  |  |  |  | 33 | my $object_id = $self->find_path($path);
388 |
389 | 2 | 50 |  |  |  | 18 | return unless defined $object_id;
390 |
391 | 2 |  |  |  |  | 114 | return $self->show($object_id);
392 |  |  |  |  |  |  | }
393 |
394 - 400 |  |
    =head2 has_format

      my $yes_or_no = $self->has_format($media_type);

    Returns true if the named media type has a format plugin.

    =cut
401 |
402 |  |  |  |  |  |  | sub has_format {
403 | 0 |  |  | 0 | 1 | 0 | my ($self, $media_type) = @_;
404 | 0 |  | 0 |  |  | 0 | $media_type //= $self->media_type;
405 |
406 | 0 |  |  |  |  | 0 | my @formatters = $self->app->formatter_plugins;
407 | 0 |  |  |  |  | 0 | for my $formatter (@formatters) {
408 | 0 | 0 |  |  |  | 0 | return 1 if $formatter->has_format($media_type);
409 |  |  |  |  |  |  | }
410 |
411 | 0 |  |  |  |  | 0 | return '';
412 |  |  |  |  |  |  | }
413 |
414 - 420 |  |
    =head2 fetch_formatted

      my $html_content = $self->fetch_formatted($ctx);

    Returns the contents of the file. If there are any configured formatter plugins for the media type of the file, those will be used to return the file.

    =cut
421 |
422 |  |  |  |  |  |  | sub fetch_formatted {
423 | 1 |  |  | 1 | 1 | 4 | my ($self, $ctx, $position) = @_;
424 | 1 |  | 50 |  |  | 6 | $position //= 0;
425 |
426 | 1 |  |  |  |  | 8 | my $media_type = $self->media_type;
427 |
428 | 1 |  |  |  |  | 92 | my $formatter;
429 | 1 |  |  |  |  | 27 | for my $plugin ($self->app->formatter_plugins) {
430 | 1 | 50 |  |  |  | 14 | return $plugin->format({
431 |  |  |  |  |  |  | context => $ctx,
432 |  |  |  |  |  |  | file => $self,
433 |  |  |  |  |  |  | position => $position,
434 |  |  |  |  |  |  | }) if $plugin->has_format($media_type);
435 |  |  |  |  |  |  | }
436 |
437 | 0 |  |  |  |  |  | return $self->fetch;
438 |  |  |  |  |  |  | }
439 |
440 - 478 |  |
    =head2 history

      my @revisions = $self->history;

    Returns a list of revisions. Each revision is a hash with the following keys:

    =over

    =item object_id

    The object ID of the commit.

    =item author_name

    The name of the commti author.

    =item date

    The date the commit was made.

    =item time_ago

    A string showing how long ago the edit took place.

    =item comment

    The comment the author made about the comment.

    =item lines_added

    Number of lines added.

    =item lines_removed

    Number of lines removed.

    =back

    =cut
479 |
480 |  |  |  |  |  |  | sub history {
481 | 0 |  |  | 0 | 1 |  | my $self = shift;
482 | 0 |  |  |  |  |  | return $self->log($self->full_path);
483 |  |  |  |  |  |  | }
484 |
485 - 496 |  |
    =head2 diff

      my @chunks = $self->diff('a939fe...', 'b7763d...');

    Given two object IDs, returns a list of chunks showing the difference between two revisions of this path. Each chunk is a two element array. The first element is the type of chunk and the second is any detail for that chunk.

    The types are:

        "+"    This chunk was added to the second revision.
        "-"    This chunk was removed in the second revision.
        " "    This chunk is the same in both revisions.
    =cut
497 |
498 |  |  |  |  |  |  | sub diff {
499 | 0 |  |  | 0 | 1 |  | my ($self, $object_id_1, $object_id_2) = @_;
500 | 0 |  |  |  |  |  | return $self->diff_blobs($self->full_path, $object_id_1, $object_id_2);
501 |  |  |  |  |  |  | }
502 |
503 - 511 |  |
    =head2 file_preview

      my $file_preview = $self->file_preview(
          content => $content,
      );

    Takes this file and returns a L<Yukki::Model::FilePreview> object, with the file contents "replaced" by the given content.

    =cut
512 |
513 |  |  |  |  |  |  | sub file_preview {
514 | 0 |  |  | 0 | 1 |  | my ($self, %params) = @_;
515 |
516 | 0 |  |  |  |  |  | Class::Load::load_class('Yukki::Model::FilePreview');
517 | 0 |  |  |  |  |  | return Yukki::Model::FilePreview->new(
518 |  |  |  |  |  |  | %params,
519 |  |  |  |  |  |  | app => $self->app,
520 |  |  |  |  |  |  | repository => $self->repository,
521 |  |  |  |  |  |  | path => $self->path,
522 |  |  |  |  |  |  | );
523 |  |  |  |  |  |  | }
524 |
525 - 531 |  |
    =head2 list_files

      my @files = $self->list_files;

    List the files attached to/under this file path.

    =cut
532 |
533 |  |  |  |  |  |  | sub list_files {
534 | 0 |  |  | 0 | 1 |  | my ($self) = @_;
535 | 0 |  |  |  |  |  | return $self->repository->list_files($self->path);
536 |  |  |  |  |  |  | }
537 |
538 - 552 |  |
    =head2 parent

      my $parent = $self->parent;

    Return a L<Yukki::Model::File> representing the parent path of the current file within the current repository. For example, if the current L<path> is:

      foo/bar/baz.pdf

    the parent of it will be:

      foo/bar.yukki

    This returns C<undef> if the current file is at the root of the repository.

    =cut
553 |
554 |  |  |  |  |  |  | sub parent {
555 | 0 |  |  | 0 | 1 |  | my $self = shift;
556 |
557 | 0 |  |  |  |  |  | my @parts = split m{/}, $self->path;
558 | 0 | 0 |  |  |  |  | return if @parts == 1;
559 |
560 | 0 |  |  |  |  |  | pop @parts;
561 | 0 |  |  |  |  |  | return Yukki::Model::File->new(
562 |  |  |  |  |  |  | app => $self->app,
563 |  |  |  |  |  |  | repository => $self->repository,
564 |  |  |  |  |  |  | path => join('/', @parts),
565 |  |  |  |  |  |  | );
566 |  |  |  |  |  |  | }
567 |
568 - 572 |  |
    =head2 branch

    Returns the repository branch to which this file belongs.

    =cut
573 |
574 |  |  |  |  |  |  | 1;