File Coverage

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

linestmtbrancondsubpodtimecode
1package Yukki::Model::File;
2
3
2
2
13
7
use v5.24;
4
2
2
2
7
2
10
use utf8;
5
2
2
2
26
3
5
use Moo;
6
7extends 'Yukki::Model';
8
9
2
2
2
366
3
75
use Class::Load;
10
2
2
2
185
476
80
use Digest::SHA1 qw( sha1_hex );
11
2
2
2
457
2299
68
use Number::Bytes::Human qw( format_bytes );
12
2
2
2
199
8794
56
use LWP::MediaTypes qw( guess_media_type );
13
2
2
2
7
5
19
use Type::Utils;
14
2
2
2
1863
3
13
use Types::Standard qw( Maybe Str );
15
2
2
2
1048
3
11
use Yukki::Error qw( http_throw );
16
17
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
45has 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
57has 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
71has 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
102sub BUILDARGS {
103
1
1
1680
    my $class = shift;
104
105
1
2
    my %args;
106
1
0
0
3
0
0
    if (@_ == 1) { %args = %{ $_[0] }; }
107
1
4
    else         { %args = @_; }
108
109
1
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
128sub full_path {
129
6
1
9
    my $self = shift;
130
131
6
9
    my $full_path;
132
6
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
148sub file_name {
149
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
161sub file_id {
162
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
172sub object_id {
173
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
183sub title {
184
1
1
72
    my $self = shift;
185
186
1
7
    if ($self->filetype eq 'yukki') {
187
1
6
        LINE: for my $line ($self->fetch) {
188
1
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
                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
212sub file_size {
213
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
223sub formatted_file_size {
224
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
234sub media_type {
235
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
258sub store {
259
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
    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
    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
    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
    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
    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
300sub rename {
301
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
    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
    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
    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
340sub remove {
341
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
    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
    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
    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
367sub exists {
368
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
383sub fetch {
384
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
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
402sub has_format {
403
0
1
0
    my ($self, $media_type) = @_;
404
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
        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
422sub fetch_formatted {
423
1
1
4
    my ($self, $ctx, $position) = @_;
424
1
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
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
480sub history {
481
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
498sub diff {
499
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
513sub file_preview {
514
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
533sub list_files {
534
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
554sub parent {
555
0
1
    my $self = shift;
556
557
0
    my @parts = split m{/}, $self->path;
558
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
5741;