view mgtool.php @ 348:596196f2b0c5 default tip

Improve relative URL translation in header text blobs.
author Matti Hamalainen <ccr@tnsp.org>
date Wed, 20 Dec 2023 09:17:55 +0200
parents 9fbec6399cdd
children
line wrap: on
line source

#!/usr/bin/php
<?php
//
// Yet Another Image Gallery
// -- Commandline tool for creating / updating gallery
// Programmed and designed by Matti 'ccr' Hamalainen <ccr@tnsp.org>
// (C) Copyright 2015-2023 Tecnic Software productions (TNSP)
//
require_once "mgallery.inc.php";

//
// Operating modes and update flags
//
define("GCMD_UPDATE"      , 1);
define("GCMD_RESCAN"      , 2);
define("GCMD_CLEAN"       , 3);

define("GCLEAN_CACHES"    , 0x01);
define("GCLEAN_IMAGES"    , 0x02);
define("GCLEAN_TRASH"     , 0x04);
define("GCLEAN_ALL"       , 0x0f);

define("GUPD_MED_IMAGE"   , 0x01);
define("GUPD_TN_IMAGE"    , 0x02);
define("GUPD_IMAGES"      , 0x0f);
define("GUPD_EXIF_INFO"   , 0x10);
define("GUPD_CAPTION"     , 0x20);


//
// Array for specifying what will be copied and converted
// from the image file's EXIF information tag(s).
//
define("GEC_TYPE"         , 0);
define("GEC_NAME"         , 1);
define("GEC_FIELDS"       , 2);
define("GEC_IS_UNIQUE"    , 3);

$galExifConversions =
[
  [ MG_STR,   "caption"     , "ImageDescription"        , TRUE ],
  [ MG_STR,   "copyright"   , "Copyright"               , FALSE ],
  [ MG_STR,   "model"       , "Model"                   , FALSE ],
  [ MG_STR,   "lensmodel"   , "UndefinedTag:0xA434"     , FALSE ],
  [ MG_INT,   "width"       , ["COMPUTED", "Width"]     , TRUE ],
  [ MG_INT,   "height"      , ["COMPUTED", "Height"]    , TRUE ],
  [ MG_DVA,   "fnumber"     , "FNumber"                 , TRUE ],
  [ MG_DVA,   "exposure"    , "ExposureTime"            , TRUE ],
  [ MG_INT,   "iso"         , "ISOSpeedRatings"         , TRUE ],
  [ MG_DVA,   "focallength" , "FocalLength"             , TRUE ],
  [ MG_DATE,  "datetime"    , "DateTimeOriginal"        , TRUE ],
  [ MG_DATE,  "datetime"    , "DateTimeDigitized"       , TRUE ],
  [ MG_INT,   "filesize"    , "FileSize"                , TRUE ],
  [ MG_STR,   "keywords"    , "keywords"                , FALSE ],
  [ MG_STR,   "title"       , "title"                   , TRUE ],
];


//
// Convert and scale image file function, for generating
// the intermediate size images and thumbnails. Uses the
// PHP ImageMagick or GraphicsMagick bindings.
//
function mgConvertImage($inFilename, $outFilename, $outDim, $outFormat, $outQuality, $thumb)
{
  if (extension_loaded("imagick"))
  {
    // Create conversion entity
    try
    {
      $img = new Imagick($inFilename);
    }
    catch (Exception $e)
    {
      return mgError("ImageMagick exception for file '".$inFilename."':\n".$e->getMessage()."\n");
    }

    if ($img === FALSE)
      return mgError("ImageMagick could not digest '".$inFilename."'.\n");

    $profiles = $img->getImageProfiles("icc", true);
    $img->setImageDepth(16);
    $img->transformImageColorspace(Imagick::COLORSPACE_SRGB);
    $img->setImageColorspace(Imagick::COLORSPACE_SRGB);

    if ($outDim !== FALSE)
    {
      // Get dimensions, setup background
      $dim = $img->getImageGeometry();
      $img->setGravity(imagick::GRAVITY_CENTER);

      // Width smaller than height? Swap dimensions
      if ($dim["width"] < $dim["height"])
      {
        $stmp = $outDim[0];
        $outDim[0] = $outDim[1];
        $outDim[1] = $stmp;
      }

      // Compute new height if needed
      if ($dim["width"] != $outDim[0] || $dim["height"] != $outDim[1])
      {
        $outDim[1] = ($dim["height"] * $outDim[0]) / $dim["width"];
      }

      // Act based on image size vs. desired size and $thumb mode
      if ($thumb || $dim["width"] != $outDim[0] || $dim["height"] != $outDim[1])
      {
        $img->resizeImage($outDim[0], $outDim[1], Imagick::FILTER_LANCZOS, 1);
        $img->setImageExtent($outDim[0], $outDim[1]);
        $img->unsharpMaskImage(0, 0.5, 1, 0.05);
      }
    }

    $img->setImageDepth(8);
    switch ($outFormat)
    {
      case "jpeg":
        $img->setFormat("JPEG");
        $img->setImageCompression(Imagick::COMPRESSION_JPEG);
        break;

      case "webp":
        $img->setFormat("WEBP");
        $img->setOption('webp:method', '6');
        break;

       default:
        return mgError("Unsupported MGallery med/tn format '".$outFormat."'.\n");
    }

    $img->setImageCompressionQuality($outQuality);

    $img->stripImage();
    if (!empty($profiles))
      $img->profileImage("icc", $profiles["icc"]);

    $img->writeImage($outFilename);
    $img->removeImage();
  }
  else
  if (extension_loaded("gmagick"))
  {
    // Create conversion entity
    try
    {
      $img = new Gmagick($inFilename);
    }
    catch (Exception $e)
    {
      return mgError("GraphicsMagick exception for file '".$inFilename."':\n".$e->getMessage()."\n");
    }

    if ($img === FALSE)
      return mgError("GraphicsMagick could not digest '".$inFilename."'.\n");

    //$profiles = $img->getImageProfile("icc");
    //$img->setImageDepth(16);
    //$img->setImageColorspace(Gmagick::COLORSPACE_SRGB);

    if ($outDim !== FALSE)
    {
      // Get dimensions, setup background
      $dim = $img->getImageGeometry();
      //$img->setGravity(Gmagick::GRAVITY_CENTER);

      // Compute new height if needed
      if ($dim["width"] < $dim["height"])
      {
        $stmp = $outDim[0];
        $outDim[0] = $outDim[1];
        $outDim[1] = $stmp;
      }

      // Compute new height if needed
      if ($dim["width"] != $outDim[0] || $dim["height"] != $outDim[1])
      {
        $outDim[1] = ($dim["height"] * $outDim[0]) / $dim["width"];
      }

      // Act based on image size vs. desired size and $thumb mode
      if ($thumb || $dim["width"] != $outDim[0] || $dim["height"] != $outDim[1])
      {
        $img->resizeImage($outDim[0], $outDim[1], Gmagick::FILTER_LANCZOS, 1);
        $img->cropImage($outDim[0], $outDim[1], 0, 0);
        $img->unsharpMaskImage(0, 0.5, 1, 0.05);
      }
    }

    $img->setImageDepth(8);
    switch ($outFormat)
    {
      case "jpeg":
        $img->setFormat("JPEG");
        $img->setImageCompression(Gmagick::COMPRESSION_JPEG);
        break;

      case "webp":
        $img->setFormat("WEBP");
        //$img->setOption('webp:method', '6');
        break;

       default:
        return mgError("Unsupported MGallery med/tn format '".$outFormat."'.\n");
    }

    $img->setCompressionQuality($outQuality);

    $img->stripImage();
    //if (!empty($profiles))
    //  $img->profileImage("icc", $profiles);

    $img->writeImage($outFilename);
    $img->destroy();
  }

  return TRUE;
}


//
// Read EXIF and XMP data from a file
//
// TODO XXX: Perhaps support XMP sidecar files?
//
function mgReadEXIFAndXMPData($filename, &$exif, &$xmp)
{
  $exif = FALSE;
  $xmp = FALSE;

  if (($fh = @fopen($filename, 'rb')) === FALSE)
    return "Could not open file for reading.";

  $fileData = fstat($fh);

  // Probe the file for type
  $probeSize = 4 * 3;
  if (($probeData = @fread($fh, $probeSize)) === FALSE)
    return "Error reading file for type probe";

  $probe = unpack("C4magic/L1riffsize/c4riffid", $probeData);

  // Check for RIFF / WEBP
  if ($probe["magic1"] == 0x52 && $probe["magic2"] == 0x49 &&
      $probe["magic3"] == 0x46 && $probe["magic4"] == 0x46 &&
      $probe["riffid1"] == 0x57 && $probe["riffid2"] == 0x45 &&
      $probe["riffid3"] == 0x42 && $probe["riffid4"] == 0x50)
  {
    if ($probe["riffsize"] > $fileData["size"])
      return "Invalid WebP file, chunk size larger than file size";

    $done = 0;
    while (!feof($fh) && ftell($fh) < $fileData["size"] && $done < 2)
    {
      // Read chunk header
      if (($data = @fread($fh, 2 * 4)) == FALSE)
        return "File read error in WebP RIFF chunk header";

      $chunk = unpack("c4id/L1size", $data);

      mgDebug(sprintf("WebP chunk: '%c%c%c%c' (%02x %02x %02x %02x) size=%d\n",
        $chunk["id1"], $chunk["id2"], $chunk["id3"], $chunk["id4"],
        $chunk["id1"], $chunk["id2"], $chunk["id3"], $chunk["id4"],
        $chunk["size"]));

      // Check for EXIF chunk
      if ($chunk["id1"] == 0x45 && $chunk["id2"] == 0x58 &&
          $chunk["id3"] == 0x49 && $chunk["id4"] == 0x46)
      {
        // This is an incredibly stupid hack to work around the
        // fact that PHP's exif_read_data() wants to seek to stream
        // start and probe things .. if we just had a direct parser
        // function we would not need this shit.
        if (($tmpEXIF = @fread($fh, $chunk["size"])) === FALSE)
          return "Error reading WebP EXIF chunk";

        // Create a temporary file for the EXIF data
        if (($tmpFile = @tmpfile()) === FALSE)
          return "Could not create temporary WebP EXIF data file";

        if ((@fwrite($tmpFile, $tmpEXIF, $chunk["size"])) === FALSE)
        {
          fclose($tmpFile);
          return "Error writing WebP EXIF chunk to temporary file";
        }

        // Parse the EXIF from the temp file
        $exif = @exif_read_data($tmpFile);
        fclose($tmpFile);

        // We need to fix the FileSize data as size comes from the temp file
        if (is_array($exif))
        {
          $exif["FileSize"] = $fileData["size"];
        }

        $done++;
      }
      else
      if ($chunk["id1"] == 0x58 && $chunk["id2"] == 0x4d &&
          $chunk["id3"] == 0x50 && $chunk["id4"] == 0x20)
      {
        // Read and parse XMP data chunk
        if (($xmpStr = fread($fh, $chunk["size"])) === FALSE)
          return "File read error in XMP data read";

        $xmp = mgParseXMPData($xmpStr);

        $done++;
      }
      else
      {
        // Skip other chunks
        if (fseek($fh, $chunk["size"], SEEK_CUR) < 0)
          return "File seek error in chunk data skip";
      }

      // If the chunk size is not aligned, skip one byte
      if ($chunk["size"] & 1)
      {
        if (fseek($fh, 1, SEEK_CUR) < 0)
          return "File seek error in chunk alignment skip";
      }
    }

    return TRUE;
  }
  else
  {
    // Other fileformats, e.g. JPEG, PNG, GIF, ..
    mgDebug("Falling back to generic XMP/EXIF read.\n");

    // Read EXIF ..
    if (fseek($fh, 0, SEEK_SET) < 0)
      return "File seek error in EXIF fptr restore";

    $exif = @exif_read_data($fh);

    if (fseek($fh, 0, SEEK_SET) < 0)
      return "File seek error in EXIF fptr restore";

    // Read XMP data block from the file .. it's a horrible hack.
    $xmpStartTag = "<x:xmpmeta";
    $xmpEndTag = "</x:xmpmeta>";
    $xmpBlockSize = 64 * 1024;

    // Check for start tag
    $buffer = "";
    $xmpOK = FALSE;
    while (!feof($fh))
    {
      if (($tmp = fread($fh, $xmpBlockSize)) === FALSE)
        return "File read error in JPEG XMP read";

      $buffer .= $tmp;
      if (($spos1 = strpos($buffer, "<")) !== FALSE)
      {
        $buffer = substr($buffer, $spos1);
        if (($spos2 = strpos($buffer, $xmpStartTag)) !== FALSE)
        {
          $buffer = substr($buffer, $spos2);
          $xmpOK = TRUE;
          break;
        }
      }
      else
        $buffer = "";
    }

    // Check for end tag if start tag was found
    if ($xmpOK)
    {
      $xmpOK = FALSE;
      $buffer2 = $buffer;
      do
      {
        if (($spos1 = strpos($buffer2, "<")) !== FALSE)
        {
          $buffer2 = substr($buffer2, $spos1);
          if (($spos2 = strpos($buffer2, $xmpEndTag)) !== FALSE)
          {
            $xmpOK = TRUE;
            break;
          }
        }

        if (($tmp = @fread($fh, $xmpBlockSize)) !== FALSE)
        {
          $buffer2 .= $tmp;
          $buffer .= $tmp;
        }
        else
        {
          $xmpOK = FALSE;
          break;
        }
      } while (!$xmpOK);

      if ($xmpOK)
      {
        if (($spos = strpos($buffer, $xmpEndTag)) !== FALSE)
          $buffer = substr($buffer, 0, $spos + strlen($xmpEndTag));
        else
          $xmpOK = FALSE;
      }
    }

    $xmp = mgParseXMPData($buffer);

    return TRUE;
  }
}


function mgConvertAttribute(&$dst, &$src, $dstName, $srcName)
{
  if (isset($src[$srcName]))
    $dst[$dstName] = (string) $src[$srcName];
}


function mgParseXMPData($xmpStr)
{
  // SimpleXML apparently can't handle namespaces,
  // so we will crudely remove them with some regexes
  $xmpPatterns =
  [
    "/<\?xpacket\s+.*?\?>/",
    "/[a-zA-Z]+:/",
    "/\/[a-zA-Z]+:/",
    "/<\/?(Bag|Alt|Seq)>/"
  ];

  $xmpReplacements =
  [
    "",
    "",
    "\/",
    "",
  ];

  $xmpStr = preg_replace($xmpPatterns, $xmpReplacements, $xmpStr);

  // Parse XML to a SimpleXMLElement structure
  if (($xmpOb = @simplexml_load_string($xmpStr)) === FALSE)
    return FALSE;

  // Process structure to simple flat array of data for the desired elements only
  $xmpData = [];

  /*
  // We get these from EXIF, or at least we should .. also EXIF contains information
  // that XMP does not have, for some reason (like width/height, ISO, etc.)
  // Leaving this code here just as an example.
  if (($xres = $xmpOb->xpath("RDF/Description")) !== FALSE)
  {
    $tmp = $xres[0]->attributes();
    mgConvertAttribute($xmpData, $tmp, "lensmodel", "Lens");
    mgConvertAttribute($xmpData, $tmp, "datetime", "CreateDate");
  }

  if (($xres = $xmpOb->xpath("RDF/Description/description/li")) !== FALSE)
    $xmpData["description"] = implode(" ", array_map(function($xe) { return (string) $xe; }, $xres));
  */

  // What EXIF does NOT have are the keywords .. so grab them.
  if (($xres = $xmpOb->xpath("RDF/Description/subject/li")) !== FALSE)
    $xmpData["keywords"] = array_map(function($xkw) { return (string) $xkw; }, $xres);

  // Get image title
  if (($xres = $xmpOb->xpath("RDF/Description/title/li")) !== FALSE)
    $xmpData["title"] = implode(" ", array_map(function($xe) { return (string) $xe; }, $xres));

  return $xmpData;
}


//
// Converts one value (mainly from EXIF tag information)
// by doing explicit type casting and special conversions.
//
function mgConvertExifData($val, $vtype)
{
  switch ($vtype)
  {
    case MG_STR: return is_array($val) ? $val : (string) $val;
    case MG_INT: return intval($val);
    case MG_BOOL: return intval($val);
    case MG_DVA:
      if (sscanf($val, "%d/%d", $v1, $v2) == 2 && $v2 != 0 && $v1 != 0)
      {
        if ($v1 < $v2)
          return sprintf("1/%1.1f", $v2 / $v1);
        else
          return sprintf("%1.1f", $v1 / $v2);
      }
      else
        return $val;

    case MG_DATE:
      return date_create_from_format("Y:m:d H:i:s", $val);

    default:
      return $val;
  }
}


//
// Conditionally copies one "field" from an associated array/hash to another.
// If destination is already SET, nothing will be done. If source does
// not exist (e.g. one or more of the keys do not exist in source array),
// a default value will be used, if provided.
// Source may have multi-depth keys, destination has one key.
//
function mgCopyEntryData(&$dst, $src, $vtype, $dkey, $skeys, $default = NULL)
{
  // Is destination already set?
  if (isset($dst[$dkey]))
    return FALSE;

  // If input key is not array, change it into one
  if (!is_array($skeys))
    $skeys = array($skeys);

  // Traverse input array by using consequent keys
  $tmp = &$src;
  foreach ($skeys as $skey)
  {
    if (!array_key_exists($skey, $tmp))
    {
      // Key didn't exist, try for default
      if ($default !== NULL)
        $dst[$dkey] = $default;

      return FALSE;
    }
    else
      $tmp = &$tmp[$skey];
  }

  // Optionally convert the input value
  $dst[$dkey] = mgConvertExifData($tmp, $vtype);

  return TRUE;
}


//
// Attempt to get gallery album data from various sources.
//
function mgGetAlbumData($galBasePath, $galPath)
{
  global $mgAlbumDefaults;

  // Check path permissions
  $galData = [];
  if (is_readable($galPath))
  {
    // First, try to read gallery/album info file
    $filename = mgGetPath($galPath, "info_file");
    if ($filename !== FALSE && file_exists($filename))
    {
      mgDebug("Reading INFOFILE: ".$filename."\n");
      if (($galData = parse_ini_file($filename, FALSE)) === FALSE)
      {
        $galData = [];
      }
      else
      {
        // Sanity check settings
        foreach ($galData as $key => $data)
        {
          if (array_key_exists($key, $mgAlbumDefaults))
            mgParseConfigSetting($galData, $mgAlbumDefaults, $key, $galData[$key]);
          else
            mgFatal("Invalid setting '".$key."' in '".$filename."'\n");
        }
      }
    }

    // Read header file, if any, and we don't have "header" field set yet
    $filename = mgGetPath($galPath, "header_file");
    if ($filename !== FALSE && file_exists($filename) &&
        !isset($galData["header"]))
    {
      mgDebug("Reading HEADERFILE: ".$filename."\n");
      $galData["header"] = file_get_contents($filename);
    }

    // Check for alternate key/values for album title/caption
    if (isset($galData["title"]) && !isset($galData["caption"]))
    {
      $galData["caption"] = $galData["title"];
      unset($galData["title"]);
    }
  }
  else
    $galData["hide"] = TRUE;

  // If caption is not set, use last path component for
  // a fallback value in case we don't discover proper title
  // from other sources we can't check here yet.
  $path = explode("/", $galPath);
  $galData["fallback_caption"] = ucfirst(str_replace("_", " ", end($path)));

  // Last, store the current gallery path
  $len = strlen($galBasePath);
  if ($len < strlen($galPath) && substr($galPath, 0, $len) == $galBasePath)
    $galData["path"] = substr($galPath, $len);
  else
    $galData["path"] = "";

  return $galData;
}


//
// Parse a simple image captions file, with format of one entry per line.
// Lines starting with "# " are comments (note the whitespace), empty lines ignored.
// First continuous non-whitespace text is file/dirname, followed
// by caption text, which is separate with whitespace from the filename.
// Filenames starting with # will be made hidden entries.
// Filenames starging with % will hide contents.
//
function mgReadCaptionsFile($galBasePath, $galPath)
{
  $captions = [];
  $filename = mgGetPath($galPath, "captions_file");
  if ($filename === FALSE || ($fp = @fopen($filename, "rb")) === FALSE)
    return $captions;

  mgDebug("Reading CAPTIONS: ".$filename."\n");

  // Read and parse data
  while (!feof($fp))
  {
    $str = trim(fgets($fp));
    // Ignore comments and empty lines
    if ($str != "#" && $str != "")
    {
      if (($hasData = preg_match("/^([#%]?)\s*(\S+?)\s+(.+)$/", $str, $m)))
        $captions[$m[2]] = ["caption" => $m[3]];
      else
        $hasData = preg_match("/^([#%]?)\s*(\S+?)$/", $str, $m);

      if ($hasData)
      {
        $captions[$m[2]] = [
          "hide" => ($m[1] == "#"),
          "hide_contents" => ($m[1] == "%"),
        ];
      }
    }
  }

  fclose($fp);
  return $captions;
}


//
// Create directory with specified permissions
//
function mgMakeDir($path, $perm)
{
  if (!file_exists($path))
  {
    if (mkdir($path, $perm, TRUE) === false)
      return mgError("Could not create directory '".$path."'\n");
  }
  return TRUE;
}


//
// Print a simple yes/no prompt with given message
// and default value.
//
function mgYesNoPrompt($msg, $default = FALSE)
{
  echo $msg." [".($default ? "Y/n" : "y/N")."]? ";
  $sprompt = strtolower(trim(fgets(STDIN)));

  if ($sprompt == "")
    return $default;
  else
    return $sprompt[0] == 'y';
}


//
// Delete given directory OR file, recursively
//
function mgDelete($path, $showFiles)
{
  global $flagDoDelete, $countDoDelete;
  if (is_dir($path))
  {
    if (($dirHandle = @opendir($path)) === FALSE)
      return mgError("Could not read directory '".$path."'.\n");

    while (($dirFile = @readdir($dirHandle)) !== FALSE)
    {
      if ($dirFile != "." && $dirFile != "..")
        mgDelete($path."/".$dirFile, $showFiles);
    }

    closedir($dirHandle);

    echo " - ".$path." [DIR]\n";
    $countDoDelete++;
    if ($flagDoDelete)
      rmdir($path);
  }
  else
  if (file_exists($path))
  {
    if ($showFiles)
      echo " - ".$path."\n";

    $countDoDelete++;
    if ($flagDoDelete)
      unlink($path);
  }
}


function mgDeleteConditional($path, &$noDelete)
{
  global $flagDoDelete, $countDoDelete;
  if (is_dir($path))
  {
    if (($dirHandle = @opendir($path)) === FALSE)
      return mgError("Could not read directory '".$path."'.\n");

    while (($dirFile = @readdir($dirHandle)) !== FALSE)
    {
      if ($dirFile != "." && $dirFile != "..")
        mgDeleteConditional($path."/".$dirFile, $noDelete);
    }

    closedir($dirHandle);

    if (!array_key_exists($path, $noDelete))
    {
      $countDoDelete++;
      if ($flagDoDelete)
        rmdir($path);
      else
        echo "DEL DIR '".$path."'\n";
    }
  }
  else
  if (file_exists($path) && !array_key_exists($path, $noDelete))
  {
    $countDoDelete++;
    if ($flagDoDelete)
      unlink($path);
    else
      echo "DEL FILE '".$path."'\n";
  }
}


//
// Check if we have received the quit signal
// and if yes, quit cleanly now.
//
function mgCheckQuit($now = FALSE)
{
  global $flagQuit;

  // Dispatch pending signals
  pcntl_signal_dispatch();

  // Check result
  if ($now && $flagQuit)
    mgFatal("Quitting.\n");

  return $flagQuit;
}


function mgNeedUpdate($entry, $field, $cvalue)
{
  if (!array_key_exists($field, $entry))
    return TRUE;

  return ($entry[$field] < $cvalue);
}


function mgSortFunc($a, $b)
{
  if (isset($a["datetime"]) && is_object($a["datetime"]) &&
      isset($b["datetime"]) && is_object($b["datetime"]))
    $cres = $b["datetime"]->getTimestamp() - $a["datetime"]->getTimestamp();
  else
  if (isset($a["datetime"]) && is_object($a["datetime"]) &&
      isset($b["base"]))
    $cres = -1;
  else
  if (isset($b["datetime"]) && is_object($b["datetime"]) &&
      isset($a["base"]))
    $cres = 1;
  else
    $cres = 0;

  if ($cres == 0 && isset($a["base"]) && isset($b["base"]))
    $cres = strcmp($b["base"], $a["base"]);

  return $cres;
}


function mgWriteGalleryCache($cacheFilename, &$gallery, &$entries, &$parentEntry)
{
  global $galBackend, $db, $galExifConversions;

  // Store gallery cache for this directory
  $images = [];
  $albums = [];
  $output = [];

  // If we are hiding contents of an album, generate no data
  if ($parentEntry === NULL || !$parentEntry["hide_contents"])
  {
    foreach ($entries as $ename => &$edata)
    {
      if ($edata["hide"])
        continue;

      unset($edata["hide"]);
      if ($edata["type"] == 0)
        $images[$ename] = &$edata;
      else
        $albums[$ename] = &$edata;

      $output[$ename] = &$edata;
    }

    uasort($images, "mgSortFunc");
    krsort($albums);

    // Choose gallery album cover image
    if (count($images) > 0)
    {
      // Is the cover image set in the album data?
      if (isset($gallery["albumpic"]))
      {
        if (!isset($images[$gallery["albumpic"]]))
          return mgError("Album cover picture '".$gallery["albumpic"]."' set, but is not found in directory.\n");
        else
          $parentEntry["image"] = $gallery["albumpic"];
      }

      // Got anything?
      if (!isset($parentEntry["image"]))
      {
        // No, use the last image in the album
        end($images);
        $parentEntry["image"] = key($images);
      }
    }
    else
    {
      // Is the cover image set in the album data?
      if (isset($gallery["albumpic"]))
      {
        if (!isset($albums[$gallery["albumpic"]]))
        {
          return mgError("Album cover picture '".$gallery["albumpic"]."' set, but subalbum not found.\n");
        }

        $parentEntry["image"] = &$albums[$gallery["albumpic"]];
      }
      else
      {
        foreach ($albums as $aid => &$adata)
        if (isset($adata["image"]))
        {
          $parentEntry["image"] = &$adata;
          break;
        }
      }
    }
  }

  $str =
    "<?php\n".
    "\$galData = ".var_export($gallery, TRUE).";\n".
    "\$galAlbumsIndex = ".var_export(array_keys($albums), TRUE).";\n".
    "\$galImagesIndex = ".var_export(array_keys($images), TRUE).";\n".
    "\$galEntries = ".var_export($output, TRUE).";\n".
    "?>";

  if (@file_put_contents($cacheFilename, $str, LOCK_EX) === FALSE)
    return mgError("Error writing '".$cacheFilename."'\n");

  return TRUE;
}


function mgHandleDirectory($mode, $basepath, $path, &$parentData, &$parentEntry, $writeMode, $startAt)
{
  global $galExifConversions, $galTNPath, $galMedPath, $galCleanFlags;

  // Get cache file path
  if (($cacheFilename = mgGetPath($path, "cache_file")) === FALSE)
    return mgError("Cache filename / path not set.\n");

  mgCheckQuit(TRUE);

  // Read directory contents
  $entries = [];
  if (($dirHandle = @opendir($path)) === FALSE)
    return mgError("Could not read directory '".$path."'.\n");

  while (($dirFile = @readdir($dirHandle)) !== FALSE)
  {
    $realFile = $path."/".$dirFile;
    if (is_dir($realFile))
    {
      if ($dirFile[0] != "." && $dirFile != $galTNPath && $dirFile != $galMedPath)
        $entries[$dirFile] = ["type" => 1, "base" => $dirFile, "ext" => "", "mtime" => filemtime($realFile)];
    }
    else
    if (preg_match("/^([^\/]+)(".mgGetSetting("format_exts").")$/i", $dirFile, $dirMatch))
      $entries[$dirFile] = ["type" => 0, "base" => $dirMatch[1], "ext" => $dirMatch[2], "mtime" => filemtime($realFile), "hide" => false];
  }
  closedir($dirHandle);

  mgCheckQuit();

  $tnPath = $path."/".$galTNPath;
  $medPath = $path."/".$galMedPath;
  $generatedFiles = [];
  $generatedFiles[$tnPath] = 1;
  $generatedFiles[$medPath] = 1;

  // Cleanup mode
  if ($mode == GCMD_CLEAN)
  {
    $gallery = mgGetAlbumData($basepath, $path);

    if ($writeMode)
    {
      if ($galCleanFlags & GCLEAN_CACHES)
        mgDelete($cacheFilename, TRUE);

      if ($galCleanFlags & GCLEAN_IMAGES)
      {
        mgDelete($path."/".$galTNPath, FALSE);
        mgDelete($path."/".$galMedPath, FALSE);
      }

      if ($galCleanFlags & GCLEAN_TRASH)
      {
        foreach ($entries as $ename => &$edata)
        {
          $medFilename = $medPath."/".$ename.".".mgGetAlbumSetting($gallery, "med_format");
          $tnFilename = $tnPath."/".$ename.".".mgGetAlbumSetting($gallery, "tn_format");
          $generatedFiles[$medFilename] = 1;
          $generatedFiles[$tnFilename] = 1;
        }

        // Delete any "trash" files from medium/thumbnail dirs
        mgDeleteConditional($tnPath, $generatedFiles);
        mgDeleteConditional($medPath, $generatedFiles);
      }
    }
  }
  else
  // Update modes
  if ($mode == GCMD_UPDATE || $mode == GCMD_RESCAN)
  {
    // Load current cache file, if it exists
    $galEntries = [];
    $cacheTime = -1;
    if ($mode == GCMD_UPDATE && file_exists($cacheFilename))
    {
      $cacheTime = filemtime($cacheFilename);
      @include $cacheFilename;
    }

    // Read caption data
    $captions = mgReadCaptionsFile($basepath, $path);
    $gallery = mgGetAlbumData($basepath, $path);
    if ($parentData !== NULL && $parentEntry !== NULL)
    {
      $gallery["parent"] = &$parentData;
      mgCopyEntryData($gallery, $parentEntry, MG_STR, "caption", "caption");
    }

    // Start actual processing
    $nentries = count($entries);
    $nentry = 0;
    echo $path." .. ";
    foreach ($entries as $ename => &$edata)
    {
      printf("\r%s (%1.1f%%) ..", $path, ($nentry * 100.0) / $nentries);

      $nentry++;
      $efilename = $path."/".$ename;
      $medFilename = $medPath."/".$ename.".".mgGetAlbumSetting($gallery, "med_format");
      $tnFilename = $tnPath."/".$ename.".".mgGetAlbumSetting($gallery, "tn_format");
      $capFilename = $path."/".$edata["base"].".txt";

      if (array_key_exists($ename, $galEntries))
        $galEntry = &$galEntries[$ename];
      else
        $galEntry = [];

      mgCheckQuit(TRUE);

      // Update with captions file data, if any
      if (array_key_exists($ename, $captions))
      {
        foreach ($captions[$ename] as $ckey => $cval)
          $edata[$ckey] = $cval;
      }

      // Handle entry based on type
      if ($edata["type"] == 0)
      {
        $updFlags = 0;

        // Check what we need to update ..
        if (!file_exists($medFilename) || filemtime($medFilename) < $edata["mtime"])
          $updFlags |= GUPD_MED_IMAGE;

        if (!file_exists($tnFilename) || filemtime($tnFilename) < $edata["mtime"])
          $updFlags |= GUPD_TN_IMAGE;

        if (mgNeedUpdate($galEntry, "mtime", $edata["mtime"]))
          $updFlags |= GUPD_EXIF_INFO;

        if (file_exists($capFilename) &&
          mgNeedUpdate($galEntry, "mtime", filemtime($capFilename)))
          $updFlags |= GUPD_CAPTION;

        // Check for EXIF and XMP info
        if (($updFlags & GUPD_EXIF_INFO) &&
            ($res = mgReadEXIFAndXMPData($efilename, $exif, $xmp)) === TRUE)
        {
          if ($xmp !== FALSE)
          {
            echo "@";
            foreach ($galExifConversions as $conv)
              mgCopyEntryData($edata, $xmp, $conv[GEC_TYPE], $conv[GEC_NAME], $conv[GEC_FIELDS]);
          }

          if ($exif !== FALSE)
          {
            echo "%";
            foreach ($galExifConversions as $conv)
              mgCopyEntryData($edata, $exif, $conv[GEC_TYPE], $conv[GEC_NAME], $conv[GEC_FIELDS]);
          }
        }
        else
        {
          // Copy old data that is not yet in new
          echo "*";
          foreach ($galEntry as $okey => $odata)
          {
            if (!array_key_exists($okey, $edata))
              $edata[$okey] = $odata;
          }
        }

        // Generate thumbnails, etc.
        if ($updFlags & GUPD_IMAGES)
        {
          mgMakeDir($tnPath, 0755);
          mgMakeDir($medPath, 0755);

          if ($updFlags & GUPD_MED_IMAGE)
          {
            echo "1";
            mgConvertImage($efilename, $medFilename,
              [mgGetAlbumSetting($gallery, "med_width"), mgGetAlbumSetting($gallery, "med_height")],
              mgGetAlbumSetting($gallery, "med_format"), mgGetAlbumSetting($gallery, "med_quality"), TRUE);
          }

          if ($updFlags & GUPD_TN_IMAGE)
          {
            echo "2";
            mgConvertImage($efilename, $tnFilename,
              [mgGetAlbumSetting($gallery, "tn_width"), mgGetAlbumSetting($gallery, "tn_height")],
              mgGetAlbumSetting($gallery, "tn_format"), mgGetAlbumSetting($gallery, "tn_quality"), TRUE);
          }
        }

        // Check for .txt caption file
        if ($updFlags & GUPD_CAPTION)
        {
          echo "?";
          if (($tmpData = @file_get_contents($capFilename)) !== FALSE)
            $edata["caption"] = $tmpData;
        }

        if ($updFlags & GUPD_EXIF_INFO)
        {
          // Get width/height information for thumbnails and mediums
          $edata["med"] = [];
          if (($info = getimagesize($medFilename)) !== FALSE && count($info) >= 2)
          {
            $edata["med"]["width"] = $info[0];
            $edata["med"]["height"] = $info[1];
            echo "+";
          }
          else
            echo "-";

          $edata["tn"] = [];
          if (($info = @getimagesize($tnFilename)) !== FALSE && count($info) > 0)
          {
            $edata["tn"]["width"] = $info[0];
            $edata["tn"]["height"] = $info[1];
            echo "+";
          }
          else
            echo "-";
        }
      }
      else
      if ($edata["type"] == 1)
      {
        // Set some of the album data here
        $tmp = mgGetAlbumData($basepath, $efilename);
        mgCopyEntryData($edata, $tmp, MG_STR, "caption", "caption");
        mgCopyEntryData($edata, $tmp, MG_STR, "caption", "fallback_caption");
        mgCopyEntryData($edata, $tmp, MG_STR, "caption", "title");
        mgCopyEntryData($edata, $tmp, MG_BOOL, "hide", "hide", FALSE);
        mgCopyEntryData($edata, $tmp, MG_BOOL, "hide_contents", "hide_contents", FALSE);
      }
    }

    echo "\r".$path." ..... DONE\n";

    mgCheckQuit(TRUE);
  }
  else
    mgFatal("Invalid work mode '".$mode."'.\n");

  mgCheckQuit(TRUE);

  // Recurse to subdirectories
  foreach ($entries as $ename => &$edata)
  if ($edata["type"] == 1)
  {
    $epath = $path."/".$ename."/";
    $newWriteMode = ($writeMode === FALSE && $epath == $startAt) || $writeMode;

    if (!mgHandleDirectory($mode, $basepath, $epath, $gallery, $edata, $newWriteMode, $startAt))
      return FALSE;
  }

  // Finish update modes
  if ($mode == GCMD_UPDATE || $mode == GCMD_RESCAN)
  {
    // Store gallery cache for this directory
    if ($writeMode && !mgWriteGalleryCache($cacheFilename, $gallery, $entries, $parentEntry))
      return FALSE;
  }

  mgCheckQuit(TRUE);

  return TRUE;
}


function mgSigHandler($signo)
{
  global $flagQuit;
  switch ($signo)
  {
    case SIGTERM:
      mgFatal("Received SIGTERM.\n");
      break;

    case SIGQUIT:
    case SIGINT:
      $flagQuit = TRUE;
      break;

  }
}


function mgCheckMPath($path, $id)
{
  if ($path."/" != mgGetSetting($id))
    mgError("Invalid ".$id." '".mgGetSetting($id)."', using '".$path."'.\n");

  if (strpos($path, "/") !== FALSE || $path == "")
    mgFatal("Invalid ".$id." '".$path."'.\n");
}


function mgProcessGalleries($cmd, $path)
{
  global $galTNPath, $galMedPath;

  // Check validity of some settings
  $galPath = mgGetSetting("base_path");
  $galTNPath = mgCleanPath(TRUE, mgGetSetting("tn_path"));
  $galMedPath = mgCleanPath(TRUE, mgGetSetting("med_path"));

  mgCheckMPath($galTNPath, "tn_path");
  mgCheckMPath($galMedPath, "med_path");

  $parentData = $parentEntry = NULL;
  $writeMode = TRUE;
  $startAt = NULL;

  // Check for path argument
  if ($path !== FALSE)
  {
    // Check the given path, needs to be "under" the gallery path
    $cmp = mgCleanPath(TRUE, mgRealPath($galPath))."/";
    $tmp = mgCleanPath(TRUE, mgRealPath($path))."/";
    if (substr($tmp, 0, strlen($cmp)) != $cmp)
      mgFatal("Path '".$path."' ('".$tmp."') does not reside under '".$galPath."' ('".$cmp."')!\n");

    // Check if we need to bootstrap
    if ($cmp != $tmp)
    {
      $bpath = mgCleanPath(TRUE, mgRealPath($tmp."../"));

      if ($cmd != GCMD_CLEAN)
      {
        $cacheFile = mgGetPath($bpath, "cache_file");
        if (!file_exists($cacheFile))
          mgFatal("Can't start working from '".$path."', parent '".$cacheFile."' does not exist!\n");

        @include($cacheFile);
        if (!isset($galEntries) || !isset($galData))
          mgFatal("Cache file '".$cacheFile."' is broken or stale.\n");
      }

      $writeMode = FALSE;
      $startAt = $tmp;
      $path = $bpath;

      echo "Starting: '".$startAt."' inside '".$path."'.\n";
    }
    else
      $path = $tmp;
  }
  else
    $path = $galPath;

  // Start working
  echo "Gallery path: '".$galPath."', starting at '".$path."' ...\n";

  mgHandleDirectory($cmd, $galPath, $path, $parentData, $parentEntry, $writeMode, $startAt);
}


function mgGetDValStr($key, $mdata, $val, $format, $multi)
{
  $vfmt = "%s";

  switch ($mdata[0])
  {
    case MG_STR_ARRAY:
      if ($val === FALSE)
        $val = [];
      else
      if (is_array($val) || is_string($val))
      {
        $vfmt = "\"%s\"";
        if (is_string($val))
          $val = [ $val ];
      }
      else
        $val = "ERROR1";
      break;

    case MG_STR:
    case MG_STR_LC:
      if (is_string($val))
      {
        $vfmt = "\"%s\"";
        if ($mdata[0] == MG_STR_LC)
          $val = strtolower($val);
      }
      else
        $val = "ERROR2";
      break;

    case MG_BOOL:
      $val = $val ? "yes" : "no";
      break;

    case MG_FLAGS:
      {
        $mstr = [];
        foreach ($mdata[2] as $vkey => $vval)
        {
          if ($val & $vval)
            $mstr[] = $vkey;
        }

        $val = "\"".implode(" | ", $mstr)."\"";
      }
      break;

    case MG_INT:
      $val = (string) $val;
      break;

    default:
      $val = "ERROR";
  }

  if (!is_array($val))
  {
    if ($val === NULL || $val === FALSE)
      $val = [];
    else
      $val = [ $val ];
  }

  $res = (count($val) == 0) ? "# " : "";

  if ($multi)
  {
    if (count($val) > 0)
    {
      foreach ($val as $vv)
        $res .= sprintf($format, $key, sprintf($vfmt, $vv));
    }
    else
      $res .= sprintf($format, $key, "");
  }
  else
  {
    $res .= sprintf($format, $key,
        implode(", ", array_map(function($vv) use ($vfmt) { return sprintf($vfmt, $vv); }, $val)));
  }

  return $res;
}


function mgShowCopyright()
{
  global $mgProgVersion, $mgProgCopyright,
    $mgProgInfo, $mgProgEmail;

  echo
    "MGTool ".$mgProgVersion." - MGallery management tool\n".
    $mgProgInfo." ".$mgProgEmail."\n".
    "(C) Copyright ".$mgProgCopyright."\n";
}


function mgShowHelp()
{
  global $argv;
  echo
    "Usage: ".basename($argv[0])." <command> [arguments]\n".
    "\n".
    "  --help      - Show this help.\n".
    "  --version   - Show version information.\n".
    "\n".
    "  update [path]\n".
    "    Update directories under <path> or all gallery dirs.\n".
    "    Conditionally scans dirs for new or changed images and\n".
    "    updates cache files as needed.\n".
    "\n".
    "  rescan [path]\n".
    "    Like 'update', but forces all cache files to be regenerated\n".
    "    and EXIF/caption/etc information to be re-scanned even\n".
    "    if the file timestamps do not justify re-scanning.\n".
    "\n".
    "  clean <option> [path]\n".
    "    all    - Delete all generated files\n".
    "    images - Delete generated images (thumbnails/mediums)\n".
    "    caches - Delete mgallery data cache files\n".
    "    trash  - Delete any 'unmanaged' files inside thumbnail/medium dirs\n".
    "\n".
    "    NOTICE! You usually probably want to use only the 'caches' and\n".
    "    'trash' options. Unless you know what you are doing, that is.\n".
    "\n".
    "  config|dump\n".
    "    Display configuration values (with extra information) or\n".
    "    \"dump\" current configuration as is.\n".
    "\n";
}


//
// Main code starts
//
if (php_sapi_name() != "cli" || !empty($_SERVER["REMOTE_ADDR"]))
{
  header("Status: 404 Not Found");
  die();
}

pcntl_signal(SIGTERM, "mgSigHandler");
pcntl_signal(SIGHUP,  "mgSigHandler");
pcntl_signal(SIGQUIT, "mgSigHandler");
pcntl_signal(SIGINT,  "mgSigHandler");

// Check for improperly configured PHP
if (extension_loaded("imagick") && extension_loaded("gmagick"))
{
  mgError("FATAL ERROR! Both ImageMagick AND GraphicsMagick modules enabled in PHP! This will cause problems! Refusing to work.\n");
  exit(1);
}
else
if (!extension_loaded("imagick") && !extension_loaded("gmagick"))
{
  mgError("No ImageMagick OR GraphicsMagick module available in PHP!\n");
  exit(1);
}

// Check settings
if (mgReadSettings($searchPaths) === FALSE)
{
  die("MGallery is not configured, failed to find a configuration file.\n".
    "Attempted search paths:\n".implode("\n", $searchPaths)."\n");
}

// Configure the timezone
if (($pageTimeZone = mgGetSetting("timezone")) !== NULL)
  date_default_timezone_set($pageTimeZone);


// Check for commandline arguments
$cmd = mgCArgLC(1);
switch ($cmd)
{
  case "metatest":
    // Test metadata extraction from given image file
    $efilename = mgCArg(2);
    if ($efilename === FALSE)
    {
      mgFatal("No filename given.\n");
    }

    if (($res = mgReadEXIFAndXMPData($efilename, $exif, $xmp)) === TRUE)
    {
      if ($exif !== FALSE)
        print_r($exif);
      else
        echo "No EXIF data found.\n";

      if ($xmp !== FALSE)
        print_r($xmp);
      else
        echo "No XMP data found.\n";
    }
    else
      mgFatal($res."\n");
    break;

  case "--version":
  case "version":
  case "ver":
    mgShowCopyright();
    break;

  case FALSE:
    // No arguments
    mgError("Nothing to do. Showing help:\n");

  case "--help":
  case "help":
    if ($cmd !== FALSE)
      mgShowCopyright();
    mgShowHelp();
    break;

  case "update": case "up": case "upd": case "upda":
    $farg = mgCArg(2);
    mgProcessGalleries(GCMD_UPDATE, $farg);
    break;

  case "rescan": case "re": case "res":
    mgProcessGalleries(GCMD_RESCAN, mgCArg(2));
    break;

  case "clean": case "cl": case "cle":
    $cmode = mgCArgLC(2, 2);
    switch ($cmode)
    {
      case "al": $galCleanFlags = GCLEAN_ALL; break;
      case "ca": $galCleanFlags = GCLEAN_CACHES; break;
      case "im": $galCleanFlags = GCLEAN_IMAGES; break;
      case "tr": $galCleanFlags = GCLEAN_TRASH; break;
      case FALSE:
        mgFatal("Cleaning requires a mode argument.\n");

      default:
        mgFatal("Invalid clean mode '".mgCArg(2)."'.\n");
    }
    $countDoDelete = 0;
    $flagDoDelete = FALSE;
    mgProcessGalleries(GCMD_CLEAN, mgCArg(3));
    echo "--\n";
    if ($countDoDelete == 0)
    {
      echo "Nothing to clean!\n";
    }
    else
    if (mgYesNoPrompt("Really delete the above files and directories?"))
    {
      echo "Deleting ...\n";
      $flagDoDelete = TRUE;
      mgProcessGalleries(GCMD_CLEAN, mgCArg(3));
    }
    else
    {
      echo "Okay, canceling operation.\n";
    }
    break;

  case "config":
  case "dump":
    foreach ($mgDefaults as $ckey => $cdata)
    {
      $sval = mgGetSetting($ckey);

      if ($cmd == "dump")
      {
        echo mgGetDValStr($ckey, $cdata, $sval, "%1\$-15s = %2\$s\n", FALSE);
      }
      else
      {
        echo mgGetDValStr($ckey, $cdata, $sval, "%1\$-15s = %2\$s", TRUE).
            (($cdata[1] !== NULL && $sval !== $cdata[1]) ?
            " (default: ".mgGetDValStr($ckey, $cdata, $cdata[1], "%2\$s", FALSE).")" : "")."\n";
      }
    }
    break;

  default:
    mgError("Unknown option/command '".$cmd."'.\n");
    break;
}

?>