# Album Plugin: org/reorganizer
# For info:     'album -plugin_info org/reorganizer'
# For usage:    'album -plugin_usage org/reorganizer'
# For license:  'album -org/reorganizer:show_license'
use strict;
use File::Copy;
use File::Path;

my $LICENSE = << 'LICENSE';
Copyright (c) 2005-2006 Scott J. Bertin

Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
LICENSE

my $DESCRIPTION = << 'DESCRIPTION';
Have images stored in one place appear in albums elsewhere.

For the most basic usage, reorganizer will recursively copy the contents of
the specified source directory to the directory being processed by album.
It will copy images, captions, and thumbnails. Normally, this copying will
be done using hard links to reduce disk usage.

If any images have a .albums file (for example image.jpg and image.albums),
that file will be read and interpreted as a list of additional directories
(relative to the album directory) where the image should appear.

If the reorganizer:albums_only option is set, the recursive copy will be
skipped, and only .albums files will be consulted for where the images should
end up.

If the reorganizer:no_copy_images option is used, the images will be accessed
from the original location with no duplication. In this case, an image in
multiple albums will only have the thumbnail generated once (unless the 
reorganizer:no_common_thumbnails option is used).

It is mandatory to supply at least one source, or reorganizer will not do
anything.

If a source directory (or subdirectory) contains a .hide_album file, it will
not be duplicated, but it will still be processed for .albums files.

If a source directory (or subdirectory) contains a .no_album file, it will
not be processed at all.

The destination directory must exist before running album.

Images with a .no_img or .hide_album file will be ignored even if there is
a .albums file for that image.

No files will be overwritten unless the -force option is used.

This plugin uses many hooks, and modifies album's internal data structures.
Not all hooks are used for their intended purpose, so it should be loaded
as the first plugin if possible.
DESCRIPTION

my $EXAMPLES = << 'EXAMPLES';
The following examples assume that the original images are in a directory
called pics, and that the directory all the html will be produced in is web.

Duplicating an existing directory of images, leaving the original directory
unchanged (the destination directory must already exist):

    album -plugin org/reorganizer -reorganizer:source=pics web

Create an album with only medium and thumbnail images in the directory:

    album -medium 600x400 -just_medium -plugin org/reorganizer \
    -reorganizer:source=pics -reorganizer:no_copy_images \
    -reorganizer:no_common_thumbnails web

And if you don't have any other plugins that match 'reorg' then you can do:

    album -medium 600x400 -just_medium -plugin org/reorganizer \
    -reorg:source=pics -reorg:no_copy_images -reorg:no_common_thumbnails web

Create an album based on .albums files, leaving the images in pics, and
thumbnails in pics/tn. web will only contain html files:

    album -plugin org/reorganizer -reorg:source=pics \
    -reorg:albums_only -reorg:no_copy_images web

EXAMPLES

my @dir_info;
my $init_dir;
my @saved_sources;

sub start_plugin {
  my ($opt) = @_;

  # Setup the options
  album::add_option(1, 'show_license', \&show_license, one_time=>1,
                    usage=>'Show the license for this plugin.');
  album::add_option(1, 'examples', \&show_examples, one_time=>1,
                    usage=>'Show examples of how to use this plugin.');
  album::add_option(1, 'source', album::OPTION_ARR,
                    args=>'<dir>', usage=>'Source directories for images, may use wildcards.');
  album::add_option(1, 'copy_images', album::OPTION_BOOL, default=>1,
                    usage=>'Copy images to new directories.');
  album::add_option(1, 'use_albums', album::OPTION_BOOL, default=>1,
                    usage=>'Use .albums files to determine additional directories for images.');
  album::add_option(2, 'albums_only', album::OPTION_BOOL,
                    usage=>"Don't replicate source directories, just use .albums files.");
  album::add_option(2, 'hard_link', album::OPTION_BOOL, default=>1,
                    usage=>'Attempt to use hard links instead of copying.');
  album::add_option(2, 'never_overwrite', album::OPTION_BOOL,
                    usage=>'Never overwrite anything while copying (even if -force).');
  album::add_option(2, 'common_thumbnails', album::OPTION_BOOL, default=>1,
                    usage=>'All albums will share the same thumbnail (only if -no_copy_images).');
  album::add_option(10, 'albums', album::OPTION_STR, default=>'.albums',
                    usage=>'Extension for the file listing albums for an image.');

  # Setup the hooks
  album::hook($opt, 'gather_contents', \&gather_contents);
  album::hook($opt, 'end_album', \&cleanup);
  album::hook($opt, 'get_exif_caption', \&set_image_path);
  album::hook($opt, 'new_image_path', \&new_image_path);

  return {
    author => 'Scott J. Bertin',
    href => 'mailto://scottbertin@yahoo.com',
    version => '1.1',
    description => $DESCRIPTION,
  };
}

sub cleanup {
  my ($opt, $data, $hookname, $dir, $album) = @_;
  
  return unless $init_dir eq $dir;
  
  $#dir_info = -1;
  $#saved_sources = -1;
  undef $init_dir;
}

sub gather_contents {
  my($opt, $data, $hookname, $path, $album) = @_;
  
  do_init($opt, $data, $path);
  
  # Are we tracking anything in this directory?
  my $dir_index = find_dir_info($path);
  return if $dir_index < 0;
  
  # Add to the list of pics
  my $dir = $dir_info[$dir_index];
  foreach my $image (@{$dir->{images}}) {
    # Run the gather_contents_item plugin to be consistent with the normal
    # gather_contents procedure
    next if album::do_hook($opt,$data,'gather_contents_item', $dir, $image->{name});
    push @{$data->{pics}}, $image->{name};
  }

  if (album::option($opt, 'image_pages')) {
    # Make sure the directory exists for the image pages
    my $slash = album::option($opt, 'slash');
    my $tndir = album::option($opt, 'dir');
    my $tn = $path.$slash.$tndir;
    mkpath($tn) if ! -d $tn;
  }
  
  # Don't return 1, normal gather_contents code should still run.
  return;
}

# This needs to be done after read_captions, or it may be clobbered.
# This needs to be done before get_exif_caption, or exif info won't be found.
sub set_image_path {
  my ($opt, $data, $hookname, $pic) = @_;
  
  # Are we tracking anything in this directory?
  my $dir_index = find_dir_info($data->{paths}{dir});
  return undef if $dir_index < 0;
  
  # Add the new image info
  foreach my $image (@{$dir_info[$dir_index]->{images}}) {
    if ($image->{name} eq $pic) {
      $data->{obj}{$pic}{full}{file} = $pic;
      $data->{obj}{$pic}{full}{path} = $image->{path};
    }
  }
  
  return undef;
}

sub new_image_path {
  my ($opt, $hookname, $dir, $pic, $type, $postfix) = @_;
  
  return undef unless album::option($opt, 'common_thumbnails');
  
  # Are we tracking anything in this directory?
  my $dir_index = find_dir_info($dir);
  return undef if $dir_index < 0;
  
  # Add the postfix and possibly new extension
  my $basename = $pic;
  my $ext = "";
  my $dot_index = rindex($pic, ".");
  if ($dot_index) {
    $basename = substr($pic,0,$dot_index);
    $ext = substr($pic, $dot_index+1);
  }
  my $file = "$basename.$postfix$ext";
  $file .= ".$type" if $type && lc($type) ne lc($ext);
  
  # Find where the image should be created
  foreach my $image (@{$dir_info[$dir_index]->{images}}) {
    return ($file, $image->{tn}.$file) if $image->{name} eq $pic;
  }
  
  return undef;
}

sub do_init {
  my($opt, $data, $dir) = @_;

  $init_dir = $dir unless $init_dir;
  
  # Check the options
  my $sources = get_new_sources($opt, $dir);
  my $albums_only = album::option($opt, 'albums_only');
  
  # Gather the image and directory information
  foreach my $source (@$sources) {
    dup_dir($opt, $dir, $source, "", $albums_only, 1);
  }
}

sub get_new_sources {
  my ($opt, $dir) = @_;
  
  my $all_sources = album::option($opt, 'source');
  my @new_sources;
  my $new_seen;
  for(my $i=0; $i<=$#$all_sources; $i++) {
    $new_seen ||= $i>$#saved_sources || $$all_sources[$i] ne $saved_sources[$i];
    push(@new_sources, $$all_sources[$i]) if $new_seen;
  }
  
  @saved_sources = @$all_sources;
  return glob_paths($opt, $dir, \@new_sources);
}

sub read_file {
  my ($f) = @_;
  return undef unless $f;
  return undef unless (-r $f);
  return undef unless (open(FILE,"<$f"));
  my @contents;
  while(<FILE>) {
    # Strip leading and trailing whitespace
    s/^\s+//; s/\s+$//;
    # Skip blank lines
    push(@contents,$_) unless length($_) == 0;
  }
  close FILE;
  return @contents;
}

sub write_file {
  my ($f, $lines) = @_;
  return unless $f;
  return unless (open(FILE,">$f"));
  foreach (@$lines) {
    print FILE "$_\n";
  }
  close FILE;
}

sub get_files {
  my ($dir) = @_;
  return undef unless $dir;
  return undef unless (-d $dir);
  return undef unless opendir(DIR,"$dir");
  my @dir = readdir(DIR);
  closedir(DIR);
  return @dir;
}

sub find_image_file {
  my ($opt, $all_files, $albums_file, $captions) = @_;

  # Get the base name of the image file
  my $albums = album::option($opt, 'albums');
  my $base = $albums_file;
  $base =~ s/\Q$albums\E$//;
  
  # Find any matching files
  my @matches = grep(/^\Q$base\E\.[^\.]*$/, @$all_files);
  
  # Check if this file should be skipped
  foreach my $skip_opt ('not_img', 'no_album', 'hide_album') {
    my $skip_ext = album::option($opt, $skip_opt);
    return undef if $skip_ext && grep(/^\Q$base\E.*\Q$skip_ext\E$/, @$all_files);
  }
  
  # Find matching images
  @matches = grep(album::is_image($opt, $_), @matches);
  
  return undef if scalar(@matches) == 0;
  
  # Find the shortest match. This should eliminate the possibility of
  # matching the wrong file.
  my $match_found = $matches[0];
  for(my $i=1; $i<=$#matches; $i++) {
    $match_found = $matches[$i] if length($matches[$i]) < length($match_found);
  }
  
  # Does captions file say to ignore this image?
  return undef if $match_found && grep(/^#\s*\Q$match_found\E\s/, @$captions);
  
  return $match_found;
}

sub find_dir_info {
  my ($dir) = @_;
  
  for my $i (0..$#dir_info) {
    return $i if $dir_info[$i]->{dir} eq $dir;
  }
  return -1;
}

# Get an absolute path even if it doesn't exist yet
sub normalize_path {
  my ($opt, $path) = @_;
  
  my $slash = album::option($opt, 'slash');
  
  # Make sure we're starting with an absolute path, not a relative path
  my $drive = "";
  if (album::option($opt, 'windows') && !album::option($opt, 'cygwin') &&
     substr($path,1,1) eq ":") {
    
    $drive = substr($path,0,2);
    $path = substr($path,2);
    if (index($path, $slash) != 0) {
      $path = album::port_abs_path($opt,$drive.".").$slash.$path;
    }
  } elsif (index($path, $slash) != 0) {
    $path = album::port_abs_path($opt,".").$slash.$path;
  }
  
  # Handle any . or .. components in the path
  my @parts;
  foreach my $orig_part (split $slash, $path) {
    if ($orig_part eq "..") {
      pop @parts;
      next;
    }
    next if $orig_part eq ".";
    next if $orig_part eq "";
    push @parts, $orig_part;
  }
  
  $parts[0] = $drive.$slash.$parts[0];
  
  return join album::option($opt, 'slash'), @parts;
}

# Copy, link, or ignore a file as appropriate
sub do_copy {
  my ($opt, $source, $target) = @_;
  
  my $hard_link = album::option($opt, 'hard_link');
  my $overwrite = album::option($opt, 'force') &&
                  !album::option($opt, 'never_overwrite');
  
  return unless -r $source;
  return if -e $target && !$overwrite;
  
  unlink $target if -e $target;
  
  return if $hard_link && link($source, $target);
  copy($source, $target);
}

# Copy a caption from one caption file to another
sub caption_copy {
  my ($opt, $image, $src, $dest, $captions) = @_;

  # Check the options
  my $slash = album::option($opt, 'slash');
  my $caption_file_name = album::option($opt, 'captions');
  
  mkpath($dest);
  $src .= $slash;
  $dest .= $slash;
  
  # Copy the entry in the captions file
  my @caption_lines = grep(/^\Q$image\E *(\t|::|$)/, @$captions);
  my $caption_line = $caption_lines[0];
  if ($caption_line) {
    my $overwrite = album::option($opt, 'force')
                && !album::option($opt, 'never_overwrite');
    
    # Check for an existing line in the caption file
    @caption_lines = read_file($dest.$caption_file_name);
    my $found = 0;
    for (my $i=0; !$found && $i<=$#caption_lines; $i++) {
      $found = $caption_lines[$i] =~ /^\Q$image\E *(\t|::|$)/;
      $caption_lines[$i] = $caption_line if $found;
    }
    
    # Add the caption if it didn't already exist
    push @caption_lines, $caption_line if !$found;
    
    # Write the replacement caption file
    write_file($dest.$caption_file_name, \@caption_lines)
      if !$found || $overwrite;
  }
}

# Copy the thumbnail and medium image files
sub thumbnail_copy {
  my ($opt, $image, $src, $dest) = @_;
  
  # Check the options
  my $slash = album::option($opt, 'slash');
  
  mkpath($dest);
  $src .= $slash;
  $dest .= $slash;
  
  # Copy the thumbnail
  my $is_movie = album::is_movie($opt, $image);
  my $type = album::option($opt, 'type');
  my @post = $is_movie ? ('snap.','snap.') : ('tn.','');
  my ($srcfile, $srcpath) =
       album::new_image_path($opt, $src, $image, $type, @post);
  my ($destfile, $destpath) =
       album::new_image_path($opt, $dest, $image, $type, @post);
  do_copy($opt, $srcpath, $destpath);

  # Copy the medium image
  if (album::option($opt, 'medium')) {
    $type = album::option($opt, 'medium_type');
    $type = album::option($opt, 'type') if $is_movie && !$type;
    ($srcfile, $srcpath) =
      album::new_image_path($opt, $src, $image, $type, 'med.', 'med.');
    ($destfile, $destpath) =
      album::new_image_path($opt, $dest, $image, $type, 'med.', 'med.');
    do_copy($opt, $srcpath, $destpath);
  }
}

# Copy the image, associated files, and caption
sub image_copy {
  my ($opt, $image, $src, $dest, $captions) = @_;
  
  # Check the options
  my $slash = album::option($opt, 'slash');
  
  mkpath($dest);
  $src .= $slash;
  $dest .= $slash;
  
  my $basename = $image;
  $basename =~ s/^(.+)\.[^\.]+$/\1/;
  
  # Copy the image and any associated files
  my @files = grep(/^\Q$basename\E\.[^\.]+$/, get_files($src));
  foreach my $file (@files) {
    do_copy($opt, "$src$file", "$dest$file");
  }
  thumbnail_copy($opt, $image, $src, $dest);
  caption_copy($opt, $image, $src, $dest, $captions);
}

# Perform filename globbing on paths (possible relative to $dir).
# Returns a list of full paths for valid directories found.
sub glob_paths {
  my ($opt, $dir, $paths) = @_;
  
  my $slash = album::option($opt, 'slash');
  my @globbed_paths;
  
  foreach my $path (@$paths) {
    my $found = 0;
    foreach my $gp (glob($path)) {
      $found = 1 if -e $gp;
      push @globbed_paths, album::port_abs_path($opt, $gp) if -d $gp;
    }
    if (!$found) {
      foreach my $gp (glob($dir.$slash.$path)) {
        push @globbed_paths, album::port_abs_path($opt, $gp) if -d $gp;
      }
    }
  }
  
  return \@globbed_paths;
}

sub image_track {
  my ($opt, $image, $src, $dest, $captions) = @_;
  
  # Check the options
  my $slash = album::option($opt, 'slash');
  my $tndir = album::option($opt, 'dir');
  my $common_thumbnails = album::option($opt, 'common_thumbnails') &&
                          !album::option($opt, 'copy_images');
  
  # If this already exists in the target directory, don't mess with it.
  return if -e $dest.$slash.$image;

  # Setup the info for this image
  my $image_info = {name=>$image, path=>$src.$slash.$image};
  my $tn = normalize_path($opt, $src.$slash.$tndir).$slash;
  $image_info->{tn} = $tn if $common_thumbnails;

  # Find the directory for tracking
  my $dir_index = find_dir_info($dest);
  if ($dir_index < 0) {
    mkpath($dest);
    push @dir_info, {dir=>$dest, images=>()};
    $dir_index = $#dir_info;
  } else {
    # Make sure this image isn't already tracked in this directory
    return if grep($_->{name} eq $image,
                   @{$dir_info[$dir_index]->{images}});
  }

  # Add this image to the list
  push @{$dir_info[$dir_index]->{images}}, $image_info;

  # Copy the caption
  caption_copy($opt, $image, $src, $dest, $captions);

  # Copy the thumbnail image if necessary
  mkpath($tn) if $common_thumbnails && ! -d $tn;
  thumbnail_copy($opt, $image, $src, $dest) if !$common_thumbnails;
}

sub copy_or_track_image {
 my ($opt, $image, $src, $dest, $captions) = @_;

  if (album::option($opt, 'copy_images')) {
    image_copy($opt, $image, $src, $dest, $captions);
  } else {
    image_track($opt, $image, $src, $dest, $captions);
  }
}

sub dup_dir {
  my($opt, $top_dir, $source_top, $subdir, $albums_only, $depth) = @_;

  # Check the options
  my $max_depth = album::option($opt, 'depth');
  my $slash = album::option($opt, 'slash');
  my $caption_file = album::option($opt, 'captions');
  my $use_albums = album::option($opt, 'use_albums');
  my $hide_album = album::option($opt, 'hide_album');
  my $no_album = album::option($opt, 'no_album');
  my $tndir = album::option($opt, 'dir');
  my $albums = album::option($opt, 'albums');
  
  return if $max_depth > 0 && $depth > $max_depth;
  
  # Gather the image and directory information
  my $source = $source_top.$subdir;
  my @files = get_files($source);
  return unless scalar(@files) > 0;

  return if grep(/^\Q$no_album\E$/, @files);
  
  if (!$albums_only && grep(/^\Q$hide_album\E$/, @files)) {
    $albums_only = 1;
    return if !$use_albums;
  }

  my $dir = $init_dir.$subdir;
  my @captions = read_file($source.$slash.$caption_file) if $caption_file;

  # Process each .albums file
  foreach my $file (@files) {
    next if $file =~ /^\./;
    my $file_path = $source.$slash.$file;
    
    if (-d $file_path) {
      dup_dir($opt, $top_dir, $source_top, $subdir.$slash.$file,
              $albums_only, $depth+1) if $file ne $tndir;
      next;
    }
    
    if (!$albums_only && album::is_image($opt, $file_path)) {
      copy_or_track_image($opt, $file, $source, $dir, \@captions);
      next;
    }
    
    next unless $use_albums;
    next unless $file =~ /.\Q$albums\E$/;
    
    my $image = find_image_file($opt, \@files, $file, \@captions);
    next unless $image;
    my $image_path = $source.$slash.$image;
    
    my @lines = read_file($source.$slash.$file);
    next unless scalar(@lines);

    # Handle additional albums
    foreach my $line (@lines) {
      copy_or_track_image($opt, $image, $source,
                          normalize_path($opt, $top_dir.$slash.$line),
                          \@captions);
    }
  }
}

sub show_license {
  my ($opt) = @_;
  
	my $me = album::curr_plugin($opt);
  print "The following license applies ONLY to the ",
        "$me plugin, and should not be construed ",
        "to apply to album, or any other plugin.\n\n", $LICENSE;
  exit;
}

sub show_examples {
  my ($opt) = @_;
  
  print $EXAMPLES;
  exit;
}

# Plugins always end with:
1;
