From 8dc93ade6d62ecb217c57053fbdd93d502eceae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Sert?= Date: Thu, 4 Jun 2026 00:58:01 +0300 Subject: [PATCH] fix(i18n): Turkish locale-aware search and sorting (#3115) * feat(i18n): add Turkish locale-aware text helpers (search/sort) * test(i18n): cover null/whitespace edges + document compareTr/normalize contract * fix(i18n): route dashboard search through Turkish-safe matchesSearch * fix(i18n): sort user-visible lists with Turkish collation (compareTr) * fix(i18n): keep providerId tiebreaker as ASCII sort (technical id) * docs(i18n): document intentional lang=en in global-error boundary * chore(lint): guard against locale-unsafe toLowerCase().includes search * fix(i18n): migrate missed provider-name search + harden lint disable placement * fix(i18n): downgrade no-restricted-syntax to warn (incremental adoption) The rule errored on ~19 pre-existing toLowerCase().includes() call-sites in src/app accumulated since this PR's base. Keep it as a warning so the guard-rail guides future code without breaking the 0-errors lint gate (project policy: 0 errors, warnings tolerated). --------- Co-authored-by: diegosouzapw --- eslint.config.mjs | 19 +++++ .../api-manager/ApiManagerPageClient.tsx | 10 ++- .../dashboard/batch/BatchListTab.tsx | 8 +- .../dashboard/batch/FilesListTab.tsx | 4 +- src/app/(dashboard)/dashboard/batch/page.tsx | 4 +- .../dashboard/cache/media/MediaPageClient.tsx | 5 ++ .../cli-code/components/CopilotToolCard.tsx | 3 +- .../cli-code/components/DroidToolCard.tsx | 1 + .../combos/BuilderIntelligentStep.tsx | 3 +- .../dashboard/endpoint/ApiEndpointsTab.tsx | 15 ++-- src/app/(dashboard)/dashboard/health/page.tsx | 3 +- .../dashboard/providers/[id]/page.tsx | 3 +- .../dashboard/providers/providerPageUtils.ts | 13 ++-- .../search-tools/components/RerankPanel.tsx | 1 + .../settings/components/ComplianceTab.tsx | 12 +-- .../settings/components/PricingTab.tsx | 17 ++--- .../settings/components/RoutingTab.tsx | 3 +- .../translator/hooks/useAvailableModels.tsx | 3 +- .../translator/hooks/useProviderOptions.tsx | 3 +- .../dashboard/usage/components/BudgetTab.tsx | 12 +-- .../dashboard/usage/components/EvalsTab.tsx | 8 +- .../usage/components/ProviderLimits/index.tsx | 3 +- src/app/(dashboard)/home/ProviderTopology.tsx | 2 +- src/app/api/cli-tools/codex-profiles/route.ts | 3 +- .../guide-settings/[toolId]/route.ts | 1 + src/app/api/logs/console/route.ts | 3 +- .../settings/qdrant/embedding-models/route.ts | 4 +- src/app/api/skills/route.ts | 11 +-- src/app/api/v1/models/catalog.ts | 1 + src/app/global-error.tsx | 5 ++ src/shared/utils/turkishText.ts | 66 ++++++++++++++++ tests/unit/turkishText.test.ts | 75 +++++++++++++++++++ 32 files changed, 258 insertions(+), 66 deletions(-) create mode 100644 src/shared/utils/turkishText.ts create mode 100644 tests/unit/turkishText.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index a2af5fa614..3a29e83b5c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,25 @@ const eslintConfig = [ ], }, }, + // i18n: ham toLowerCase().includes() arama pattern'ini engelle + // (Türkçe İ/ı karakterlerini bozar — matchesSearch kullanılmalı). + // "warn" (error değil): kuralın eklendiği anda kod tabanında zaten bu pattern'i + // kullanan ~19 satır var; aşamalı temizlik için uyarı seviyesinde tutuluyor + // (proje politikası: 0 error, warning'ler tolere edilir). + { + files: ["src/app/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"], + rules: { + "no-restricted-syntax": [ + "warn", + { + selector: + "CallExpression[callee.property.name='includes'][callee.object.callee.property.name='toLowerCase']", + message: + "Türkçe-güvenli arama için matchesSearch() kullan (@/shared/utils/turkishText). Ham toLowerCase().includes() İ/ı karakterlerini bozar.", + }, + ], + }, + }, // Relaxed rules for open-sse and tests (incremental adoption) { files: ["open-sse/**/*.ts", "tests/**/*.mjs", "tests/**/*.ts"], diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index a09be3cab3..8b155975cf 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -5,6 +5,7 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; import { getProviderDisplayName } from "@/lib/display/names"; +import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; import { ENDPOINT_CATEGORIES } from "@/shared/constants/endpointCategories"; import ApiKeyFilterBar from "./components/ApiKeyFilterBar"; import { @@ -675,20 +676,21 @@ export default function ApiManagerPageClient() { if (!grouped[provider]) grouped[provider] = []; grouped[provider].push(model); } - return Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0])); + return Object.entries(grouped).sort((a, b) => compareTr(a[0], b[0])); }, [allModels, t]); // Filter models based on debounced search const filteredModelsByProvider = useMemo((): ProviderGroup[] => { if (!debouncedSearchModel.trim()) return modelsByProvider; - const search = debouncedSearchModel.toLowerCase(); return modelsByProvider .map( ([provider, models]): ProviderGroup => [ provider, models.filter( - (m) => m.id.toLowerCase().includes(search) || provider.toLowerCase().includes(search) + (m) => + matchesSearch(m.id, debouncedSearchModel) || + matchesSearch(provider, debouncedSearchModel) ), ] ) @@ -2437,7 +2439,7 @@ const PermissionsModal = memo(function PermissionsModal({ return acc; }, {}) ) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => compareTr(a, b)) .map(([provider, conns]) => (

diff --git a/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx b/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx index 4980308a10..b281abae1a 100644 --- a/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx +++ b/src/app/(dashboard)/dashboard/batch/BatchListTab.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; +import { matchesSearch } from "@/shared/utils/turkishText"; import BatchDetailModal from "./BatchDetailModal"; import ExpirationBadge from "./components/ExpirationBadge"; import ProgressBarBicolor from "./components/ProgressBarBicolor"; @@ -348,11 +349,10 @@ export default function BatchListTab({ const filtered = batches.filter((b) => { if (statusFilter !== "all" && b.status !== statusFilter) return false; if (searchQuery) { - const q = searchQuery.toLowerCase(); return ( - b.id.toLowerCase().includes(q) || - b.endpoint.toLowerCase().includes(q) || - (b.model ?? "").toLowerCase().includes(q) + matchesSearch(b.id, searchQuery) || + matchesSearch(b.endpoint, searchQuery) || + matchesSearch(b.model ?? "", searchQuery) ); } return true; diff --git a/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx b/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx index 9902509539..38df109db8 100644 --- a/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx +++ b/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; +import { matchesSearch } from "@/shared/utils/turkishText"; import FileDetailModal from "./FileDetailModal"; import ExpirationBadge from "./components/ExpirationBadge"; @@ -90,8 +91,7 @@ export default function FilesListTab({ const filtered = files.filter((f) => { if (purposeFilter !== "all" && f.purpose !== purposeFilter) return false; if (searchQuery) { - const q = searchQuery.toLowerCase(); - return f.id.toLowerCase().includes(q) || f.filename.toLowerCase().includes(q); + return matchesSearch(f.id, searchQuery) || matchesSearch(f.filename, searchQuery); } return true; }); diff --git a/src/app/(dashboard)/dashboard/batch/page.tsx b/src/app/(dashboard)/dashboard/batch/page.tsx index 996ac551c6..b0d12bf5e1 100644 --- a/src/app/(dashboard)/dashboard/batch/page.tsx +++ b/src/app/(dashboard)/dashboard/batch/page.tsx @@ -83,7 +83,7 @@ export default function BatchPage() { batchMap.set(m.id, m); } return Array.from(batchMap.values()).sort( - (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id), + (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id), // teknik sıralama: ASCII kasıtlı ); }); } else { @@ -104,7 +104,7 @@ export default function BatchPage() { fileMap.set(m.id, m); } return Array.from(fileMap.values()).sort( - (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id), + (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id), // teknik sıralama: ASCII kasıtlı ); }); } else { diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index 1e97577beb..cb89cd20c3 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -376,10 +376,15 @@ function parseApiError(raw: any, statusCode: number): { message: string; isCrede const isCredentials = typeof msg === "string" && + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil (msg.toLowerCase().includes("no credentials") || + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil msg.toLowerCase().includes("invalid api key") || + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil msg.toLowerCase().includes("unauthorized") || + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil msg.toLowerCase().includes("authentication") || + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil msg.toLowerCase().includes("api key") || statusCode === 401 || statusCode === 403); diff --git a/src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx index 17128b165b..190247283a 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button } from "@/shared/components"; import Image from "next/image"; import { useTranslations } from "next-intl"; +import { matchesSearch } from "@/shared/utils/turkishText"; /** * GitHub Copilot Configuration Generator @@ -78,7 +79,7 @@ export default function CopilotToolCard({ // Filter models by search const availableModels = searchFilter - ? allModels.filter((m) => m.label.toLowerCase().includes(searchFilter.toLowerCase())) + ? allModels.filter((m) => matchesSearch(m.label, searchFilter)) : allModels; // Persist selection diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx index b08b89db24..6b656c59bb 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx @@ -259,6 +259,7 @@ export default function DroidToolCard({ }; const platform = typeof navigator !== "undefined" && navigator.platform; + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil const isWindows = platform?.toLowerCase().includes("win"); const settingsPath = isWindows ? "%USERPROFILE%\\.factory\\settings.json" diff --git a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx index c24c260f94..d6ac7bad83 100644 --- a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx +++ b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx @@ -9,6 +9,7 @@ import { ROUTER_STRATEGY_OPTIONS, normalizeIntelligentRoutingConfig, } from "@/lib/combos/intelligentRouting"; +import { compareTr } from "@/shared/utils/turkishText"; function getI18nOrFallback(t: any, key: string, fallback: string) { if (typeof t?.has === "function" && t.has(key)) return t(key); @@ -51,7 +52,7 @@ function toProviderOptions(activeProviders: any[] = [], candidatePool: string[] } }); - return [...uniqueProviders.values()].sort((a, b) => a.label.localeCompare(b.label)); + return [...uniqueProviders.values()].sort((a, b) => compareTr(a.label, b.label)); } export default function BuilderIntelligentStep({ diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index b02166acaf..9ffa88f36c 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; import { useDisplayBaseUrl } from "@/shared/hooks"; +import { matchesSearch } from "@/shared/utils/turkishText"; /* ─── Types ──────────────────────────────────────────── */ interface Endpoint { @@ -136,12 +137,16 @@ export default function ApiEndpointsTab() { const filteredEndpoints = useMemo(() => { if (!catalog) return []; return catalog.endpoints.filter((ep) => { + // Keep the internal-endpoint visibility toggle + security-tier filter + // (from release) while using the locale-aware matchesSearch helper. The + // local var is matchesEndpoint (not matchesSearch) to avoid shadowing the + // imported helper used in its own initializer. if (!showInternal && ep.internal) return false; - const matchesSearch = + const matchesEndpoint = !search || - ep.path.toLowerCase().includes(search.toLowerCase()) || - ep.summary.toLowerCase().includes(search.toLowerCase()) || - ep.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())); + matchesSearch(ep.path, search) || + matchesSearch(ep.summary, search) || + ep.tags.some((t) => matchesSearch(t, search)); const matchesTag = !selectedTag || ep.tags.includes(selectedTag); const matchesTier = securityTier === "all" || @@ -149,7 +154,7 @@ export default function ApiEndpointsTab() { (securityTier === "always-protected" && ep.alwaysProtected) || (securityTier === "auth" && ep.security && !ep.loopbackOnly && !ep.alwaysProtected) || (securityTier === "public" && !ep.security && !ep.loopbackOnly && !ep.alwaysProtected); - return matchesSearch && matchesTag && matchesTier; + return matchesEndpoint && matchesTag && matchesTier; }); }, [catalog, search, selectedTag, showInternal, securityTier]); diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 5cd1c6dee2..12605e5da9 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { getProviderDisplayName } from "@/lib/display/names"; +import { compareTr } from "@/shared/utils/turkishText"; import { useTranslations } from "next-intl"; import TelemetryCard from "./TelemetryCard"; import ProviderHealthAutopilotCard from "./ProviderHealthAutopilotCard"; @@ -891,7 +892,7 @@ export default function HealthPage() { const aActive = (a.status.queued || 0) + (a.status.running || 0); const bActive = (b.status.queued || 0) + (b.status.running || 0); if (aActive !== bActive) return bActive - aActive; - return a.displayName.localeCompare(b.displayName); + return compareTr(a.displayName, b.displayName); }); return ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index be36ab75fc..ffd748746a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -85,6 +85,7 @@ import { } from "@/lib/providers/codexFastTier"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; import { parseExtraApiKeys } from "@/shared/utils/parseApiKeys"; +import { compareTr } from "@/shared/utils/turkishText"; import RiskNoticeModal from "../components/RiskNoticeModal"; import { isRiskAcknowledged, useRiskAcknowledged } from "../hooks/useRiskAcknowledged"; import { resolveDashboardProviderInfo } from "../providerPageUtils"; @@ -4414,7 +4415,7 @@ export default function ProviderDetailPage() { const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { if (a === "") return -1; if (b === "") return 1; - return a.localeCompare(b); + return compareTr(a, b); }); return ( diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 432cb64b87..845a82a64e 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -7,6 +7,7 @@ import { type ResolvedProviderCatalogEntry, type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; +import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; export interface ProviderStatsSnapshot { total?: number; @@ -52,9 +53,9 @@ export function sortProviderEntriesByName( entries: ProviderEntry[] ): ProviderEntry[] { return [...entries].sort((a, b) => { - const nameCompare = getProviderSortLabel(a).localeCompare(getProviderSortLabel(b)); + const nameCompare = compareTr(getProviderSortLabel(a), getProviderSortLabel(b)); if (nameCompare !== 0) return nameCompare; - return a.providerId.localeCompare(b.providerId); + return a.providerId.localeCompare(b.providerId); // teknik sıralama: ASCII kasıtlı }); } @@ -117,12 +118,12 @@ export function filterConfiguredProviderEntries( } if (searchQuery && searchQuery.trim()) { - const query = searchQuery.trim().toLowerCase(); filtered = filtered.filter((entry) => { const provider = entry.provider as Record; - const name = String(provider.name || "").toLowerCase(); - const id = entry.providerId.toLowerCase(); - return name.includes(query) || id.includes(query); + return ( + matchesSearch(String(provider.name || ""), searchQuery) || + matchesSearch(entry.providerId, searchQuery) + ); }); } diff --git a/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx b/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx index 6c6d60746f..ecdd0847f3 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx @@ -32,6 +32,7 @@ export default function RerankPanel({ query, results, onClose }: RerankPanelProp .then((res) => res.json()) .then((data) => { const rerankModels = (data?.data || []) + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil .filter((m: any) => m.id.toLowerCase().includes("rerank")) .map((m: any) => ({ value: m.id, label: m.id })); setModels(rerankModels); diff --git a/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.tsx index d0433f0519..47b1f00213 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; +import { matchesSearch } from "@/shared/utils/turkishText"; export default function ComplianceTab() { const [logs, setLogs] = useState([]); @@ -43,12 +44,11 @@ export default function ComplianceTab() { const filtered = logs.filter((l) => { if (search) { - const q = search.toLowerCase(); - const matchesSearch = - l.action?.toLowerCase().includes(q) || - l.actor?.toLowerCase().includes(q) || - (l.details && JSON.stringify(l.details).toLowerCase().includes(q)); - if (!matchesSearch) return false; + const matchesQuery = + matchesSearch(l.action ?? "", search) || + matchesSearch(l.actor ?? "", search) || + (l.details && matchesSearch(JSON.stringify(l.details), search)); + if (!matchesQuery) return false; } if (filters.action && l.action !== filters.action) return false; if (filters.actor && l.actor !== filters.actor) return false; diff --git a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx index c4c90d9979..bde7ff2fb2 100644 --- a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx @@ -5,6 +5,7 @@ import { Card, Button } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import InfoTooltip from "@/shared/components/InfoTooltip"; import { useTranslations } from "next-intl"; +import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; type CoverageFilter = "all" | "lt50" | "gte50lt100" | "full"; type AuthFilter = "all" | "oauth" | "apikey" | "unknown"; @@ -141,15 +142,13 @@ export default function PricingTab() { }, [catalog, pricingData]); const filteredProviders = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); - const matchesSearch = (provider: (typeof allProviders)[number]) => { - if (!query) return true; + const providerMatchesSearch = (provider: (typeof allProviders)[number]) => { + if (!searchQuery.trim()) return true; return ( - provider.alias.toLowerCase().includes(query) || - provider.id.toLowerCase().includes(query) || + matchesSearch(provider.alias, searchQuery) || + matchesSearch(provider.id, searchQuery) || provider.models.some( - (model) => - model.id.toLowerCase().includes(query) || model.name.toLowerCase().includes(query) + (model) => matchesSearch(model.id, searchQuery) || matchesSearch(model.name, searchQuery) ) ); }; @@ -171,7 +170,7 @@ export default function PricingTab() { }; const filtered = allProviders.filter( - (p) => matchesSearch(p) && matchesCoverage(p) && matchesAuth(p) + (p) => providerMatchesSearch(p) && matchesCoverage(p) && matchesAuth(p) ); // Sort @@ -187,7 +186,7 @@ export default function PricingTab() { sorted.sort((a, b) => coveragePct(a) - coveragePct(b)); break; case "nameAsc": - sorted.sort((a, b) => a.alias.localeCompare(b.alias)); + sorted.sort((a, b) => compareTr(a.alias, b.alias)); break; } return sorted; diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index e243f9bd72..847114d64c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -11,6 +11,7 @@ import { normalizeCliCompatProviderId, } from "@/shared/constants/cliCompatProviders"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { compareTr } from "@/shared/utils/turkishText"; // Provider keys (mirror of open-sse/services/systemTransforms.ts). const PROVIDER_CLAUDE = "claude"; @@ -28,7 +29,7 @@ const PROVIDER_CATALOG: ProviderCatalogEntry[] = (() => { name: p.name ?? p.id, })); entries.push({ id: PROVIDER_CC_BRIDGE, name: "Anthropic-compatible CC bridge" }); - entries.sort((a, b) => a.name.localeCompare(b.name)); + entries.sort((a, b) => compareTr(a.name, b.name)); return entries; })(); diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx index 20891d215f..9b2a0424d1 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { compareTr } from "@/shared/utils/turkishText"; /** * Prefix-based format→model matching, used to pick a smart default @@ -34,7 +35,7 @@ export function useAvailableModels() { try { const res = await fetch("/api/v1/models"); const data = await res.json(); - const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b)); + const models = (data.data || []).map((m) => m.id).sort((a, b) => compareTr(a, b)); setAvailableModels(models); } catch { setAvailableModels([]); diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx index a7d2cb7b50..f0c2655bc2 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx @@ -8,6 +8,7 @@ import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; +import { compareTr } from "@/shared/utils/turkishText"; /** * Hook to fetch and manage provider options for the Translator tools. @@ -48,7 +49,7 @@ export function useProviderOptions(initialProvider = "openai") { label = node?.name || t("anthropicCompatibleLabel"); return { value: pid, label }; }) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort((a, b) => compareTr(a.label, b.label)); const nextOptions = options.length > 0 diff --git a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx index 91dc1fd2bb..b91d1fc46e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; import { Card, Button, Input, EmptyState } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; +import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; // ──────────────────────────────────────────────────────────────────────────── // Types @@ -269,17 +270,16 @@ export default function BudgetTab() { // ── Derived data ───────────────────────────────────────────────────────── const visibleRows = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); const matchesQuery = (r: KeyRow) => - !query || - (r.name || "").toLowerCase().includes(query) || - r.id.toLowerCase().includes(query) || - (r.provider || "").toLowerCase().includes(query); + !searchQuery.trim() || + matchesSearch(r.name ?? "", searchQuery) || + matchesSearch(r.id, searchQuery) || + matchesSearch(r.provider ?? "", searchQuery); const matchesStatus = (r: KeyRow) => statusFilter === "all" || statusOf(r) === statusFilter; const filtered = rows.filter((r) => matchesQuery(r) && matchesStatus(r)); return [...filtered].sort((a, b) => { - if (sortKey === "name") return (a.name || a.id).localeCompare(b.name || b.id); + if (sortKey === "name") return compareTr(a.name || a.id, b.name || b.id); if (sortKey === "todayDesc") return (b.budget?.totalCostToday || 0) - (a.budget?.totalCostToday || 0); if (sortKey === "monthDesc") diff --git a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx index d12a754f16..6b417efd98 100644 --- a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx @@ -13,6 +13,7 @@ import { Select, } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; +import { matchesSearch } from "@/shared/utils/turkishText"; type EvalTargetType = "suite-default" | "model" | "combo"; @@ -505,11 +506,10 @@ export default function EvalsTab() { const filteredSuites = !search.trim() ? suites : suites.filter((suite) => { - const term = search.toLowerCase(); return ( - suite.name?.toLowerCase().includes(term) || - suite.id?.toLowerCase().includes(term) || - suite.description?.toLowerCase().includes(term) + matchesSearch(suite.name ?? "", search) || + matchesSearch(suite.id ?? "", search) || + matchesSearch(suite.description ?? "", search) ); }); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 940f4611f2..64a009d665 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -20,6 +20,7 @@ import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import QuotaCutoffModal from "./QuotaCutoffModal"; import QuotaCardGrid from "./QuotaCardGrid"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; +import { compareTr } from "@/shared/utils/turkishText"; const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter"; const LS_STATUS_FILTER = "omniroute:limits:statusFilter"; @@ -593,7 +594,7 @@ export default function ProviderLimits({ const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim(); if (tag) tags.add(tag); } - return [...tags].sort((a, b) => a.localeCompare(b)); + return [...tags].sort((a, b) => compareTr(a, b)); }, [sortedConnections]); const envCounts = useMemo(() => { diff --git a/src/app/(dashboard)/home/ProviderTopology.tsx b/src/app/(dashboard)/home/ProviderTopology.tsx index e201bbc966..404231efd3 100644 --- a/src/app/(dashboard)/home/ProviderTopology.tsx +++ b/src/app/(dashboard)/home/ProviderTopology.tsx @@ -217,7 +217,7 @@ function buildLayout( return 3; }; const d = rank(aId) - rank(bId); - return d !== 0 ? d : aId.localeCompare(bId); + return d !== 0 ? d : aId.localeCompare(bId); // teknik sıralama: ASCII kasıtlı }); let provIdx = 0; diff --git a/src/app/api/cli-tools/codex-profiles/route.ts b/src/app/api/cli-tools/codex-profiles/route.ts index 78c99d7068..93fd06ad43 100644 --- a/src/app/api/cli-tools/codex-profiles/route.ts +++ b/src/app/api/cli-tools/codex-profiles/route.ts @@ -6,6 +6,7 @@ import path from "path"; import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime"; import { resolveDataDir } from "@/lib/dataPaths"; +import { compareTr } from "@/shared/utils/turkishText"; import { codexProfileIdSchema, codexProfileNameSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -88,7 +89,7 @@ export async function GET(request: Request) { } // Sort by name - profiles.sort((a, b) => a.name.localeCompare(b.name)); + profiles.sort((a, b) => compareTr(a.name, b.name)); return NextResponse.json({ profiles }); } catch (error) { console.log("Error listing codex profiles:", error.message); diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index fd9a69424b..cfc3e16db5 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -134,6 +134,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) { normalizeApiBase(m.apiBase).includes("omniroute") || normalizeApiBase(m.apiBase).includes(`localhost:${apiPort}`) || normalizeApiBase(m.apiBase).includes(`127.0.0.1:${apiPort}`) || + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil String(m.apiKey || "") .toLowerCase() .includes("sk_omniroute")) diff --git a/src/app/api/logs/console/route.ts b/src/app/api/logs/console/route.ts index ca32c2af6d..94daed683a 100644 --- a/src/app/api/logs/console/route.ts +++ b/src/app/api/logs/console/route.ts @@ -14,6 +14,7 @@ import { NextRequest, NextResponse } from "next/server"; import { readFileSync, existsSync } from "fs"; import { getAppLogFilePath } from "@/lib/logEnv"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { matchesSearch } from "@/shared/utils/turkishText"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; const LEVEL_ORDER: Record = { @@ -115,7 +116,7 @@ export async function GET(req: NextRequest) { // Filter by component if (componentFilter) { const comp = entry.component || entry.module || ""; - if (!comp.toLowerCase().includes(componentFilter.toLowerCase())) continue; + if (!matchesSearch(comp, componentFilter)) continue; } // Normalize timestamp field diff --git a/src/app/api/settings/qdrant/embedding-models/route.ts b/src/app/api/settings/qdrant/embedding-models/route.ts index 5a5f6eb1d0..9683e270f4 100644 --- a/src/app/api/settings/qdrant/embedding-models/route.ts +++ b/src/app/api/settings/qdrant/embedding-models/route.ts @@ -30,7 +30,7 @@ export async function GET(request: NextRequest) { value: `${m.provider}/${m.model}`, label: `${m.provider}/${m.model} - ${m.name}`, })) - .sort((a, b) => a.value.localeCompare(b.value)); + .sort((a, b) => a.value.localeCompare(b.value)); // teknik sıralama: ASCII kasıtlı // Add OpenRouter account models that explicitly support embeddings. try { @@ -85,7 +85,7 @@ export async function GET(request: NextRequest) { }); } - options.sort((a, b) => a.value.localeCompare(b.value)); + options.sort((a, b) => a.value.localeCompare(b.value)); // teknik sıralama: ASCII kasıtlı return NextResponse.json({ models: options }); } catch (error) { diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 18fd67ee29..2a0c6a26d5 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -3,6 +3,7 @@ import { skillRegistry } from "@/lib/skills/registry"; import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination"; import { getSkillsProviderSetting } from "@/lib/skills/providerSettings"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { matchesSearch } from "@/shared/utils/turkishText"; const POPULAR_BY_PROVIDER = { skillsmp: ["web-search", "file-reader", "sql-assistant", "devops-helper", "docs-assistant"], @@ -18,7 +19,7 @@ export async function GET(request?: Request) { const provider = await getSkillsProviderSetting(); const url = request?.url || "http://localhost/api/skills"; const parsedUrl = new URL(url); - const query = parsedUrl.searchParams.get("q")?.trim().toLowerCase() || ""; + const query = parsedUrl.searchParams.get("q")?.trim() || ""; const modeFilter = parsedUrl.searchParams.get("mode"); const sourceFilter = parsedUrl.searchParams.get("source"); @@ -26,11 +27,11 @@ export async function GET(request?: Request) { if (query) { allSkills = allSkills.filter((skill) => { - const tags = Array.isArray(skill.tags) ? skill.tags.join(" ").toLowerCase() : ""; + const tagsText = Array.isArray(skill.tags) ? skill.tags.join(" ") : ""; return ( - skill.name.toLowerCase().includes(query) || - skill.description.toLowerCase().includes(query) || - tags.includes(query) + matchesSearch(skill.name, query) || + matchesSearch(skill.description, query) || + matchesSearch(tagsText, query) ); }); } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 9ff3385149..24a50f5c78 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -462,6 +462,7 @@ export async function getUnifiedModelsResponse( ? synced.attachment : syncedInputModalities.length > 0 || syncedOutputModalities.length > 0 ? [...syncedInputModalities, ...syncedOutputModalities].some((entry) => + // eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil entry.toLowerCase().includes("image") ) : undefined; diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index b5b7d74890..1203cfca47 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -15,6 +15,11 @@ interface GlobalErrorProps { export default function GlobalError({ error, reset }: GlobalErrorProps) { return ( + // lang="en" is intentional: global-error is a client-side root boundary that + // renders ABOVE the next-intl provider, so the active locale isn't reliably + // available here. Its visible text is static English, so lang="en" stays + // consistent with the content. User-facing locale is handled by the normal + // layout ( in src/app/layout.tsx).

diff --git a/src/shared/utils/turkishText.ts b/src/shared/utils/turkishText.ts new file mode 100644 index 0000000000..6c73b1ec3d --- /dev/null +++ b/src/shared/utils/turkishText.ts @@ -0,0 +1,66 @@ +/** + * Türkçe-güvenli metin normalizasyonu, arama ve sıralama yardımcıları. + * + * JavaScript'in locale-bağımsız `toLowerCase()`'i Türkçe `İ/I/ı/i` + * karakterlerini bozar: `"İ".toLowerCase()` → `"i̇"` (U+0069 U+0307 + * combining dot above). Bu, `toLowerCase().includes()` tabanlı aramada + * Türkçe girdileri eşleşmez kılar. + * + * Saf fonksiyonlar — yan etki ve bağımlılık yok. + * + * @module shared/utils/turkishText + */ + +/** NFD ile katlanmayan Türkçe harfler için açık eşleme. */ +const TR_FOLD: Readonly> = { + ı: "i", + ş: "s", + ğ: "g", + ü: "u", + ö: "o", + ç: "c", +}; + +/** + * Aksan-duyarsız, Türkçe-güvenli arama anahtarı üretir. + * `"İstanbul"` ve `"istanbul"` → `"istanbul"`; `"Şarj"` → `"sarj"`. + * Baştaki ve sondaki boşluklar kırpılır. + */ +export function normalizeForSearch(input: string | null | undefined): string { + if (!input) return ""; + let s = input.toLocaleLowerCase("tr"); // İ→i, I→ı (Türkçe-güvenli) + s = s.replace(/[ışğüöç]/g, (ch) => TR_FOLD[ch] ?? ch); // açık Türkçe fold + s = s.normalize("NFD").replace(/[̀-ͯ]/g, ""); // kalan aksanları sil + return s.trim(); +} + +/** + * `text`, `query`'yi (aksan-duyarsız, Türkçe-güvenli) içeriyor mu? + * Boş sorgu her zaman `true` döner. + */ +export function matchesSearch( + text: string | null | undefined, + query: string | null | undefined +): boolean { + const q = normalizeForSearch(query); + if (!q) return true; + return normalizeForSearch(text).includes(q); +} + +const trCollator = new Intl.Collator("tr", { + sensitivity: "base", + numeric: true, +}); + +/** + * Kullanıcı-görünür listeler için Türkçe alfabe + sayısal-duyarlı + * karşılaştırma. `Array.prototype.sort` ile doğrudan kullanılabilir. + * + * `sensitivity: "base"` kullanıldığından aksan/büyük-küçük harf varyantları + * sıralama açısından eşit sayılır — örneğin `"Şehir"` ile `"şehir"` berabere + * gelir. Bu karşılaştırıcı görsel liste sıralaması içindir, kesin + * aksan-farklılaştırmalı sıralama için değil. + */ +export function compareTr(a: string | null | undefined, b: string | null | undefined): number { + return trCollator.compare(a ?? "", b ?? ""); +} diff --git a/tests/unit/turkishText.test.ts b/tests/unit/turkishText.test.ts new file mode 100644 index 0000000000..280f39594d --- /dev/null +++ b/tests/unit/turkishText.test.ts @@ -0,0 +1,75 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeForSearch, + matchesSearch, + compareTr, +} from "../../src/shared/utils/turkishText.ts"; + +test("normalizeForSearch: İ combining-dot üretmez (i'ye katlanır)", () => { + const out = normalizeForSearch("İ"); + assert.equal(out, "i"); + assert.ok(!out.includes("̇"), "combining dot above bulunmamalı"); +}); + +test("normalizeForSearch: I (dotless) → i", () => { + assert.equal(normalizeForSearch("IĞDIR"), "igdir"); +}); + +test("normalizeForSearch: aksanları katlar (şğüöç)", () => { + assert.equal(normalizeForSearch("Şarj Çözüm Güven"), "sarj cozum guven"); +}); + +test("matchesSearch: İstanbul'u 'istanbul' sorgusuyla bulur", () => { + assert.equal(matchesSearch("İstanbul", "istanbul"), true); +}); + +test("matchesSearch: Latin 'Istanbul' sorgusu (noktasız I) dotted İ metnini bulur", () => { + // Real-world: data has the dotted Turkish İ, the user types a Latin capital I + // (no dot) from a non-Turkish keyboard. Both must fold to "istanbul". + assert.equal(matchesSearch("İstanbul", "Istanbul"), true); + assert.equal(matchesSearch("Istanbul", "İstanbul"), true); +}); + +test("matchesSearch: aksan-duyarsız (Şarj İstasyonu ↔ 'sarj')", () => { + assert.equal(matchesSearch("Şarj İstasyonu", "sarj"), true); +}); + +test("matchesSearch: eşleşmeyen sorgu false döner", () => { + assert.equal(matchesSearch("OpenAI", "anthropic"), false); +}); + +test("matchesSearch: boş sorgu her şeyi eşler", () => { + assert.equal(matchesSearch("herhangi", ""), true); +}); + +test("compareTr: Türkçe alfabede ç, c'den sonra gelir", () => { + assert.deepEqual(["d", "ç", "c", "b"].sort(compareTr), ["b", "c", "ç", "d"]); +}); + +test("compareTr: ı, i'den önce gelir (Türkçe)", () => { + assert.equal(compareTr("ı", "i") < 0, true); +}); + +test("compareTr: sayısal-duyarlı (item2 < item10)", () => { + assert.equal(compareTr("item2", "item10") < 0, true); +}); + +test("normalizeForSearch: null/undefined boş string döner", () => { + assert.equal(normalizeForSearch(null), ""); + assert.equal(normalizeForSearch(undefined), ""); +}); + +test("normalizeForSearch: yalnızca boşluk → boş string", () => { + assert.equal(normalizeForSearch(" "), ""); +}); + +test("matchesSearch: yalnızca boşluk sorgusu her şeyi eşler", () => { + assert.equal(matchesSearch("herhangi", " "), true); +}); + +test("compareTr: null/undefined argümanları güvenli (?? '' guard)", () => { + assert.equal(compareTr(null, "a") < 0, true); + assert.equal(compareTr("a", null) > 0, true); + assert.equal(compareTr(null, undefined), 0); +});