File Coverage

blib/lib/Photography/Website.pm
Criterion Covered Total %
statement 45 183 24.5
branch 0 84 0.0
condition 0 21 0.0
subroutine 15 28 53.5
pod 10 12 83.3
total 70 328 21.3


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