// SPDX-License-Identifier: AGPL-3.0-or-later namespace OCA\Forum\Controller; use OCA\Forum\Attribute\RequirePermission; use OCA\Forum\Db\CategoryMapper; use OCA\Forum\Db\DraftMapper; use OCA\Forum\Db\ForumUserMapper; use OCA\Forum\Db\Post; use OCA\Forum\Db\PostMapper; use OCA\Forum\Db\ReadMarkerMapper; use OCA\Forum\Db\Thread; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Db\ThreadSubscriptionMapper; use OCA\Forum\Service\GuestService; use OCA\Forum\Service\NotificationService; use OCA\Forum\Service\PermissionService; use OCA\Forum\Service\ThreadEnrichmentService; use OCA\Forum\Service\UserPreferencesService; use OCA\Forum\Service\UserService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IUserSession; use Psr\Log\LoggerInterface; class ThreadController extends OCSController { public function __construct( string $appName, IRequest $request, private ThreadMapper $threadMapper, private CategoryMapper $categoryMapper, private PostMapper $postMapper, private ForumUserMapper $forumUserMapper, private ThreadSubscriptionMapper $threadSubscriptionMapper, private DraftMapper $draftMapper, private ReadMarkerMapper $readMarkerMapper, private ThreadEnrichmentService $threadEnrichmentService, private UserPreferencesService $userPreferencesService, private UserService $userService, private PermissionService $permissionService, private NotificationService $notificationService, private GuestService $guestService, private IUserSession $userSession, private LoggerInterface $logger, ) { parent::__construct($appName, $request); } /** * Get all threads * * @param int<1, 200> $limit Maximum number of threads to return * @param int<0, max> $offset Offset for pagination * @return DataResponse>, array{}> * * 200: Threads returned */ #[NoAdminRequired] #[PublicPage] #[ApiRoute(verb: 'GET', url: '/api/threads')] public function index(int $limit = 200, int $offset = 0): DataResponse { try { $threads = array_slice($this->threadMapper->findAll(), $offset, $limit); // Extract unique author IDs (thread authors + last reply authors) $authorIds = array_map(fn ($t) => $t->getAuthorId(), $threads); $lastReplyAuthorIds = array_filter(array_map(fn ($t) => $t->getLastReplyAuthorId(), $threads)); $allAuthorIds = array_unique(array_merge($authorIds, $lastReplyAuthorIds)); // Batch fetch author data (includes roles) $authors = $this->userService->enrichMultipleUsers($allAuthorIds); // Enrich threads with pre-fetched author data and last reply info return new DataResponse(array_map(function ($t) use ($authors) { $lastReply = null; $lastReplyAuthorId = $t->getLastReplyAuthorId(); if ($lastReplyAuthorId !== null) { $lastReply = [ 'postId' => $t->getLastPostId(), 'author' => $authors[$lastReplyAuthorId] ?? null, 'createdAt' => $t->getLastReplyAt(), ]; } return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null, $lastReply); }, $threads)); } catch (\Exception $e) { $this->logger->error('Error fetching threads: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Get threads by category * * @param int $categoryId Category ID * @param int $page Page number (1-indexed) * @param int $perPage Number of threads per page * @return DataResponse>, pagination: array{page: int, perPage: int, total: int, totalPages: int}}, array{}> * * 200: Threads returned with pagination metadata */ #[NoAdminRequired] #[PublicPage] #[RequirePermission('canView', resourceType: 'category', resourceIdParam: 'categoryId')] #[ApiRoute(verb: 'GET', url: '/api/categories/{categoryId}/threads')] public function byCategory(int $categoryId, int $page = 1, int $perPage = 20): DataResponse { try { // Count total threads in category $totalThreads = $this->threadMapper->countByCategoryId($categoryId); $totalPages = max(1, (int)ceil($totalThreads / $perPage)); // Ensure page is within valid range $page = max(1, min($page, $totalPages)); $offset = ($page - 1) * $perPage; // Fetch threads for the current page $threads = $this->threadMapper->findByCategoryId($categoryId, $perPage, $offset); // Extract unique author IDs (thread authors + last reply authors) $authorIds = array_map(fn ($t) => $t->getAuthorId(), $threads); $lastReplyAuthorIds = array_filter(array_map(fn ($t) => $t->getLastReplyAuthorId(), $threads)); $allAuthorIds = array_unique(array_merge($authorIds, $lastReplyAuthorIds)); // Batch fetch author data (includes roles) $authors = $this->userService->enrichMultipleUsers($allAuthorIds); // Enrich threads with pre-fetched author data and last reply info $enrichedThreads = array_map(function ($t) use ($authors) { $lastReply = null; $lastReplyAuthorId = $t->getLastReplyAuthorId(); if ($lastReplyAuthorId !== null) { $lastReply = [ 'postId' => $t->getLastPostId(), 'author' => $authors[$lastReplyAuthorId] ?? null, 'createdAt' => $t->getLastReplyAt(), ]; } return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null, $lastReply); }, $threads); return new DataResponse([ 'threads' => $enrichedThreads, 'pagination' => [ 'page' => $page, 'perPage' => $perPage, 'total' => $totalThreads, 'totalPages' => $totalPages, ], ]); } catch (\Exception $e) { $this->logger->error('Error fetching threads by category: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Get threads by author * * @param string $authorId Author user ID * @param int<1, 200> $limit Maximum number of threads to return * @param int $offset Offset for pagination * @return DataResponse>, array{}> * * 200: Threads returned */ #[NoAdminRequired] #[PublicPage] #[ApiRoute(verb: 'GET', url: '/api/users/{authorId}/threads')] public function byAuthor(string $authorId, int $limit = 50, int $offset = 0): DataResponse { try { $threads = $this->threadMapper->findByAuthorId($authorId, $limit, $offset); // Collect author IDs (thread author + last reply authors) $lastReplyAuthorIds = array_filter(array_map(fn ($t) => $t->getLastReplyAuthorId(), $threads)); $allAuthorIds = array_unique(array_merge([$authorId], $lastReplyAuthorIds)); $authors = $this->userService->enrichMultipleUsers($allAuthorIds); // Enrich threads with pre-fetched author data and last reply info return new DataResponse(array_map(function ($t) use ($authors) { $lastReply = null; $lastReplyAuthorId = $t->getLastReplyAuthorId(); if ($lastReplyAuthorId !== null) { $lastReply = [ 'postId' => $t->getLastPostId(), 'author' => $authors[$lastReplyAuthorId] ?? null, 'createdAt' => $t->getLastReplyAt(), ]; } return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null, $lastReply); }, $threads)); } catch (\Exception $e) { $this->logger->error('Error fetching threads by author: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Get a single thread * * @param int $id Thread ID * @param string $incrementView Whether to increment view count (1 or 0) * @return DataResponse, array{}> * * 200: Thread returned */ #[NoAdminRequired] #[PublicPage] #[RequirePermission('canView', resourceType: 'category', resourceIdFromThreadId: 'id')] #[ApiRoute(verb: 'GET', url: '/api/threads/{id}')] public function show(int $id, string $incrementView = '1'): DataResponse { try { $thread = $this->threadMapper->find($id); // Increment view count only if requested if ($incrementView === '1') { $thread->setViewCount($thread->getViewCount() + 1); /** @var \OCA\Forum\Db\Thread */ $thread = $this->threadMapper->update($thread); } return new DataResponse($this->threadEnrichmentService->enrichThread($thread)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error fetching thread: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to fetch thread'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Get a thread by slug * * @param string $slug Thread slug * @param string $incrementView Whether to increment view count (1 or 0) * @return DataResponse, array{}> * * 200: Thread returned */ #[NoAdminRequired] #[PublicPage] #[ApiRoute(verb: 'GET', url: '/api/threads/slug/{slug}')] public function bySlug(string $slug, string $incrementView = '1'): DataResponse { try { $thread = $this->threadMapper->findBySlug($slug); // Increment view count only if requested if ($incrementView === '1') { $thread->setViewCount($thread->getViewCount() + 1); /** @var \OCA\Forum\Db\Thread */ $thread = $this->threadMapper->update($thread); } return new DataResponse($this->threadEnrichmentService->enrichThread($thread)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error fetching thread by slug: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to fetch thread'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Create a new thread with initial post * * @param int $categoryId Category ID * @param string $title Thread title * @param string $content Initial post content * @param string $guestToken Guest session token (32-char hex, for unauthenticated users) * @return DataResponse, array{}> * * 201: Thread created */ #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] #[RequirePermission('canPost', resourceType: 'category', resourceIdBody: 'categoryId')] #[ApiRoute(verb: 'POST', url: '/api/threads')] public function create(int $categoryId, string $title, string $content, string $guestToken = ''): DataResponse { try { $user = $this->userSession->getUser(); // Resolve author identity if ($user) { $authorId = $user->getUID(); } elseif ($guestToken !== '') { try { $authorId = $this->guestService->resolveGuestIdentity($guestToken); } catch (\InvalidArgumentException $e) { return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } else { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } // Check if the category was already read before creating the thread // (used later to decide whether to update the category read marker) $wasCategoryRead = false; if ($user) { try { $lastActivity = $this->threadMapper->getLastActivityForCategory($categoryId); if ($lastActivity === null) { $wasCategoryRead = true; } else { $marker = $this->readMarkerMapper->findByUserAndCategory($user->getUID(), $categoryId); $wasCategoryRead = $marker->getReadAt() >= $lastActivity; } } catch (DoesNotExistException $e) { // No read marker means the category is unread } catch (\Exception $e) { $this->logger->warning('Failed to check category read state: ' . $e->getMessage()); } } // Generate slug from title $slug = $this->generateSlug($title); // Ensure slug is unique $slug = $this->ensureUniqueSlug($slug); $thread = new \OCA\Forum\Db\Thread(); $thread->setCategoryId($categoryId); $thread->setAuthorId($authorId); $thread->setTitle($title); $thread->setSlug($slug); $thread->setViewCount(0); $thread->setPostCount(0); $thread->setIsLocked(false); $thread->setIsPinned(false); $thread->setIsHidden(false); $thread->setCreatedAt(time()); $thread->setUpdatedAt(time()); /** @var \OCA\Forum\Db\Thread */ $createdThread = $this->threadMapper->insert($thread); // Create the initial post $post = new \OCA\Forum\Db\Post(); $post->setThreadId($createdThread->getId()); $post->setAuthorId($authorId); $post->setContent($content); $post->setIsEdited(false); $post->setIsFirstPost(true); $post->setCreatedAt(time()); $post->setUpdatedAt(time()); /** @var \OCA\Forum\Db\Post */ $createdPost = $this->postMapper->insert($post); // Update thread with post count and last post // Note: post_count does NOT include the first post (is_first_post=true) $createdThread->setPostCount(0); $createdThread->setLastPostId($createdPost->getId()); $this->threadMapper->update($createdThread); // Update category counts (thread count only, first post doesn't count) try { $category = $this->categoryMapper->find($categoryId); $category->setThreadCount($category->getThreadCount() + 1); $this->categoryMapper->update($category); } catch (\Exception $e) { $this->logger->warning('Failed to update category counts: ' . $e->getMessage()); } // User-only operations (forum user stats, subscriptions, read markers, drafts) if ($user) { // Update forum user (thread count only, first post doesn't count) try { $this->forumUserMapper->incrementThreadCount($user->getUID()); } catch (\Exception $e) { $this->logger->warning('Failed to update forum user: ' . $e->getMessage()); } // Auto-subscribe the thread creator to receive notifications (if preference is enabled) try { $autoSubscribe = $this->userPreferencesService->getPreference( $user->getUID(), UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS ); if ($autoSubscribe) { $this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId()); } } catch (\Exception $e) { $this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage()); } // Update category read marker so the category stays read, // but only if it was not already unread before this thread was created if ($wasCategoryRead) { try { $this->readMarkerMapper->createOrUpdateCategoryMarker($user->getUID(), $categoryId); } catch (\Exception $e) { $this->logger->warning('Failed to update category read marker: ' . $e->getMessage()); } } // Delete any draft for this category now that the thread is created try { $this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId); } catch (\Exception $e) { $this->logger->warning('Failed to delete thread draft: ' . $e->getMessage()); } } // Notify mentioned users in the initial post (works for both guest and authenticated posts) try { $mentionedUsers = $this->notificationService->extractMentions($content); $this->notificationService->notifyMentionedUsers( $createdPost->getId(), $createdThread->getId(), $authorId, $mentionedUsers ); } catch (\Exception $e) { $this->logger->warning('Failed to send mention notifications: ' . $e->getMessage()); } return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED); } catch (\Exception $e) { $this->logger->error('Error creating thread: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to create thread'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Update a thread * * @param int $id Thread ID * @param string|null $title Thread title * @param bool|null $isLocked Whether the thread is locked * @param bool|null $isPinned Whether the thread is pinned * @param bool|null $isHidden Whether the thread is hidden * @return DataResponse, array{}> * * 200: Thread updated */ #[NoAdminRequired] #[RequirePermission('canView', resourceType: 'category', resourceIdFromThreadId: 'id')] #[ApiRoute(verb: 'PUT', url: '/api/threads/{id}')] public function update(int $id, ?string $title = null, ?bool $isLocked = null, ?bool $isPinned = null, ?bool $isHidden = null): DataResponse { try { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } $thread = $this->threadMapper->find($id); // Check if user is the author or has moderation permission $isAuthor = $thread->getAuthorId() === $user->getUID(); $canModerate = $this->permissionService->hasCategoryPermission( $user->getUID(), $thread->getCategoryId(), 'canModerate' ); // Title can be updated by author or moderator if ($title !== null) { if (!$isAuthor && !$canModerate) { return new DataResponse( ['error' => 'You do not have permission to edit this thread title'], Http::STATUS_FORBIDDEN ); } $thread->setTitle($title); } // Lock, pin, and hidden status can only be updated by moderators if (($isLocked !== null || $isPinned !== null || $isHidden !== null) && !$canModerate) { return new DataResponse( ['error' => 'You do not have permission to modify thread status'], Http::STATUS_FORBIDDEN ); } if ($isLocked !== null) { $thread->setIsLocked($isLocked); } if ($isPinned !== null) { $thread->setIsPinned($isPinned); } if ($isHidden !== null) { $thread->setIsHidden($isHidden); } $thread->setUpdatedAt(time()); /** @var \OCA\Forum\Db\Thread */ $updatedThread = $this->threadMapper->update($thread); return new DataResponse($this->threadEnrichmentService->enrichThread($updatedThread)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error updating thread: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to update thread'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Move thread to a different category * * @param int $id Thread ID * @param int $categoryId New category ID * @return DataResponse, array{}> * * 200: Thread moved successfully */ #[NoAdminRequired] #[RequirePermission('canModerate', resourceType: 'category', resourceIdFromThreadId: 'id')] #[ApiRoute(verb: 'PUT', url: '/api/threads/{id}/move')] public function move(int $id, int $categoryId): DataResponse { try { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } $thread = $this->threadMapper->find($id); // Verify the target category exists and user has permission to post there try { $targetCategory = $this->categoryMapper->find($categoryId); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Target category not found'], Http::STATUS_NOT_FOUND); } // Check if user has moderation permission on target category $canModerateTarget = $this->permissionService->hasCategoryPermission( $user->getUID(), $categoryId, 'canModerate' ); if (!$canModerateTarget) { return new DataResponse( ['error' => 'You do not have permission to move threads to this category'], Http::STATUS_FORBIDDEN ); } $oldCategoryId = $thread->getCategoryId(); // Update thread category $thread->setCategoryId($categoryId); $thread->setUpdatedAt(time()); /** @var \OCA\Forum\Db\Thread */ $updatedThread = $this->threadMapper->update($thread); // Update category counts for both old and new categories try { // Decrement old category counts $oldCategory = $this->categoryMapper->find($oldCategoryId); $oldCategory->setThreadCount(max(0, $oldCategory->getThreadCount() - 1)); $oldCategory->setPostCount(max(0, $oldCategory->getPostCount() - $thread->getPostCount())); $this->categoryMapper->update($oldCategory); // Increment new category counts $targetCategory->setThreadCount($targetCategory->getThreadCount() + 1); $targetCategory->setPostCount($targetCategory->getPostCount() + $thread->getPostCount()); $this->categoryMapper->update($targetCategory); } catch (\Exception $e) { $this->logger->warning('Failed to update category counts after thread move: ' . $e->getMessage()); // Don't fail the request if category update fails } return new DataResponse($this->threadEnrichmentService->enrichThread($updatedThread)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error moving thread: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to move thread'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Toggle thread lock status * * @param int $id Thread ID * @param bool $locked New lock status * @return DataResponse, array{}> * * 200: Thread lock status updated */ #[NoAdminRequired] #[RequirePermission('canModerate', resourceType: 'category', resourceIdFromThreadId: 'id')] #[ApiRoute(verb: 'PUT', url: '/api/threads/{id}/lock')] public function setLocked(int $id, bool $locked): DataResponse { try { $thread = $this->threadMapper->find($id); $thread->setIsLocked($locked); $thread->setUpdatedAt(time()); /** @var \OCA\Forum\Db\Thread */ $updatedThread = $this->threadMapper->update($thread); return new DataResponse($this->threadEnrichmentService->enrichThread($updatedThread)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error updating thread lock status: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to update thread lock status'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Toggle thread pin status * * @param int $id Thread ID * @param bool $pinned New pin status * @return DataResponse, array{}> * * 200: Thread pin status updated */ #[NoAdminRequired] #[RequirePermission('canModerate', resourceType: 'category', resourceIdFromThreadId: 'id')] #[ApiRoute(verb: 'PUT', url: '/api/threads/{id}/pin')] public function setPinned(int $id, bool $pinned): DataResponse { try { $thread = $this->threadMapper->find($id); $thread->setIsPinned($pinned); $thread->setUpdatedAt(time()); /** @var \OCA\Forum\Db\Thread */ $updatedThread = $this->threadMapper->update($thread); return new DataResponse($this->threadEnrichmentService->enrichThread($updatedThread)); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error updating thread pin status: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to update thread pin status'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Delete a thread (soft delete) * * @param int $id Thread ID * @return DataResponse * * 200: Thread deleted */ #[NoAdminRequired] #[RequirePermission('canModerate', resourceType: 'category', resourceIdFromThreadId: 'id')] #[ApiRoute(verb: 'DELETE', url: '/api/threads/{id}')] public function destroy(int $id): DataResponse { try { $thread = $this->threadMapper->find($id); // Get category for slug and count updates $category = $this->categoryMapper->find($thread->getCategoryId()); $categorySlug = $category->getSlug(); // Soft delete the thread $thread->setDeletedAt(time()); $thread->setUpdatedAt(time()); $this->threadMapper->update($thread); // Update category counts (decrement thread count and post count) try { $category->setThreadCount(max(0, $category->getThreadCount() - 1)); $category->setPostCount(max(0, $category->getPostCount() - $thread->getPostCount())); $this->categoryMapper->update($category); } catch (\Exception $e) { $this->logger->warning('Failed to update category counts after thread deletion: ' . $e->getMessage()); // Don't fail the request if category update fails } // Update author's forum user (decrement thread count and all posts in this thread) try { $this->forumUserMapper->decrementThreadCount($thread->getAuthorId()); // Decrement post count by the number of posts in this thread if ($thread->getPostCount() > 0) { $this->forumUserMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount()); } } catch (\Exception $e) { $this->logger->warning('Failed to update forum user after thread deletion: ' . $e->getMessage()); // Don't fail the request if forum user update fails } // Dismiss all mention notifications for posts in this thread try { $this->notificationService->dismissAllThreadMentionNotifications($id); } catch (\Exception $e) { $this->logger->warning('Failed to dismiss mention notifications after thread deletion: ' . $e->getMessage()); // Don't fail the request if notification dismissal fails } return new DataResponse([ 'success' => true, 'categorySlug' => $categorySlug, ]); } catch (DoesNotExistException $e) { return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Error deleting thread: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to delete thread'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Generate a URL-friendly slug from a string * * @param string $text The text to convert to a slug * @return string A URL-friendly slug */ private function generateSlug(string $text): string { // Convert to lowercase $slug = mb_strtolower($text, 'UTF-8'); // Replace spaces and underscores with hyphens $slug = preg_replace('/[\s_]+/', '-', $slug); // Remove all non-word chars except hyphens $slug = preg_replace('/[^\w\-]+/u', '', $slug); // Replace multiple hyphens with single hyphen $slug = preg_replace('/-+/', '-', $slug); // Remove leading/trailing hyphens $slug = trim($slug, '-'); // If slug is empty after processing, generate a random one if (empty($slug)) { $slug = 'thread-' . uniqid(); } return $slug; } /** * Ensure slug is unique by appending a number if necessary * * @param string $slug The base slug to make unique * @return string A unique slug */ private function ensureUniqueSlug(string $slug): string { $originalSlug = $slug; $counter = 1; // Keep trying until we find a unique slug while (true) { try { // Try to find a thread with this slug $this->threadMapper->findBySlug($slug); // If we get here, slug exists, try the next one $slug = $originalSlug . '-' . $counter; $counter++; } catch (DoesNotExistException $e) { // Slug doesn't exist, we can use it return $slug; } } } }