File Coverage

blib/lib/PFT/Content.pm
Criterion Covered Total %
statement 181 190 95.2
branch 45 68 66.1
condition 4 14 28.5
subroutine 48 51 94.1
pod 18 25 72.0
total 296 348 85.0


line stmt bran cond sub pod time code
1             # Copyright 2014-2016 - Giovanni Simoni
2             #
3             # This file is part of PFT.
4             #
5             # PFT is free software: you can redistribute it and/or modify it under the
6             # terms of the GNU General Public License as published by the Free
7             # Software Foundation, either version 3 of the License, or (at your
8             # option) any later version.
9             #
10             # PFT is distributed in the hope that it will be useful, but WITHOUT ANY
11             # WARRANTY; without even the implied warranty of MERCHANTABILITY or
12             # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13             # for more details.
14             #
15             # You should have received a copy of the GNU General Public License along
16             # with PFT. If not, see .
17             #
18             package PFT::Content v1.4.1;
19              
20             =encoding utf8
21              
22             =head1 NAME
23              
24             PFT::Content - Filesytem tree mapping content
25              
26             =head1 SYNOPSIS
27              
28             PFT::Content->new($basedir);
29             PFT::Content->new($basedir, {create => 1});
30              
31             =head1 DESCRIPTION
32              
33             The structure is the following:
34              
35             content
36             ├── attachments
37             ├── blog
38             ├── pages
39             ├── pics
40             └── tags
41              
42             =cut
43              
44 5     5   72047 use strict;
  5         24  
  5         144  
45 5     5   25 use warnings;
  5         9  
  5         114  
46 5     5   22 use utf8;
  5         8  
  5         32  
47 5     5   149 use v5.16;
  5         16  
48              
49 5     5   24 use Carp;
  5         15  
  5         350  
50 5     5   2234 use Encode::Locale;
  5         59764  
  5         217  
51 5     5   35 use Encode;
  5         8  
  5         358  
52              
53 5     5   33 use File::Basename qw/dirname basename/;
  5         9  
  5         488  
54 5     5   34 use File::Path qw/make_path/;
  5         16  
  5         271  
55 5     5   31 use File::Spec;
  5         8  
  5         108  
56              
57 5     5   2123 use PFT::Content::Attachment;
  5         15  
  5         147  
58 5     5   1857 use PFT::Content::Blog;
  5         16  
  5         176  
59 5     5   1972 use PFT::Content::Month;
  5         13  
  5         157  
60 5     5   2028 use PFT::Content::Page;
  5         14  
  5         148  
61 5     5   1873 use PFT::Content::Picture;
  5         14  
  5         147  
62 5     5   1955 use PFT::Content::Tag;
  5         13  
  5         159  
63 5     5   30 use PFT::Date;
  5         9  
  5         83  
64 5     5   24 use PFT::Header;
  5         9  
  5         80  
65 5     5   1908 use PFT::Util;
  5         11  
  5         211  
66              
67             use constant {
68 5         12911 path_sep => File::Spec->catfile('',''), # portable '/'
69 5     5   33 };
  5         8  
70              
71             sub new {
72 5     5 0 1964 my $cls = shift;
73 5         10 my $base = shift;
74 5         12 my $opts = shift;
75              
76 5         17 my $self = bless { base => $base }, $cls;
77 5 100       27 $opts->{create} and $self->_create();
78 5         48 $self;
79             }
80              
81             sub _create {
82 2     2   6 my $self = shift;
83 2         11 make_path(map $self->$_ => qw/
84             dir_blog
85             dir_pages
86             dir_tags
87             dir_pics
88             dir_attachments
89             /), {
90             #verbose => 1,
91             mode => 0711,
92             }
93             }
94              
95             =head2 Properties
96              
97             Quick accessors for directories
98              
99             $tree->dir_root
100             $tree->dir_blog
101             $tree->dir_pages
102             $tree->dir_tags
103             $tree->dir_pics
104             $tree->dir_attachments
105              
106             Non-existing directories are created by the constructor if the
107             C<{create =E 1}> option is passed as last constructor argument.
108              
109             =cut
110              
111 0     0 0 0 sub dir_root { shift->{base} }
112 706     706 0 27383 sub dir_blog { File::Spec->catdir(shift->{base}, 'blog') }
113 11     11 0 103 sub dir_pages { File::Spec->catdir(shift->{base}, 'pages') }
114 6     6 0 44 sub dir_tags { File::Spec->catdir(shift->{base}, 'tags') }
115 6     6 0 72 sub dir_pics { File::Spec->catdir(shift->{base}, 'pics') }
116 6     6 0 971 sub dir_attachments { File::Spec->catdir(shift->{base}, 'attachments') }
117              
118             =head2 Methods
119              
120             =over
121              
122             =item new_entry
123              
124             Create and return a page. A header is required as argument.
125              
126             If the page does not exist it gets created according to the header. If the
127             header contains a date, the page is considered to be a I (and
128             positioned as such). If the data is missing the I information, the
129             entry is a I.
130              
131             =cut
132              
133             sub new_entry {
134 47     47 1 106 my $self = shift;
135 47         61 my $hdr = shift;
136              
137 47         108 my $p = $self->entry($hdr);
138 47 50       167 $hdr->dump($p->open('w')) unless $p->exists;
139 47         20815 return $p
140             }
141              
142             =item entry
143              
144             Similar to C, but does not create a content file if it
145             doesn't exist already.
146              
147             =cut
148              
149             sub entry {
150 50     50 1 66 my $self = shift;
151 50         65 my $hdr = shift;
152 50 50       175 confess "Not a header: $hdr" unless $hdr->isa('PFT::Header');
153              
154 50         128 my $params = {
155             tree => $self,
156             path => $self->hdr_to_path($hdr),
157             name => $hdr->title,
158             };
159              
160 50         115 my $d = $hdr->date;
161 50 100       184 defined $d
    100          
162             ? $d->complete
163             ? PFT::Content::Blog->new($params)
164             : PFT::Content::Month->new($params)
165             : PFT::Content::Page->new($params)
166             }
167              
168             =item hdr_to_path
169              
170             Given a PFT::Header object, returns the path of a page or blog page within
171             the tree.
172              
173             Note: this function does not work properly if you are seeking for a
174             I. I are a different beast, since they have the same header as
175             a page, but they belong to a different place.
176              
177             =cut
178              
179             sub hdr_to_path {
180 51     51 1 66 my $self = shift;
181 51         84 my $hdr = shift;
182 51 50       120 confess 'Not a header' unless $hdr->isa('PFT::Header');
183              
184 51 100       106 if (defined(my $d = $hdr->date)) {
185 44         62 my($basedir, $fname);
186              
187 44 50 33     110 defined $d->y && defined $d->m
188             or confess 'Year and month are required';
189              
190 44         97 my $ym = sprintf('%04d-%02d', $d->y, $d->m);
191 44 100       109 if (defined $d->d) {
192 39         83 $basedir = File::Spec->catdir($self->dir_blog, $ym);
193 39         182 $fname = sprintf('%02d-%s', $d->d, $hdr->slug);
194             } else {
195 5         14 $basedir = $self->dir_blog;
196 5         56 $fname = $ym . '.month';
197             }
198              
199 44         403 File::Spec->catfile($basedir, $fname)
200             } else {
201 7         23 File::Spec->catfile($self->dir_pages, $hdr->slug)
202             }
203             }
204              
205             =item new_tag
206              
207             Create and return a I. A header is required as argument. If the
208             tag page does not exist it gets created according to the header.
209              
210             =cut
211              
212             sub new_tag {
213 1     1 1 3 my $self = shift;
214 1         3 my $hdr = shift;
215              
216 1         3 my $p = $self->tag($hdr);
217 1 50       9 $hdr->dump($p->open('w')) unless $p->exists;
218 1         428 return $p;
219             }
220              
221             =item tag
222              
223             Similar to C, but does not create the content file if it doesn't
224             exist already.
225              
226             =cut
227              
228             sub tag {
229 4     4 1 9 my $self = shift;
230 4         8 my $hdr = shift;
231              
232 4 50       17 confess "Not a header: $hdr" unless $hdr->isa('PFT::Header');
233 4         16 PFT::Content::Tag->new({
234             tree => $self,
235             path => File::Spec->catfile($self->dir_tags, $hdr->slug),
236             name => $hdr->title,
237             })
238             }
239              
240             sub _text_ls {
241 6     6   59 my $self = shift;
242              
243 6         9 my @out;
244 6         21 for my $path (PFT::Util::locale_glob @_) {
245 17 50       1509 my $hdr = eval { PFT::Header->load($path) }
  17         50  
246             or confess "Loading header of $path: " . $@ =~ s/ at .*$//rs;
247              
248 17         73 push @out, {
249             tree => $self,
250             path => $path,
251             name => $hdr->title,
252             };
253             }
254             @out
255 6         166 }
256              
257             =item blog_ls
258              
259             List all blog entries (days and months).
260              
261             =cut
262              
263             sub blog_ls {
264 2     2 1 6 my $self = shift;
265 2         8 map(
266             PFT::Content::Blog->new($_),
267             $self->_text_ls(File::Spec->catfile($self->dir_blog, '*', '*'))
268             ),
269             map(
270             PFT::Content::Month->new($_),
271             $self->_text_ls(File::Spec->catfile($self->dir_blog, '*.month'))
272             )
273             }
274              
275             =item pages_ls
276              
277             List all pages (not tags pages)
278              
279             =cut
280              
281             sub pages_ls {
282 2     2 1 5 my $self = shift;
283 2         8 map PFT::Content::Page->new($_),
284             $self->_text_ls(File::Spec->catfile($self->dir_pages, '*'))
285             }
286              
287             =item tags_ls
288              
289             List all tag pages (not regular pages)
290              
291             =cut
292              
293             sub tags_ls {
294 0     0 1 0 my $self = shift;
295 0         0 map PFT::Content::Tag->new($_),
296             $self->_text_ls(File::Spec->catfile($self->dir_tags, '*'))
297             }
298              
299             =item entry_ls
300              
301             List all entries (pages + blog + tags)
302              
303             =cut
304              
305             sub entry_ls {
306 0     0 1 0 my $self = shift;
307 0         0 $self->pages_ls,
308             $self->blog_ls,
309             $self->tags_ls,
310             }
311              
312             sub _blob {
313 4     4   35 my $self = shift;
314 4         10 my $pfxlen = length(my $pfx = shift) + length(path_sep);
315 4 50       13 confess 'No path?' unless @_;
316              
317 4         32 my $path = File::Spec->catfile($pfx, @_);
318             {
319 4         92 tree => $self,
320             path => $path,
321             relpath => [File::Spec->splitdir(substr($path, $pfxlen))],
322             }
323             }
324              
325             sub _blob_ls {
326 4     4   95 my $self = shift;
327              
328 4         12 my $pfxlen = length(my $pfx = shift) + length(path_sep);
329 4         14 map {
330             tree => $self,
331             path => $_,
332             relpath => [File::Spec->splitdir(substr($_, $pfxlen))],
333             },
334             PFT::Util::list_files($pfx)
335             }
336              
337             =item pic
338              
339             Get a picture.
340              
341             Accepts a list of strings which will be joined into the path of a
342             picture file. Returns a C instance, which could
343             correspond to a non-existing file. The caller might create it (e.g. by
344             copying a picture on the corresponding path).
345              
346             =cut
347              
348             sub pic {
349 2     2 1 9 my $self = shift;
350 2         7 PFT::Content::Picture->new($self->_blob($self->dir_pics, @_))
351             }
352              
353             =item pics_ls
354              
355             List all pictures.
356              
357             =cut
358              
359             sub pics_ls {
360 2     2 1 5 my $self = shift;
361 2         8 map PFT::Content::Picture->new($_), $self->_blob_ls($self->dir_pics)
362             }
363              
364             =item attachment
365              
366             Get an attachment.
367              
368             Accepts a list of strings which will be joined into the path of an
369             attachment file. Returns a C instance, which could
370             correspond to a non-existing file. The caller might create it (e.g. by
371             copying a file on the corresponding path).
372              
373             Note that the input path should be made by strings in encoded form, in
374             order to match the filesystem path.
375              
376             =cut
377              
378             sub attachment {
379 2     2 1 8 my $self = shift;
380 2         7 PFT::Content::Attachment->new($self->_blob($self->dir_attachments, @_))
381             }
382              
383             =item attachments_ls
384              
385             List all attachments.
386              
387             =cut
388              
389             sub attachments_ls {
390 2     2 1 6 my $self = shift;
391 2         89 map PFT::Content::Attachment->new($_),
392             $self->_blob_ls($self->dir_attachments)
393             }
394              
395             sub _blog_from_path {
396 26     26   1984 my($self, $path) = @_;
397 26         41 my $h = eval { PFT::Header->load($path) };
  26         74  
398 26 50       84 $h or carp("Loading $path: " . $@ =~ s/ at .*$//rs);
399              
400 26 50       101 PFT::Content::Blog->new({
401             tree => $self,
402             path => $path,
403             name => $h ? $h->title : '?',
404             })
405             }
406              
407             sub _path_to_date {
408 628     628   34526 my($self, $path) = @_;
409              
410 628         1005 my $rel = File::Spec->abs2rel($path, $self->dir_blog);
411 628 50       2600 return undef if index($rel, File::Spec->updir) >= 0;
412              
413 628         2175 my($ym, $dt) = File::Spec->splitdir($rel);
414              
415             PFT::Date->new(
416             substr($ym, 0, 4),
417             substr($ym, 5, 2),
418 628 50       2605 defined($dt) ? substr($dt, 0, 2) : do {
419 0 0       0 $ym =~ /^\d{4}-\d{2}.month$/
420             or confess "Unexpected $ym for $path";
421             undef
422 0         0 }
423             )
424             }
425              
426             =item blog_back
427              
428             Go back in blog history of a number of days, return the entries
429             corresponding to that date.
430              
431             Expects one optional argument as the number of backward days in the blog
432             history. If such argument is not provided, it defaults to 0, returning the
433             entries of the latest edit day.
434              
435             Please note that only days containing entries really count. If a blog had
436             one entry today, no entry for yesterday and one the day before yesterday,
437             C will return today's entry, and C will return
438             the entry of two days ago.
439              
440             Returns a list PFT::Content::Blog object, possibly empty if the blog does
441             not have that many days.
442              
443             =cut
444              
445             sub blog_back {
446 25     25 1 7165 my $self = shift;
447 25   100     73 my $back = shift || 0;
448              
449 25 50       53 confess 'Negative back?' if $back < 0;
450              
451             my @paths_and_dates =
452 25         63 sort { $b->[1] <=> $a->[1] }
  604         1178  
453             map [$_, $self->_path_to_date($_)],
454             PFT::Util::locale_glob(
455             File::Spec->catfile($self->dir_blog, '*', '*')
456             );
457              
458 25         134 my %dates;
459             my @out;
460 25         38 $back ++; # Instead of doing $seen_dates == $back + 1 at every loop
461 25         50 foreach (@paths_and_dates) {
462 328         500 my($path, $date) = @$_;
463 328         652 $dates{$date}++;
464              
465 328         589 my $seen_dates = keys %dates;
466 328 100       557 if ($seen_dates == $back) {
467 26 50       38 my $hdr = eval { PFT::Header->load($path) }
  26         93  
468             or confess "Loading header of $path: " . $@ =~ s/ at .*$//rs;
469              
470 26         110 push @out => PFT::Content::Blog->new({
471             tree => $self,
472             path => $path,
473             name => $hdr->title,
474             });
475             }
476 328 100       641 last if $seen_dates > $back;
477             }
478              
479 25         323 @out;
480             }
481              
482             =item blog_at
483              
484             Go back in blog history to a certain date.
485              
486             Expects as argument a C item indicating a date to seek for blog
487             entries.
488              
489             Returns a possibly empty list of C objects corresponding
490             to the zero, one or more entries in the specified date.
491              
492             =cut
493              
494             sub blog_at {
495 3     3 1 10 my($self, $date) = @_;
496              
497 3 50 33     24 confess "Expecting date" unless defined($date) && $date->isa('PFT::Date');
498              
499 3 100       10 my $y = defined($date->y) ? sprintf('%04d', $date->y) : '*';
500 3 100       10 my $m = defined($date->m) ? sprintf('%02d', $date->m) : '*';
501 3 100       8 my $d = defined($date->d) ? sprintf('%02d', $date->d) : '*';
502              
503 3         13 map $self->_blog_from_path($_), PFT::Util::locale_glob(
504             File::Spec->catfile($self->dir_blog, "$y-$m", "$d-*")
505             );
506             }
507              
508             =item detect_date
509              
510             Given a C object (or any subclass) determines the
511             corresponding date by analyzing the path. Returns a C object or
512             undef if the page does not have date.
513              
514             This function is helpful for checking inconsistency between the date
515             declared in headers and the date used on the file system.
516              
517             =cut
518              
519             sub detect_date {
520 3     3 1 28 my($self, $content) = @_;
521              
522 3 50       30 unless ($content->isa('PFT::Content::File')) {
523 0   0     0 confess 'Cannot determine path: ',
524             ref $content || $content, ' is not not PFT::Content::File'
525             }
526              
527 3 100       20 return undef unless $content->isa('PFT::Content::Blog');
528 2 50       8 $self->_path_to_date($content->path) or die 'blog/month without date?';
529             }
530              
531             =item detect_slug
532              
533             Given a C object (or any subclass) determines the
534             corresponding slug by analyzing the path. Returns the slug or undef if the
535             content does not have a slug (e.g. months).
536              
537             This function is helpful for checking inconsistency between the slug
538             declared in headers and the slug used on the file system.
539              
540             =cut
541              
542             sub detect_slug {
543 4     4 1 28 my($self, $content) = @_;
544              
545 4 50       18 unless ($content->isa('PFT::Content::File')) {
546 0   0     0 confess 'Cannot determine path: ',
547             ref $content || $content, ' is not not PFT::Content::File'
548             }
549              
550 4 50       28 return undef if $content->isa('PFT::Content::Month');
551              
552 4         20 my $fname = basename($content->path);
553 4 100       28 $fname =~ s/^\d{2}-// if $content->isa('PFT::Content::Blog');
554 4         24 $fname
555             }
556              
557             =item was_renamed
558              
559             Notify this content abstraction about the renaming of the corresponding
560             content file. First parameter is the original name, second parameter is the
561             new name.
562              
563             =cut
564              
565             sub was_renamed {
566 1     1 1 3 my $self = shift;
567 1         32 my $d = dirname shift;
568              
569             # $ignored = shift;
570             # Actually, we internally ignore the original name. The parameter is
571             # maintained just in case we need it in future. For the moment we are
572             # interested in getting rid of empty directories.
573 1 50       36 opendir(my $dh, $d) or return;
574 1 50       65 rmdir $d unless File::Spec->no_upwards(readdir $dh);
575 1         51 close $dh;
576             }
577              
578             =back
579              
580             =cut
581              
582             1;