301 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			301 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
 | 
						|
namespace App\Services;
 | 
						|
 | 
						|
use App\Models\User;
 | 
						|
use App\Models\UserChangelogRead;
 | 
						|
use Carbon\Carbon;
 | 
						|
use Illuminate\Support\Collection;
 | 
						|
use Illuminate\Support\Facades\Cache;
 | 
						|
use Illuminate\Support\Facades\Log;
 | 
						|
use Spatie\LaravelMarkdown\MarkdownRenderer;
 | 
						|
 | 
						|
class ChangelogService
 | 
						|
{
 | 
						|
    public function getEntries(int $recentMonths = 3): Collection
 | 
						|
    {
 | 
						|
        // For backward compatibility, check if old changelog.json exists
 | 
						|
        if (file_exists(base_path('changelog.json'))) {
 | 
						|
            $data = $this->fetchChangelogData();
 | 
						|
 | 
						|
            if (! $data || ! isset($data['entries'])) {
 | 
						|
                return collect();
 | 
						|
            }
 | 
						|
 | 
						|
            return collect($data['entries'])
 | 
						|
                ->filter(fn ($entry) => $this->validateEntryData($entry))
 | 
						|
                ->map(function ($entry) {
 | 
						|
                    $entry['published_at'] = Carbon::parse($entry['published_at']);
 | 
						|
                    $entry['content_html'] = $this->parseMarkdown($entry['content']);
 | 
						|
 | 
						|
                    return (object) $entry;
 | 
						|
                })
 | 
						|
                ->filter(fn ($entry) => $entry->published_at <= now())
 | 
						|
                ->sortBy('published_at')
 | 
						|
                ->reverse()
 | 
						|
                ->values();
 | 
						|
        }
 | 
						|
 | 
						|
        // Load entries from recent months for performance
 | 
						|
        $availableMonths = $this->getAvailableMonths();
 | 
						|
        $monthsToLoad = $availableMonths->take($recentMonths);
 | 
						|
 | 
						|
        return $monthsToLoad
 | 
						|
            ->flatMap(fn ($month) => $this->getEntriesForMonth($month))
 | 
						|
            ->sortBy('published_at')
 | 
						|
            ->reverse()
 | 
						|
            ->values();
 | 
						|
    }
 | 
						|
 | 
						|
    public function getAllEntries(): Collection
 | 
						|
    {
 | 
						|
        $availableMonths = $this->getAvailableMonths();
 | 
						|
 | 
						|
        return $availableMonths
 | 
						|
            ->flatMap(fn ($month) => $this->getEntriesForMonth($month))
 | 
						|
            ->sortBy('published_at')
 | 
						|
            ->reverse()
 | 
						|
            ->values();
 | 
						|
    }
 | 
						|
 | 
						|
    public function getEntriesForUser(User $user): Collection
 | 
						|
    {
 | 
						|
        $entries = $this->getEntries();
 | 
						|
        $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
 | 
						|
 | 
						|
        return $entries->map(function ($entry) use ($readIdentifiers) {
 | 
						|
            $entry->is_read = in_array($entry->tag_name, $readIdentifiers);
 | 
						|
 | 
						|
            return $entry;
 | 
						|
        })->sortBy([
 | 
						|
            ['is_read', 'asc'],  // unread first
 | 
						|
            ['published_at', 'desc'],  // then by date
 | 
						|
        ])->values();
 | 
						|
    }
 | 
						|
 | 
						|
    public function getUnreadCountForUser(User $user): int
 | 
						|
    {
 | 
						|
        if (isDev()) {
 | 
						|
            $entries = $this->getEntries();
 | 
						|
            $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
 | 
						|
 | 
						|
            return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
 | 
						|
        } else {
 | 
						|
            return Cache::remember(
 | 
						|
                'user_unread_changelog_count_'.$user->id,
 | 
						|
                now()->addHour(),
 | 
						|
                function () use ($user) {
 | 
						|
                    $entries = $this->getEntries();
 | 
						|
                    $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
 | 
						|
 | 
						|
                    return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
 | 
						|
                }
 | 
						|
            );
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public function getAvailableMonths(): Collection
 | 
						|
    {
 | 
						|
        $pattern = base_path('changelogs/*.json');
 | 
						|
        $files = glob($pattern);
 | 
						|
 | 
						|
        if ($files === false) {
 | 
						|
            return collect();
 | 
						|
        }
 | 
						|
 | 
						|
        return collect($files)
 | 
						|
            ->map(fn ($file) => basename($file, '.json'))
 | 
						|
            ->filter(fn ($name) => preg_match('/^\d{4}-\d{2}$/', $name))
 | 
						|
            ->sort()
 | 
						|
            ->reverse()
 | 
						|
            ->values();
 | 
						|
    }
 | 
						|
 | 
						|
    public function getEntriesForMonth(string $month): Collection
 | 
						|
    {
 | 
						|
        $path = base_path("changelogs/{$month}.json");
 | 
						|
 | 
						|
        if (! file_exists($path)) {
 | 
						|
            return collect();
 | 
						|
        }
 | 
						|
 | 
						|
        $content = file_get_contents($path);
 | 
						|
 | 
						|
        if ($content === false) {
 | 
						|
            Log::error("Failed to read changelog file: {$month}.json");
 | 
						|
 | 
						|
            return collect();
 | 
						|
        }
 | 
						|
 | 
						|
        $data = json_decode($content, true);
 | 
						|
 | 
						|
        if (json_last_error() !== JSON_ERROR_NONE) {
 | 
						|
            Log::error("Invalid JSON in {$month}.json: ".json_last_error_msg());
 | 
						|
 | 
						|
            return collect();
 | 
						|
        }
 | 
						|
 | 
						|
        if (! isset($data['entries']) || ! is_array($data['entries'])) {
 | 
						|
            return collect();
 | 
						|
        }
 | 
						|
 | 
						|
        return collect($data['entries'])
 | 
						|
            ->filter(fn ($entry) => $this->validateEntryData($entry))
 | 
						|
            ->map(function ($entry) {
 | 
						|
                $entry['published_at'] = Carbon::parse($entry['published_at']);
 | 
						|
                $entry['content_html'] = $this->parseMarkdown($entry['content']);
 | 
						|
 | 
						|
                return (object) $entry;
 | 
						|
            })
 | 
						|
            ->filter(fn ($entry) => $entry->published_at <= now())
 | 
						|
            ->sortBy('published_at')
 | 
						|
            ->reverse()
 | 
						|
            ->values();
 | 
						|
    }
 | 
						|
 | 
						|
    private function fetchChangelogData(): ?array
 | 
						|
    {
 | 
						|
        // Legacy support for old changelog.json
 | 
						|
        $path = base_path('changelog.json');
 | 
						|
 | 
						|
        if (file_exists($path)) {
 | 
						|
            $content = file_get_contents($path);
 | 
						|
 | 
						|
            if ($content === false) {
 | 
						|
                Log::error('Failed to read changelog.json file');
 | 
						|
 | 
						|
                return null;
 | 
						|
            }
 | 
						|
 | 
						|
            $data = json_decode($content, true);
 | 
						|
 | 
						|
            if (json_last_error() !== JSON_ERROR_NONE) {
 | 
						|
                Log::error('Invalid JSON in changelog.json: '.json_last_error_msg());
 | 
						|
 | 
						|
                return null;
 | 
						|
            }
 | 
						|
 | 
						|
            return $data;
 | 
						|
        }
 | 
						|
 | 
						|
        // New monthly structure - combine all months
 | 
						|
        $allEntries = [];
 | 
						|
        foreach ($this->getAvailableMonths() as $month) {
 | 
						|
            $monthEntries = $this->getEntriesForMonth($month);
 | 
						|
            foreach ($monthEntries as $entry) {
 | 
						|
                $allEntries[] = (array) $entry;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return ['entries' => $allEntries];
 | 
						|
    }
 | 
						|
 | 
						|
    public function markAsReadForUser(string $version, User $user): void
 | 
						|
    {
 | 
						|
        UserChangelogRead::markAsRead($user->id, $version);
 | 
						|
        Cache::forget('user_unread_changelog_count_'.$user->id);
 | 
						|
    }
 | 
						|
 | 
						|
    public function markAllAsReadForUser(User $user): void
 | 
						|
    {
 | 
						|
        $entries = $this->getEntries();
 | 
						|
 | 
						|
        foreach ($entries as $entry) {
 | 
						|
            UserChangelogRead::markAsRead($user->id, $entry->tag_name);
 | 
						|
        }
 | 
						|
 | 
						|
        Cache::forget('user_unread_changelog_count_'.$user->id);
 | 
						|
    }
 | 
						|
 | 
						|
    private function validateEntryData(array $data): bool
 | 
						|
    {
 | 
						|
        $required = ['tag_name', 'title', 'content', 'published_at'];
 | 
						|
 | 
						|
        foreach ($required as $field) {
 | 
						|
            if (! isset($data[$field]) || empty($data[$field])) {
 | 
						|
                return false;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return true;
 | 
						|
    }
 | 
						|
 | 
						|
    public function clearAllReadStatus(): array
 | 
						|
    {
 | 
						|
        try {
 | 
						|
            $count = UserChangelogRead::count();
 | 
						|
            UserChangelogRead::truncate();
 | 
						|
 | 
						|
            // Clear all user caches
 | 
						|
            $this->clearAllUserCaches();
 | 
						|
 | 
						|
            return [
 | 
						|
                'success' => true,
 | 
						|
                'message' => "Successfully cleared {$count} read status records",
 | 
						|
            ];
 | 
						|
        } catch (\Exception $e) {
 | 
						|
            Log::error('Failed to clear read status: '.$e->getMessage());
 | 
						|
 | 
						|
            return [
 | 
						|
                'success' => false,
 | 
						|
                'message' => 'Failed to clear read status: '.$e->getMessage(),
 | 
						|
            ];
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private function clearAllUserCaches(): void
 | 
						|
    {
 | 
						|
        $users = User::select('id')->get();
 | 
						|
 | 
						|
        foreach ($users as $user) {
 | 
						|
            Cache::forget('user_unread_changelog_count_'.$user->id);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private function parseMarkdown(string $content): string
 | 
						|
    {
 | 
						|
        $renderer = app(MarkdownRenderer::class);
 | 
						|
 | 
						|
        $html = $renderer->toHtml($content);
 | 
						|
 | 
						|
        // Apply custom Tailwind CSS classes for dark mode compatibility
 | 
						|
        $html = $this->applyCustomStyling($html);
 | 
						|
 | 
						|
        return $html;
 | 
						|
    }
 | 
						|
 | 
						|
    private function applyCustomStyling(string $html): string
 | 
						|
    {
 | 
						|
        // Headers
 | 
						|
        $html = preg_replace('/<h1[^>]*>/', '<h1 class="text-xl font-bold dark:text-white mb-2">', $html);
 | 
						|
        $html = preg_replace('/<h2[^>]*>/', '<h2 class="text-lg font-semibold dark:text-white mb-2">', $html);
 | 
						|
        $html = preg_replace('/<h3[^>]*>/', '<h3 class="text-md font-semibold dark:text-white mb-1">', $html);
 | 
						|
 | 
						|
        // Paragraphs
 | 
						|
        $html = preg_replace('/<p[^>]*>/', '<p class="mb-2 dark:text-neutral-300">', $html);
 | 
						|
 | 
						|
        // Lists
 | 
						|
        $html = preg_replace('/<ul[^>]*>/', '<ul class="mb-2 ml-4 list-disc">', $html);
 | 
						|
        $html = preg_replace('/<ol[^>]*>/', '<ol class="mb-2 ml-4 list-decimal">', $html);
 | 
						|
        $html = preg_replace('/<li[^>]*>/', '<li class="dark:text-neutral-300">', $html);
 | 
						|
 | 
						|
        // Code blocks and inline code
 | 
						|
        $html = preg_replace('/<pre[^>]*>/', '<pre class="bg-gray-100 dark:bg-coolgray-300 p-2 rounded text-sm overflow-x-auto my-2">', $html);
 | 
						|
        $html = preg_replace('/<code[^>]*>/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">', $html);
 | 
						|
 | 
						|
        // Links - Apply styling to existing markdown links
 | 
						|
        $html = preg_replace('/<a([^>]*)>/', '<a$1 class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">', $html);
 | 
						|
 | 
						|
        // Convert plain URLs to clickable links (that aren't already in <a> tags)
 | 
						|
        $html = preg_replace('/(?<!href="|href=\')(?<!>)(?<!\/)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/', '<a href="$1" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
 | 
						|
 | 
						|
        // Strong/bold text
 | 
						|
        $html = preg_replace('/<strong[^>]*>/', '<strong class="font-semibold dark:text-white">', $html);
 | 
						|
 | 
						|
        // Emphasis/italic text
 | 
						|
        $html = preg_replace('/<em[^>]*>/', '<em class="italic dark:text-neutral-300">', $html);
 | 
						|
 | 
						|
        return $html;
 | 
						|
    }
 | 
						|
}
 |