railpage/railpagecore

View on GitHub
lib/Images/Image.php

Summary

Maintainability
F
2 wks
Test Coverage
<?php

/**
 * Store and fetch image data from our local database
 *
 * @since   Version 3.8.7
 * @package Railpage
 * @author  Michael Greenhill
 */

namespace Railpage\Images;

use Railpage\Users\User;
use Railpage\Users\Factory as UserFactory;
use Railpage\Users\Utility\UserUtility;
use Railpage\Images\Utility\ImageUtility;
use Railpage\API;
use Railpage\AppCore;
use Railpage\Place;
use Railpage\PlaceUtility;
use Railpage\Locos\Factory as LocosFactory;
use Railpage\Locos\Locomotive;
use Railpage\Locos\LocoClass;
use Railpage\Locos\Liveries\Livery;
use Railpage\Module;
use Railpage\Url;
use Railpage\Debug;
use Exception;
use DateTime;
use DateTimeZone;
use DateInterval;
use stdClass;
use DomDocument;
use GuzzleHttp\Client;
use Zend_Db_Expr;
use Railpage\ContentUtility;

/**
 * Store and fetch data of Flickr, Weston Langford, etc images in our local database
 *
 * @since Version 3.8.7
 */
class Image extends AppCore {

    /**
     * Max age of data before it requires refreshing
     */

    const MAXAGE = 14;

    /**
     * Image ID in sparta
     *
     * @since Version 3.8.7
     * @var int $id
     */

    public $id;

    /**
     * Image title
     *
     * @since Version 3.8.7
     * @var string $title
     */

    public $title;

    /**
     * Image description
     *
     * @since Version 3.8.7
     * @var string $description
     */

    public $description;

    /**
     * Image provider
     *
     * @since Version 3.8.7
     * @var string $provider
     */

    public $provider;

    /**
     * Photo ID from the image provider
     *
     * @since Version 3.8.7
     * @var int $photo_id
     */

    public $photo_id;

    /**
     * Geographic place where this photo was taken
     *
     * @since Version 3.8.7
     * @var \Railpage\Place $Place
     */

    public $Place;

    /**
     * Nearest geoplace to this photo
     *
     * @since Version 3.9.1
     * @var \Railpage\Place $GeoPlace
     */

    public $GeoPlace;

    /**
     * Array of image sizes and their source URLs
     *
     * @since Version 3.8.7
     * @var array $sizes
     */

    public $sizes;

    /**
     * Object of image page URLs
     *
     * @since Version 3.8.7
     * @var \stdClass $links
     */

    public $links;

    /**
     * Object representing the image author
     *
     * @since Version 3.8.7
     * @var \stdClass $author
     */

    public $author;

    /**
     * URL to this image on Railpage
     *
     * @since Version 3.8.7
     * @var string $url
     */

    public $url;

    /**
     * Source URL to this image
     *
     * @since Version 3.8.7
     * @var string $source
     */

    public $source;

    /**
     * Image meta data
     *
     * @since Version 3.8.7
     * @var array $meta
     */

    public $meta;

    /**
     * Date modified
     *
     * @since Version 3.8.7
     * @var \DateTime $Date
     */

    public $Date;

    /**
     * Date the photo was taken
     *
     * @since Version 3.9.1
     * @var \DateTime $DateCaptured
     */

    public $DateCaptured;

    /**
     * Memcached identifier key
     *
     * @since Version 3.8.7
     * @var string $mckey
     */

    public $mckey;

    /**
     * JSON data string of this image
     *
     * @since Version 3.8.7
     * @var string $json
     */

    public $json;

    /**
     * Image provider options
     *
     * @since Version 3.9.1
     * @var array $providerOptions
     */

    private $providerOptions = array();

    /**
     * Image provider
     *
     * @since Version 3.9.1
     * @var object $ImageProvider
     */

    private $ImageProvider;

    /**
     * Latitude
     *
     * @since Version 3.9.1
     * @var float $lat
     */

    private $lat;

    /**
     * Longitude
     *
     * @since Version 3.9.1
     * @var float $lon
     */

    private $lon;

    /**
     * Constructor
     *
     * @since Version 3.8.7
     *
     * @param int $id
     * @param int $option
     */

    public function __construct($id = null, $option = null) {

        parent::__construct();

        $timer = Debug::getTimer();

        $this->GuzzleClient = new Client;

        $this->Module = new Module("images");

        if ($this->id = filter_var($id, FILTER_VALIDATE_INT)) {
            $this->load($option);
        }

        Debug::logEvent(__METHOD__, $timer);

    }

    /**
     * Populate this object from an array
     *
     * @since Version 3.9.1
     * @return void
     */

    public function populateFromArray($row) {

        $this->provider = $row['provider'];
        $this->photo_id = $row['photo_id'];
        $this->Date = new DateTime($row['modified']);
        $this->DateCaptured = !isset( $row['captured'] ) || is_null($row['captured']) ? null : new DateTime($row['captured']);

        $this->title = !empty( $row['meta']['title'] ) ? ContentUtility::FormatTitle($row['meta']['title']) : "Untitled";
        $this->description = $row['meta']['description'];
        $this->sizes = $row['meta']['sizes'];
        $this->links = $row['meta']['links'];
        $this->meta = $row['meta']['data'];
        $this->lat = $row['lat'];
        $this->lon = $row['lon'];

        #printArray($row['meta']);die;

        if (!$this->DateCaptured instanceof DateTime) {
            if (isset( $row['meta']['data']['dates']['taken'] )) {
                $this->DateCaptured = new DateTime($row['meta']['data']['dates']['taken']);
            }
        }

        /**
         * Normalize some sizes
         */

        if (count($this->sizes)) {
            $this->sizes = Images::normaliseSizes($this->sizes);
        }

        $this->url = Utility\Url::CreateFromImageID($this->id);

        if (empty( $this->mckey )) {
            $this->mckey = sprintf("railpage:image=%d", $this->id);
        }

    }

    /**
     * Populate this image object
     *
     * @since Version 3.9.1
     * @return void
     *
     * @param int $option
     */

    private function load($option = null) {

        Debug::RecordInstance();

        $this->mckey = sprintf("railpage:image=%d", $this->id);

        if (( defined("NOREDIS") && NOREDIS == true ) || !$row = $this->Redis->fetch($this->mckey)) {

            Debug::LogCLI("Fetching data for " . $this->id . " from database");

            $query = "SELECT i.title, i.description, i.id, i.provider, i.photo_id, i.modified, i.meta, i.lat, i.lon, i.user_id, i.geoplace, i.captured FROM image AS i WHERE i.id = ?";

            $row = $this->db->fetchRow($query, $this->id);
            $row['meta'] = json_decode($row['meta'], true);

            $this->Redis->save($this->mckey, $row, strtotime("+24 hours"));
        }

        $this->populateFromArray($row);

        if ($this->provider == "rpoldgallery") {
            $GalleryImage = new \Railpage\Gallery\Image($this->photo_id);
            $this->url->source = $GalleryImage->url->url;

            if (empty( $this->meta['source'] )) {
                $this->meta['source'] = $this->url->source;
            }
        }

        if (!isset( $row['user_id'] )) {
            $row['user_id'] = 0;
        }

        /**
         * Update the database row
         */

        if (( ( !isset( $row['title'] ) || empty( $row['title'] ) || is_null($row['title']) ) && !empty( $this->title ) ) ||
            ( ( !isset( $row['description'] ) || empty( $row['description'] ) || is_null($row['description']) ) && !empty( $this->description ) )
        ) {
            $row['title'] = $this->title;
            $row['description'] = $this->description;

            $this->Redis->save($this->mckey, $row, strtotime("+24 hours"));

            $this->commit();
        }

        /**
         * Load the author. If we don't know who it is, attempt to re-populate the data
         */

        if (isset( $row['meta']['author'] )) {
            $this->author = json_decode(json_encode($row['meta']['author']));

            if (isset( $this->author->railpage_id ) && $row['user_id'] === 0) {
                $row['user_id'] == $this->author->railpage_id;
            }

            if (filter_var($row['user_id'], FILTER_VALIDATE_INT)) {
                $this->author->User = UserFactory::CreateUser($row['user_id']);
            }
        } else {
            Debug::LogCLI("No author found in local cache - refreshing from " . $this->provider);
            Debug::LogEvent("No author found in local cache - refreshing from " . $this->provider);
            $this->populate(true, $option);
        }

        /**
         * Unless otherwise instructed load the places object if lat/lng are present
         */

        if ($option != Images::OPT_NOPLACE && round($row['lat'], 3) != "0.000" && round($row['lon'],
                3) != "0.000"
        ) {
            try {
                $this->Place = Place::Factory($row['lat'], $row['lon']);
            } catch (Exception $e) {
                // Throw it away. Don't care.
            }
        }

        /**
         * Set the source URL
         */

        if (isset( $this->meta['source'] )) {
            $this->source = $this->meta['source'];
        } else {
            switch ($this->provider) {
                case "flickr" :
                    if (function_exists("base58_encode")) {
                        $this->source = "https://flic.kr/p/" . base58_encode($this->photo_id);
                    }
            }
        }

        /**
         * Create an array/JSON object
         */

        $this->getJSON();

    }

    /**
     * Validate changes to this image
     *
     * @since Version 3.8.7
     * @return boolean
     * @throws \Exception if $this->provider is empty
     * @throws \Exception if $this->photo_id is empty
     */

    public function validate() {

        if (empty( $this->provider ) && strpos($this->author->id, "@") !== false) {
            $this->provider = "flickr";
        }

        if (empty( $this->provider )) {
            throw new Exception("Image provider cannot be empty");
        }

        if (!filter_var($this->photo_id)) {
            throw new Exception("Photo ID from the image provider cannot be empty");
        }

        return true;
    }

    /**
     * Commit changes to this image
     *
     * @since Version 3.8.7
     * @return boolean
     */

    public function commit() {

        $this->validate();

        $user_id = isset( $this->author->User ) && $this->author->User instanceof User ? $this->author->User->id : 0;

        $author = $this->author;
        unset( $author->User );

        $data = array(
            "title"       => $this->title,
            "description" => $this->description,
            "captured"    => $this->DateCaptured instanceof DateTime ? $this->DateCaptured->format("Y-m-d H:i:s") : null,
            "provider"    => $this->provider,
            "photo_id"    => $this->photo_id,
            "user_id"     => $user_id,
            "meta"        => json_encode(array(
                "title"       => $this->title,
                "description" => $this->description,
                "sizes"       => $this->sizes,
                "links"       => $this->links,
                "data"        => $this->meta,
                "author"      => $author
            ))
        );

        if ($this->Place instanceof Place) {
            $data['lat'] = $this->Place->lat;
            $data['lon'] = $this->Place->lon;
        }

        // Update
        if (filter_var($this->id, FILTER_VALIDATE_INT)) {
            $this->Memcached->delete($this->mckey);
            $this->Redis->delete($this->mckey);

            $where = array(
                "id = ?" => $this->id
            );

            $Date = new DateTime();
            $data['modified'] = $Date->format("Y-m-d g:i:s");

            $this->db->update("image", $data, $where);

            $this->getJSON();
            
            return $this;
        }
        
        // Insert
        $this->db->insert("image", $data);
        $this->id = $this->db->lastInsertId();
        $this->url = Utility\Url::CreateFromImageID($this->id);

        $this->getJSON();

        return $this;
    }

    /**
     * Check if this image has become stale
     *
     * @since Version 3.8.7
     * @return boolean
     */

    public function isStale() {

        if (!$this->Date instanceof DateTime) {
            return true;
        }

        $Now = new DateTime;
        $Diff = $this->Date->diff($Now);

        if ($Diff->d >= self::MAXAGE) {
            $this->Memcached->delete($this->mckey);

            Debug::LogCLI("Image ID " . $this->id . " (photo ID " . $this->photo_id . ") is stale");

            return true;
        }

        return false;
    }

    /**
     * Get an instance of the image provider
     *
     * @since Version 3.9.1
     * @return object
     */

    public function getProvider() {

        if (!is_null($this->ImageProvider)) {
            return $this->ImageProvider;
        }

        return ImageUtility::CreateImageProvider($this->provider, $this->providerOptions);

    }

    /**
     * Set the image provider's options
     *
     * @since Version 3.9.1
     *
     * @param array $options
     *
     * @return \Railpage\Images\Image
     */

    public function setProviderOptions($options) {

        $this->providerOptions = $options;

        if (!is_null($this->ImageProvider)) {
            $this->ImageProvider->setOptions($this->providerOptions);
        }

        return $this;

    }

    /**
     * Populate this image with fresh data
     *
     * @since Version 3.8.7
     * @return $this
     *
     * @param boolean $force
     * @param int     $option
     *
     * @throws \Exception if the photo cannot be found on the image provider
     * @todo  Split this into utility functions
     */

    public function populate($force = false, $option = null) {

        if ($force === false && !$this->isStale()) {
            return $this;
        }

        Debug::LogCLI("Fetching data from " . $this->provider . " for image ID " . $this->id . " (photo ID " . $this->photo_id . ")");

        /**
         * Start the debug timer
         */

        if (RP_DEBUG) {
            global $site_debug;
            $debug_timer_start = microtime(true);
        }

        /**
         * New and improved populator using image providers
         */

        $Provider = $this->getProvider();

        $data = false;

        try {
            $data = $Provider->getImage($this->photo_id, $force);
        } catch (Exception $e) {
            $expected = array(
                sprintf(
                    "Unable to fetch data from Flickr: Photo \"%s\" not found (invalid ID) (1)",
                    $this->photo_id
                ),
                "Unable to fetch data from Flickr: Photo not found (1)"
            );

            if (in_array($e->getMessage(), $expected)) {

                $where = ["image_id = ?" => $this->id];
                $this->db->delete("image_link", $where);

                $where = ["id = ?" => $this->id];
                $this->db->delete("image", $where);

                throw new Exception("Photo no longer available from " . $this->provider);
            }
        }

        if ($data) {
            $this->sizes = $data['sizes'];
            $this->title = empty( $data['title'] ) ? "Untitled" : $data['title'];
            $this->description = $data['description'];
            $this->meta = array(
                "dates" => array(
                    "posted" => $data['dates']['uploaded'] instanceof DateTime ? $data['dates']['uploaded']->format("Y-m-d H:i:s") : $data['dates']['uploaded']['date'],
                    "taken"  => $data['dates']['taken'] instanceof DateTime ? $data['dates']['taken']->format("Y-m-d H:i:s") : $data['dates']['taken']['date'],
                )
            );

            $this->author = new stdClass;
            $this->author->username = $data['author']['username'];
            $this->author->realname = !empty( $data['author']['realname'] ) ? $data['author']['realname'] : $data['author']['username'];
            $this->author->id = $data['author']['id'];
            $this->author->url = "https://www.flickr.com/photos/" . $this->author->id;

            if ($user_id = UserUtility::findFromFlickrNSID($this->author->id)) {
                $data['author']['railpage_id'] = $user_id;
            }

            if (isset( $data['author']['railpage_id'] ) && filter_var($data['author']['railpage_id'],
                    FILTER_VALIDATE_INT)
            ) {
                $this->author->User = UserFactory::CreateUser($data['author']['railpage_id']);
            }

            /**
             * Load the tags
             */

            if (isset( $data['tags'] ) && is_array($data['tags']) && count($data['tags'])) {
                foreach ($data['tags'] as $row) {
                    $this->meta['tags'][] = $row['raw'];
                }
            }

            /**
             * Load the Place object
             */

            if ($option != Images::OPT_NOPLACE && isset( $data['location'] ) && !empty( $data['location'] )) {
                try {
                    $this->Place = Place::Factory($data['location']['latitude'], $data['location']['longitude']);
                } catch (Exception $e) {
                    // Throw it away. Don't care.
                }
            }

            $this->links = new stdClass;
            $this->links->provider = isset( $data['urls']['url'][0]['_content'] ) ? $data['urls']['url'][0]['_content'] : $data['urls'][key($data['urls'])];

            $this->commit();
            $this->cacheGeoData();

            return true;
        }

        /**
         * Fetch data in various ways for different photo providers
         */

        switch ($this->provider) {

            /**
             * Picasa
             */

            case "picasaweb" :

                if (
                    empty( $this->meta ) && 
                    !is_null(filter_input(INPUT_SERVER, "HTTP_REFERER", FILTER_SANITIZE_URL)) && 
                    strpos(filter_input(INPUT_SERVER, "HTTP_REFERER", FILTER_SANITIZE_URL), "picasaweb.google.com")
                ) {
                    $album = preg_replace(
                        "@(http|https)://picasaweb.google.com/([a-zA-Z\-\.]+)/(.+)@", "$2",
                        filter_input(INPUT_SERVER, "HTTP_REFERER", FILTER_SANITIZE_URL)
                    );

                    if (is_string($album)) {
                        $update_url = sprintf(
                            "https://picasaweb.google.com/data/feed/api/user/%s/photoid/%s?alt=json",
                            $album, 
                            $this->photo_id
                        );
                    }
                }

                if (isset( $update_url )) {
                    $data = file_get_contents($update_url);
                    $json = json_decode($data, true);

                    $this->meta = array(
                        "title"       => $json['feed']['subtitle']['$t'],
                        "description" => $json['feed']['title']['$t'],
                        "dates"       => array(
                            "posted" => date("Y-m-d H:i:s", $json['feed']['gphoto$timestamp']['$t']),
                        ),
                        "sizes"       => array(
                            "original" => array(
                                "width"  => $json['feed']['gphoto$width']['$t'],
                                "height" => $json['feed']['gphoto$height']['$t'],
                                "source" => str_replace(
                                    sprintf("/s%d/", $json['feed']['media$group']['media$thumbnail'][0]['width']),
                                    sprintf("/s%d/", $json['feed']['gphoto$width']['$t']),
                                    $json['feed']['media$group']['media$thumbnail'][0]['url']
                                ),
                            ),
                            "largest"  => array(
                                "width"  => $json['feed']['gphoto$width']['$t'],
                                "height" => $json['feed']['gphoto$height']['$t'],
                                "source" => str_replace(
                                    sprintf("/s%d/", $json['feed']['media$group']['media$thumbnail'][0]['width']),
                                    sprintf("/s%d/", $json['feed']['gphoto$width']['$t']),
                                    $json['feed']['media$group']['media$thumbnail'][0]['url']
                                ),
                            ),
                        ),
                        "photo_id"    => $json['feed']['gphoto$id']['$t'],
                        "album_id"    => $json['feed']['gphoto$albumid']['$t'],
                        "updateurl"   => sprintf("%s?alt=json", $json['feed']['id']['$t'])
                    );

                    foreach ($json['feed']['media$group']['media$thumbnail'] as $size) {
                        if ($size['width'] <= 500 && $size['width'] > 200) {
                            $this->meta['sizes']['small'] = array(
                                "width"  => $size['width'],
                                "height" => $size['height'],
                                "source" => $size['url']
                            );
                        }

                        if ($size['width'] <= 200) {
                            $this->meta['sizes']['small'] = array(
                                "width"  => $size['width'],
                                "height" => $size['height'],
                                "source" => $size['url']
                            );
                        }

                        if ($size['width'] <= 1024 && $size['width'] > 500) {
                            $this->meta['sizes']['large'] = array(
                                "width"  => $size['width'],
                                "height" => $size['height'],
                                "source" => $size['url']
                            );
                        }
                    }

                    foreach ($json['feed']['link'] as $link) {
                        if ($link['rel'] == "alternate" && $link['type'] == "text/html") {
                            $this->meta['source'] = $link['href'];
                        }
                    }

                    if ($option != Images::OPT_NOPLACE && isset( $json['feed']['georss$where']['gml$Point'] ) && is_array($json['feed']['georss$where']['gml$Point'])) {
                        $pos = explode(" ", $json['feed']['georss$where']['gml$Point']['gml$pos']['$t']);
                        $this->Place = Place::Factory($pos[0], $pos[1]);
                    }

                    $this->title = $this->meta['title'];
                    $this->description = $this->meta['description'];

                    $this->author = new stdClass;
                    $this->author->username = $album;
                    $this->author->id = $album;
                    $this->author->url = sprintf("%s/%s", $json['feed']['generator']['uri'], $album);
                }

                $this->sizes = $this->meta['sizes'];

                $this->commit();
                break;

            /**
             * Vicsig
             */

            case "vicsig" :

                if (strpos(filter_input(INPUT_SERVER, "HTTP_REFERER", FILTER_SANITIZE_URL), "vicsig.net/photo")) {
                    $this->meta['source'] = filter_input(INPUT_SERVER, "HTTP_REFERER", FILTER_SANITIZE_STRING);

                    $response = $this->GuzzleClient->get($this->meta['source']);

                    if ($response->getStatusCode() != 200) {
                        throw new Exception(
                            sprintf(
                                "Failed to fetch image data from %s: HTTP error %s",
                                $this->provider, 
                                $response->getStatusCode()
                            )
                        );
                    }

                    /**
                     * Start fetching it
                     */

                    $data = $response->getBody();

                    $doc = new DomDocument();
                    $doc->loadHTML($data);

                    $images = $doc->getElementsByTagName("img");

                    foreach ($images as $element) {

                        if (!empty( $element->getAttribute("src") ) && !empty( $element->getAttribute("alt") )) {
                            #$image_title = $element->getAttribute("alt");

                            $this->sizes['original'] = array(
                                "source" => $element->getAttribute("src"),
                                "width"  => $element->getAttribute("width"),
                                "height" => $element->getAttribute("height"),
                            );

                            if (substr($this->sizes['original']['source'], 0, 1) == "/") {
                                $this->sizes['original']['source'] = "http://www.vicsig.net" . $this->sizes['original']['source'];
                            }

                            break;
                        }
                    }

                    $desc = $doc->getElementsByTagName("i");

                    foreach ($desc as $element) {
                        if (!isset( $image_desc )) {
                            $text = trim($element->nodeValue);
                            $text = str_replace("\r\n", "\n", $text);
                            $text = explode("\n", $text);

                            /**
                             * Loop through the exploded text and remove the obvious date/author/etc
                             */

                            foreach ($text as $k => $line) {

                                // Get the author
                                if (preg_match("@Photo: @i", $line)) {
                                    $this->author = new stdClass;
                                    $this->author->realname = str_replace("Photo: ", "", $line);
                                    $this->author->url = filter_input(
                                        INPUT_SERVER, 
                                        "HTTP_REFERER",
                                        FILTER_SANITIZE_STRING
                                    );
                                    
                                    unset( $text[$k] );
                                }

                                // Get the date
                                try {
                                    $this->meta['dates']['posted'] = (new DateTime($line))->format("Y-m-d H:i:s");
                                    unset( $text[$k] );
                                } catch (Exception $e) {
                                    // Throw it away
                                }
                            }

                            /**
                             * Whatever's left must be the photo title and description
                             */

                            foreach ($text as $k => $line) {
                                if (empty( $this->title )) {
                                    $this->title = $line;
                                    continue;
                                }

                                $this->description .= $line;
                            }

                            $this->links = new stdClass;
                            $this->links->provider = filter_input(
                                INPUT_SERVER, 
                                "HTTP_REFERER",
                                FILTER_SANITIZE_STRING
                            );

                            $this->commit();
                        }
                    }

                }

                break;

        }

        /**
         * End the debug timer
         */

        if (RP_DEBUG) {
            $site_debug[] = __CLASS__ . "::" . __FUNCTION__ . "() : completed in " . round(microtime(true) - $debug_timer_start, 5) . "s";
        }

        return $this;
    }

    /**
     * Link this image to a loco, location, etc
     *
     * @param string     $namespace
     * @param int|string $namespaceKey
     *
     * @throws \Exception if $namespace is null
     * @throws \Exception if $namespaceKey is null
     * @return \Railpage\Images\Image
     */

    public function addLink($namespace = null, $namespaceKey = null) {

        if (is_null($namespace)) {
            throw new Exception("Parameter 1 (namespace) cannot be empty");
        }

        if (!filter_var($namespaceKey, FILTER_VALIDATE_INT)) {
            throw new Exception("Parameter 2 (namespace_key) cannot be empty");
        }

        $id = $this->db->fetchOne(
            "SELECT id FROM image_link WHERE namespace = ? AND namespace_key = ? AND image_id = ?",
            array($namespace, $namespaceKey, $this->id))
        ;

        if (!filter_var($id, FILTER_VALIDATE_INT)) {
            $data = array(
                "image_id"      => $this->id,
                "namespace"     => $namespace,
                "namespace_key" => $namespaceKey,
                "ignored"       => 0
            );

            $this->db->insert("image_link", $data);
        }

        $this->getJSON();

        return $this;
    }

    /**
     * Generate the JSON data string
     *
     * @return $this
     */

    public function getJSON() {

        if (isset( $this->author )) {
            $author = clone $this->author;

            if (isset( $author->User ) && $author->User instanceof User) {
                $author->User = $author->User->getArray();
            }
        }

        $data = array(
            "id"          => $this->id,
            "title"       => $this->title,
            "description" => $this->description,
            "score"       => $this->getScore(),
            "provider"    => array(
                "name"     => $this->provider,
                "photo_id" => $this->photo_id
            ),
            "sizes"       => Images::NormaliseSizes($this->sizes),
            "srcset"      => implode(", ", Utility\ImageUtility::generateSrcSet($this)),
            "author"      => isset( $author ) ? $author : false,
            "url"         => $this->url instanceof Url ? $this->url->getURLs() : array(),
            "dates"       => array()
        );

        $times = ["posted", "taken"];


        #printArray($this->meta['dates']);die;

        foreach ($times as $time) {
            if (isset( $this->meta['dates'][$time] )) {
                $Date = filter_var($this->meta['dates'][$time],
                    FILTER_VALIDATE_INT) ? new DateTime("@" . $this->meta['dates'][$time]) : new DateTime($this->meta['dates'][$time]);

                $data['dates'][$time] = array(
                    "absolute" => $Date->format("Y-m-d H:i:s"),
                    "nice"     => $Date->Format("d H:i:s") == "01 00:00:00" ? $Date->Format("F Y") : $Date->format("F j, Y, g:i a"),
                    "relative" => ContentUtility::RelativeTime($Date)
                );
            }
        }

        if ($this->Place instanceof Place) {
            $data['place'] = array(
                "url"     => $this->Place->url,
                "lat"     => $this->Place->lat,
                "lon"     => $this->Place->lon,
                "name"    => $this->Place->name,
                "country" => array(
                    "code" => $this->Place->Country->code,
                    "name" => $this->Place->Country->name,
                    "url"  => $this->Place->Country->url
                )
            );
        }

        $this->json = json_encode($data);

        return $this;
    }

    /**
     * Get locos in this image
     *
     * @since Version 3.8.7
     * @return array
     */

    public function getLocos() {

        $query = "SELECT namespace_key AS loco_id FROM image_link WHERE image_id = ? AND namespace = ? AND ignored = 0";

        $return = array();

        foreach ($this->db->fetchAll($query, array($this->id, "railpage.locos.loco")) as $row) {
            $return[] = $row['loco_id'];
        }

        return $return;
    }

    /**
     * Find Railpage objects (loco, class, livery) in this image
     *
     * @since Version 3.8.7
     *
     * @param string  $namespace
     * @param boolean $force
     *
     * @return \Railpage\Images\Image;
     * @throws \Exception if $namespace is null or empty
     */

    public function findObjects($namespace = null, $force = false) {

        if (is_null($namespace)) {
            throw new Exception("Parameter 1 (namespace) cannot be empty");
        }

        $key = sprintf("railpage:images.image=%d;objects.namespace=%s;lastupdate", $this->id, $namespace);

        $lastupdate = $this->Memcached->fetch($key);

        if (!$force && $lastupdate && $lastupdate > strtotime("1 day ago")) {
            return $this;
        }

        /**
         * Start the debug timer
         */

        $timer = Debug::GetTimer();

        switch ($namespace) {

            case "railpage.locos.loco" :
                if (isset( $this->meta['tags'] )) {

                    foreach ($this->meta['tags'] as $tag) {
                        if (preg_match("@railpage:class=([0-9]+)@", $tag, $matches)) {
                            Debug::LogEvent(__METHOD__ . " :: #1 Instantating new LocoClass object with ID " . $matches[1] . "  ");
                            $LocoClass = LocosFactory::CreateLocoClass($matches[1]);
                        }
                    }

                    foreach ($this->meta['tags'] as $tag) {
                        if (isset( $LocoClass ) && $LocoClass instanceof LocoClass && preg_match("@railpage:loco=([a-zA-Z0-9]+)@", $tag, $matches) ) {
                            Debug::LogEvent(__METHOD__ . " :: #2 Instantating new LocoClass object with class ID " . $LocoClass->id . " and loco number " . $matches[1] . "  ");
                            $Loco = LocosFactory::CreateLocomotive(false, $LocoClass->id, $matches[1]);

                            if (filter_var($Loco->id, FILTER_VALIDATE_INT)) {
                                $this->addLink($Loco->namespace, $Loco->id);
                            }
                        }
                    }

                    foreach ($this->db->fetchAll("SELECT id AS class_id, flickr_tag AS class_tag FROM loco_class") as $row) {
                        foreach ($this->meta['tags'] as $tag) {
                            if (stristr($tag, $row['class_tag']) && strlen(str_replace($row['class_tag'] . "-", "", $tag) > 0) ) {
                                $loco_num = str_replace($row['class_tag'] . "-", "", $tag);
                                Debug::LogEvent(__METHOD__ . " :: #3 Instantating new LocoClass object with class ID " . $row['class_id'] . " and loco number " . $loco_num . "  ");
                                $Loco = LocosFactory::CreateLocomotive(false, $row['class_id'], $loco_num);

                                if (filter_var($Loco->id, FILTER_VALIDATE_INT)) {
                                    $this->addLink($Loco->namespace, $Loco->id);

                                    if (!$Loco->hasCoverImage()) {
                                        $Loco->setCoverImage($this);
                                    }

                                    if (!$Loco->Class->hasCoverImage()) {
                                        $Loco->Class->setCoverImage($this);
                                    }
                                }
                            }
                        }
                    }
                }

                break;

            case "railpage.locos.class" :
                if (isset( $this->meta['tags'] )) {
                    foreach ($this->db->fetchAll("SELECT id AS class_id, flickr_tag AS class_tag FROM loco_class") as $row) {
                        foreach ($this->meta['tags'] as $tag) {
                            if ($tag == $row['class_tag']) {
                                $LocoClass = LocosFactory::CreateLocoClass($row['class_id']);

                                if (filter_var($LocoClass->id, FILTER_VALIDATE_INT)) {
                                    $this->addLink($LocoClass->namespace, $LocoClass->id);
                                }
                            }
                        }
                    }

                    foreach ($this->meta['tags'] as $tag) {
                        if (preg_match("@railpage:class=([0-9]+)@", $tag, $matches)) {
                            $LocoClass = LocosFactory::CreateLocoClass($matches[1]);

                            if (filter_var($LocoClass->id, FILTER_VALIDATE_INT)) {
                                $this->addLink($LocoClass->namespace, $LocoClass->id);

                                if (!$LocoClass->hasCoverImage()) {
                                    $LocoClass->setCoverImage($this);
                                }
                            }
                        }
                    }
                }

                break;

            case "railpage.locos.liveries.livery" :
                if (isset( $this->meta['tags'] )) {
                    foreach ($this->meta['tags'] as $tag) {
                        if (preg_match("@railpage:livery=([0-9]+)@", $tag, $matches)) {
                            $Livery = new Livery($matches[1]);

                            if (filter_var($Livery->id, FILTER_VALIDATE_INT)) {
                                $this->addLink($Livery->namespace, $Livery->id);
                            }
                        }
                    }
                }

                break;
        }

        Debug::LogEvent(__METHOD__ . "(\"" . $namespace . "\")", $timer);

        $this->Memcached->save($key, time());

        return $this;
    }

    /**
     * Get objects linked to this image
     *
     * @since Version 3.8.7
     * @return array
     *
     * @param string $namespace
     */

    public function getObjects($namespace = null) {

        $params = array(
            $this->id
        );
        
        $where_namespace = "";

        if (!is_null($namespace)) {
            $params[] = $namespace;
            $where_namespace = "AND namespace = ?";
        }

        $rs = $this->db->fetchAll(
            "SELECT * FROM image_link WHERE image_id = ? " . $where_namespace . " AND ignored = 0",
            $params
        );

        return $rs;
    }

    /**
     * Mark an image as ignored
     *
     * @since Version 3.8.7
     *
     * @param boolean $ignored
     *
     * @return boolean
     */

    public function ignored($ignored = 1, $linkId = 0) {

        $data = array(
            "ignored" => intval($ignored)
        );

        $where = array(
            "image_id = ?" => $this->id
        );

        if (filter_var($linkId, FILTER_VALIDATE_INT) && $linkId > 0) {
            $where['id = ?'] = $linkId;
        }

        $this->db->update("image_link", $data, $where);

        return true;
    }

    /**
     * Get an associative array representing this object
     *
     * @since Version 3.9.1
     * @return array
     */

    public function getArray() {

        $this->getJSON();

        return json_decode($this->json, true);
    }

    /**
     * Suggest locos to tag
     *
     * @since Version 3.9.1
     * @return array
     *
     * @param boolean $skipTagged Remove locos already tagged in this photo from the list of suggested locos
     */

    public function suggestLocos($skipTagged = null) {
        
        return Utility\Tagger::suggestLocos($this, $skipTagged); 
        
    }

    /**
     * Suggest liveries to tag based on other locos in this class
     *
     * @since Version 3.9.1
     * @return array
     */

    public function suggestLiveries() {

        $query = '
            SELECT livery.livery_id AS id, livery.livery AS name, livery.photo_id
            FROM loco_livery AS livery
                LEFT JOIN image_link AS link ON link.namespace_key = livery.livery_id
            WHERE link.namespace = "railpage.locos.liveries.livery"
                AND image_id IN (
                    SELECT image_id 
                    FROM image_link 
                    WHERE namespace = ? 
                        AND namespace_key IN (
                            SELECT namespace_key AS class_id 
                            FROM image_link 
                            WHERE namespace = ? 
                                AND image_id = ?
                                AND ignored = 0
                        )
                )
                AND livery_id NOT IN (
                    SELECT namespace_key FROM image_link WHERE namespace = "railpage.locos.liveries.livery" AND image_id = ?
                )
            GROUP BY livery.livery_id
            ORDER BY link.id DESC';

        $params = [
            "railpage.locos.loco",
            "railpage.locos.loco",
            $this->id,
            $this->id
        ];

        $liveries = $this->db->fetchAll($query, $params);

        if (count($liveries) === 0) {
            $params = [
                "railpage.locos.class",
                "railpage.locos.class",
                $this->id,
                $this->id
            ];

            $liveries = $this->db->fetchAll($query, $params);
        }

        return $liveries;
    }

    /**
     * Insert this photo into the geocache
     *
     * @since Version 3.9.1
     * @return \Railpage\Images\Image
     */

    private function cacheGeoData() {

        if (!$this->Place instanceof Place) {
            return $this;
        }

        $data = [
            "photo_id"   => $this->photo_id,
            "lat"        => $this->Place->lat,
            "lon"        => $this->Place->lon,
            "owner"      => $this->author->url,
            "ownername"  => $this->author->username,
            "title"      => $this->title,
            "tags"       => isset( $this->meta['tags'] ) ? $this->meta['tags'] : array(),
            "dateadded"  => "",
            "dateupload" => "",
            "datetaken"  => ""
        ];

        $sizes = [75, 100, 240, 500, 640, 1024, 320, 800];

        foreach ($this->sizes as $k => $row) {
            $i = array_search($row['width'], $sizes);
            if ($i !== false) {
                $size = sprintf("size%d", $i);
                $width = sprintf("%s_w", $size);
                $height = sprintf("%s_h", $size);

                $data[$size] = $row['source'];
                $data[$width] = $row['width'];
                $data[$height] = $row['height'];

                continue;
            }

            if ($k == "original" || $k == "largest" || $row['width'] >= 1024) {
                $size = sprintf("size%d", 8);
                $width = sprintf("%s_w", $size);
                $height = sprintf("%s_h", $size);

                $data[$size] = $row['source'];
                $data[$width] = $row['width'];
                $data[$height] = $row['height'];

                continue;
            }
        }

        if (is_array($data['tags'])) {
            $data['tags'] = implode(" ", $data['tags']);
        }

        $query = "INSERT IGNORE INTO flickr_geodata SET ";

        $terms = count($data);
        foreach ($data as $key => $val) {
            $terms--;

            $query .= $key . ' = ' . $this->db->quote($val);

            if ($terms) {
                $query .= ', ';
            }
        }

        $cn = $this->db->getConnection();

        $cn->query($query);

        return $this;

    }

    /**
     * Bump the hit counter
     *
     * @since Version 3.9.1
     * @return \Railpage\Images\Image
     */

    public function hit() {

        $data = [
            "hits_today"   => new Zend_Db_Expr('hits_today + 1'),
            "hits_weekly"  => new Zend_Db_Expr('hits_weekly + 1'),
            "hits_overall" => new Zend_Db_Expr('hits_overall + 1')
        ];

        $where = [
            "id = ?" => $this->id
        ];

        $this->db->update("image", $data, $where);

        return $this;

    }

    /**
     * Update the geoplace reference for this image
     *
     * @since Version 3.9.1
     * @return void
     */

    public function updateGeoPlace() {

        if (!filter_var($this->lat, FILTER_VALIDATE_FLOAT) || !filter_var($this->lat, FILTER_VALIDATE_FLOAT)) {
            return;
        }

        $timer = microtime(true);

        $GeoPlaceID = PlaceUtility::findGeoPlaceID($this->lat, $this->lon);

        #var_dump($GeoPlaceID);die;

        $data = ["geoplace" => $GeoPlaceID];

        $where = ["id = ?" => $this->id];
        $this->db->update("image", $data, $where);

        $this->Memcached->delete($this->mckey);
        $this->Redis->delete($this->mckey);

        Debug::logEvent(__METHOD__, $timer);
        Debug::LogCLI(__METHOD__, $timer);

        return;

    }

    /**
     * Hide this photo from the pool and searches
     *
     * @since Version 3.9.1
     * @return \Railpage\Images\Image
     */

    public function hide() {

        $data = ["hidden" => 1];
        $where = ["id = ?" => $this->id];

        $this->db->update("image", $data, $where);

        return $this;

    }

    /**
     * Get the score out of 100 of this image, based on titles, tags, description, etc.
     *
     * @since Version 3.10.0
     * @return int
     */

    public function getScore() {

        $score = 0;

        if ($this->title != "Untitled") {
            $score += 20;
        }

        if (strlen($this->title) > 30) {
            $score -= strlen($this->title) - 30;
        }

        $score += min(( strlen($this->description) / 20 ) * 3, 70);

        if (!preg_match("/([a-z])/", $this->description)) {
            $score -= 34;
        }

        if (filter_var($this->lat, FILTER_VALIDATE_FLOAT) && filter_var($this->lon, FILTER_VALIDATE_FLOAT)) {
            $score += 14;
        }

        return min($score, 100);

    }
}