From be3dbf42d2beda6be9a72c7db8823fb063bcc58e Mon Sep 17 00:00:00 2001 From: yguerdat Date: Tue, 10 Mar 2026 18:56:58 +0100 Subject: [PATCH 1/6] feat: nested categories, i18n language filtering, and locale field in article responses - GET /categories now returns a recursive tree (children array per node) - ?flat=1 param restores the flat list with parent_id for backward compat - ?lang= / ?locale= on /categories filters out categories with no articles in that locale (tree and flat modes) - GET /languages endpoint returns distinct locales of published articles - ?lang= / ?locale= on /search filters articles by locale (no param = all languages, backward compat) - All article responses now include a `locale` field (category, article, search, export endpoints) - `resolveLocale()` helper centralises lang > locale > mailbox default resolution across all endpoints Co-Authored-By: Claude Sonnet 4.6 --- .../KnowledgeBaseApiController.php | 242 +++++++++++++----- Http/routes.php | 1 + 2 files changed, 183 insertions(+), 60 deletions(-) diff --git a/Http/Controllers/KnowledgeBaseApiController.php b/Http/Controllers/KnowledgeBaseApiController.php index cd0238f..e276450 100644 --- a/Http/Controllers/KnowledgeBaseApiController.php +++ b/Http/Controllers/KnowledgeBaseApiController.php @@ -40,44 +40,24 @@ public function get(Request $request, $mailboxId) if ($mailbox === null) { return Response::json(['error' => 'Mailbox not found'], 404); } - $categories = \KbCategory::getTree($mailbox->id, [], 0, true); - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); + $locale = $this->resolveLocale($request, $mailbox); + $filterLocale = $request->input('lang') ?? $request->input('locale'); + $flat = (bool) $request->input('flat', false); - $items = []; + $categories = \KbCategory::getTree($mailbox->id, [], 0, true); + $allCategories = collect($categories)->all(); - foreach ($categories as $c) { - if (!$c->checkVisibility()) { - continue; - } - - // Generate URL for the category - $categoryUrl = $this->buildCategoryUrl($mailbox->id, $c->id); - - // Generate client URL if template is set - $clientUrl = $this->buildClientCategoryUrl($mailbox->id, $c->id); - - // Get article count - only published articles - $articleCount = 0; - if (method_exists($c, 'getArticlesSorted')) { - $articles = $c->getArticlesSorted(true); // true = published only - $articleCount = count($articles); - } - - $items[] = (object)[ - 'id' => $c->id, - 'name' => $c->getAttributeInLocale('name', $locale), - 'description' => $c->getAttributeInLocale('description', $locale), - 'url' => $categoryUrl, - 'client_url' => $clientUrl, - 'article_count' => $articleCount - ]; + if ($flat) { + $result = $this->buildCategoryFlat($allCategories, $locale, $mailbox->id, $filterLocale); + } else { + $result = $this->buildCategoryTree($allCategories, 0, $locale, $mailbox->id, $filterLocale); } return Response::json([ 'mailbox_id' => $mailbox->id, 'name' => $mailbox->name, - 'categories' => $items, + 'categories' => $result, ], 200); } catch (\Exception $e) { return Response::json(['error' => $e->getMessage()], 500); @@ -116,7 +96,7 @@ public function category(Request $request, $mailboxId, $categoryId) $sortedArticles = $category->getArticlesSorted(true); } - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); + $locale = $this->resolveLocale($request, $mailbox); foreach ($sortedArticles as $a) { $a->setLocale($locale); @@ -128,17 +108,15 @@ public function category(Request $request, $mailboxId, $categoryId) $clientUrl = $this->buildClientArticleUrl($mailbox->id, $category->id, $a->id); $articles[] = (object)[ - 'id' => $a->id, - 'title' => $a->getAttributeInLocale('title', $locale), - 'text' => $a->getAttributeInLocale('text', $locale), - 'url' => $articleUrl, - 'client_url' => $clientUrl + 'id' => $a->id, + 'title' => $a->getAttributeInLocale('title', $locale), + 'text' => $a->getAttributeInLocale('text', $locale), + 'locale' => $a->locale, + 'url' => $articleUrl, + 'client_url' => $clientUrl, ]; } - // Get locale - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); - // Generate category URLs $categoryUrl = $this->buildCategoryUrl($mailbox->id, $category->id); $clientCategoryUrl = $this->buildClientCategoryUrl($mailbox->id, $category->id); @@ -181,11 +159,14 @@ public function search(Request $request, $mailboxId) return Response::json(['error' => 'Search keyword is required'], 400); } - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); - + $locale = $this->resolveLocale($request, $mailbox); + + // Only filter by locale if explicitly requested + $filterLocale = $request->input('lang') ?? $request->input('locale'); + // Convert keyword to lowercase for case-insensitive search $keyword = mb_strtolower($keyword); - + // Search in published articles only, using case-insensitive search $articles = KbArticle::where('mailbox_id', $mailbox->id) ->where(function($query) use ($keyword) { @@ -193,6 +174,9 @@ public function search(Request $request, $mailboxId) ->orWhereRaw('LOWER(text) LIKE ?', ['%'.$keyword.'%']); }) ->where('status', KbArticle::STATUS_PUBLISHED) + ->when($filterLocale, function($q) use ($filterLocale) { + $q->where('locale', $filterLocale); + }) ->get(); $results = []; @@ -222,12 +206,13 @@ public function search(Request $request, $mailboxId) $clientArticleUrl = $this->buildClientArticleUrl($mailbox->id, $firstCategoryId, $article->id); $results[] = [ - 'id' => $article->id, - 'title' => $article->getAttributeInLocale('title', $locale), - 'text' => $article->getAttributeInLocale('text', $locale), + 'id' => $article->id, + 'title' => $article->getAttributeInLocale('title', $locale), + 'text' => $article->getAttributeInLocale('text', $locale), + 'locale' => $article->locale, 'categories' => $categories, - 'url' => $articleUrl, - 'client_url' => $clientArticleUrl + 'url' => $articleUrl, + 'client_url' => $clientArticleUrl, ]; } } @@ -297,7 +282,7 @@ public function article(Request $request, $mailboxId, $categoryId, $articleId) KbArticleViews::incrementViews($articleId, $categoryId, $mailboxId); // Get locale - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); + $locale = $this->resolveLocale($request, $mailbox); $article->setLocale($locale); // Use the helper method to build the URL @@ -318,11 +303,12 @@ public function article(Request $request, $mailboxId, $categoryId, $articleId) 'client_url' => $clientCategoryUrl ], 'article' => [ - 'id' => $article->id, - 'title' => $article->getAttributeInLocale('title', $locale), - 'text' => $article->getAttributeInLocale('text', $locale), - 'url' => $articleUrl, - 'client_url' => $clientArticleUrl + 'id' => $article->id, + 'title' => $article->getAttributeInLocale('title', $locale), + 'text' => $article->getAttributeInLocale('text', $locale), + 'locale' => $article->locale, + 'url' => $articleUrl, + 'client_url' => $clientArticleUrl, ] ], 200); } catch (\Exception $e) { @@ -330,6 +316,141 @@ public function article(Request $request, $mailboxId, $categoryId, $articleId) } } + /** + * Resolve locale from request, preferring `lang` over `locale`, falling back to mailbox default. + * + * @param Request $request + * @param \App\Mailbox $mailbox + * @return string + */ + private function resolveLocale(Request $request, $mailbox): string + { + return $request->input('lang') + ?? $request->input('locale') + ?? \Kb::defaultLocale($mailbox); + } + + /** + * Recursively build a nested category tree from a flat list. + * When $filterLocale is set, categories with no published articles in that locale are excluded. + * + * @param array $allCategories + * @param int $parentId + * @param string $locale + * @param int $mailboxId + * @param string|null $filterLocale + * @return array + */ + private function buildCategoryTree(array $allCategories, int $parentId, string $locale, int $mailboxId, ?string $filterLocale = null): array + { + $tree = []; + foreach ($allCategories as $c) { + $catParentId = (int)($c->parent_id ?? 0); + if ($catParentId !== $parentId) { + continue; + } + if (!$c->checkVisibility()) { + continue; + } + + $children = $this->buildCategoryTree($allCategories, (int)$c->id, $locale, $mailboxId, $filterLocale); + + $articleCount = 0; + if (method_exists($c, 'getArticlesSorted')) { + $articles = $c->getArticlesSorted(true); + if ($filterLocale) { + $articles = array_filter($articles, fn($a) => $a->locale === $filterLocale); + } + $articleCount = count($articles); + } + + // Skip categories with no matching articles and no matching children + if ($filterLocale && $articleCount === 0 && empty($children)) { + continue; + } + + $tree[] = [ + 'id' => $c->id, + 'name' => $c->getAttributeInLocale('name', $locale), + 'description' => $c->getAttributeInLocale('description', $locale), + 'url' => $this->buildCategoryUrl($mailboxId, $c->id), + 'client_url' => $this->buildClientCategoryUrl($mailboxId, $c->id), + 'article_count' => $articleCount, + 'children' => $children, + ]; + } + return $tree; + } + + /** + * Build a flat (non-nested) list of visible categories. + * When $filterLocale is set, categories with no published articles in that locale are excluded. + * + * @param array $allCategories + * @param string $locale + * @param int $mailboxId + * @param string|null $filterLocale + * @return array + */ + private function buildCategoryFlat(array $allCategories, string $locale, int $mailboxId, ?string $filterLocale = null): array + { + $flat = []; + foreach ($allCategories as $c) { + if (!$c->checkVisibility()) { + continue; + } + $articleCount = 0; + if (method_exists($c, 'getArticlesSorted')) { + $articles = $c->getArticlesSorted(true); + if ($filterLocale) { + $articles = array_filter($articles, fn($a) => $a->locale === $filterLocale); + } + $articleCount = count($articles); + } + if ($filterLocale && $articleCount === 0) { + continue; + } + $flat[] = [ + 'id' => $c->id, + 'parent_id' => $c->parent_id ? (int)$c->parent_id : null, + 'name' => $c->getAttributeInLocale('name', $locale), + 'description' => $c->getAttributeInLocale('description', $locale), + 'url' => $this->buildCategoryUrl($mailboxId, $c->id), + 'client_url' => $this->buildClientCategoryUrl($mailboxId, $c->id), + 'article_count' => $articleCount, + ]; + } + return $flat; + } + + /** + * Get the list of distinct locales available for published articles in a mailbox. + * + * @param Request $request + * @param int $mailboxId + * @return \Illuminate\Http\JsonResponse + */ + public function languages(Request $request, $mailboxId) + { + try { + $mailbox = Mailbox::findOrFail($mailboxId); + $locales = KbArticle::where('mailbox_id', $mailbox->id) + ->where('status', KbArticle::STATUS_PUBLISHED) + ->whereNotNull('locale') + ->distinct() + ->pluck('locale') + ->filter() + ->values() + ->toArray(); + return Response::json([ + 'mailbox_id' => $mailbox->id, + 'languages' => $locales, + ], 200); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 500); + } + } + /** * Build article URL based on settings. * @@ -459,7 +580,7 @@ public function popular(Request $request, $mailboxId) $limit = (int) $request->input('limit', 5); $type = $request->input('type', 'all'); - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); + $locale = $this->resolveLocale($request, $mailbox); $response = [ 'mailbox_id' => $mailbox->id, @@ -554,7 +675,7 @@ public function export(Request $request, $mailboxId) return Response::json(['error' => 'Mailbox not found'], 404); } - $locale = $request->input('locale') ?? \Kb::defaultLocale($mailbox); + $locale = $this->resolveLocale($request, $mailbox); $includeHidden = $request->input('include_hidden', false); // Get all categories for this mailbox @@ -597,11 +718,12 @@ public function export(Request $request, $mailboxId) $article->setLocale($locale); $articleData = [ - 'id' => $article->id, - 'title' => $article->getAttributeInLocale('title', $locale), - 'text' => $article->getAttributeInLocale('text', $locale), - 'status' => $article->status, - 'url' => $this->buildArticleUrl($mailbox->id, $category->id, $article->id), + 'id' => $article->id, + 'title' => $article->getAttributeInLocale('title', $locale), + 'text' => $article->getAttributeInLocale('text', $locale), + 'locale' => $article->locale, + 'status' => $article->status, + 'url' => $this->buildArticleUrl($mailbox->id, $category->id, $article->id), 'client_url' => $this->buildClientArticleUrl($mailbox->id, $category->id, $article->id), ]; diff --git a/Http/routes.php b/Http/routes.php index 62dca73..7869ba9 100644 --- a/Http/routes.php +++ b/Http/routes.php @@ -20,4 +20,5 @@ Route::get('/api/knowledgebase/{mailboxId}/search', ['uses' => 'KnowledgeBaseApiController@search', 'laroute' => false])->name('knowledgebase.search'); Route::get('/api/knowledgebase/{mailboxId}/popular', ['uses' => 'KnowledgeBaseApiController@popular', 'laroute' => false])->name('knowledgebase.popular'); Route::get('/api/knowledgebase/{mailboxId}/export', ['uses' => 'KnowledgeBaseApiController@export', 'laroute' => false])->name('knowledgebase.export'); + Route::get('/api/knowledgebase/{mailboxId}/languages', ['uses' => 'KnowledgeBaseApiController@languages', 'laroute' => false])->name('knowledgebase.languages'); }); From 28d11a21bcf0350e1f85afc3590685e41930c201 Mon Sep 17 00:00:00 2001 From: yguerdat Date: Fri, 20 Mar 2026 09:57:45 +0100 Subject: [PATCH 2/6] fix: use KbCategory->categories and getSubCategories() for nested category tree The FreeScout KnowledgeBase module uses kb_category_id (not parent_id) and getTree() returns a pre-built tree with children in ->categories. Adapted buildCategoryTree/buildCategoryFlat to use this structure. Added children to single category endpoint response. Updated .gitignore to exclude SSH keys and .claude directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 +- .../KnowledgeBaseApiController.php | 116 +++++++++++------- 2 files changed, 77 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index a52f0e4..9c58631 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ Homestead.json /.vagrant .phpunit.result.cache release.sh -.DS_Store \ No newline at end of file +.DS_Store +support_eseances_ch* +.claude/ \ No newline at end of file diff --git a/Http/Controllers/KnowledgeBaseApiController.php b/Http/Controllers/KnowledgeBaseApiController.php index e276450..f5b4d8c 100644 --- a/Http/Controllers/KnowledgeBaseApiController.php +++ b/Http/Controllers/KnowledgeBaseApiController.php @@ -45,13 +45,12 @@ public function get(Request $request, $mailboxId) $filterLocale = $request->input('lang') ?? $request->input('locale'); $flat = (bool) $request->input('flat', false); - $categories = \KbCategory::getTree($mailbox->id, [], 0, true); - $allCategories = collect($categories)->all(); + $categories = \KbCategory::getTree($mailbox->id); if ($flat) { - $result = $this->buildCategoryFlat($allCategories, $locale, $mailbox->id, $filterLocale); + $result = $this->buildCategoryFlat($categories, $locale, $mailbox->id, $filterLocale); } else { - $result = $this->buildCategoryTree($allCategories, 0, $locale, $mailbox->id, $filterLocale); + $result = $this->buildCategoryTree($categories, $locale, $mailbox->id, $filterLocale); } return Response::json([ @@ -87,50 +86,74 @@ public function category(Request $request, $mailboxId, $categoryId) if ($category === null) { return Response::json(['error' => 'Category not found or not visible'], 404); } - + // Track category view KbCategoryViews::incrementViews($categoryId, $mailboxId); - - $articles = []; - if ($category) { - $sortedArticles = $category->getArticlesSorted(true); - } $locale = $this->resolveLocale($request, $mailbox); + $filterLocale = $request->input('lang') ?? $request->input('locale'); + // Get articles for this category + $articles = []; + $sortedArticles = $category->getArticlesSorted(true); foreach ($sortedArticles as $a) { $a->setLocale($locale); - - // Use custom URL if configured - $articleUrl = $this->buildArticleUrl($mailbox->id, $category->id, $a->id); - - // Generate client URL if template is set - $clientUrl = $this->buildClientArticleUrl($mailbox->id, $category->id, $a->id); - + + if ($filterLocale && $a->locale !== $filterLocale) { + continue; + } + $articles[] = (object)[ 'id' => $a->id, 'title' => $a->getAttributeInLocale('title', $locale), 'text' => $a->getAttributeInLocale('text', $locale), 'locale' => $a->locale, - 'url' => $articleUrl, - 'client_url' => $clientUrl, + 'url' => $this->buildArticleUrl($mailbox->id, $category->id, $a->id), + 'client_url' => $this->buildClientArticleUrl($mailbox->id, $category->id, $a->id), ]; } + // Get sub-categories + $subCategories = $category->getSubCategories(); + $children = []; + if ($subCategories && count($subCategories) > 0) { + foreach ($subCategories as $sub) { + if (!$sub->checkVisibility()) { + continue; + } + $subArticleCount = 0; + if (method_exists($sub, 'getArticlesSorted')) { + $subArticles = $sub->getArticlesSorted(true); + if ($filterLocale) { + $subArticles = array_filter($subArticles, fn($a) => $a->locale === $filterLocale); + } + $subArticleCount = count($subArticles); + } + $children[] = [ + 'id' => $sub->id, + 'name' => $sub->getAttributeInLocale('name', $locale), + 'description' => $sub->getAttributeInLocale('description', $locale), + 'url' => $this->buildCategoryUrl($mailbox->id, $sub->id), + 'client_url' => $this->buildClientCategoryUrl($mailbox->id, $sub->id), + 'article_count' => $subArticleCount, + ]; + } + } + // Generate category URLs $categoryUrl = $this->buildCategoryUrl($mailbox->id, $category->id); $clientCategoryUrl = $this->buildClientCategoryUrl($mailbox->id, $category->id); return Response::json([ - 'id' => 0, 'mailbox_id' => $mailbox->id, 'name' => $mailbox->name, - 'category' => (object)[ - 'id' => $category->id, - 'name' => $category->getAttributeInLocale('name', $locale), - 'description' => $category->getAttributeInLocale('description', $locale), - 'url' => $categoryUrl, - 'client_url' => $clientCategoryUrl + 'category' => [ + 'id' => $category->id, + 'name' => $category->getAttributeInLocale('name', $locale), + 'description' => $category->getAttributeInLocale('description', $locale), + 'url' => $categoryUrl, + 'client_url' => $clientCategoryUrl, + 'children' => $children, ], 'articles' => $articles, ], 200); @@ -331,29 +354,30 @@ private function resolveLocale(Request $request, $mailbox): string } /** - * Recursively build a nested category tree from a flat list. - * When $filterLocale is set, categories with no published articles in that locale are excluded. + * Recursively build a nested category tree. + * Uses the ->categories property populated by KbCategory::getTree(). * - * @param array $allCategories - * @param int $parentId + * @param array $categories * @param string $locale * @param int $mailboxId * @param string|null $filterLocale * @return array */ - private function buildCategoryTree(array $allCategories, int $parentId, string $locale, int $mailboxId, ?string $filterLocale = null): array + private function buildCategoryTree(array $categories, string $locale, int $mailboxId, ?string $filterLocale = null): array { $tree = []; - foreach ($allCategories as $c) { - $catParentId = (int)($c->parent_id ?? 0); - if ($catParentId !== $parentId) { - continue; - } + foreach ($categories as $c) { if (!$c->checkVisibility()) { continue; } - $children = $this->buildCategoryTree($allCategories, (int)$c->id, $locale, $mailboxId, $filterLocale); + $children = []; + if (!empty($c->categories)) { + $children = $this->buildCategoryTree( + is_array($c->categories) ? $c->categories : $c->categories->all(), + $locale, $mailboxId, $filterLocale + ); + } $articleCount = 0; if (method_exists($c, 'getArticlesSorted')) { @@ -384,18 +408,18 @@ private function buildCategoryTree(array $allCategories, int $parentId, string $ /** * Build a flat (non-nested) list of visible categories. - * When $filterLocale is set, categories with no published articles in that locale are excluded. + * Recursively flattens the tree from KbCategory::getTree(). * - * @param array $allCategories + * @param array $categories * @param string $locale * @param int $mailboxId * @param string|null $filterLocale * @return array */ - private function buildCategoryFlat(array $allCategories, string $locale, int $mailboxId, ?string $filterLocale = null): array + private function buildCategoryFlat(array $categories, string $locale, int $mailboxId, ?string $filterLocale = null): array { $flat = []; - foreach ($allCategories as $c) { + foreach ($categories as $c) { if (!$c->checkVisibility()) { continue; } @@ -412,13 +436,21 @@ private function buildCategoryFlat(array $allCategories, string $locale, int $ma } $flat[] = [ 'id' => $c->id, - 'parent_id' => $c->parent_id ? (int)$c->parent_id : null, + 'parent_id' => $c->kb_category_id ? (int)$c->kb_category_id : null, 'name' => $c->getAttributeInLocale('name', $locale), 'description' => $c->getAttributeInLocale('description', $locale), 'url' => $this->buildCategoryUrl($mailboxId, $c->id), 'client_url' => $this->buildClientCategoryUrl($mailboxId, $c->id), 'article_count' => $articleCount, ]; + // Recurse into sub-categories + if (!empty($c->categories)) { + $subFlat = $this->buildCategoryFlat( + is_array($c->categories) ? $c->categories : $c->categories->all(), + $locale, $mailboxId, $filterLocale + ); + $flat = array_merge($flat, $subFlat); + } } return $flat; } From e505dc3e695dad7a7de6a5983db19cdd2824c819 Mon Sep 17 00:00:00 2001 From: yguerdat Date: Wed, 25 Mar 2026 12:46:13 +0100 Subject: [PATCH 3/6] feat: add lang parameter support to article, popular, and export endpoints Consistent language filtering across all API endpoints via ?lang= or ?locale= parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../KnowledgeBaseApiController.php | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/Http/Controllers/KnowledgeBaseApiController.php b/Http/Controllers/KnowledgeBaseApiController.php index f5b4d8c..9625259 100644 --- a/Http/Controllers/KnowledgeBaseApiController.php +++ b/Http/Controllers/KnowledgeBaseApiController.php @@ -306,8 +306,14 @@ public function article(Request $request, $mailboxId, $categoryId, $articleId) // Get locale $locale = $this->resolveLocale($request, $mailbox); + $filterLocale = $request->input('lang') ?? $request->input('locale'); $article->setLocale($locale); + // If a language filter is specified, check that the article matches + if ($filterLocale && $article->locale !== $filterLocale) { + return Response::json(['error' => 'Article not available in the requested language'], 404); + } + // Use the helper method to build the URL $articleUrl = $this->buildArticleUrl($mailbox->id, $category->id, $article->id); $clientArticleUrl = $this->buildClientArticleUrl($mailbox->id, $category->id, $article->id); @@ -613,7 +619,8 @@ public function popular(Request $request, $mailboxId) $limit = (int) $request->input('limit', 5); $type = $request->input('type', 'all'); $locale = $this->resolveLocale($request, $mailbox); - + $filterLocale = $request->input('lang') ?? $request->input('locale'); + $response = [ 'mailbox_id' => $mailbox->id, 'name' => $mailbox->name, @@ -665,10 +672,15 @@ public function popular(Request $request, $mailboxId) $category = KbCategory::find($articleView->category_id); if ($article && $article->status == KbArticle::STATUS_PUBLISHED && $category && $category->checkVisibility()) { + // Skip articles that don't match the requested language + if ($filterLocale && $article->locale !== $filterLocale) { + continue; + } + // Generate URL for the article $articleUrl = $this->buildArticleUrl($mailbox->id, $category->id, $article->id); $clientArticleUrl = $this->buildClientArticleUrl($mailbox->id, $category->id, $article->id); - + $popularArticles[] = [ 'id' => $article->id, 'title' => $article->getAttributeInLocale('title', $locale), @@ -708,8 +720,9 @@ public function export(Request $request, $mailboxId) } $locale = $this->resolveLocale($request, $mailbox); + $filterLocale = $request->input('lang') ?? $request->input('locale'); $includeHidden = $request->input('include_hidden', false); - + // Get all categories for this mailbox $categories = KbCategory::where('mailbox_id', $mailbox->id) ->orderBy('id') @@ -748,7 +761,12 @@ public function export(Request $request, $mailboxId) // Process each article foreach ($articles as $article) { $article->setLocale($locale); - + + // Skip articles that don't match the requested language + if ($filterLocale && $article->locale !== $filterLocale) { + continue; + } + $articleData = [ 'id' => $article->id, 'title' => $article->getAttributeInLocale('title', $locale), @@ -762,6 +780,11 @@ public function export(Request $request, $mailboxId) $categoryData['articles'][] = $articleData; } + // Skip categories with no articles when filtering by language + if ($filterLocale && empty($categoryData['articles'])) { + continue; + } + $exportData['categories'][] = $categoryData; } From 4caa5801603a861536a61bd4c6315896de301ec4 Mon Sep 17 00:00:00 2001 From: yguerdat Date: Wed, 25 Mar 2026 12:54:44 +0100 Subject: [PATCH 4/6] fix: use collect() to handle Collection from getArticlesSorted() in locale filtering getArticlesSorted() returns an Eloquent Collection, not a plain array, causing array_filter() to fail on the production server. Co-Authored-By: Claude Opus 4.6 (1M context) --- Http/Controllers/KnowledgeBaseApiController.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Http/Controllers/KnowledgeBaseApiController.php b/Http/Controllers/KnowledgeBaseApiController.php index 9625259..eb19b34 100644 --- a/Http/Controllers/KnowledgeBaseApiController.php +++ b/Http/Controllers/KnowledgeBaseApiController.php @@ -123,9 +123,9 @@ public function category(Request $request, $mailboxId, $categoryId) } $subArticleCount = 0; if (method_exists($sub, 'getArticlesSorted')) { - $subArticles = $sub->getArticlesSorted(true); + $subArticles = collect($sub->getArticlesSorted(true)); if ($filterLocale) { - $subArticles = array_filter($subArticles, fn($a) => $a->locale === $filterLocale); + $subArticles = $subArticles->filter(fn($a) => $a->locale === $filterLocale); } $subArticleCount = count($subArticles); } @@ -387,11 +387,11 @@ private function buildCategoryTree(array $categories, string $locale, int $mailb $articleCount = 0; if (method_exists($c, 'getArticlesSorted')) { - $articles = $c->getArticlesSorted(true); + $articles = collect($c->getArticlesSorted(true)); if ($filterLocale) { - $articles = array_filter($articles, fn($a) => $a->locale === $filterLocale); + $articles = $articles->filter(fn($a) => $a->locale === $filterLocale); } - $articleCount = count($articles); + $articleCount = $articles->count(); } // Skip categories with no matching articles and no matching children @@ -431,11 +431,11 @@ private function buildCategoryFlat(array $categories, string $locale, int $mailb } $articleCount = 0; if (method_exists($c, 'getArticlesSorted')) { - $articles = $c->getArticlesSorted(true); + $articles = collect($c->getArticlesSorted(true)); if ($filterLocale) { - $articles = array_filter($articles, fn($a) => $a->locale === $filterLocale); + $articles = $articles->filter(fn($a) => $a->locale === $filterLocale); } - $articleCount = count($articles); + $articleCount = $articles->count(); } if ($filterLocale && $articleCount === 0) { continue; From 8cad057e67be58d73ed44754c8ecda59a94edaca Mon Sep 17 00:00:00 2001 From: yguerdat Date: Wed, 25 Mar 2026 13:01:16 +0100 Subject: [PATCH 5/6] fix: treat articles with empty locale as matching the mailbox default locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Articles in the KB store locale in a JSON field ("l" key). Most existing articles have an empty locale value. The lang filter was comparing empty string to "fr", causing all articles to be excluded. Now uses a localeMatches() helper that falls back to the mailbox default locale when an article's locale is empty/null. Also fixes search endpoint which was using a SQL WHERE on a non-existent "locale" column — filtering is now done in PHP after fetching results. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../KnowledgeBaseApiController.php | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/Http/Controllers/KnowledgeBaseApiController.php b/Http/Controllers/KnowledgeBaseApiController.php index eb19b34..e2ecf6a 100644 --- a/Http/Controllers/KnowledgeBaseApiController.php +++ b/Http/Controllers/KnowledgeBaseApiController.php @@ -43,14 +43,15 @@ public function get(Request $request, $mailboxId) $locale = $this->resolveLocale($request, $mailbox); $filterLocale = $request->input('lang') ?? $request->input('locale'); + $defaultLocale = \Kb::defaultLocale($mailbox); $flat = (bool) $request->input('flat', false); $categories = \KbCategory::getTree($mailbox->id); if ($flat) { - $result = $this->buildCategoryFlat($categories, $locale, $mailbox->id, $filterLocale); + $result = $this->buildCategoryFlat($categories, $locale, $mailbox->id, $filterLocale, $defaultLocale); } else { - $result = $this->buildCategoryTree($categories, $locale, $mailbox->id, $filterLocale); + $result = $this->buildCategoryTree($categories, $locale, $mailbox->id, $filterLocale, $defaultLocale); } return Response::json([ @@ -92,6 +93,7 @@ public function category(Request $request, $mailboxId, $categoryId) $locale = $this->resolveLocale($request, $mailbox); $filterLocale = $request->input('lang') ?? $request->input('locale'); + $defaultLocale = \Kb::defaultLocale($mailbox); // Get articles for this category $articles = []; @@ -99,7 +101,7 @@ public function category(Request $request, $mailboxId, $categoryId) foreach ($sortedArticles as $a) { $a->setLocale($locale); - if ($filterLocale && $a->locale !== $filterLocale) { + if ($filterLocale && !$this->localeMatches($a->locale, $filterLocale, $defaultLocale)) { continue; } @@ -125,7 +127,7 @@ public function category(Request $request, $mailboxId, $categoryId) if (method_exists($sub, 'getArticlesSorted')) { $subArticles = collect($sub->getArticlesSorted(true)); if ($filterLocale) { - $subArticles = $subArticles->filter(fn($a) => $a->locale === $filterLocale); + $subArticles = $subArticles->filter(fn($a) => $this->localeMatches($a->locale, $filterLocale, $defaultLocale)); } $subArticleCount = count($subArticles); } @@ -186,6 +188,7 @@ public function search(Request $request, $mailboxId) // Only filter by locale if explicitly requested $filterLocale = $request->input('lang') ?? $request->input('locale'); + $defaultLocale = \Kb::defaultLocale($mailbox); // Convert keyword to lowercase for case-insensitive search $keyword = mb_strtolower($keyword); @@ -197,13 +200,15 @@ public function search(Request $request, $mailboxId) ->orWhereRaw('LOWER(text) LIKE ?', ['%'.$keyword.'%']); }) ->where('status', KbArticle::STATUS_PUBLISHED) - ->when($filterLocale, function($q) use ($filterLocale) { - $q->where('locale', $filterLocale); - }) ->get(); $results = []; foreach ($articles as $article) { + // Skip articles that don't match the requested language + if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { + continue; + } + // Get categories for this article and check if at least one is visible $hasVisibleCategory = false; $categories = []; @@ -307,10 +312,11 @@ public function article(Request $request, $mailboxId, $categoryId, $articleId) // Get locale $locale = $this->resolveLocale($request, $mailbox); $filterLocale = $request->input('lang') ?? $request->input('locale'); + $defaultLocale = \Kb::defaultLocale($mailbox); $article->setLocale($locale); // If a language filter is specified, check that the article matches - if ($filterLocale && $article->locale !== $filterLocale) { + if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { return Response::json(['error' => 'Article not available in the requested language'], 404); } @@ -359,6 +365,21 @@ private function resolveLocale(Request $request, $mailbox): string ?? \Kb::defaultLocale($mailbox); } + /** + * Check if an article's locale matches the requested filter locale. + * Articles with empty/null locale are treated as matching the mailbox default locale. + * + * @param string|null $articleLocale + * @param string $filterLocale + * @param string $defaultLocale + * @return bool + */ + private function localeMatches(?string $articleLocale, string $filterLocale, string $defaultLocale): bool + { + $effectiveLocale = (!empty($articleLocale)) ? $articleLocale : $defaultLocale; + return $effectiveLocale === $filterLocale; + } + /** * Recursively build a nested category tree. * Uses the ->categories property populated by KbCategory::getTree(). @@ -367,9 +388,10 @@ private function resolveLocale(Request $request, $mailbox): string * @param string $locale * @param int $mailboxId * @param string|null $filterLocale + * @param string $defaultLocale * @return array */ - private function buildCategoryTree(array $categories, string $locale, int $mailboxId, ?string $filterLocale = null): array + private function buildCategoryTree(array $categories, string $locale, int $mailboxId, ?string $filterLocale = null, string $defaultLocale = ''): array { $tree = []; foreach ($categories as $c) { @@ -381,7 +403,7 @@ private function buildCategoryTree(array $categories, string $locale, int $mailb if (!empty($c->categories)) { $children = $this->buildCategoryTree( is_array($c->categories) ? $c->categories : $c->categories->all(), - $locale, $mailboxId, $filterLocale + $locale, $mailboxId, $filterLocale, $defaultLocale ); } @@ -389,7 +411,7 @@ private function buildCategoryTree(array $categories, string $locale, int $mailb if (method_exists($c, 'getArticlesSorted')) { $articles = collect($c->getArticlesSorted(true)); if ($filterLocale) { - $articles = $articles->filter(fn($a) => $a->locale === $filterLocale); + $articles = $articles->filter(fn($a) => $this->localeMatches($a->locale, $filterLocale, $defaultLocale)); } $articleCount = $articles->count(); } @@ -420,9 +442,10 @@ private function buildCategoryTree(array $categories, string $locale, int $mailb * @param string $locale * @param int $mailboxId * @param string|null $filterLocale + * @param string $defaultLocale * @return array */ - private function buildCategoryFlat(array $categories, string $locale, int $mailboxId, ?string $filterLocale = null): array + private function buildCategoryFlat(array $categories, string $locale, int $mailboxId, ?string $filterLocale = null, string $defaultLocale = ''): array { $flat = []; foreach ($categories as $c) { @@ -433,7 +456,7 @@ private function buildCategoryFlat(array $categories, string $locale, int $mailb if (method_exists($c, 'getArticlesSorted')) { $articles = collect($c->getArticlesSorted(true)); if ($filterLocale) { - $articles = $articles->filter(fn($a) => $a->locale === $filterLocale); + $articles = $articles->filter(fn($a) => $this->localeMatches($a->locale, $filterLocale, $defaultLocale)); } $articleCount = $articles->count(); } @@ -453,7 +476,7 @@ private function buildCategoryFlat(array $categories, string $locale, int $mailb if (!empty($c->categories)) { $subFlat = $this->buildCategoryFlat( is_array($c->categories) ? $c->categories : $c->categories->all(), - $locale, $mailboxId, $filterLocale + $locale, $mailboxId, $filterLocale, $defaultLocale ); $flat = array_merge($flat, $subFlat); } @@ -620,6 +643,7 @@ public function popular(Request $request, $mailboxId) $type = $request->input('type', 'all'); $locale = $this->resolveLocale($request, $mailbox); $filterLocale = $request->input('lang') ?? $request->input('locale'); + $defaultLocale = \Kb::defaultLocale($mailbox); $response = [ 'mailbox_id' => $mailbox->id, @@ -673,7 +697,7 @@ public function popular(Request $request, $mailboxId) if ($article && $article->status == KbArticle::STATUS_PUBLISHED && $category && $category->checkVisibility()) { // Skip articles that don't match the requested language - if ($filterLocale && $article->locale !== $filterLocale) { + if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { continue; } @@ -721,6 +745,7 @@ public function export(Request $request, $mailboxId) $locale = $this->resolveLocale($request, $mailbox); $filterLocale = $request->input('lang') ?? $request->input('locale'); + $defaultLocale = \Kb::defaultLocale($mailbox); $includeHidden = $request->input('include_hidden', false); // Get all categories for this mailbox @@ -763,7 +788,7 @@ public function export(Request $request, $mailboxId) $article->setLocale($locale); // Skip articles that don't match the requested language - if ($filterLocale && $article->locale !== $filterLocale) { + if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { continue; } From d54625be515c3ed996c438324f23ec0ccb7dadd8 Mon Sep 17 00:00:00 2001 From: yguerdat Date: Wed, 25 Mar 2026 13:10:54 +0100 Subject: [PATCH 6/6] fix: check actual JSON translation keys instead of locale accessor for lang filtering The locale accessor returns the "l" field from JSON which is often empty. STATUS_PUBLISHED is 2 (not 1 as assumed), so most translated articles were actually published. New articleHasLocale() method inspects the raw JSON title to check if a translation key exists (e.g. "de" key for German). For the default locale, articles always match via the "v" (default value) key. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../KnowledgeBaseApiController.php | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/Http/Controllers/KnowledgeBaseApiController.php b/Http/Controllers/KnowledgeBaseApiController.php index e2ecf6a..0b5fa1b 100644 --- a/Http/Controllers/KnowledgeBaseApiController.php +++ b/Http/Controllers/KnowledgeBaseApiController.php @@ -101,7 +101,7 @@ public function category(Request $request, $mailboxId, $categoryId) foreach ($sortedArticles as $a) { $a->setLocale($locale); - if ($filterLocale && !$this->localeMatches($a->locale, $filterLocale, $defaultLocale)) { + if ($filterLocale && !$this->articleHasLocale($a, $filterLocale, $defaultLocale)) { continue; } @@ -127,7 +127,7 @@ public function category(Request $request, $mailboxId, $categoryId) if (method_exists($sub, 'getArticlesSorted')) { $subArticles = collect($sub->getArticlesSorted(true)); if ($filterLocale) { - $subArticles = $subArticles->filter(fn($a) => $this->localeMatches($a->locale, $filterLocale, $defaultLocale)); + $subArticles = $subArticles->filter(fn($a) => $this->articleHasLocale($a, $filterLocale, $defaultLocale)); } $subArticleCount = count($subArticles); } @@ -205,7 +205,7 @@ public function search(Request $request, $mailboxId) $results = []; foreach ($articles as $article) { // Skip articles that don't match the requested language - if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { + if ($filterLocale && !$this->articleHasLocale($article, $filterLocale, $defaultLocale)) { continue; } @@ -316,7 +316,7 @@ public function article(Request $request, $mailboxId, $categoryId, $articleId) $article->setLocale($locale); // If a language filter is specified, check that the article matches - if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { + if ($filterLocale && !$this->articleHasLocale($article, $filterLocale, $defaultLocale)) { return Response::json(['error' => 'Article not available in the requested language'], 404); } @@ -366,18 +366,37 @@ private function resolveLocale(Request $request, $mailbox): string } /** - * Check if an article's locale matches the requested filter locale. - * Articles with empty/null locale are treated as matching the mailbox default locale. + * Check if an article has content available in the requested locale. * - * @param string|null $articleLocale - * @param string $filterLocale - * @param string $defaultLocale + * The KB stores translations as JSON: {"v":"default text","l":"fr","de":"German text"}. + * - "v" is the default/fallback content + * - "l" is the default locale (often empty, meaning the mailbox default) + * - Other keys (e.g. "de") are explicit translations + * + * For the default locale (or when "l" matches): the article always has content via "v". + * For other locales: the article must have an explicit translation key. + * + * @param object $article The article model + * @param string $filterLocale The requested locale (e.g. "fr", "de") + * @param string $defaultLocale The mailbox default locale * @return bool */ - private function localeMatches(?string $articleLocale, string $filterLocale, string $defaultLocale): bool + private function articleHasLocale($article, string $filterLocale, string $defaultLocale): bool { - $effectiveLocale = (!empty($articleLocale)) ? $articleLocale : $defaultLocale; - return $effectiveLocale === $filterLocale; + // If requesting the default locale, the article always has content via "v" + $articleDefaultLocale = !empty($article->locale) ? $article->locale : $defaultLocale; + if ($filterLocale === $articleDefaultLocale) { + return true; + } + + // For other locales, check if an explicit translation key exists in the raw JSON + $rawTitle = $article->getAttributes()['title'] ?? ''; + $json = json_decode($rawTitle, true); + if (is_array($json) && isset($json[$filterLocale])) { + return true; + } + + return false; } /** @@ -411,7 +430,7 @@ private function buildCategoryTree(array $categories, string $locale, int $mailb if (method_exists($c, 'getArticlesSorted')) { $articles = collect($c->getArticlesSorted(true)); if ($filterLocale) { - $articles = $articles->filter(fn($a) => $this->localeMatches($a->locale, $filterLocale, $defaultLocale)); + $articles = $articles->filter(fn($a) => $this->articleHasLocale($a, $filterLocale, $defaultLocale)); } $articleCount = $articles->count(); } @@ -456,7 +475,7 @@ private function buildCategoryFlat(array $categories, string $locale, int $mailb if (method_exists($c, 'getArticlesSorted')) { $articles = collect($c->getArticlesSorted(true)); if ($filterLocale) { - $articles = $articles->filter(fn($a) => $this->localeMatches($a->locale, $filterLocale, $defaultLocale)); + $articles = $articles->filter(fn($a) => $this->articleHasLocale($a, $filterLocale, $defaultLocale)); } $articleCount = $articles->count(); } @@ -697,7 +716,7 @@ public function popular(Request $request, $mailboxId) if ($article && $article->status == KbArticle::STATUS_PUBLISHED && $category && $category->checkVisibility()) { // Skip articles that don't match the requested language - if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { + if ($filterLocale && !$this->articleHasLocale($article, $filterLocale, $defaultLocale)) { continue; } @@ -788,7 +807,7 @@ public function export(Request $request, $mailboxId) $article->setLocale($locale); // Skip articles that don't match the requested language - if ($filterLocale && !$this->localeMatches($article->locale, $filterLocale, $defaultLocale)) { + if ($filterLocale && !$this->articleHasLocale($article, $filterLocale, $defaultLocale)) { continue; }