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;
 | |
|     }
 | |
| }
 | 
