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