fulldecent/cameralife

View on GitHub
sources/Models/Photo.php

Summary

Maintainability
F
4 days
Test Coverage
<?php
namespace CameraLife\Models;

/**
 * Class Photo provides a front end to working with photos
 *
 * @author    William Entriken <cameralife@phor.net>
 * @access    public
 * @version
 * @copyright 2001-2009 William Entriken
 */
class Photo extends IndexedModel
{
    public $record;

    public $image;

    /**
     * The file extension, e.g. png
     *
     * @var    String
     * @access public
     */
    public $extension;

    /**
     * context
     * an Album, Search or Folder of where the user came from to get to this photo
     *
     * @var    mixed
     * @access private
     */
     ///TODO TEMPORARY
    public $context;

    /**
     * contextPhotos
     * An ordered set of photos in the same context as this one
     *
     * @var    mixed
     * @access private
     */
    private $contextPhotos;

    /**
     * contextPrev
     * the previous photo in context
     *
     * @var    mixed
     * @access private
     */
    private $contextPrev;

    /**
     * contextNext
     * the next photo in contex
     *
     * @var    mixed
     * @access private
     */
    private $contextNext;

    //todo I hate this function
    /**
     * Loads a photo with a given FILEPATH
     *
     * @access public
     * @static
     * @param  array photo record from the database
     * @return Photo
     */
    private static function getPhotoWithRecord($record)
    {
        $retval = new Photo();
        $retval->record = $record;
        if (isset($retval->record['filename'])) {
            $pathParts = pathinfo($retval->record['filename']);
        }
        if (isset($pathParts['extension'])) {
            $retval->extension = strtolower($pathParts['extension']);
        }
        $retval->id = $retval->record['id'];
        return $retval;
    }

    /**
     * Creates a photo with given record
     *
     * @access public
     * @static
     * @param  array $record
     * @return Photo
     */
    public static function createPhotoWithRecord($record)
    {
        $defaults['description'] = 'unnamed';
        $defaults['status'] = '0';
        $defaults['created'] = date('Y-m-d');
        $defaults['modified'] = '0';
        $defaults['mtime'] = '0';
        $finalRecord = array_merge($defaults, $record);
        $finalRecord['id'] = Database::insert('photos', $finalRecord);
        $retval = self::getPhotoWithRecord($finalRecord);
        return $retval;
    }

    /**
     * Loads a photo with a given FILEPATH
     *
     * @access public
     * @static
     * @param  mixed $filePath string like /folder/photo.jpg
     * @return Photo
     */
    public static function getPhotoWithFilePath($filePath)
    {
        $filename = basename($filePath);
        $path = '/' . trim(substr($filePath, 0, -strlen($filename)), '/');
        $bind = array('f'=>$filename, 'p'=>$path);
        $result = Database::select('photos', '*', "filename=:f AND path=:p", null, null, $bind);
        $record = $result->fetchAssoc();
        if (!$record) {
            throw new \Exception('Photo not found in database with filePath ' . $filePath);
        }
        return Photo::getPhotoWithRecord($record);
    }

    /**
     * Loads a photo with a given id
     *
     * @access public
     * @static
     * @param  integer
     * @return Photo
     */
    public static function getPhotoWithID($photoId)
    {
        // TODO most calls to this function are better served by getPhotoWithRecord
        $bind = array('i'=>$photoId);
        $result = Database::select('photos', '*', "id=:i", null, null, $bind);
        $result->id = $photoId;
        $record = $result->fetchAssoc();
        if (!$record) {
            throw new \Exception("Photo #$photoId not found");
        }
        return Photo::getPhotoWithRecord($record);
    }

    /**
     * __construct function.
     *
     * @access protected
     * @return void
     */
    protected function __construct()
    {
        $this->context = false;
        $this->contextPrev = false;
        $this->contextNext = false;
        $this->contextPhotos = array();
        $this->EXIF = array();
    }

    //////////////////////////////////////////////////////////

    public static function photoExists($photoId)
    {
        $numMatchingPhotos = Database::selectOne('photos', 'COUNT(*)', 'id=:id', null, null, ['id'=>$photoId]);
        return $numMatchingPhotos > 0;
    }

    public function set($key, $value, User $user = null)
    {
        $receipt = null;
        $this->record[$key] = $value;
        Database::update('photos', array($key => $value), 'id=' . $this->record['id']);
        if (isset($user)) {
            $receipt = AuditTrail::createAuditTrailForChange($user, 'photo', $this->record['id'], $key, $this->record[$key], $value);
        }
        return $receipt;
    }

    public function get($key)
    {
        if (isset($this->record[$key])) {
            return $this->record[$key];
        } else {
            return null;
        }
    }

    /// Initialize <var>$this->image</var> variable and collect fsize and $this->loadEXIF if possible
    /// Loads the original image, not the modified
    public function loadImage()
    {
        if (isset($this->image)) {
            return;
        }
        $fullpath = rtrim('/' . ltrim($this->record['path'], '/'), '/') . '/' . $this->record['filename'];
        $fileStore = FileStore::fileStoreWithName('photo');
        list ($file, $temp, $this->record['mtime']) = $fileStore->getFile($fullpath);
        $this->record['fsize'] = filesize($file);
        $this->record['created'] = date('Y-m-d', $this->record['mtime']);
        $this->loadEXIF($file);

        $this->image = new Image($file);
        if (!$this->image) {
            throw new \Exception("Bad photo load: $file");
        }
        if (!$this->image->check()) {
            throw new \Exception("Bad photo processing: $file");
        }
        if ($temp) {
            unlink($file);
        }
    }

    /// Scale image to all needed sizes and save in file store, update image/tn sizes
    /// also update fsize if this is unmodified.
    public function generateThumbnail()
    {
        $this->loadImage(); // sets $this->EXIF and $this-record
        if ($this->record['modified'] == '1') {
            $this->record['modified'] = null; // legacy before 2.7
            Database::update('photos', $this->record, 'id=' . $this->record['id']);
        }

        if (Preferences::valueForModuleWithKey('CameraLife', 'autorotate')  == 'yes'
            && (!$this->record['modified'] || $this->record['modified'] == '1')
        ) {
            $this->rotateEXIF();
        }
        $activeImage = $this->image;

        // Apply all modifications
        if ($this->record['modified']) {
            $modArray = json_decode($this->record['modified'], true);
            $rotation = isset($modArray['rotate']) ? $modArray['rotate'] : 0;
            $activeImage->rotate($rotation);
            $tempfile = tempnam(sys_get_temp_dir(), 'cameralife_mod');
            $activeImage->save($tempfile);
            $filename = '/' . $this->record['id'] . '_mod.' . $this->extension;
            $store = FileStore::fileStoreWithName('other');
            $store->putFile($filename, $tempfile, $this->record['status'] != 0);
            //todo warning secure!
        }

        $imagesize = $activeImage->getSize();
        $this->record['width'] = $imagesize[0];
        $this->record['height'] = $imagesize[1];

        $thumbSize = Preferences::valueForModuleWithKey('CameraLife', 'thumbsize');
        $scaledSize = Preferences::valueForModuleWithKey('CameraLife', 'scaledsize');
        $optionSizes = Preferences::valueForModuleWithKey('CameraLife', 'optionsizes');

        $sizes = array($thumbSize, $scaledSize);
        preg_match_all('/[0-9]+/', $optionSizes, $matches);
        $sizes = array_merge($sizes, $matches[0]);
        rsort($sizes);

        foreach ($sizes as $cursize) {
            $tempfile = tempnam(sys_get_temp_dir(), 'cameralife_' . $cursize);
            $dims = $activeImage->resize($tempfile, $cursize);
            $filename = '/' . $this->record['id'] . '_' . $cursize . '.' . $this->extension;
            $fileStore = FileStore::fileStoreWithName('other');
            $fileStore->putFile($filename, $tempfile, $this->record['status'] != 0);
            if ($cursize == $thumbSize) {
                $this->record['tn_width'] = $dims[0];
                $this->record['tn_height'] = $dims[1];
            }
        }
        Database::update('photos', $this->record, 'id=' . $this->record['id']);
    }

    private function deleteThumbnails()
    {
        // TODO
        // $cameralife->fileStore->EraseFile('/' . $this->record['id'] . '_mod.' . $this->extension);...
        // $cameralife->fileStore->EraseFile('/' . $this->record['id'] . '_' . $cameralife->getPref('scaledsize') ...
        // $cameralife->fileStore->EraseFile('/' . $this->record['id'] . '_' . $cameralife->getPref('thumbsize') ...
    }

    // Remove all modifications from the photo
    public function revert()
    {
        if (!$this->record['modified']) {
            return;
        }
        $this->record['modified'] = null;
        Database::update('photos', $this->record, 'id=' . $this->record['id']);
        $this->deleteThumbnails();
    }

    public function rotate($angle)
    {
        $modifications = json_decode($this->record['modified'], true);
        if (!is_array($modifications)) {
            $modifications = array();
        }
        $rotation = isset($modifications['rotate']) ? $modifications['rotate'] : 0;
        $rotation = ($rotation + $angle) % 360;
        $modifications['rotate'] = $rotation;
        if ($modifications['rotate'] == 0) {
            unset($modifications['rotate']);
        }
        $this->record['modified'] = count($modifications) ? json_encode($modifications) : null;
        Database::update('photos', $this->record, 'id=' . $this->record['id']);
        $this->deleteThumbnails();
    }

    public function rotateEXIF()
    {
        $this->loadImage(); // sets $this->EXIF and $this-record
        if (!isset($this->EXIF['Orientation'])) {
            return;
        }
        if ($this->EXIF['Orientation'] == 3) {
            $this->rotate(180);
        } elseif ($this->EXIF['Orientation'] == 6) {
            $this->rotate(90);
        } elseif ($this->EXIF['Orientation'] == 8) {
            $this->rotate(270);
        }
    }

    public function erase()
    {
        $this->set('status', 9);
        /*
        ///TODO
        $cameralife->database->Delete('photos','id='.$this->record['id']);
        $cameralife->database->Delete('logs',"record_type='photo' AND record_id=".$this->record['id']);
        $cameralife->database->Delete('ratings',"id=".$this->record['id']);
        $cameralife->database->Delete('comments',"photo_id=".$this->record['id']);
        $cameralife->database->Delete('exif',"photoid=".$this->record['id']);
        */
        $this->destroy();
    }

    public function destroy()
    {
        if ($this->image) {
            $this->image->Destroy();
        }
    }

    public function getMediaURL($scale = 'thumbnail')
    {
        $bucket = 'other';
        $path = '';
        if ($scale == 'photo') {
            if ($this->get('modified')) {
                $path = "/{$this->record['id']}_mod.{$this->extension}";
            } else {
                $path = "/{$this->record['path']}{$this->record['filename']}";
                $bucket = 'photo';
            }
        } elseif ($scale == 'scaled') {
            $thumbSize = Preferences::valueForModuleWithKey('CameraLife', 'scaledsize');
            $path = "/{$this->record['id']}_{$thumbSize}.{$this->extension}";
        } elseif ($scale == 'thumbnail') {
            $thumbSize = Preferences::valueForModuleWithKey('CameraLife', 'thumbsize');
            $path = "/{$this->record['id']}_{$thumbSize}.{$this->extension}";
        } elseif (is_numeric($scale)) {
            $valid = preg_split('/[, ]+/', Preferences::valueForModuleWithKey('CameraLife', 'optionsizes'));
            if (!in_array($scale, $valid)) {
                throw new \Exception('This image size has not been allowed');
            }
            $path = "/{$this->record['id']}_{$format}.{$this->extension}";
        } else {
            throw new \Exception('Missing or bad size parameter');
        }
        $fileStore = FileStore::fileStoreWithName($bucket);
        $url = $fileStore->getUrl($path);
        if ($url) {
            return $url;
        }
        $url = constant('BASE_URL') . "/media/{$this->record['id']}.{$this->extension}?scale={$scale}&ver={$this->record['mtime']}";
        if (Preferences::valueForModuleWithKey('CameraLife', 'rewrite') == 'no') {
            $url = constant('BASE_URL') . "/index.php?page=Media&id={$this->record['id']}&scale={$scale}&ver={$this->record['mtime']}";
        }
        return $url;
    }

    /**
     * isCacheMissing function.
     * Return true if thumbnail is missing or if needed _mod is missing
     *
     * @access public
     * @return bool
     */
    public function isCacheMissing()
    {
        if ($this->record['modified'] == '1') {
            return true; //legacy before 2.7
        }
        $cacheBucket = FileStore::fileStoreWithName('other');
        if ($this->record['modified']) {
            $filename = '/' . $this->record['id'] . '_mod.' . $this->extension;
            $stat = $cacheBucket->listFiles($filename);
            if (!count($stat)) {
                return true;
            }
        }
        $sizes = array();
        $sizes[] = Preferences::valueForModuleWithKey('CameraLife', 'thumbsize');
        $sizes[] = Preferences::valueForModuleWithKey('CameraLife', 'scaledsize');
        $options = Preferences::valueForModuleWithKey('CameraLife', 'optionsizes');
        preg_match_all('/[0-9]+/', $options, $matches);
        $sizes = array_merge($sizes, $matches[0]);

        foreach ($sizes as $cursize) {
            $filename = '/' . $this->record['id'] . '_' . $cursize . '.' . $this->extension;
            $stat = $cacheBucket->listFiles($filename);
            if (!count($stat)) {
                return true;
            }
        }
        return false;
    }

    public function getFolder()
    {
        return new Folder($this->record['path']);
    }

    public function getEXIF()
    {
        $this->EXIF = array();
        $query = Database::select('exif', '*', "photoid=" . $this->record['id']);

        while ($row = $query->fetchAssoc()) {
            if ($row['tag'] == 'empty') {
                continue;
            }
            $this->EXIF[$row['tag']] = $row['value'];
        }

        return $this->EXIF;
    }

    private function loadEXIF($file)
    {
        $exif = @exif_read_data($file, 'IFD0', true);
        if ($exif === false) {
            return;
        }
        $this->EXIF = array();
        $focallength = $exposuretime = null;
        if (isset($exif['EXIF']['DateTimeOriginal'])) {
            $this->EXIF["Date taken"] = $exif['EXIF']['DateTimeOriginal'];
            $exifPieces = explode(" ", $this->EXIF["Date taken"]);
            if (count($exifPieces) == 2) {
                $this->record['created'] = date(
                    "Y-m-d",
                    strtotime(str_replace(":", "-", $exifPieces[0]) . " " . $exifPieces[1])
                );
            }
        }
        if (isset($exif['IFD0']['Model'])) {
            $this->EXIF["Camera Model"] = $exif['IFD0']['Model'];
        }
        if (isset($exif['COMPUTED']['ApertureFNumber'])) {
            $this->EXIF["Aperture"] = $exif['COMPUTED']['ApertureFNumber'];
        }
        if (isset($exif['EXIF']['ExposureTime'])) {
            $this->EXIF["Speed"] = $exif['EXIF']['ExposureTime'];
        }
        if (isset($exif['EXIF']['ISOSpeedRatings'])) {
            $this->EXIF["ISO"] = $exif['EXIF']['ISOSpeedRatings'];
        }
        if (isset($exif['EXIF']['FocalLength'])) {
            if (preg_match('#([0-9]+)/([0-9]+)#', $exif['EXIF']['FocalLength'], $regs)) {
                $focallength = $regs[1] / $regs[2];
            }
            $this->EXIF["Focal distance"] = "${focallength}mm";
        }
        if (!empty($focallength)) {
            $ccd = 35;
            if (isset($exif['COMPUTED']['CCDWidth'])) {
                $ccd = str_replace('mm', '', $exif['COMPUTED']['CCDWidth']);
            }
            $fov = round(2 * rad2deg(atan($ccd / 2 / $focallength)), 2);
            //@link http://www.rags-int-inc.com/PhotoTechStuff/Lens101/
            $this->EXIF["Field of view"] = $fov . "&deg; horizontal";
        }
        if ($focallength && $exposuretime) {
            $iso = isset($this->EXIF["ISO"]) ? $this->EXIF["ISO"] : 100;
            if ($exif['EXIF']['Flash'] % 2 == 1) {
                $light = 'Flash';
            } else {
                preg_match('#([0-9]+)/([0-9]+)#', $exposuretime, $regs);
                $exposuretime = $regs[1] / $regs[2];

                $electronVolts = pow(str_replace('f/', '', $fnumber), 2) / $iso?:100 / $exposuretime;
                $light = $electronVolts > 10 ? 'Probably outdoors' : 'Probably indoors';
            }
            $this->EXIF["Lighting"] = $light;
        }
        if (isset($exif['IFD0']['Orientation'])) {
            $this->EXIF["Orientation"] = $exif['IFD0']['Orientation'];
        }
        if (isset($exif['GPS']) && isset($exif['GPS']['GPSLatitude']) && isset($exif['GPS']['GPSLongitude'])) {
            $lat = 0;
            if (count($exif['GPS']['GPSLatitude']) > 0) {
                $lat += $this->gpsToNumber($exif['GPS']['GPSLatitude'][0]);
            }
            if (count($exif['GPS']['GPSLatitude']) > 1) {
                $lat += $this->gpsToNumber($exif['GPS']['GPSLatitude'][1]) / 60;
            }
            if (count($exif['GPS']['GPSLatitude']) > 2) {
                $lat += $this->gpsToNumber($exif['GPS']['GPSLatitude'][2]) / 3600;
            }

            $lon = 0;
            if (count($exif['GPS']['GPSLongitude']) > 0) {
                $lon += $this->gpsToNumber($exif['GPS']['GPSLongitude'][0]);
            }
            if (count($exif['GPS']['GPSLongitude']) > 1) {
                $lon += $this->gpsToNumber($exif['GPS']['GPSLongitude'][1]) / 60;
            }
            if (count($exif['GPS']['GPSLongitude']) > 2) {
                $lon += $this->gpsToNumber($exif['GPS']['GPSLongitude'][2]) / 3600;
            }

            if ($exif['GPS']['GPSLatitudeRef'] == 'S') {
                $lat *= -1;
            }
            if ($exif['GPS']['GPSLongitudeRef'] == 'W') {
                $lon *= -1;
            }

            if ($lat != 0 && $lon != 0) {
                $this->EXIF["Location"] = sprintf("%.6f, %.6f", $lat, $lon);
            }
        }

        if (!count($this->EXIF)) {
            $this->EXIF = array('empty' => 'true');
        }

        Database::delete('exif', 'photoid=' . $this->record['id']);
        foreach ($this->EXIF as $tag => $value) {
            Database::insert(
                'exif',
                array('photoid' => $this->record['id'], 'tag' => $tag, 'value' => $value)
            );
        }
    }

    /**
     * getRelated function sets this->context
     *
     * @access public
     * @return array - set of views that contain this photo
     */
    public function getRelated()
    {
        //todo pass in referrer
        global $_SERVER;
        $retval = array($this->getFolder());
        $this->context = $this->getFolder();

        // Given no better information, best context is this photo's path
        if (!isset($_SERVER['HTTP_REFERER'])) {
            return $retval;
        }

        // Find if the referer is an album
        if (preg_match("/album/", $_SERVER['HTTP_REFERER'], $regs)) {
            if (isset($_SERVER['HTTP_REFERER'])
                && (preg_match("#album.php\?id=([0-9]*)#", $_SERVER['HTTP_REFERER'], $regs) || preg_match(
                    "#albums/([0-9]+)#",
                    $_SERVER['HTTP_REFERER'],
                    $regs
                ))
            ) {
                $album = new Album($regs[1]);
                $retval[] = $album;
                $this->context = $album;
            }
        }

        // Find all albums that contain this photo, this is not 100%
        $result = Database::select(
            'albums',
            'id,name',
            "'" . addslashes($this->get('description')) . "' LIKE '%' || term || '%'"
            // for mysql: "'" . addslashes($this->get('description')) . "' LIKE CONCAT('%',term,'%')"
        );
        while ($albumrecord = $result->fetchAssoc()) {
            if (($this->context instanceof Album) && $this->context->get('id') == $albumrecord['id']) {
                continue;
            }
            $album = new Tag($albumrecord['id']);
            $retval[] = $album;
        }

        // Did they come from a search??
        if (preg_match("#q=([^&]*)#", $_SERVER['HTTP_REFERER'], $regs)) {
            $search = new Search($regs[1]);
            $retval[] = $search;
            $this->context = $search;
        } else {
            // Find all photos named exactly like this
            $search = new Search($this->get('description'));
            if ($search->getPhotoCount() > 1) {
                $retval[] = $search;
            }
        }
        return $retval;
    }

    /**
     * Convert "2/4" to 0.5 and "4" to 4
     * @access private
     */
    private function gpsToNumber($num)
    {
        $parts = explode('/', $num);
        if (count($parts) == 0) {
            return 0;
        }
        if (count($parts) == 1) {
            return $parts[0];
        }
        return floatval($parts[0]) / floatval($parts[1]);
    }

    public function getLikeCount()
    {
        $ratings = Database::selectOne(
            'ratings',
            'COUNT(rating)',
            'id=' . $this->get('id') . ' AND rating > 0'
        );
        return $ratings;
    }

    public function getContext()
    {
        if (!$this->context) {
            $this->getRelated();
        }
        if (!count($this->contextPhotos)) {
            $this->context->SetPage(0, 99);
            $this->contextPhotos = $this->context->getPhotos(); /* Using the base class, how hot is that? */
            $last = null;
            foreach ($this->contextPhotos as $cur) {
                if ($cur->get('id') == $this->get('id') && $last) {
                    $this->contextPrev = $last;
                }
                if ($last && $last->get('id') == $this->get('id')) {
                    $this->contextNext = $cur;
                }
                $last = $cur;
            }
        }
        return $this->contextPhotos;
    }

    public function getPrevious()
    {
        if (!count($this->contextPhotos)) {
            $this->getContext();
        }

        return $this->contextPrev;
    }

    // returns the next photo or false if none exists
    public function getNext()
    {
        if (!count($this->contextPhotos)) {
            $this->getContext();
        }

        return $this->contextNext;
    }

    ///////////////////////////////////////////////////

    public function favoriteByUser(User $user)
    {
        //todo set RECEIPT in the user session
        $condition = 'id = ' . $this->record['id'] . ' AND ';
        if ($user->isLoggedIn) {
            $condition .= 'username = "' . $user->name . '"';
        } else {
            $condition .= 'user_ip = "' . $user->remoteAddr . '"';
        }
        Database::delete('ratings', $condition);
        Database::insert('ratings', ['id'=>$this->record['id'], 'username'=>$user->name, 'user_ip'=>$user->remoteAddr, 'date'=>date('Y-m-d H:i:s'), 'rating'=>5]);
    }

    public function unfavoriteByUser(User $user)
    {
        //todo set RECEIPT in the user session
        $condition = 'id = ' . $this->record['id'] . ' AND ';
        if ($user->isLoggedIn) {
            $condition .= 'username = "' . $user->name . '"';
        } else {
            $condition .= 'user_ip = "' . $user->remoteAddr . '"';
        }
        Database::delete('ratings', $condition);
    }
}