File Coverage

blib/lib/App/WRT/EntryStore.pm
Criterion Covered Total %
statement 185 187 98.9
branch 34 42 80.9
condition 5 6 83.3
subroutine 39 41 95.1
pod 30 33 90.9
total 293 309 94.8


line stmt bran cond sub pod time code
1             package App::WRT::EntryStore;
2              
3 9     9   64 use strict;
  9         26  
  9         273  
4 9     9   43 use warnings;
  9         16  
  9         231  
5 9     9   101 use 5.10.0;
  9         46  
6              
7 9     9   55 use File::Find;
  9         16  
  9         573  
8 9     9   64 use Carp;
  9         26  
  9         511  
9 9     9   3359 use App::WRT::Sort qw(sort_entries);
  9         23  
  9         546  
10 9     9   2801 use App::WRT::Util qw(file_get_contents);
  9         20  
  9         23907  
11              
12             =pod
13              
14             =head1 NAME
15              
16             App::WRT::EntryStore - model the contents of a wrt repo's entry_dir
17              
18             =head1 SYNOPSIS
19              
20             use App::WRT::EntryStore;
21             my $entries = App::WRT::EntryStore->new('./archives');
22              
23             my @all = $entries->all();
24             my @months = $entries->all_months();
25             my @years = $entries->all_years();
26             my @days = $entries->all_days();
27              
28             # all_* are wrappers for dates_by_depth():
29             my @days = $entries->dates_by_depth(
30             3 # 1 for years, 2 for months, 3 for days
31             );
32              
33             my @recent_days = $entries->recent_days(30);
34             my @recent_months = $entries->recent_months(12);
35             my @recent_years = $entries->recent_years(10);
36              
37             # recent_* are wrappers for recent_by_depth():
38             my @recent_days $entries->recent_by_depth(
39             3, # 1 for years, 2 for months, 3 for days
40             30 # count
41             );
42              
43             =cut
44              
45             # "Constants"
46              
47             my $ENTRYTYPE_FILE = 0;
48             my $ENTRYTYPE_DIR = 1;
49             my $ENTRYTYPE_VIRT = 2;
50              
51             my %SUBENTRY_IGNORE = ('index' => 1);
52             my $SUBENTRY_EXPR = qr{
53             ^
54             [[:lower:][:digit:]_-]+
55             (
56             [.]
57             (tgz|zip|tar[.]gz|gz|txt)
58             )?
59             $
60             }x;
61              
62             # What gets considered a renderable entry path:
63             my $RENDERABLE_EXPR = qr{
64             ^
65             (
66             [[:lower:][:digit:]_\/-]+
67             )
68             $
69             }x;
70              
71             =head1 METHODS
72              
73             =over
74              
75             =item new($class, $entry_dir)
76              
77             Get a new EntryStore, using a given $entry_dir.
78              
79             Finds a list of entries for the given directory, and builds data structures
80             which can be used to index into entries by depth, property, and next/previous
81             entry.
82              
83             =cut
84              
85             sub new {
86 12     12 1 179 my $class = shift;
87 12         41 my ($entry_dir) = @_;
88              
89 12         44 my %params = (
90             entry_dir => $entry_dir
91             );
92              
93 12         28 my $self = \%params;
94              
95 12         27 bless $self, $class;
96              
97 12         65 my @entries;
98             my %source_files;
99 12         0 my %entry_properties;
100 12         0 my %property_entries;
101 12         0 my %children;
102              
103             find(
104             sub {
105 453 100   453   4821 return unless $File::Find::name =~ m{^ \Q$entry_dir\E / (.*) $}x;
106              
107 441         1041 my $target = $1;
108              
109             # Build an ordered array of entries:
110 441         850 push @entries, $target;
111              
112             # Build a hash indicating:
113             # a. that a file exists
114             # b. whether it's a flatfile or a directory
115 441 100       7044 if (-f $_) {
    50          
116 276         1048 $source_files{$target} = $ENTRYTYPE_FILE;
117             } elsif (-d $_) {
118 165         649 $source_files{$target} = $ENTRYTYPE_DIR;
119             }
120              
121             # Build hashes of all properties of entries, and all entries of properties:
122 441 100       15999 if ($target =~ m{(.*) / (.*) [.]prop $}x) {
123 55         212 my ($entry, $property) = ($1, $2);
124              
125 55   100     273 $entry_properties{$entry} //= [];
126 55         99 push @{ $entry_properties{$entry} }, $property;
  55         154  
127              
128 55   50     256 $property_entries{$property} //= [];
129 55         73 push @{ $property_entries{$property} }, $entry;
  55         818  
130             }
131             },
132 12         1292 $entry_dir
133             );
134              
135             # Ensure that the entry list for every property is sorted:
136 12         161 for (keys %property_entries) {
137 55         89 $property_entries{$_} = [ sort_entries(@{ $property_entries{$_} }) ];
  55         154  
138             }
139              
140             # Create virtual entries based on tags, _if there's not already a file
141             # there_:
142 12         52 foreach my $prop (keys %property_entries) {
143 55 100       187 if ( $prop =~ m/^ tag[.] (.*)$/x ) {
144 33         79 my $tag = $1;
145 33         91 $tag =~ s{[.]}{/}g;
146 33 50       93 unless (defined $source_files{$tag}) {
147 33         90 push @entries, $tag;
148 33         84 $source_files{$tag} = $ENTRYTYPE_VIRT;
149             }
150             }
151             }
152              
153             # Stash refs for future use:
154 12         86 $self->{entries} = \@entries;
155 12         91 $self->{source_files} = \%source_files;
156 12         29 $self->{property_entries} = \%property_entries;
157 12         38 $self->{entry_properties} = \%entry_properties;
158              
159 12         55 $self->generate_date_hashes();
160 12         58 $self->store_children();
161              
162 12         100 return $self;
163             }
164              
165             =item all()
166              
167             Returns a list of all source files for the current entry archive (excepting
168             index files, which are a special case - this part could use some work).
169              
170             This was originally in App::WRT::Renderer, so there may be some pitfalls here.
171              
172             =cut
173              
174             sub all {
175 62     62 1 103 my ($self) = shift;
176 62         78 return @{ $self->{entries} };
  62         2273  
177             }
178              
179             =item all_renderable()
180              
181             Returns a list of all existing source paths which are considered "renderable".
182              
183             A path should match C<$RENDERABLE_EXPR> and not be an index file.
184              
185             =cut
186              
187             sub all_renderable() {
188 11     11 1 31 my ($self) = shift;
189             return grep {
190 431 100       2046 (index($_, '/index', -6) == -1)
191             &&
192             m/$RENDERABLE_EXPR/
193 11         16 } @{ $self->{entries} };
  11         30  
194             }
195              
196             =item dates_by_depth($depth)
197              
198             Returns a sorted list of all date-like entries which are at a specified depth.
199             Use 1 for years, 2 for months, and 3 for days.
200              
201             Fairly silly, but entertaining in its perverse way. all_years(), all_months(),
202             and all_days() are provided for convenience.
203              
204             =cut
205              
206             sub dates_by_depth {
207 156     156 1 261 my ($self) = shift;
208 156         277 my ($depth) = @_;
209              
210 156 50       319 croak('No $depth given.')
211             unless defined $depth;
212              
213             # Check if we already have a value cached:
214 120         402 return @{ $self->{by_depth}->{$depth} }
215 156 100       422 if defined $self->{by_depth}->{$depth};
216              
217             # Build a pattern for matching the given depth of date-like entries. For
218             # example, a day would be depth 3, and matched by \d+/\d+/\d+
219 36         59 my @particles;
220 36         94 for (my $i = 0; $i < $depth; $i++) {
221 72         156 push @particles, '\d+';
222             }
223 36         89 my $pattern = join '/', @particles;
224              
225 36         94 my @by_depth = sort_entries(
226             grep m{^ $pattern $}x, $self->all()
227             );
228              
229             # Stash arrayref for future use:
230 36         194 $self->{by_depth}->{$depth} = \@by_depth;
231              
232 36         102 return @by_depth;
233             }
234              
235             =item all_years(), all_months(), all_days()
236              
237             Convenience wrappers for dates_by_depth().
238              
239             =cut
240              
241 4     4 1 319 sub all_years { return $_[0]->dates_by_depth(1); }
242 33     33 1 432 sub all_months { return $_[0]->dates_by_depth(2); }
243 74     74 1 148 sub all_days { return $_[0]->dates_by_depth(3); }
244              
245             =item days_for($month), months_for($year)
246              
247             Convenience wrappers for extracting days or months in a given month
248             or year.
249              
250             =cut
251              
252             sub days_for {
253 72     72 1 1076 my ($self, $container) = @_;
254 72         157 return grep { m{^ \Q$container\E / }x } $self->all_days();
  216         1143  
255             }
256              
257             sub months_for {
258 31     31 1 613 my ($self, $year) = @_;
259 31         87 return grep { m{^ \Q$year\E / }x } $self->all_months();
  124         853  
260             }
261              
262             =item recent_by_depth($depth, $entry_count)
263              
264             Returns the $entry_count most recent dated entries at $depth (1 for year, 2 for
265             month, 3 for day). recent_years(), recent_months(), and recent_days() are
266             provided for convenience.
267              
268             =cut
269              
270             sub recent_by_depth {
271 9     9 1 21 my ($self) = shift;
272 9         21 my ($depth, $entry_count) = @_;
273              
274 9         16 my @entries;
275 9         27 for my $entry (reverse $self->dates_by_depth($depth)) {
276 27 50       53 last if scalar(@entries) == $entry_count;
277 27         54 push @entries, $entry;
278             }
279              
280 9         59 return @entries;
281             }
282              
283             =item all_years(), all_months(), all_days()
284              
285             Convenience wrappers for recent_by_depth().
286              
287             =cut
288              
289 0     0 0 0 sub recent_years { return $_[0]->recent_by_depth(1, $_[1]); }
290 0     0 0 0 sub recent_months { return $_[0]->recent_by_depth(2, $_[1]); }
291 9     9 0 40 sub recent_days { return $_[0]->recent_by_depth(3, $_[1]); }
292              
293             =item generate_date_hashes()
294              
295             Store hashes which map dated entries to their previous and next entries at the
296             same depth in the tree. That is, something like:
297              
298             %prev_dates = {
299             '2014' => '2013',
300             '2014/1' => '2013/12'
301             '2014/1/1' => '2013/12/30',
302             ...
303             }
304              
305             %next_dates = {
306             '2013' => '2014',
307             '2013/12' => '2014/1',
308             '2013/12/30' => '2014/1/1',
309             ...
310             }
311              
312             =cut
313              
314             sub generate_date_hashes {
315 12     12 1 35 my $self = shift;
316              
317 12         26 my %prev;
318              
319             # Depth 1 is years, 2 is months, 3 is days. Get lists for all:
320 12         47 for my $depth (1, 2, 3) {
321 36         115 my @dates = $self->dates_by_depth($depth);
322              
323 36         54 my $last_seen;
324 36         76 foreach my $current_date (@dates) {
325 121 100       196 if ($last_seen) {
326 88         142 $prev{$current_date} = $last_seen;
327             }
328 121         198 $last_seen = $current_date;
329             }
330             }
331              
332 12         87 $self->{prev_dates} = { %prev };
333 12         99 $self->{next_dates} = { reverse %prev };
334             }
335              
336             =item store_children
337              
338             Store hashes of arrayrefs which maps parents to their immediate children.
339              
340             =cut
341              
342             sub store_children {
343 12     12 1 31 my $self = shift;
344              
345 12         21 my %child_cache;
346              
347 12         34 for my $entry ($self->all()) {
348 474         837 my $dirname = $self->dirname($entry);
349 474   100     1215 $child_cache{$dirname} //= [ ];
350 474         514 push @{ $child_cache{$dirname} }, $entry;
  474         975  
351             }
352              
353 12         140 $self->{child_cache} = { %child_cache };
354             }
355              
356             =item parent($entry)
357              
358             Return an entry's parent, or undef if it's at the top level.
359              
360             =cut
361              
362             sub parent {
363 3     3 1 614 my $self = shift;
364 3         60 my ($entry) = @_;
365              
366             # Explode unless an entry actually exists in the archives:
367 3 100       8 croak("No such entry: $entry") unless $self->is_extant($entry);
368              
369 2         9 my (@components) = split '/', $entry;
370 2         4 pop @components;
371 2 100       6 if (@components) {
372 1         6 return join '/', @components;
373             }
374 1         3 return undef;
375             }
376              
377             =item children($entry)
378              
379             Return an entry's (immediate) children, if any.
380              
381             =cut
382              
383             sub children {
384 164     164 1 913 my $self = shift;
385 164         245 my ($entry) = @_;
386              
387             # Explode unless an entry actually exists in the archives:
388 164 50       315 croak("No such entry: $entry") unless $self->is_extant($entry);
389              
390 164 100       436 if (defined $self->{child_cache}{$entry}) {
391 133         162 return @{ $self->{child_cache}{$entry} };
  133         485  
392             }
393 31         74 return ();
394             }
395              
396             =item children_basenames($entry)
397              
398             Returns an entry's immediate children, but just basenames - not full paths.
399              
400             =cut
401              
402             sub children_basenames {
403 162     162 1 574 my $self = shift;
404 162         257 my ($entry) = @_;
405              
406 162         711 return map { $self->basename($_) } $self->children($entry);
  297         534  
407             }
408              
409             =item get_sub_entries($entry_loc)
410              
411             Returns "sub entries" based on the C regexp.
412              
413             =cut
414              
415             sub get_sub_entries {
416 161     161 1 694 my ($self, $entry) = @_;
417              
418             # index gets special treatment as the text body of an entry, rather
419             # than as a sub-entry:
420 161         363 my @subs = grep { m/$SUBENTRY_EXPR/ } $self->children_basenames($entry);
  293         1893  
421 161         371 return grep { ! $SUBENTRY_IGNORE{$_} } @subs;
  192         547  
422              
423             # return grep { ! $SUBENTRY_IGNORE{$_} }
424             # grep { m/$SUBENTRY_EXPR/ }
425             # $self->children_basenames($entry);
426             }
427              
428             =item previous($entry)
429              
430             Return the previous entry at the same depth for the given entry.
431              
432             =cut
433              
434             sub previous {
435 61     61 1 154 return $_[0]->{prev_dates}->{ $_[1] };
436             }
437              
438             =item next($entry)
439              
440             Return the next entry at the same depth for the given entry.
441              
442             =cut
443              
444             sub next {
445 61     61 1 785 return $_[0]->{next_dates}->{ $_[1] };
446             }
447              
448             =item by_prop($property)
449              
450             Return an array of any entries for the given property.
451              
452             =cut
453              
454             sub by_prop {
455 433     433 1 666 my ($self, $property) = @_;
456              
457 433         492 my @entries;
458 433 100       851 if (defined $self->{property_entries}{$property}) {
459 31         52 @entries = @{ $self->{property_entries}{$property} };
  31         84  
460             }
461              
462 433         728 return @entries;
463             }
464              
465             =item props_for($entry)
466              
467             Return an array of any properties for the given entry.
468              
469             =cut
470              
471             sub props_for {
472 216     216 1 363 my ($self, $entry) = @_;
473              
474 216         274 my @props;
475 216 100       527 if (defined $self->{entry_properties}{$entry}) {
476 54         79 @props = @{ $self->{entry_properties}{$entry} };
  54         138  
477             }
478              
479 216         563 return @props;
480             }
481              
482             =item has_prop($entry, $prop)
483              
484             Return 1 if the given entry has the given property.
485              
486             =cut
487              
488             sub has_prop {
489 13     13 1 45 my ($self, $entry, $prop) = @_;
490 13         40 my @props = grep { $_ eq $prop } $self->props_for($entry);
  2         8  
491 13         77 return (@props == 1);
492             }
493              
494             =item prop_value($entry, $prop)
495              
496             Return the value of given property, if it exists. Otherwise return undef.
497              
498             =cut
499              
500             sub prop_value {
501 2     2 1 1428 my ($self, $entry, $prop) = @_;
502 2 100       5 if ($self->has_prop($entry, $prop)) {
503             return file_get_contents(
504 1         9 $self->{entry_dir} . '/' . $entry . '/' . $prop . '.prop'
505             );
506             }
507 1         5 return undef;
508             }
509              
510             =item all_props()
511              
512             Return an array of all properties.
513              
514             =cut
515              
516             sub all_props {
517 2     2 1 5 my $self = shift;
518 2         5 return sort keys %{ $self->{property_entries} };
  2         15  
519             }
520              
521             =item is_extant($entry)
522              
523             Check if a given entry exists.
524              
525             =cut
526              
527             sub is_extant {
528 2777     2777 1 5082 my ($self, $entry) = @_;
529 2777         7453 return exists($self->{source_files}{$entry});
530             }
531              
532             =item is_dir($entry)
533              
534             Check if an entry is a directory.
535              
536             =cut
537              
538             sub is_dir {
539 277     277 1 810 my ($self, $entry) = @_;
540 277 50       472 croak("No such entry: $entry") unless $self->is_extant($entry);
541 277         813 return ($self->{source_files}{$entry} == $ENTRYTYPE_DIR);
542             }
543              
544             =item is_file($entry)
545              
546             Check if an entry is a flatfile.
547              
548             =cut
549              
550             sub is_file {
551 658     658 1 1178 my ($self, $entry) = @_;
552 658 50       1206 croak("No such entry: $entry") unless $self->is_extant($entry);
553 658         2278 return ($self->{source_files}{$entry} == $ENTRYTYPE_FILE);
554             }
555              
556             =item is_renderable($entry)
557              
558             Check if an entry path is, theoretically, renderable.
559              
560             =cut
561              
562             sub is_renderable {
563 74     74 1 126 my ($self, $entry) = @_;
564 74         532 return ($entry =~ $RENDERABLE_EXPR);
565             }
566              
567             =item has_index($entry)
568              
569             Check if an entry contains an index file.
570              
571             TODO: Should this care about the pathological (?) case where index is a
572             directory?
573              
574             =cut
575              
576             sub has_index {
577 222     222 1 362 my ($self, $entry) = @_;
578 222 50       363 croak("No such entry: $entry") unless $self->is_extant($entry);
579 222         577 return $self->is_extant($entry . '/index');
580             }
581              
582             =item basename($entry)
583              
584             Get a base name (i.e., filename without path) for a given entry.
585              
586             =cut
587              
588             sub basename {
589 370     370 1 597 my ($self, $entry) = @_;
590 370         886 my @parts = split '/', $entry;
591 370         1021 return pop @parts;
592             }
593              
594             =item dirname($entry)
595              
596             Get a directory name (i.e., directory without filename) for a given entry.
597              
598             =cut
599              
600             sub dirname {
601 474     474 1 676 my ($self, $entry) = @_;
602 474         850 my @parts = split '/', $entry;
603 474         551 pop @parts;
604 474         966 return join '/', @parts;
605             }
606              
607             =back
608              
609             =cut
610              
611             1;