refactor: consolidate single-source routing and protocol helpers
This commit is contained in:
@@ -149,4 +149,9 @@ describe('api proxy test timeout handling', () => {
|
||||
data: 'data:application/pdf;base64,UERG',
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the same proxy test implementations for legacy aliases', () => {
|
||||
expect(api.proxyTest).toBe(api.testProxy);
|
||||
expect(api.proxyTestStream).toBe(api.testProxyStream);
|
||||
});
|
||||
});
|
||||
|
||||
+29
-44
@@ -194,6 +194,31 @@ function resolveProxyTestTimeoutMs(data: ProxyTestRequestEnvelope) {
|
||||
return DEFAULT_PROXY_TEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function proxyTestRequest(data: ProxyTestRequestEnvelope) {
|
||||
return request('/api/test/proxy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
timeoutMs: resolveProxyTestTimeoutMs(data),
|
||||
});
|
||||
}
|
||||
|
||||
async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: AbortSignal) {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
clearAuthSession(localStorage);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return fetch('/api/test/proxy/stream', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export type ProxyTestJobResponse = {
|
||||
jobId: string;
|
||||
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
|
||||
@@ -721,52 +746,12 @@ export const api = {
|
||||
data: `data:${mimeType};base64,${base64}`,
|
||||
};
|
||||
},
|
||||
testProxy: (data: ProxyTestRequestEnvelope) =>
|
||||
request('/api/test/proxy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
timeoutMs: resolveProxyTestTimeoutMs(data),
|
||||
}),
|
||||
proxyTest: (data: ProxyTestRequestEnvelope) =>
|
||||
request('/api/test/proxy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
timeoutMs: resolveProxyTestTimeoutMs(data),
|
||||
}),
|
||||
testProxy: proxyTestRequest,
|
||||
proxyTest: proxyTestRequest,
|
||||
testChat: (data: TestChatRequestPayload) =>
|
||||
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
|
||||
testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
clearAuthSession(localStorage);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return fetch('/api/test/proxy/stream', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
clearAuthSession(localStorage);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return fetch('/api/test/proxy/stream', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
testProxyStream: proxyTestStreamRequest,
|
||||
proxyTestStream: proxyTestStreamRequest,
|
||||
testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
import {
|
||||
isTruthyFlag,
|
||||
parsePositiveInt,
|
||||
resolveAccountCredentialMode,
|
||||
} from './helpers/accountConnection.js';
|
||||
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
import { tr } from '../i18n.js';
|
||||
@@ -58,17 +63,6 @@ function createRebindForm(platformUserId = '') {
|
||||
return { accessToken: '', platformUserId, refreshToken: '', tokenExpiresAt: '' };
|
||||
}
|
||||
|
||||
function isTruthyFlag(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | null): number {
|
||||
const parsed = Number.parseInt(String(value || '').trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function resolveConnectionsSegment(search: string): ConnectionsSegment {
|
||||
const rawSegment = new URLSearchParams(search).get('segment');
|
||||
if (rawSegment === 'apikey' || rawSegment === 'tokens') return rawSegment;
|
||||
@@ -194,18 +188,6 @@ export default function Accounts() {
|
||||
resetAddForms();
|
||||
};
|
||||
|
||||
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
const fromServer = account?.capabilities;
|
||||
if (fromServer && typeof fromServer.proxyOnly === 'boolean') {
|
||||
return fromServer.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
const hasSession = typeof account?.accessToken === 'string' && account.accessToken.trim().length > 0;
|
||||
return hasSession ? 'session' : 'apikey';
|
||||
};
|
||||
|
||||
const resolveAccountDisplayName = (account: any) => {
|
||||
const username = typeof account?.username === 'string' ? account.username.trim() : '';
|
||||
if (username) return username;
|
||||
|
||||
+14
-33
@@ -6,7 +6,7 @@ import { useToast } from '../components/Toast.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand } from '../components/BrandIcon.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
@@ -17,11 +17,15 @@ import { fuzzyMatch } from './helpers/fuzzySearch.js';
|
||||
import { clearAuthSession } from '../authSession.js';
|
||||
import { clearAppInstallationState } from '../appLocalState.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
|
||||
import {
|
||||
isExactModelPattern,
|
||||
parseBrandIconValue,
|
||||
resolveRouteTitle,
|
||||
ROUTE_ICON_NONE_VALUE,
|
||||
} from './token-routes/utils.js';
|
||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
|
||||
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
||||
const FACTORY_RESET_CONFIRM_SECONDS = 3;
|
||||
const CHECKIN_SCHEDULE_MODE_OPTIONS = [
|
||||
@@ -174,31 +178,8 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRegexModelPattern(pattern: string): boolean {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
}
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isRegexModelPattern(normalized)) return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
}
|
||||
|
||||
function routeTitle(route: RouteSelectorItem): string {
|
||||
const displayName = (route.displayName || '').trim();
|
||||
return displayName || route.modelPattern;
|
||||
}
|
||||
|
||||
function parseBrandIconValue(raw: string | null | undefined): string | null {
|
||||
const normalized = (raw || '').trim();
|
||||
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
|
||||
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
|
||||
return normalizeBrandIconKey(icon);
|
||||
}
|
||||
|
||||
function resolveRouteBrandSource(route: RouteSelectorItem): string {
|
||||
const title = routeTitle(route);
|
||||
const title = resolveRouteTitle(route);
|
||||
if (getBrand(title)) return title;
|
||||
return route.modelPattern;
|
||||
}
|
||||
@@ -302,7 +283,7 @@ export default function Settings() {
|
||||
const groupRouteOptions = useMemo(() => (
|
||||
selectorRoutes
|
||||
.filter((route) => !isExactModelPattern(route.modelPattern))
|
||||
.sort((a, b) => routeTitle(a).localeCompare(routeTitle(b), undefined, { sensitivity: 'base' }))
|
||||
.sort((a, b) => resolveRouteTitle(a).localeCompare(resolveRouteTitle(b), undefined, { sensitivity: 'base' }))
|
||||
), [selectorRoutes]);
|
||||
|
||||
const filteredExactModelOptions = useMemo(() => {
|
||||
@@ -315,7 +296,7 @@ export default function Settings() {
|
||||
const query = selectorGroupSearch.trim();
|
||||
if (!query) return groupRouteOptions;
|
||||
return groupRouteOptions.filter((route) => {
|
||||
const matchText = `${routeTitle(route)} ${route.modelPattern} ${route.displayName || ''}`;
|
||||
const matchText = `${resolveRouteTitle(route)} ${route.modelPattern} ${route.displayName || ''}`;
|
||||
return fuzzyMatch(matchText, query);
|
||||
});
|
||||
}, [groupRouteOptions, selectorGroupSearch]);
|
||||
@@ -1926,7 +1907,7 @@ export default function Settings() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
||||
) : filteredGroupRouteOptions.map((route) => {
|
||||
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
|
||||
const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
|
||||
const explicitBrandIcon = parseBrandIconValue(route.displayIcon || '');
|
||||
const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE;
|
||||
const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim();
|
||||
return (
|
||||
@@ -1985,9 +1966,9 @@ export default function Settings() {
|
||||
{explicitBrandIcon ? (
|
||||
<BrandGlyph
|
||||
icon={explicitBrandIcon}
|
||||
alt={routeTitle(route)}
|
||||
alt={resolveRouteTitle(route)}
|
||||
size={18}
|
||||
fallbackText={routeTitle(route)}
|
||||
fallbackText={resolveRouteTitle(route)}
|
||||
/>
|
||||
) : textIcon ? (
|
||||
textIcon
|
||||
@@ -1996,7 +1977,7 @@ export default function Settings() {
|
||||
)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{routeTitle(route)}
|
||||
{resolveRouteTitle(route)}
|
||||
</span>
|
||||
{!route.enabled && (
|
||||
<span
|
||||
|
||||
@@ -7,6 +7,11 @@ import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import {
|
||||
isTruthyFlag,
|
||||
parsePositiveInt,
|
||||
resolveAccountCredentialMode,
|
||||
} from './helpers/accountConnection.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
@@ -39,18 +44,6 @@ type AccountTokenSyncResult = {
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
|
||||
return account.capabilities.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
return typeof account?.accessToken === 'string' && account.accessToken.trim()
|
||||
? 'session'
|
||||
: 'apikey';
|
||||
};
|
||||
|
||||
const isAccountSyncable = (account: any) =>
|
||||
resolveAccountCredentialMode(account) === 'session'
|
||||
&& account?.status === 'active'
|
||||
@@ -105,18 +98,6 @@ async function copyText(text: string) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function parsePositiveInt(input: string | null): number {
|
||||
const value = Number.parseInt(String(input || '').trim(), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTruthyFlag(input: string | null): boolean {
|
||||
if (!input) return false;
|
||||
const normalized = input.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isTruthyFlag,
|
||||
parsePositiveInt,
|
||||
resolveAccountCredentialMode,
|
||||
} from './accountConnection.js';
|
||||
|
||||
describe('accountConnection helpers', () => {
|
||||
it('resolves account credential mode from explicit mode, capabilities, and token presence', () => {
|
||||
expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey');
|
||||
expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey');
|
||||
expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session');
|
||||
expect(resolveAccountCredentialMode({})).toBe('apikey');
|
||||
});
|
||||
|
||||
it('parses positive integers from query values', () => {
|
||||
expect(parsePositiveInt('42')).toBe(42);
|
||||
expect(parsePositiveInt(' 0 ')).toBe(0);
|
||||
expect(parsePositiveInt('abc')).toBe(0);
|
||||
expect(parsePositiveInt(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('treats common truthy query flags as enabled', () => {
|
||||
expect(isTruthyFlag('1')).toBe(true);
|
||||
expect(isTruthyFlag(' TRUE ')).toBe(true);
|
||||
expect(isTruthyFlag('yes')).toBe(true);
|
||||
expect(isTruthyFlag('no')).toBe(false);
|
||||
expect(isTruthyFlag(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export function resolveAccountCredentialMode(account: any): 'session' | 'apikey' {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
|
||||
return account.capabilities.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
return typeof account?.accessToken === 'string' && account.accessToken.trim()
|
||||
? 'session'
|
||||
: 'apikey';
|
||||
}
|
||||
|
||||
export function parsePositiveInt(input: string | null): number {
|
||||
const value = Number.parseInt(String(input || '').trim(), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isTruthyFlag(input: string | null): boolean {
|
||||
if (!input) return false;
|
||||
const normalized = input.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeTokenRouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
|
||||
export type RouteListVisibilityItem = {
|
||||
id: number;
|
||||
modelPattern: string;
|
||||
@@ -8,7 +10,7 @@ export type RouteListVisibilityItem = {
|
||||
};
|
||||
|
||||
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
return normalizeTokenRouteMode(routeMode);
|
||||
}
|
||||
|
||||
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { BrandInfo } from '../../components/BrandIcon.js';
|
||||
import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
|
||||
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
export type RouteSortDir = 'asc' | 'desc';
|
||||
export type GroupFilter = null | '__all__' | number;
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||
export type RouteRowKind = 'persisted' | 'zero_channel';
|
||||
export type RouteMode = 'pattern' | 'explicit_group';
|
||||
|
||||
export type RouteChannelDraft = {
|
||||
accountId: number;
|
||||
tokenId: number;
|
||||
@@ -81,31 +80,6 @@ export type RouteSummaryRow = {
|
||||
isVirtual?: boolean;
|
||||
};
|
||||
|
||||
export type RouteDecisionCandidate = {
|
||||
channelId: number;
|
||||
accountId: number;
|
||||
username: string;
|
||||
siteName: string;
|
||||
tokenName: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
eligible: boolean;
|
||||
recentlyFailed: boolean;
|
||||
avoidedByRecentFailure: boolean;
|
||||
probability: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type RouteDecision = {
|
||||
requestedModel: string;
|
||||
actualModel: string;
|
||||
matched: boolean;
|
||||
selectedChannelId?: number;
|
||||
selectedLabel?: string;
|
||||
summary: string[];
|
||||
candidates: RouteDecisionCandidate[];
|
||||
};
|
||||
|
||||
export type ChannelDecisionState = {
|
||||
probability: number;
|
||||
showBar: boolean;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
|
||||
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow, RouteMode } from './types.js';
|
||||
import { normalizeTokenRouteMode, type RouteDecisionCandidate, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
import type { RouteRow, RouteChannel, ChannelDecisionState, RouteSummaryRow } from './types.js';
|
||||
import {
|
||||
isExactTokenRouteModelPattern,
|
||||
isTokenRouteRegexPattern,
|
||||
matchesTokenRouteModelPattern,
|
||||
parseTokenRouteRegexPattern,
|
||||
} from '../../../shared/tokenRoutePatterns.js';
|
||||
|
||||
export const AUTO_ROUTE_DECISION_LIMIT = 80;
|
||||
export const ROUTE_RENDER_CHUNK = 40;
|
||||
@@ -39,18 +46,15 @@ export const PLATFORM_ALIASES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function isRegexModelPattern(modelPattern: string): boolean {
|
||||
return modelPattern.trim().toLowerCase().startsWith('re:');
|
||||
return isTokenRouteRegexPattern(modelPattern);
|
||||
}
|
||||
|
||||
export function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isRegexModelPattern(normalized)) return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
return isExactTokenRouteModelPattern(modelPattern);
|
||||
}
|
||||
|
||||
export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
return normalizeTokenRouteMode(routeMode);
|
||||
}
|
||||
|
||||
export function isExplicitGroupRoute(route: Pick<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
|
||||
@@ -62,81 +66,11 @@ export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'model
|
||||
}
|
||||
|
||||
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
|
||||
if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null };
|
||||
const body = modelPattern.trim().slice(3).trim();
|
||||
if (!body) return { regex: null, error: 're: 后缺少正则表达式' };
|
||||
try {
|
||||
return { regex: new RegExp(body), error: null };
|
||||
} catch (error) {
|
||||
return { regex: null, error: (error as Error)?.message || '无效正则' };
|
||||
}
|
||||
return parseTokenRouteRegexPattern(modelPattern);
|
||||
}
|
||||
|
||||
function globToRegexSource(glob: string): string {
|
||||
let source = '';
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
const ch = glob[i];
|
||||
if (ch === '*') {
|
||||
source += '.*';
|
||||
continue;
|
||||
}
|
||||
if (ch === '?') {
|
||||
source += '.';
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
const closeIndex = glob.indexOf(']', i + 1);
|
||||
if (closeIndex > i + 1) {
|
||||
source += glob.slice(i, closeIndex + 1);
|
||||
i = closeIndex;
|
||||
continue;
|
||||
}
|
||||
source += '\\[';
|
||||
continue;
|
||||
}
|
||||
source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&');
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
const compiledGlobCache = new Map<string, RegExp | null>();
|
||||
|
||||
function matchesGlobPattern(model: string, pattern: string): boolean {
|
||||
let re = compiledGlobCache.get(pattern);
|
||||
if (re === undefined) {
|
||||
try {
|
||||
re = new RegExp(`^${globToRegexSource(pattern)}$`);
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
compiledGlobCache.set(pattern, re);
|
||||
}
|
||||
return re ? re.test(model) : false;
|
||||
}
|
||||
|
||||
const matchCache = new Map<string, boolean>();
|
||||
const MATCH_CACHE_LIMIT = 4000;
|
||||
|
||||
export function matchesModelPattern(model: string, pattern: string): boolean {
|
||||
const normalized = (pattern || '').trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized === model) return true;
|
||||
|
||||
const cacheKey = `${model}\0${normalized}`;
|
||||
const cached = matchCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let result: boolean;
|
||||
if (isRegexModelPattern(normalized)) {
|
||||
const parsed = parseRegexModelPattern(normalized);
|
||||
result = !!parsed.regex && parsed.regex.test(model);
|
||||
} else {
|
||||
result = matchesGlobPattern(model, normalized);
|
||||
}
|
||||
|
||||
if (matchCache.size >= MATCH_CACHE_LIMIT) matchCache.clear();
|
||||
matchCache.set(cacheKey, result);
|
||||
return result;
|
||||
return matchesTokenRouteModelPattern(model, pattern);
|
||||
}
|
||||
|
||||
export function getModelPatternError(modelPattern: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user