86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
/**
|
||||
|
|
* الگوریتم مشترک جستجو در کل API:
|
|||
|
|
* - عبارت به کلمات شکسته میشود و همه کلمات باید (در هر فیلدی) پیدا شوند؛
|
|||
|
|
* پس «علی رضا» هم «علی رضایی» را پیدا میکند و بخشی از نام هم کافی است.
|
|||
|
|
* - حروف عربی/فارسی (ي/ی، ك/ک، ة/ه، أ/ا …) و ارقام فارسی/عربی/لاتین یکسان در نظر گرفته میشوند.
|
|||
|
|
* - برای شماره تلفن، پیشوند 0 / 98 / +98 نادیده گرفته میشود.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const MAX_TOKENS = 6;
|
|||
|
|
|
|||
|
|
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
|||
|
|
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
|||
|
|
|
|||
|
|
function toAsciiDigits(s: string): string {
|
|||
|
|
return s.replace(/[۰-۹٠-٩]/g, (ch) => {
|
|||
|
|
const fa = FA_DIGITS.indexOf(ch);
|
|||
|
|
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toFaDigits(s: string): string {
|
|||
|
|
return s.replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]!);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toPersianLetters(s: string): string {
|
|||
|
|
return s
|
|||
|
|
.replace(/[يىئ]/g, 'ی')
|
|||
|
|
.replace(/ك/g, 'ک')
|
|||
|
|
.replace(/ة/g, 'ه')
|
|||
|
|
.replace(/[أإٱ]/g, 'ا')
|
|||
|
|
.replace(/ؤ/g, 'و');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toArabicLetters(s: string): string {
|
|||
|
|
return s.replace(/ی/g, 'ي').replace(/ک/g, 'ك');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** متن ورودی جستجو را یکدست میکند (ارقام لاتین، حروف فارسی، فاصلههای یکدست). */
|
|||
|
|
export function normalizeSearchText(raw: string | null | undefined): string {
|
|||
|
|
return toPersianLetters(toAsciiDigits(String(raw ?? '')))
|
|||
|
|
.replace(/[\u064B-\u065F\u0670]/g, '')
|
|||
|
|
.replace(/[\u200c\u200f\u200e]/g, ' ')
|
|||
|
|
.replace(/\s+/g, ' ')
|
|||
|
|
.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function searchTokens(raw: string | null | undefined): string[] {
|
|||
|
|
const text = normalizeSearchText(raw);
|
|||
|
|
if (!text) return [];
|
|||
|
|
return [...new Set(text.split(' ').filter(Boolean))].slice(0, MAX_TOKENS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** شکلهای مختلف یک کلمه که ممکن است در دیتابیس ذخیره شده باشد. */
|
|||
|
|
export function tokenVariants(token: string): string[] {
|
|||
|
|
const out = new Set<string>([token, toArabicLetters(token)]);
|
|||
|
|
if (/[0-9]/.test(token)) {
|
|||
|
|
out.add(toFaDigits(token));
|
|||
|
|
if (/^\+?[0-9]+$/.test(token)) {
|
|||
|
|
const core = token.replace(/^\+/, '').replace(/^(0098|98|0)/, '');
|
|||
|
|
if (core.length >= 3) out.add(core);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return [...out].filter(Boolean);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* شرط Prisma برای جستجو: AND روی کلمات، OR روی شکلها و فیلدها.
|
|||
|
|
* `fieldsFor(term)` شرطهای OR یک کلمه را برای فیلدهای آن مدل برمیگرداند.
|
|||
|
|
*/
|
|||
|
|
export function buildSearchWhere<W extends object>(
|
|||
|
|
raw: string | null | undefined,
|
|||
|
|
fieldsFor: (term: string) => W[],
|
|||
|
|
): W | undefined {
|
|||
|
|
const tokens = searchTokens(raw);
|
|||
|
|
if (!tokens.length) return undefined;
|
|||
|
|
const parts = tokens.map((token) => ({
|
|||
|
|
OR: tokenVariants(token).flatMap((v) => fieldsFor(v)),
|
|||
|
|
}));
|
|||
|
|
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
|||
|
|
export function ci(term: string) {
|
|||
|
|
return { contains: term, mode: 'insensitive' as const };
|
|||
|
|
}
|