fulltext-search-kit
Version:
A utility library for full-text search in TypeScript
142 lines (141 loc) • 4.43 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.fullTextSearch = fullTextSearch;
exports.getFullTextSearchHighlights = getFullTextSearchHighlights;
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isKorean(char) {
return /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char);
}
function getInitialsListWithSpaces(text) {
const INITIALS = [
'ㄱ',
'ㄲ',
'ㄴ',
'ㄷ',
'ㄸ',
'ㄹ',
'ㅁ',
'ㅂ',
'ㅃ',
'ㅅ',
'ㅆ',
'ㅇ',
'ㅈ',
'ㅉ',
'ㅊ',
'ㅋ',
'ㅌ',
'ㅍ',
'ㅎ',
];
const result = [];
const positions = [];
let offset = 0;
for (const char of text) {
const code = char.charCodeAt(0);
if (code >= 0xac00 && code <= 0xd7a3) {
const index = Math.floor((code - 0xac00) / 588);
result.push(INITIALS[index]);
positions.push(offset);
}
else if (/^[ㄱ-ㅎ]$/.test(char)) {
result.push(char);
positions.push(offset);
}
offset += char.length;
}
return { list: result, positions };
}
function convertToSearchableText(text) {
return text
.replace(/\s/g, '') // remove spaces
.normalize('NFC'); // normalize Korean
}
function matchKeyword(text, keyword) {
const normalizedText = convertToSearchableText(text);
const normalizedKeyword = convertToSearchableText(keyword);
// 기본 완성형 비교
if (normalizedText.includes(normalizedKeyword)) {
return true;
}
// 초성 fallback
const initials = getInitialsListWithSpaces(normalizedText).list;
const keywordChars = Array.from(normalizedKeyword);
let idx = 0;
for (const char of keywordChars) {
if (/^[ㄱ-ㅎ]$/.test(char)) {
if (initials[idx] !== char)
return false;
}
else {
if (normalizedText[idx] !== char)
return false;
}
idx++;
}
return true;
}
function highlightText(text, keyword) {
const normalizedText = convertToSearchableText(text);
const normalizedKeyword = convertToSearchableText(keyword);
const baseMatch = normalizedText.indexOf(normalizedKeyword);
if (baseMatch !== -1) {
return (text.slice(0, baseMatch) +
`<mark>${text.slice(baseMatch, baseMatch + keyword.length)}</mark>` +
text.slice(baseMatch + keyword.length));
}
// 초성 fallback highlight
const { list: initials, positions } = getInitialsListWithSpaces(normalizedText);
const keywordChars = Array.from(normalizedKeyword);
let matched = true;
for (let i = 0; i < keywordChars.length; i++) {
const k = keywordChars[i];
if (/^[ㄱ-ㅎ]$/.test(k)) {
if (initials[i] !== k) {
matched = false;
break;
}
}
else {
if (normalizedText[i] !== k) {
matched = false;
break;
}
}
}
if (!matched)
return text;
const start = positions[0];
const end = positions[keywordChars.length - 1] + 1;
return (text.slice(0, start) +
`<mark>${text.slice(start, end)}</mark>` +
text.slice(end));
}
function fullTextSearch({ data, searchRequirement, }) {
if (!data || !(searchRequirement === null || searchRequirement === void 0 ? void 0 : searchRequirement.length))
return data;
return data.filter((item) => searchRequirement.every(({ value, keywords }) => {
const rawValue = item[value];
if (!rawValue || !keywords.length)
return true;
return keywords.every((keyword) => matchKeyword(String(rawValue), keyword));
}));
}
function getFullTextSearchHighlights({ item, searchRequirement, }) {
const highlights = {};
for (const { value, keywords } of searchRequirement) {
const rawValue = item[value];
if (!rawValue || !keywords.length)
continue;
const originalText = String(rawValue);
for (const keyword of keywords) {
if (matchKeyword(originalText, keyword)) {
highlights[value] = highlightText(originalText, keyword);
break;
}
}
}
return highlights;
}