1
0
forked from maik/IPFS-portal
Files
IPFS-portal-deploy/src/lib/search-index.ts
T

208 lines
6.6 KiB
TypeScript
Raw Normal View History

/* ── Search Index — Client-side full-text search voor IPFS ──
*
* Bewaart een miniatuur search index in localStorage.
* Periodiek te updaten door gepinde files te crawlen en te indexeren.
*
* Beperking: alleen tekstuele content (.txt, .md, .json, .html)
*/
'use client';
/* ════════════════════════════ Types ════════════════════════════ */
export interface IndexedEntry {
cid: string;
name: string;
type: 'file' | 'dir';
text: string; // geëxtraheerde tekst (max 2048 chars)
size: number;
indexedAt: number; // timestamp
}
export interface SearchResult {
entry: IndexedEntry;
score: number; // relevance (higher = better)
matchField: 'name' | 'text' | 'cid';
}
/* ════════════════════════════ Storage ════════════════════════════ */
const STORAGE_KEY = 'ipfs-search-index';
function loadIndex(): IndexedEntry[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveIndex(entries: IndexedEntry[]) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// localStorage vol of blocked — silently fail
}
}
/* ════════════════════════════ Tekst extractie ════════════════════════════ */
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.json', '.html', '.htm', '.csv', '.log', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg']);
export function isTextFile(name: string): boolean {
const ext = name.toLowerCase().slice(name.lastIndexOf('.'));
return TEXT_EXTENSIONS.has(ext);
}
export function extractText(raw: string): string {
return raw
.replace(/<[^>]*>/g, ' ') // strip HTML tags
.replace(/\s+/g, ' ') // collapse whitespace
.replace(/[{}\[\]]/g, ' ') // strip JSON braces
.trim()
.slice(0, 2048);
}
/* ════════════════════════════ Index beheer ════════════════════════════ */
export function addToIndex(entry: IndexedEntry) {
const index = loadIndex();
// Remove oud voor deze CID
const filtered = index.filter((e) => e.cid !== entry.cid);
filtered.push(entry);
saveIndex(filtered);
}
export function removeFromIndex(cid: string) {
const index = loadIndex().filter((e) => e.cid !== cid);
saveIndex(index);
}
export function clearIndex() {
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
export function getIndexStats() {
const index = loadIndex();
const totalSize = index.reduce((sum, e) => sum + e.text.length, 0);
return {
entries: index.length,
totalChars: totalSize,
lastIndexed: index.length > 0 ? Math.max(...index.map((e) => e.indexedAt)) : 0,
};
}
/* ════════════════════════════ Zoeken ════════════════════════════ */
const STOP_WORDS = new Set([
'de', 'het', 'een', 'van', 'en', 'in', 'is', 'te', 'met', 'op',
'voor', 'aan', 'dat', 'die', 'door', 'bij', 'ook', 'maar', 'uit',
'naar', 'om', 'al', 'als', 'nog', 'dan', 'of', 'er', 'niet', 'dit',
'the', 'a', 'an', 'and', 'or', 'in', 'on', 'at', 'to', 'for',
'of', 'is', 'it', 'be', 'by', 'as', 'are', 'was', 'were', 'been',
]);
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
}
function scoreEntry(queryTokens: string[], entry: IndexedEntry): number {
let score = 0;
const nameLower = entry.name.toLowerCase();
const textLower = entry.text.toLowerCase();
const cidLower = entry.cid.toLowerCase();
for (const token of queryTokens) {
// Exact match in name = hoogste score
if (nameLower.includes(token)) {
score += token.length * 10;
if (nameLower === token) score += 20; // exact match bonus
}
// Match in text
if (textLower.includes(token)) {
score += token.length * 3;
}
// Match in CID (prefix)
if (cidLower.startsWith(token)) {
score += token.length * 8;
}
// Frequentie in text
const count = (textLower.match(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
score += count;
}
return score;
}
export function searchIndex(query: string, maxResults = 20): SearchResult[] {
if (!query || query.trim().length < 2) return [];
const queryTokens = tokenize(query);
if (queryTokens.length === 0) return [];
const index = loadIndex();
const scored: SearchResult[] = [];
for (const entry of index) {
const score = scoreEntry(queryTokens, entry);
if (score > 0) {
// Bepaal het beste match veld
const nameLower = entry.name.toLowerCase();
const cidLower = entry.cid.toLowerCase();
let matchField: 'name' | 'text' | 'cid' = 'text';
if (queryTokens.some((t) => nameLower.includes(t))) matchField = 'name';
else if (queryTokens.some((t) => cidLower.startsWith(t))) matchField = 'cid';
scored.push({ entry, score, matchField });
}
}
return scored.sort((a, b) => b.score - a.score).slice(0, maxResults);
}
/* ════════════════════════════ Crawler ════════════════════════════ */
export async function rebuildIndex(
cids: { cid: string; name: string }[],
fetchContent: (cid: string) => Promise<string>,
onProgress?: (done: number, total: number) => void,
): Promise<{ indexed: number; failed: number }> {
let indexed = 0;
let failed = 0;
// Filter op text files
const textFiles = cids.filter((c) => isTextFile(c.name));
for (let i = 0; i < textFiles.length; i++) {
const file = textFiles[i];
try {
const raw = await fetchContent(file.cid);
const text = extractText(raw);
if (text.length > 0) {
addToIndex({
cid: file.cid,
name: file.name,
type: 'file',
text,
size: raw.length,
indexedAt: Date.now(),
});
indexed++;
}
} catch {
failed++;
}
onProgress?.(i + 1, textFiles.length);
}
return { indexed, failed };
}