import { TarotCard } from './types.js'; import { getSecureRandom } from './utils.js'; export interface SearchOptions { keyword?: string; suit?: string; arcana?: 'major' | 'minor'; element?: 'fire' | 'water' | 'air' | 'earth'; number?: number; orientation?: 'upright' | 'reversed'; } export interface SearchResult { card: TarotCard; relevanceScore: number; matchedFields: string[]; } /** * Advanced search functionality for the tarot card database */ export class TarotCardSearch { private cards: readonly TarotCard[]; constructor(cards: readonly TarotCard[]) { this.cards = cards; } /** * Search cards by various criteria */ search(options: SearchOptions): SearchResult[] { let results: SearchResult[] = []; for (const card of this.cards) { const result = this.evaluateCard(card, options); if (result.relevanceScore > 0) { results.push(result); } } // Sort by relevance score (highest first) return results.sort((a, b) => b.relevanceScore - a.relevanceScore); } /** * Search by keyword in card meanings, keywords, and symbolism */ searchByKeyword(keyword: string): SearchResult[] { return this.search({ keyword }); } /** * Find cards by suit */ findBySuit(suit: string): TarotCard[] { return this.cards.filter(card => card.suit === suit); } /** * Find cards by arcana type */ findByArcana(arcana: 'major' | 'minor'): TarotCard[] { return this.cards.filter(card => card.arcana === arcana); } /** * Find cards by element */ findByElement(element: 'fire' | 'water' | 'air' | 'earth'): TarotCard[] { return this.cards.filter(card => card.element === element); } /** * Get cards with similar meanings */ findSimilarCards(cardId: string, limit: number = 5): TarotCard[] { const targetCard = this.cards.find(card => card.id === cardId); if (!targetCard) return []; const similarities: { card: TarotCard; score: number }[] = []; for (const card of this.cards) { if (card.id === cardId) continue; const score = this.calculateSimilarity(targetCard, card); similarities.push({ card, score }); } return similarities .sort((a, b) => b.score - a.score) .slice(0, limit) .map(item => item.card); } /** * Get random cards with optional filters */ getRandomCards(count: number = 1, options?: Partial): TarotCard[] { let filteredCards = this.cards; if (options) { const searchResults = this.search(options as SearchOptions); filteredCards = searchResults.map(result => result.card); } // Use Fisher-Yates shuffle with secure random for true randomness const shuffled = this.fisherYatesShuffle(filteredCards); return shuffled.slice(0, Math.min(count, shuffled.length)); } private evaluateCard(card: TarotCard, options: SearchOptions): SearchResult { let score = 0; const matchedFields: string[] = []; // Exact matches get higher scores if (options.suit && card.suit === options.suit) { score += 10; matchedFields.push('suit'); } if (options.arcana && card.arcana === options.arcana) { score += 8; matchedFields.push('arcana'); } if (options.element && card.element === options.element) { score += 8; matchedFields.push('element'); } if (options.number !== undefined && card.number === options.number) { score += 10; matchedFields.push('number'); } // Keyword search in various fields if (options.keyword) { const keyword = options.keyword.toLowerCase(); // Search in card name if (card.name.toLowerCase().includes(keyword)) { score += 15; matchedFields.push('name'); } // Search in keywords const orientation = options.orientation || 'upright'; const keywords = card.keywords[orientation]; if (keywords.some(kw => kw.toLowerCase().includes(keyword))) { score += 12; matchedFields.push('keywords'); } // Search in meanings const meanings = card.meanings[orientation]; for (const [field, meaning] of Object.entries(meanings)) { if (meaning.toLowerCase().includes(keyword)) { score += 8; matchedFields.push(`meaning_${field}`); } } // Search in symbolism if (card.symbolism.some(symbol => symbol.toLowerCase().includes(keyword))) { score += 6; matchedFields.push('symbolism'); } // Search in description if (card.description.toLowerCase().includes(keyword)) { score += 4; matchedFields.push('description'); } } return { card, relevanceScore: score, matchedFields }; } private calculateSimilarity(card1: TarotCard, card2: TarotCard): number { let score = 0; // Same suit/arcana if (card1.suit === card2.suit) score += 3; if (card1.arcana === card2.arcana) score += 2; if (card1.element === card2.element) score += 3; // Similar numbers if (card1.number !== undefined && card2.number !== undefined) { const numDiff = Math.abs(card1.number - card2.number); if (numDiff <= 1) score += 2; else if (numDiff <= 2) score += 1; } // Keyword overlap const keywords1 = [...card1.keywords.upright, ...card1.keywords.reversed]; const keywords2 = [...card2.keywords.upright, ...card2.keywords.reversed]; for (const kw1 of keywords1) { for (const kw2 of keywords2) { if (kw1.toLowerCase() === kw2.toLowerCase()) { score += 2; } } } return score; } /** * Get statistics about the card database */ getStatistics() { const stats = { totalCards: this.cards.length, majorArcana: this.cards.filter(c => c.arcana === 'major').length, minorArcana: this.cards.filter(c => c.arcana === 'minor').length, suits: {} as Record, elements: {} as Record, mostCommonKeywords: this.getMostCommonKeywords(10) }; // Count by suits for (const card of this.cards) { if (card.suit) { stats.suits[card.suit] = (stats.suits[card.suit] || 0) + 1; } if (card.element) { stats.elements[card.element] = (stats.elements[card.element] || 0) + 1; } } return stats; } private getMostCommonKeywords(limit: number): Array<{ keyword: string; count: number }> { const keywordCounts: Record = {}; for (const card of this.cards) { const allKeywords = [...card.keywords.upright, ...card.keywords.reversed]; for (const keyword of allKeywords) { keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1; } } return Object.entries(keywordCounts) .map(([keyword, count]) => ({ keyword, count })) .sort((a, b) => b.count - a.count) .slice(0, limit); } /** * Fisher-Yates shuffle algorithm using cryptographically secure random */ private fisherYatesShuffle(array: readonly T[]): T[] { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(getSecureRandom() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } }