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 <diegosouza.pw@gmail.com>
This commit is contained in:
Oğuzhan Sert
2026-06-04 00:58:01 +03:00
committed by GitHub
parent 80c9ca7096
commit 8dc93ade6d
32 changed files with 258 additions and 66 deletions

View File

@@ -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"],

View File

@@ -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]) => (
<div key={provider}>
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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

View File

@@ -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"

View File

@@ -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({

View File

@@ -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]);

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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<TProvider>(
entries: ProviderEntry<TProvider>[]
): ProviderEntry<TProvider>[] {
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<TProvider>(
}
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
filtered = filtered.filter((entry) => {
const provider = entry.provider as Record<string, unknown>;
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)
);
});
}

View File

@@ -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);

View File

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

View File

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

View File

@@ -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;
})();

View File

@@ -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([]);

View File

@@ -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

View File

@@ -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")

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -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);

View File

@@ -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"))

View File

@@ -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<string, number> = {
@@ -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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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 (<html lang={locale}> in src/app/layout.tsx).
<html lang="en">
<body className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main font-[system-ui,-apple-system,sans-serif] text-center m-0">
<main role="alert" aria-live="assertive" className="flex flex-col items-center">

View File

@@ -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<Record<string, string>> = {
ı: "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 ?? "");
}

View File

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