RSSGenerator.php

14.29 KB
31/07/2025 12:17
PHP
RSSGenerator.php
<?php

/**
 * RSSGenerator class for creating RSS 2.0 XML feeds
 * Generates RSS feeds for selected albums with photo metadata
 * Includes caching and auto-update functionality
 */
class RSSGenerator
{
    /**
     * @var mixed
     */
    private $dataManager;
    /**
     * @var mixed
     */
    private $baseUrl;
    /**
     * @var mixed
     */
    private $config;
    /**
     * @var mixed
     */
    private $cacheDir;
    /**
     * @var mixed
     */
    private $cacheFile;
    /**
     * @var mixed
     */
    private $cacheLifetime;

    /**
     * Constructor
     * @param DataManager $dataManager Data manager instance
     * @param string $baseUrl Base URL for the gallery
     */
    public function __construct(DataManager $dataManager, $baseUrl = null)
    {
        $this->dataManager = $dataManager;
        $this->config = $dataManager->getAllConfig();
        $this->baseUrl = $baseUrl ?: $this->config['rss_base_url'] ?? 'http://localhost/';

        // Ensure base URL ends with slash
        $this->baseUrl = rtrim($this->baseUrl, '/').'/';

        // Initialize caching
        $this->cacheDir = __DIR__.'/../data/cache';
        $this->cacheFile = $this->cacheDir.'/rss_feed.xml';
        $this->cacheLifetime = 300; // 5 minutes default cache lifetime

        // Ensure cache directory exists
        if (!is_dir($this->cacheDir)) {
            mkdir($this->cacheDir, 0755, true);
        }
    }

    /**
     * Generate complete RSS feed XML with caching support
     * @param bool $forceRegenerate Force regeneration ignoring cache
     * @return string RSS XML content
     */
    public function generateFeed($forceRegenerate = false)
    {
        try {
            // Check if cached version exists and is still valid
            if (!$forceRegenerate && $this->isCacheValid()) {
                return file_get_contents($this->cacheFile);
            }

            $enabledAlbums = $this->getEnabledAlbums();
            $rssItems = [];

            // Collect photos from all RSS-enabled albums
            foreach ($enabledAlbums as $album) {
                $photos = $this->dataManager->getPhotos($album['id']);

                foreach ($photos as $photo) {
                    $rssItems[] = $this->formatPhotoForRSS($photo, $album);
                }
            }

            // Sort items by upload date (newest first)
            usort($rssItems, function ($a, $b) {
                return strtotime($b['pubDate']) - strtotime($a['pubDate']);
            });

            // Limit items based on configuration
            $maxItems = $this->config['rss_max_items'] ?? 50;
            $rssItems = array_slice($rssItems, 0, $maxItems);

            // Generate RSS XML
            $xml = $this->generateRSSHeader();

            foreach ($rssItems as $item) {
                $xml .= $this->generateRSSItem($item);
            }

            $xml .= $this->generateRSSFooter();

            // Validate the generated XML
            if (!$this->validateRSSXML($xml)) {
                throw new Exception('Generated RSS XML is invalid');
            }

            // Cache the generated feed
            $this->cacheRSSFeed($xml);

            return $xml;

        } catch (Exception $e) {
            throw new Exception('Failed to generate RSS feed: '.$e->getMessage());
        }
    }

    /**
     * Get albums that are enabled for RSS
     * @return array Array of RSS-enabled albums
     */
    public function getEnabledAlbums()
    {
        try {
            $allAlbums = $this->dataManager->getAlbums(true);

            return array_filter($allAlbums, function ($album) {
                return !empty($album['is_rss_enabled']) && $album['photo_count'] > 0;
            });

        } catch (Exception $e) {
            throw new Exception('Failed to get enabled albums: '.$e->getMessage());
        }
    }

    /**
     * Format photo data for RSS item
     * @param array $photo Photo data
     * @param array $album Album data
     * @return array RSS item data
     */
    private function formatPhotoForRSS($photo, $album)
    {
        $photoUrl = $this->baseUrl."albums/{$album['id']}/{$photo['filename']}";
        $albumUrl = $this->baseUrl."?album={$album['id']}";

        // Create enhanced description with photo metadata and direct album link
        $description = "<p>Photo from album: <strong><a href=\"{$albumUrl}\">{$album['title']}</a></strong></p>";

        if (!empty($album['description'])) {
            $description .= "<p><em>{$album['description']}</em></p>";
        }

        $description .= "<p><img src=\"{$photoUrl}\" alt=\"Photo from {$album['title']}\" style=\"max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);\" /></p>";

        // Add direct link to view full album
        $description .= "<p><a href=\"{$albumUrl}\" style=\"display: inline-block; padding: 8px 16px; background: #3498db; color: white; text-decoration: none; border-radius: 4px; font-weight: 500;\">📸 View Full Album</a></p>";

        // Add photo metadata
        if (!empty($photo['width']) && !empty($photo['height'])) {
            $description .= "<p><small>📐 Dimensions: {$photo['width']} × {$photo['height']} pixels</small></p>";
        }

        if (!empty($photo['file_size'])) {
            $fileSize = $this->formatBytes($photo['file_size']);
            $description .= "<p><small>💾 File size: {$fileSize}</small></p>";
        }

        if (!empty($photo['uploaded_at'])) {
            $uploadDate = date('F j, Y g:i A', strtotime($photo['uploaded_at']));
            $description .= "<p><small>📅 Uploaded: {$uploadDate}</small></p>";
        }

        // Create enhanced title with album name
        $title = $photo['original_filename'] ?? $photo['filename'];
        if (!empty($album['title'])) {
            $title .= " | {$album['title']}";
        }

        return [
            'title' => $this->escapeXml($title),
            'link' => $this->escapeXml($albumUrl),
            'description' => $this->escapeXml($description),
            'pubDate' => $this->formatRSSDate($photo['uploaded_at']),
            'guid' => $this->escapeXml($photoUrl),
            'enclosure' => [
                'url' => $this->escapeXml($photoUrl),
                'length' => $photo['file_size'] ?? 0,
                'type' => 'image/webp'
            ]
        ];
    }

    /**
     * Generate RSS header
     * @return string RSS header XML
     */
    private function generateRSSHeader()
    {
        $title = $this->escapeXml($this->config['rss_title'] ?? 'Photo Gallery RSS Feed');
        $description = $this->escapeXml($this->config['rss_description'] ?? 'Latest photos from selected albums');
        $link = $this->escapeXml($this->baseUrl);
        $buildDate = $this->formatRSSDate(date('c'));

        return '<?xml version="1.0" encoding="UTF-8"?>'."\n".
            '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">'."\n".
            '<channel>'."\n".
            "<title>{$title}</title>\n".
            "<link>{$link}</link>\n".
            "<description>{$description}</description>\n".
            "<language>en-us</language>\n".
            "<lastBuildDate>{$buildDate}</lastBuildDate>\n".
            "<generator>Photo Gallery Backend</generator>\n".
            "<atom:link href=\"{$link}api.php?action=getRSSFeed\" rel=\"self\" type=\"application/rss+xml\" />\n";
    }

    /**
     * Generate RSS footer
     * @return string RSS footer XML
     */
    private function generateRSSFooter()
    {
        return "</channel>\n</rss>\n";
    }

    /**
     * Generate single RSS item
     * @param array $item RSS item data
     * @return string RSS item XML
     */
    private function generateRSSItem($item)
    {
        $xml = "<item>\n";
        $xml .= "<title>{$item['title']}</title>\n";
        $xml .= "<link>{$item['link']}</link>\n";
        $xml .= "<description><![CDATA[{$item['description']}]]></description>\n";
        $xml .= "<pubDate>{$item['pubDate']}</pubDate>\n";
        $xml .= "<guid isPermaLink=\"false\">{$item['guid']}</guid>\n";

        if (!empty($item['enclosure'])) {
            $xml .= "<enclosure url=\"{$item['enclosure']['url']}\" ".
                "length=\"{$item['enclosure']['length']}\" ".
                "type=\"{$item['enclosure']['type']}\" />\n";
        }

        $xml .= "</item>\n";

        return $xml;
    }

    /**
     * Format date for RSS (RFC 2822 format)
     * @param string $date ISO date string
     * @return string RFC 2822 formatted date
     */
    private function formatRSSDate($date)
    {
        if (empty($date)) {
            return date('r');
        }

        return date('r', strtotime($date));
    }

    /**
     * Escape XML special characters
     * @param string $text Text to escape
     * @return string Escaped text
     */
    private function escapeXml($text)
    {
        return htmlspecialchars($text, ENT_XML1 | ENT_COMPAT, 'UTF-8');
    }

    /**
     * Format bytes to human readable format
     * @param int $bytes Number of bytes
     * @return string Formatted string
     */
    private function formatBytes($bytes)
    {
        if ($bytes == 0) {
            return '0 B';
        }

        $units = ['B', 'KB', 'MB', 'GB'];
        $factor = floor(log($bytes, 1024));

        return sprintf('%.1f %s', $bytes / pow(1024, $factor), $units[$factor]);
    }

    /**
     * Check if cached RSS feed is still valid
     * @return bool True if cache is valid
     */
    private function isCacheValid()
    {
        if (!file_exists($this->cacheFile)) {
            return false;
        }

        $cacheTime = filemtime($this->cacheFile);
        $currentTime = time();

        // Check if cache has expired
        if (($currentTime - $cacheTime) > $this->cacheLifetime) {
            return false;
        }

        // Check if any RSS-enabled albums have been modified since cache was created
        $enabledAlbums = $this->getEnabledAlbums();
        foreach ($enabledAlbums as $album) {
            if (strtotime($album['updated_at']) > $cacheTime) {
                return false;
            }
        }

        return true;
    }

    /**
     * Cache the RSS feed content
     * @param string $xml RSS XML content
     * @return bool Success status
     */
    private function cacheRSSFeed($xml)
    {
        try {
            return file_put_contents($this->cacheFile, $xml) !== false;
        } catch (Exception $e) {
            error_log('Failed to cache RSS feed: '.$e->getMessage());
            return false;
        }
    }

    /**
     * Validate RSS XML format
     * @param string $xml RSS XML content
     * @return bool True if valid
     */
    private function validateRSSXML($xml)
    {
        try {
            // Enable user error handling
            libxml_use_internal_errors(true);
            libxml_clear_errors();

            // Create DOMDocument and load XML
            $dom = new DOMDocument();
            $dom->loadXML($xml);

            // Check for XML errors
            $errors = libxml_get_errors();
            if (!empty($errors)) {
                error_log('RSS XML validation errors: '.print_r($errors, true));
                return false;
            }

            // Validate RSS structure
            $rssElements = $dom->getElementsByTagName('rss');
            if ($rssElements->length !== 1) {
                error_log('RSS XML validation error: Missing or multiple RSS elements');
                return false;
            }

            $channelElements = $dom->getElementsByTagName('channel');
            if ($channelElements->length !== 1) {
                error_log('RSS XML validation error: Missing or multiple channel elements');
                return false;
            }

            // Check required channel elements
            $requiredElements = ['title', 'link', 'description'];
            $channel = $channelElements->item(0);

            foreach ($requiredElements as $element) {
                $elements = $channel->getElementsByTagName($element);
                if ($elements->length === 0) {
                    error_log("RSS XML validation error: Missing required element: {$element}");
                    return false;
                }
            }

            return true;

        } catch (Exception $e) {
            error_log('RSS XML validation exception: '.$e->getMessage());
            return false;
        } finally {
            // Restore error handling
            libxml_use_internal_errors(false);
        }
    }

    /**
     * Invalidate RSS cache (force regeneration on next request)
     * @return bool Success status
     */
    public function invalidateCache()
    {
        if (file_exists($this->cacheFile)) {
            return unlink($this->cacheFile);
        }
        return true;
    }

    /**
     * Trigger RSS feed regeneration (auto-update mechanism)
     * Called when photos are added or albums are modified
     * @return bool Success status
     */
    public function triggerAutoUpdate()
    {
        try {
            // Invalidate cache first
            $this->invalidateCache();

            // Generate new feed (this will create new cache)
            $this->generateFeed(true);

            return true;

        } catch (Exception $e) {
            error_log('RSS auto-update failed: '.$e->getMessage());
            return false;
        }
    }

    /**
     * Get RSS feed discovery meta tags for HTML head
     * @return string HTML meta tags
     */
    public function getRSSDiscoveryTags()
    {
        $rssUrl = $this->baseUrl.'api.php?action=getRSSFeed';
        $title = $this->config['rss_title'] ?? 'Photo Gallery RSS Feed';

        return '<link rel="alternate" type="application/rss+xml" title="'.
        htmlspecialchars($title, ENT_QUOTES, 'UTF-8').
        '" href="'.htmlspecialchars($rssUrl, ENT_QUOTES, 'UTF-8').'" />';
    }

    /**
     * Get cache statistics
     * @return array Cache information
     */
    public function getCacheStats()
    {
        $stats = [
            'cache_exists' => file_exists($this->cacheFile),
            'cache_valid' => false,
            'cache_size' => 0,
            'cache_age' => 0,
            'cache_lifetime' => $this->cacheLifetime
        ];

        if ($stats['cache_exists']) {
            $stats['cache_size'] = filesize($this->cacheFile);
            $stats['cache_age'] = time() - filemtime($this->cacheFile);
            $stats['cache_valid'] = $this->isCacheValid();
        }

        return $stats;
    }
}