package com.rssuper.search import com.rssuper.database.daos.FeedItemDao import com.rssuper.database.entities.FeedItemEntity private const val MAX_QUERY_LENGTH = 500 private const val MAX_HIGHLIGHT_LENGTH = 200 /** * SearchResultProvider - Provides search results from the database */ class SearchResultProvider( private val feedItemDao: FeedItemDao ) { companion object { fun sanitizeFtsQuery(query: String): String { return query.replace("\\".toRegex(), "\\\\") .replace("*".toRegex(), "\\*") .replace("\"".toRegex(), "\\\"") .replace("(".toRegex(), "\\(") .replace(")".toRegex(), "\\)") .replace("~".toRegex(), "\\~") } fun validateQuery(query: String): Result { if (query.isEmpty()) { return Result.failure(Exception("Query cannot be empty")) } if (query.length > MAX_QUERY_LENGTH) { return Result.failure(Exception("Query exceeds maximum length of $MAX_QUERY_LENGTH characters")) } val suspiciousPatterns = listOf( "DELETE ", "DROP ", "INSERT ", "UPDATE ", "SELECT ", "UNION ", "--", ";" ) val queryUpper = query.uppercase() for (pattern in suspiciousPatterns) { if (queryUpper.contains(pattern)) { return Result.failure(Exception("Query contains invalid characters")) } } return Result.success(query) } } suspend fun search(query: String, limit: Int = 20): Result> { val validation = validateQuery(query) if (validation.isFailure) return validation val sanitizedQuery = sanitizeFtsQuery(query.getOrNull() ?: query) val results = feedItemDao.searchByFts(sanitizedQuery, limit) return Result.success(results.mapIndexed { index, item -> SearchResult( feedItem = item, relevanceScore = calculateRelevance(query.getOrNull() ?: query, item, index), highlight = generateHighlight(item, query.getOrNull() ?: query) ) }) } suspend fun searchWithPagination(query: String, limit: Int = 20, offset: Int = 0): Result> { val validation = validateQuery(query) if (validation.isFailure) return validation val sanitizedQuery = sanitizeFtsQuery(query.getOrNull() ?: query) val results = feedItemDao.searchByFtsWithPagination(sanitizedQuery, limit, offset) return Result.success(results.mapIndexed { index, item -> SearchResult( feedItem = item, relevanceScore = calculateRelevance(query.getOrNull() ?: query, item, index), highlight = generateHighlight(item, query.getOrNull() ?: query) ) }) } suspend fun searchBySubscription(query: String, subscriptionId: String, limit: Int = 20): Result> { val validation = validateQuery(query) if (validation.isFailure) return validation val sanitizedQuery = sanitizeFtsQuery(query.getOrNull() ?: query) val results = feedItemDao.searchByFtsPaginated(sanitizedQuery, subscriptionId, limit, 0) return Result.success(results.mapIndexed { index, item -> SearchResult( feedItem = item, relevanceScore = calculateRelevance(query.getOrNull() ?: query, item, index), highlight = generateHighlight(item, query.getOrNull() ?: query) ) }) } private fun calculateRelevance(query: String, item: FeedItemEntity, position: Int): Float { val queryLower = query.lowercase() var score = 0.0f // Title match (highest weight) if (item.title.lowercase().contains(queryLower)) { score += 1.0f } // Author match if (item.author?.lowercase()?.contains(queryLower) == true) { score += 0.5f } // Position bonus (earlier results are more relevant) score += (1.0f / (position + 1)) * 0.3f return score.coerceIn(0.0f, 1.0f) } private fun generateHighlight(item: FeedItemEntity, query: String): String? { var text = item.title if (item.description?.isNotEmpty() == true) { text += " ${item.description}" } if (text.length > MAX_HIGHLIGHT_LENGTH) { text = text.substring(0, MAX_HIGHLIGHT_LENGTH) + "..." } return sanitizeOutput(text) } private fun sanitizeOutput(text: String): String { return text.replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'") } }