File Coverage

blib/lib/Photography/Website.pm
Criterion Covered Total %
statement 45 179 25.1
branch 0 74 0.0
condition 0 18 0.0
subroutine 15 28 53.5
pod 10 12 83.3
total 70 311 22.5


line stmt bran cond sub pod time code
1             my $DIST = 'Photography-Website';
2             package Photography::Website;
3             $Photography::Website::VERSION = '0.25';
4 1     1   9202 use strict;
  1         1  
  1         26  
5 1     1   4 use warnings;
  1         1  
  1         23  
6 1     1   3 use feature 'say';
  1         3  
  1         66  
7              
8 1     1   312 use Photography::Website::Configure;
  1         2  
  1         36  
9 1     1   5 use DateTime;
  1         1  
  1         16  
10 1     1   4 use File::Path qw(make_path remove_tree);
  1         0  
  1         49  
11 1     1   4 use File::Basename qw(basename dirname);
  1         1  
  1         38  
12 1     1   4 use File::ShareDir qw(dist_dir);
  1         1  
  1         31  
13 1     1   4 use File::Spec::Functions qw(catfile catdir);
  1         1  
  1         32  
14 1     1   456 use File::Copy::Recursive qw(dircopy);
  1         4071  
  1         59  
15 1     1   514 use Image::Size qw(imgsize);
  1         3485  
  1         47  
16 1     1   1212 use Image::ExifTool qw(ImageInfo);
  1         34382  
  1         117  
17 1     1   6 use String::Random qw(random_regex);
  1         1  
  1         33  
18 1     1   437 use Algorithm::Numerical::Shuffle qw(shuffle);
  1         242  
  1         40  
19 1     1   281819 use Template; my $tt = Template->new({ABSOLUTE => 1});
  1         20710  
  1         1505  
20              
21             our $silent = 0;
22             our $verbose = 0;
23              
24             =head1 NAME
25              
26             Photography::Website - Photography Website Generator
27              
28             =head1 SYNOPSIS
29              
30             use Photography::Website;
31              
32             my $source = "$ENV{HOME}/Pictures";
33             my $destination = "$ENV{HOME}/public_html";
34              
35             # Process the pictures tree
36             my $website = Photography::Website::create_album($source);
37              
38             # Generate the website
39             Photography::Website::generate($website);
40              
41             =head1 DESCRIPTION
42              
43             The Photography::Website module contains the core of the Photog!
44             photography website generator. Please refer to L for a more
45             general introduction on how to run Photog! and how to configure
46             it. All of the configuration options are documented in
47             L. If you want to learn about the
48             internals of Photog!, read on.
49              
50             A photography website is generated in two stages. The first stage
51             searches the source directory tree for images and optional
52             C files, and processes them into a data structure of
53             nested albums. An album is simply a hash of configuration variables
54             one of which ($album->{items}) references a list of further
55             hashes. This stage is kicked off by the create_album() function.
56              
57             The second stage loops through this data structure, compares all the
58             sources with their destinations, and (re)generates them if needed. It
59             builds a website with nested album pages that contain image thubmnails
60             and album preview thumbnails. The structure of album pages mirrors the
61             structure of the source image directory. This process is started with
62             the generate() function.
63              
64             =head1 FUNCTIONS
65              
66             =over
67              
68             =item B(I<$source>[, I<$parent>])
69              
70             The main entry point for creating a website structure. $source should
71             be a directory name, $parent is only used when this function is called
72             recursively. Returns an album with nested sub-albums that represents
73             the source directory tree.
74              
75             =cut
76              
77             sub create_album {
78 0     0 1   my $source = shift;
79 0           my $parent = shift; # optional
80 0   0       my $album = Photography::Website::Configure::album($source, $parent) || return;
81              
82 0           for (list($source)) {
83 0           my $item;
84 0 0         if (-f) {
    0          
85 0   0       $item = Photography::Website::Configure::image($_, $album) || next;
86             }
87             elsif (-d) {
88 0   0       $item = create_album($_, $album) || next;
89             }
90 0           $album->{allfiles}->{$item->{destination}} = 1;
91 0           $album->{allfiles}->{$item->{thumbnail}} = 1;
92 0           push @{$album->{items}}, $item;
  0            
93             }
94 0           return $album;
95             }
96              
97             =item B(I<$album>)
98              
99             The second main entry point that generates the actual website images
100             and HTML files at the destinations specified inside the $album data
101             structure. Returns nothing.
102              
103             =cut
104              
105             sub generate {
106 0     0 1   my $album = shift;
107 0           my $outdated = 0;
108              
109             # Copy static files to destination root
110 0 0         if (not $album->{parent}) {
111 0           push @{$album->{protected}}, 'static';
  0            
112 0           my $static_source = catdir(dist_dir($DIST), 'static');
113 0           my $static_destination = catdir($album->{destination}, 'static');
114 0 0         dircopy($static_source, $static_destination) and say "/static/";
115             }
116              
117             # Recursively update image files and album pages
118 0           for my $item (@{$album->{items}}) {
  0            
119 0 0         if ($item->{type} eq 'image') {
    0          
120 0 0         if (update_image($item)) {
121 0           $outdated = 1;
122             }
123             }
124             elsif ($item->{type} eq 'album') {
125 0 0 0       if (generate($item, $album) and not $item->{unlisted}) {
126 0           $outdated = 1;
127             }
128             }
129             }
130              
131 0           return update_album($album, $outdated);
132             }
133              
134             =item B(I<$image>[, I<$force>])
135              
136             Given an $image node, checks if the image source is newer than the
137             destination. If needed, or if $force is true, it builds new
138             destination files. Returns true if any images have been (re)generated.
139              
140             =cut
141              
142             sub update_image {
143 0     0 1   my $img = shift;
144 0   0       my $update_needed = shift || (
145             not -f $img->{destination} or
146             not -f $img->{thumbnail} or
147             is_newer($img->{source}, $img->{destination})
148             );
149              
150 0 0         if ($update_needed) {
151 0           build_image($img);
152 0           return 1;
153             }
154             else {
155 0           return 0;
156             }
157             }
158              
159             =item B(I<$album>[, I<$force>])
160              
161             Given an $album node, first deletes any destination files that don't
162             have a corresponding source. Then it (re)builds the album's preview
163             and index if an update is needed or if $force is true. Returns true
164             if any changes have been made at the destination directory.
165              
166             =cut
167              
168             sub update_album {
169 0     0 1   my $album = shift;
170 0   0       my $update_needed = shift || ( # optional
171             not -f $album->{index} or
172             (not -f $album->{thumbnail} and not $album->{unlisted}) or
173             is_newer($album->{config}, $album->{index})
174             );
175              
176 0 0         if (not -d $album->{destination}) {
177 0           make_path($album->{destination});
178             }
179              
180             # Delete all destinations that do not appear in the allfiles hash, unless they are protected
181 0           for my $dest (list($album->{destination}), list(catdir($album->{destination}, 'thumbnails'))) {
182 0           my $file = basename($dest);
183 0 0         if (not exists $album->{allfiles}->{$dest}) {
184 0 0         if (not grep {$_ eq $file} @{$album->{protected}}) {
  0            
  0            
185 0 0         say "Removing $dest" unless $silent;
186 0           remove_tree($dest);
187 0           $update_needed = 1;
188             }
189             }
190             }
191              
192 0 0         if ($update_needed) {
193 0 0         build_preview($album) unless $album->{unlisted};
194 0           build_index($album);
195             }
196              
197 0           return $update_needed;
198             }
199              
200             =item B(I<$image>)
201              
202             Builds the image's destination files, by shelling out to the the
203             watermark or scale and thumbnail commands.
204              
205             =cut
206              
207             sub build_image {
208 0     0 1   my $img = shift;
209 0 0         say $img->{url} unless $silent;
210 0           make_path(dirname($img->{destination}));
211 0 0         if ($img->{watermark}) {
212 0 0         system($img->{watermark_command},
213             $img->{source},
214             $img->{watermark},
215             $img->{destination},
216             ) and die "ERROR: Watermark command failed\n";
217             }
218             else {
219 0 0         system($img->{scale_command},
220             $img->{source},
221             $img->{destination},
222             ) and die "ERROR: Scale command failed\n";
223             }
224 0           make_path(dirname($img->{thumbnail}));
225 0 0         system($img->{thumbnail_command},
226             $img->{source},
227             $img->{thumbnail},
228             ) and die "ERROR: Thumbnail command failed\n";
229             }
230              
231             =item B(I<$album>)
232              
233             Given an $album node, builds an album preview image and the album's
234             C after sorting the album's images according to Exif
235             dates.
236              
237             =cut
238              
239             sub build_index {
240 0     0 1   my $album = shift;
241              
242             # This defines a function named 'root' to be used in templates to
243             # calculate the relative pathname to the website root (which
244             # ensures that the website can be viewed by a browser locally)
245 0           my $rel = $album->{url};
246 0           $rel =~ s:[^/]+/:\.\./:g;
247 0           $rel =~ s:^/::;
248 0     0     $album->{root} = sub { $rel.$_[0] };
  0            
249              
250             # Calculate and store image sizes and dates
251 0           for (@{$album->{items}}) {
  0            
252 0           ($_->{width}, $_->{height}) = imgsize($_->{thumbnail});
253 0 0         if ($_->{type} eq 'image') {
254 0           $_->{date} = exifdate($_->{source});
255             }
256             }
257              
258 0 0         @{$album->{items}} = sort {
  0            
259 0           return $a->{date} cmp $b->{date} if $album->{sort} eq 'ascending';
260 0 0         return $b->{date} cmp $a->{date} if $album->{sort} eq 'descending';
261 0           } @{$album->{items}};
262              
263 0 0         if (not -f $album->{thumbnail}) {
264 0           $album->{unlisted} = 1;
265             }
266              
267 0           say $album->{url} . "index.html";
268 0 0         $tt->process($album->{template}, $album, $album->{index})
269             || die $tt->error();
270             }
271              
272             =item B(I<$album>)
273              
274             Creates an album preview image by making a random selection of the
275             album's images and calling the C command.
276              
277             =cut
278              
279             sub build_preview {
280 0     0 0   my $album = shift;
281              
282 0           my @images = select_images($album);
283 0           my $size = scalar @images;
284 0 0         if ($size < 3) {
    0          
285 0           say "WARNING: Not enough images available in '$album->{name}' to create a preview";
286 0           return;
287             }
288             elsif ($size < $album->{preview}) {
289 0 0         say "WARNING: Only $size preview images available for '$album->{name}' ($album->{preview} requested)" unless $silent;
290 0           $album->{preview} = $size;
291             }
292              
293             # Round the number of preview images down to 3, 6, or 9
294 0           $album->{preview}-- until grep {$_ == $album->{preview}} (3, 6, 9);
  0            
295              
296             # Shuffle the list and pick N preview images
297 0           @images = @{[shuffle @images]}[0..($album->{preview})-1];
  0            
298              
299 0           make_path(dirname($album->{thumbnail}));
300 0 0         system($album->{preview_command},
301             @images,
302             $album->{thumbnail},
303             ) and die "ERROR: Preview command failed\n";
304             }
305              
306             =item B(I<$album>)
307              
308             Returns a list of image paths that are eligible for inclusion in an
309             album preview. It makes sure that the list only contains images whose
310             filename does not appear in the parent album. The reason for this is
311             that the author of Photog! likes to show the best photographs from an
312             album on the front page, but not also have those photographs included
313             in an album preview.
314              
315             =cut
316              
317             sub select_images {
318 0     0 1   my $album = shift;
319 0 0         if ($album->{parent}) {
320              
321             # Read the following lines from end to beginning
322 0           my %excl = map {$_ => 1}
  0            
323 0           map {$_->{href}}
324 0           grep {$_->{type} eq 'image'}
325 0           @{$album->{parent}->{items}};
326              
327 0           return map {$_->{thumbnail}}
  0            
328 0           grep {not $excl{$_->{href}}}
329 0           grep { $_->{type} eq 'image' }
330 0           @{$album->{items}};
331             }
332             else {
333 0           return map {$_->{thumbnail}} @{$album->{items}};
  0            
  0            
334             }
335             }
336              
337             =item B(I<$dir>)
338              
339             Returns a list of absolute pathnames to all the files and directories
340             inside $dir.
341              
342             =cut
343              
344             sub list {
345 0     0 1   my $dir = shift;
346 0           my @files;
347             my @dirs;
348 0 0         opendir(my $dh, $dir) or return ();
349 0           while (readdir $dh) {
350 0 0         next if /^\./;
351 0 0         push @files, catfile($dir, $_) if -f catfile($dir,$_);
352 0 0         push @dirs, catdir($dir, $_) if -d catdir($dir, $_);
353             }
354 0     0 0   sub alphabetical { lc($a) cmp lc($b) }
355 0           @files = sort alphabetical @files;
356 0           @dirs = sort alphabetical @dirs;
357 0           return @dirs, @files;
358             }
359              
360             =item B(I<$file1>, I<$file2>)
361              
362             Determines the modification times of $file1 and $file2 (which should
363             pathnames). It both files exist and $file1 is newer than $file2, it
364             returns true. Beware: if both files are of the same age, $file1 is not
365             newer than $file2.
366              
367             =cut
368              
369             sub is_newer {
370 0     0 1   my $file1 = shift;
371 0           my $file2 = shift;
372 0 0 0       return unless -f $file1 and -f $file2;
373 0           my $time1 = (stat $file1)[9];
374 0           my $time2 = (stat $file2)[9];
375 0           return $time1 > $time2;
376             }
377              
378             =item B(I<$file>)
379              
380             Extracts the value of the Exif tag C from the
381             provided image path, converts it to ISO 8601 format, and returns
382             it. Prints a warning and returns 0 if the Exif tag could not be found.
383              
384             =cut
385              
386             sub exifdate {
387 0 0   0 1   my $file = shift or die;
388 0           my $exif = ImageInfo($file, 'DateTimeOriginal');
389 0 0         if (not $exif->{DateTimeOriginal}) {
390 0           say "WARNING: Exif tag 'DateTimeOriginal' missing from '$file'";
391 0           return 0;
392             }
393 0           my ($date, $time) = split(/ /, $exif->{DateTimeOriginal});
394 0           $date =~ s/:/-/g;
395 0           return $date . 'T' . $time;
396             }
397              
398             =back
399              
400             =head1 SEE ALSO
401              
402             L, L
403              
404             =head1 AUTHOR
405              
406             Photog! was written by Jaap Joris Vens , and is used to
407             create his personal photography website at L
408              
409             =cut
410              
411             1;