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.3.0;
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   67828 use strict;
  5         26  
  5         153  
45 5     5   27 use warnings;
  5         9  
  5         132  
46 5     5   23 use utf8;
  5         10  
  5         32  
47 5     5   153 use v5.16;
  5         18  
48              
49 5     5   24 use Carp;
  5         9  
  5         426  
50 5     5   2420 use Encode::Locale;
  5         62996  
  5         287  
51 5     5   38 use Encode;
  5         9  
  5         374  
52              
53 5     5   32 use File::Basename qw/dirname basename/;
  5         10  
  5         510  
54 5     5   36 use File::Path qw/make_path/;
  5         10  
  5         306  
55 5     5   32 use File::Spec;
  5         10  
  5         127  
56              
57 5     5   2280 use PFT::Content::Attachment;
  5         14  
  5         159  
58 5     5   2090 use PFT::Content::Blog;
  5         14  
  5         181  
59 5     5   2472 use PFT::Content::Month;
  5         13  
  5         161  
60 5     5   2012 use PFT::Content::Page;
  5         15  
  5         154  
61 5     5   1934 use PFT::Content::Picture;
  5         13  
  5         151  
62 5     5   1991 use PFT::Content::Tag;
  5         14  
  5         149  
63 5     5   30 use PFT::Date;
  5         10  
  5         86  
64 5     5   22 use PFT::Header;
  5         11  
  5         84  
65 5     5   1995 use PFT::Util;
  5         15  
  5         220  
66              
67             use constant {
68 5         13183 path_sep => File::Spec->catfile('',''), # portable '/'
69 5     5   33 };
  5         10  
70              
71             sub new {
72 5     5 0 1860 my $cls = shift;
73 5         14 my $base = shift;
74 5         11 my $opts = shift;
75              
76 5         22 my $self = bless { base => $base }, $cls;
77 5 100       27 $opts->{create} and $self->_create();
78 5         50 $self;
79             }
80              
81             sub _create {
82 2     2   4 my $self = shift;
83 2         14 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 28767 sub dir_blog { File::Spec->catdir(shift->{base}, 'blog') }
113 11     11 0 152 sub dir_pages { File::Spec->catdir(shift->{base}, 'pages') }
114 6     6 0 46 sub dir_tags { File::Spec->catdir(shift->{base}, 'tags') }
115 6     6 0 73 sub dir_pics { File::Spec->catdir(shift->{base}, 'pics') }
116 6     6 0 1008 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 113 my $self = shift;
135 47         68 my $hdr = shift;
136              
137 47         106 my $p = $self->entry($hdr);
138 47 50       191 $hdr->dump($p->open('w')) unless $p->exists;
139 47         21689 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 71 my $self = shift;
151 50         72 my $hdr = shift;
152 50 50       181 confess "Not a header: $hdr" unless $hdr->isa('PFT::Header');
153              
154 50         138 my $params = {
155             tree => $self,
156             path => $self->hdr_to_path($hdr),
157             name => $hdr->title,
158             };
159              
160 50         122 my $d = $hdr->date;
161 50 100       209 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 86 my $self = shift;
181 51         71 my $hdr = shift;
182 51 50       143 confess 'Not a header' unless $hdr->isa('PFT::Header');
183              
184 51 100       134 if (defined(my $d = $hdr->date)) {
185 44         73 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         100 my $ym = sprintf('%04d-%02d', $d->y, $d->m);
191 44 100       129 if (defined $d->d) {
192 39         99 $basedir = File::Spec->catdir($self->dir_blog, $ym);
193 39         184 $fname = sprintf('%02d-%s', $d->d, $hdr->slug);
194             } else {
195 5         13 $basedir = $self->dir_blog;
196 5         55 $fname = $ym . '.month';
197             }
198              
199 44         434 File::Spec->catfile($basedir, $fname)
200             } else {
201 7         25 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         1 my $hdr = shift;
215              
216 1         4 my $p = $self->tag($hdr);
217 1 50       7 $hdr->dump($p->open('w')) unless $p->exists;
218 1         535 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 8 my $self = shift;
230 4         6 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   62 my $self = shift;
242              
243 6         9 my @out;
244 6         21 for my $path (PFT::Util::locale_glob @_) {
245 17 50       1542 my $hdr = eval { PFT::Header->load($path) }
  17         52  
246             or confess "Loading header of $path: " . $@ =~ s/ at .*$//rs;
247              
248 17         71 push @out, {
249             tree => $self,
250             path => $path,
251             name => $hdr->title,
252             };
253             }
254             @out
255 6         153 }
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 5 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         9 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       12 confess 'No path?' unless @_;
316              
317 4         33 my $path = File::Spec->catfile($pfx, @_);
318             {
319 4         89 tree => $self,
320             path => $path,
321             relpath => [File::Spec->splitdir(substr($path, $pfxlen))],
322             }
323             }
324              
325             sub _blob_ls {
326 4     4   27 my $self = shift;
327              
328 4         11 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 10 my $self = shift;
350 2         9 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 4 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 67 my $self = shift;
391 2         12 map PFT::Content::Attachment->new($_),
392             $self->_blob_ls($self->dir_attachments)
393             }
394              
395             sub _blog_from_path {
396 26     26   2185 my($self, $path) = @_;
397 26         48 my $h = eval { PFT::Header->load($path) };
  26         76  
398 26 50       106 $h or carp("Loading $path: " . $@ =~ s/ at .*$//rs);
399              
400 26 50       115 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   37912 my($self, $path) = @_;
409              
410 628         1131 my $rel = File::Spec->abs2rel($path, $self->dir_blog);
411 628 50       2710 return undef if index($rel, File::Spec->updir) >= 0;
412              
413 628         2165 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       2865 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 7413 my $self = shift;
447 25   100     106 my $back = shift || 0;
448              
449 25 50       84 confess 'Negative back?' if $back < 0;
450              
451             my @paths_and_dates =
452 25         106 sort { $b->[1] <=> $a->[1] }
  604         1245  
453             map [$_, $self->_path_to_date($_)],
454             PFT::Util::locale_glob(
455             File::Spec->catfile($self->dir_blog, '*', '*')
456             );
457              
458 25         287 my %dates;
459             my @out;
460 25         51 $back ++; # Instead of doing $seen_dates == $back + 1 at every loop
461 25         99 foreach (@paths_and_dates) {
462 328         584 my($path, $date) = @$_;
463 328         694 $dates{$date}++;
464              
465 328         613 my $seen_dates = keys %dates;
466 328 100       623 if ($seen_dates == $back) {
467 26 50       67 my $hdr = eval { PFT::Header->load($path) }
  26         191  
468             or confess "Loading header of $path: " . $@ =~ s/ at .*$//rs;
469              
470 26         194 push @out => PFT::Content::Blog->new({
471             tree => $self,
472             path => $path,
473             name => $hdr->title,
474             });
475             }
476 328 100       686 last if $seen_dates > $back;
477             }
478              
479 25         489 @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 13 my($self, $date) = @_;
496              
497 3 50 33     26 confess "Expecting date" unless defined($date) && $date->isa('PFT::Date');
498              
499 3 100       11 my $y = defined($date->y) ? sprintf('%04d', $date->y) : '*';
500 3 100       13 my $m = defined($date->m) ? sprintf('%02d', $date->m) : '*';
501 3 100       11 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 38 my($self, $content) = @_;
521              
522 3 50       50 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       23 return undef unless $content->isa('PFT::Content::Blog');
528 2 50       13 $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 27 my($self, $content) = @_;
544              
545 4 50       22 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       29 return undef if $content->isa('PFT::Content::Month');
551              
552 4         22 my $fname = basename($content->path);
553 4 100       28 $fname =~ s/^\d{2}-// if $content->isa('PFT::Content::Blog');
554 4         21 $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         31 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       41 opendir(my $dh, $d) or return;
574 1 50       69 rmdir $d unless File::Spec->no_upwards(readdir $dh);
575 1         69 close $dh;
576             }
577              
578             =back
579              
580             =cut
581              
582             1;