feat: add typo-tolerant and Korean initial search to Q&A list

This commit is contained in:
root committed 2026-10-30 13:19:36 +09:00
1 parent ccf48c8224
commit 688d16555e
7 files changed
+328 -21

No files matched your search

+126 -13
View File
@@ -137,10 +137,114 @@
};
}
async function loadRemotePosts() {
const result = await requestJson("/api/feedbacks?limit=100&page=1", { method: "GET" });
const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []);
return { posts: (Array.isArray(items) ? items : []).map(normalizeRemotePost).filter(function (post) { return post.id; }), meta: result.meta || result.data && result.data.meta || {} };
async function loadRemotePosts(onPage) {
const posts = [];
const seen = new Set();
let meta = {};
for (let page = 1; ; page += 1) {
const result = await requestJson('/api/feedbacks?limit=100&page=' + page, { method: 'GET' });
const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []);
if (!Array.isArray(items)) throw new Error('문의 목록 응답을 확인할 수 없습니다.');
const previousCount = posts.length;
items.map(normalizeRemotePost).forEach(function (post) {
if (post.id && !seen.has(post.id)) { seen.add(post.id); posts.push(post); }
});
if (page > 1 && items.length && posts.length === previousCount) throw new Error('문의 목록의 다음 페이지를 불러오지 못했습니다.');
meta = result.meta || result.data && result.data.meta || {};
const totalPages = Number(meta.totalPages ?? meta.total_pages);
const total = Number(meta.total ?? meta.totalCount ?? meta.total_count);
if (!items.length && (Number.isFinite(total) && posts.length < total || Number.isFinite(totalPages) && page < totalPages)) {
throw new Error('문의 목록을 끝까지 불러오지 못했습니다.');
}
const hasMore = items.length > 0 && (Number.isFinite(totalPages) ? page < totalPages : Number.isFinite(total) ? posts.length < total : items.length === 100);
if (onPage) onPage({ posts: posts.slice(), meta: meta, complete: !hasMore });
if (!hasMore) return { posts: posts, meta: meta };
}
}
const HANGUL_INITIALS = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const searchIndex = new WeakMap();
function normalizeSearchText(value) {
return String(value || '').normalize('NFKC').toLowerCase()
.replace(/[\u1100-\u1112]/g, function (char) { return HANGUL_INITIALS[char.charCodeAt(0) - 0x1100]; })
.replace(/[\p{P}\p{S}]+/gu, ' ').replace(/\s+/g, ' ').trim();
}
function hangulInitials(value) {
return Array.from(value).map(function (char) {
const code = char.charCodeAt(0) - 0xac00;
return code >= 0 && code <= 11171 ? HANGUL_INITIALS[Math.floor(code / 588)] : char;
}).join('');
}
// Find a near substring, allowing insertions, deletions, substitutions and adjacent swaps.
function fuzzySubstringDistance(query, text, limit) {
const cap = limit + 1;
const characters = Array.from({ length: query.length }, function (_, index) { return query.charCodeAt(index); });
let previous = new Uint8Array(query.length + 1);
let beforePrevious = new Uint8Array(query.length + 1);
let current = new Uint8Array(query.length + 1);
for (let j = 0; j <= query.length; j += 1) previous[j] = Math.min(j, cap);
let best = limit + 1;
for (let i = 0; i < text.length; i += 1) {
const character = text.charCodeAt(i);
const preceding = text.charCodeAt(i - 1);
current[0] = 0;
for (let j = 1; j <= query.length; j += 1) {
let distance = previous[j - 1] + (character === characters[j - 1] ? 0 : 1);
if (previous[j] + 1 < distance) distance = previous[j] + 1;
if (current[j - 1] + 1 < distance) distance = current[j - 1] + 1;
if (i > 0 && j > 1 && character === characters[j - 2] && preceding === characters[j - 1] && beforePrevious[j - 2] + 1 < distance) {
distance = beforePrevious[j - 2] + 1;
}
current[j] = distance > cap ? cap : distance;
}
if (current[query.length] < best) best = current[query.length];
// Exact substrings are checked by the caller; one edit is the best remaining match.
if (best <= 1) return best;
const reusable = beforePrevious;
beforePrevious = previous;
previous = current;
current = reusable;
}
return best;
}
function searchPosts(posts, query) {
const terms = normalizeSearchText(query).split(' ').filter(Boolean);
if (!terms.length) return posts.slice();
return posts.map(function (post, index) {
let fields = searchIndex.get(post);
if (!fields) {
fields = [post.title, post.content, getPostCompanyLabel(post), post.department, post.author].map(function (value, field) {
const text = normalizeSearchText(value).replace(/ /g, '');
return { text: text, initials: hangulInitials(text), characters: new Set(text), weight: field === 0 ? 0 : field === 1 ? 3 : 2 };
});
searchIndex.set(post, fields);
}
let score = 0;
for (const term of terms) {
let best = Infinity;
const initialsOnly = /^[ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ]{2,}$/.test(term);
const limit = term.length >= 6 ? 2 : term.length >= 3 ? 1 : 0;
for (const field of fields) {
if (field.text.includes(term)) best = Math.min(best, field.weight);
else if (initialsOnly && field.initials.includes(term)) best = Math.min(best, 1 + field.weight);
else if (!initialsOnly && limit && term.length <= 32 && field.text) {
if (Array.from(term).filter(function (char) { return !field.characters.has(char); }).length > limit) continue;
const distance = fuzzySubstringDistance(term, field.text, limit);
if (distance <= limit) best = Math.min(best, 10 * distance + field.weight);
}
if (best === 0) break;
}
if (!Number.isFinite(best)) return { post: post, index: index, score: Infinity };
score += best;
}
return { post: post, index: index, score: score };
}).filter(function (match) { return Number.isFinite(match.score); })
.sort(function (a, b) { return a.score - b.score || a.index - b.index; })
.map(function (match) { return match.post; });
}
function savePost(post) {
@@ -475,6 +579,9 @@
let page = 1;
let posts = getPosts();
let remoteLoaded = false;
let loadFailed = false;
let receivedRemotePage = false;
const searchStatus = qs('#searchStatus');
function getPostCompany(post, user) { return getPostCompanyLabel(post, user); }
@@ -484,19 +591,20 @@
const categoryFilter = selectedCategory ? selectedCategory.value : "전체";
const onlyMine = qs("#onlyMine").checked;
const user = getAuthUser();
const filtered = posts.filter(function (post) {
const filtered = searchPosts(posts.filter(function (post) {
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
const company = getPostCompany(post, user);
const haystack = [post.title, post.content, company, post.department, post.author].join(" ").toLowerCase();
const matchesQuery = !query || haystack.indexOf(query) > -1;
const matchesMine = !onlyMine || isPostOwner(post, user);
return matchesCategory && matchesQuery && matchesMine;
});
return matchesCategory && matchesMine;
}), query);
const pageSize = config.pageSize || 10;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
page = Math.min(page, totalPages);
const rows = filtered.slice((page - 1) * pageSize, page * pageSize);
if (resultCount) resultCount.textContent = "총 " + filtered.length + "건";
if (searchStatus) {
searchStatus.hidden = remoteLoaded && !loadFailed && !query;
searchStatus.textContent = loadFailed ? '목록을 모두 불러오지 못했습니다. 현재 불러온 글에서 검색합니다. 새로고침해 주세요.' : !remoteLoaded ? '문의 목록을 불러오는 중입니다. 검색 결과가 추가될 수 있습니다.' : '검색 결과 ' + filtered.length + '건';
}
if (empty) {
empty.textContent = remoteLoaded || posts.length ? "검색 조건에 맞는 문의가 없습니다." : "문의 목록을 불러오는 중입니다.";
empty.style.display = rows.length ? "none" : "block";
@@ -514,7 +622,11 @@
"<td>" + escapeHtml(post.dateTime || post.date) + "</td><td>" + (status ? "<span class=\"status " + statusClass + "\">" + escapeHtml(status) + "</span>" : "-") + "</td></tr>";
}).join("");
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return "<button type=\"button\" class=\"" + (i + 1 === page ? "active" : "") + "\" data-page=\"" + (i + 1) + "\">" + (i + 1) + "</button>"; }).join("");
const visiblePages = Array.from(new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2])).filter(function (value) { return value >= 1 && value <= totalPages; }).sort(function (a, b) { return a - b; });
pagination.innerHTML = '<button type="button" data-page="' + Math.max(1, page - 1) + '" aria-label="이전 페이지"' + (page === 1 ? ' disabled' : '') + '>‹</button>' + visiblePages.map(function (value, index) {
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '<span aria-hidden="true">…</span>' : '';
return gap + '<button type="button" class="' + (value === page ? 'active' : '') + '" data-page="' + value + '"' + (value === page ? ' aria-current="page"' : '') + '>' + value + '</button>';
}).join('') + '<button type="button" data-page="' + Math.min(totalPages, page + 1) + '" aria-label="다음 페이지"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
qsa("#pagination button").forEach(function (button) { button.addEventListener("click", function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: "smooth" }); }); });
}
@@ -522,15 +634,16 @@
qsa("input[name=category], #onlyMine").forEach(function (input) { input.addEventListener("change", function () { page = 1; render(); }); });
render();
if (authReady) authReady.then(render);
loadRemotePosts().then(function (result) {
loadRemotePosts(function (result) { receivedRemotePage = true; posts = result.posts; render(); }).then(function (result) {
posts = result.posts;
remoteLoaded = true;
render();
}).catch(function (error) {
console.warn("[QA] feedback list load failed:", error.message);
remoteLoaded = true;
loadFailed = true;
render();
refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
if (!receivedRemotePage) refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
});
}