conflicting pathing
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Integration Tests (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled

This commit is contained in:
2026-03-31 11:46:15 -04:00
parent ba1e2e96e7
commit 199c711dd4
23 changed files with 3439 additions and 378 deletions

View File

@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="UTF-8"?>
<EntityDescription>
<Name>FeedItem</Name>
<Attributes>
<AttributeDescription>
<Name>id</Name>
<Type>NSUUID</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>subscriptionId</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>title</Name>
<Type>NSString</Type>
<Required>true</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>link</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>description</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>content</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>author</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>published</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>updated</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>categories</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enclosureUrl</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enclosureType</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enclosureLength</Name>
<Type>NSNumber</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>guid</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>isRead</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>isStarred</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
</Attributes>
<Relationships>
<RelationshipDescription>
<Name>subscription</Name>
<SourceEntity>FeedItem</SourceEntity>
<DestinationEntity>FeedSubscription</DestinationEntity>
<IsOptional>false</IsOptional>
<IsNullable>true</IsNullable>
</RelationshipDescription>
</Relationships>
</EntityDescription>
<EntityDescription>
<Name>FeedSubscription</Name>
<Attributes>
<AttributeDescription>
<Name>id</Name>
<Type>NSUUID</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>url</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>title</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enabled</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>lastFetchedAt</Name>
<Type>NSNumber</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>nextFetchAt</Name>
<Type>NSNumber</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>error</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
</Attributes>
<Relationships>
<RelationshipDescription>
<Name>feedItems</Name>
<SourceEntity>FeedSubscription</SourceEntity>
<DestinationEntity>FeedItem</DestinationEntity>
<IsOptional>true</IsOptional>
<IsNullable>true</IsNullable>
</RelationshipDescription>
</Relationships>
</EntityDescription>
<EntityDescription>
<Name>SearchHistoryEntry</Name>
<Attributes>
<AttributeDescription>
<Name>id</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>query</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>filtersJson</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>sortOption</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>page</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>pageSize</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>resultCount</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>createdAt</Name>
<Type>NSDate</Type>
<Required>true</Required>
</AttributeDescription>
</Attributes>
</EntityDescription>

View File

@@ -0,0 +1,98 @@
/*
* SearchFilters.swift
*
* Search filter model for iOS search service.
*/
import Foundation
/// Search filter configuration
class SearchFilters: Codable {
/// Date range filter
let dateFrom: Date?
/// Date range filter
let dateTo: Date?
/// Feed ID filter
let feedIds: [String]?
/// Author filter
let author: String?
/// Category filter
let category: String?
/// Enclosure type filter
let enclosureType: String?
/// Enclosure length filter
let enclosureLength: Double?
/// Is read filter
let isRead: Bool?
/// Is starred filter
let isStarred: Bool?
/// Initialize search filters
init(
dateFrom: Date? = nil,
dateTo: Date? = nil,
feedIds: [String]? = nil,
author: String? = nil,
category: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
isRead: Bool? = nil,
isStarred: Bool? = nil
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
/// Initialize with values
init(
dateFrom: Date?,
dateTo: Date?,
feedIds: [String]?,
author: String?,
category: String?,
enclosureType: String?,
enclosureLength: Double?,
isRead: Bool?,
isStarred: Bool?
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
}
/// Search filter to string converter
extension SearchFilters {
func filtersToJSON() -> String {
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
}
init?(json: String) {
guard let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else {
return nil
}
self = decoded
}
}

View File

@@ -0,0 +1,212 @@
/*
* SearchQuery.swift
*
* Search query model for iOS search service.
*/
import Foundation
/// Search query parameters
class SearchQuery: Codable {
/// The search query string
let query: String
/// Current page number (0-indexed)
let page: Int
/// Items per page
let pageSize: Int
/// Optional filters
let filters: [SearchFilter]?
/// Sort option
let sortOrder: SearchSortOption
/// Timestamp when query was made
let createdAt: Date
/// Human-readable description
var description: String {
guard !query.isEmpty else { return "Search" }
return query
}
/// JSON representation
var jsonRepresentation: String {
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
}
/// Initialize a search query
init(
query: String,
page: Int = 0,
pageSize: Int = 50,
filters: [SearchFilter]? = nil,
sortOrder: SearchSortOption = .relevance
) {
self.query = query
self.page = page
self.pageSize = pageSize
self.filters = filters
self.sortOrder = sortOrder
self.createdAt = Date()
}
/// Initialize with values
init(
query: String,
page: Int,
pageSize: Int,
filters: [SearchFilter]?,
sortOrder: SearchSortOption
) {
self.query = query
self.page = page
self.pageSize = pageSize
self.filters = filters
self.sortOrder = sortOrder
self.createdAt = Date()
}
}
/// Search filter options
enum SearchFilter: String, Codable, CaseIterable {
case dateRange
case feedID
case author
case category
case enclosureType
case enclosureLength
case isRead
case isStarred
case publishedDateRange
case title
}
/// Search sort options
enum SearchSortOption: String, Codable, CaseIterable {
case relevance
case publishedDate
case updatedDate
case title
case feedTitle
case author
}
/// Search sort option converter
extension SearchSortOption {
static func sortOptionToKey(_ option: SearchSortOption) -> String {
switch option {
case .relevance: return "relevance"
case .publishedDate: return "publishedDate"
case .updatedDate: return "updatedDate"
case .title: return "title"
case .feedTitle: return "feedTitle"
case .author: return "author"
}
}
static func sortOptionFromKey(_ key: String) -> SearchSortOption {
switch key {
case "relevance": return .relevance
case "publishedDate": return .publishedDate
case "updatedDate": return .updatedDate
case "title": return .title
case "feedTitle": return .feedTitle
case "author": return .author
default: return .relevance
}
}
}
/// Search filter configuration
class SearchFilters: Codable {
/// Date range filter
let dateFrom: Date?
/// Date range filter
let dateTo: Date?
/// Feed ID filter
let feedIds: [String]?
/// Author filter
let author: String?
/// Category filter
let category: String?
/// Enclosure type filter
let enclosureType: String?
/// Enclosure length filter
let enclosureLength: Double?
/// Is read filter
let isRead: Bool?
/// Is starred filter
let isStarred: Bool?
/// Initialize search filters
init(
dateFrom: Date? = nil,
dateTo: Date? = nil,
feedIds: [String]? = nil,
author: String? = nil,
category: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
isRead: Bool? = nil,
isStarred: Bool? = nil
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
/// Initialize with values
init(
dateFrom: Date?,
dateTo: Date?,
feedIds: [String]?,
author: String?,
category: String?,
enclosureType: String?,
enclosureLength: Double?,
isRead: Bool?,
isStarred: Bool?
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
}
/// Search filter to string converter
extension SearchFilters {
func filtersToJSON() -> String {
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
}
init?(json: String) {
guard let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else {
return nil
}
self = decoded
}
}

View File

@@ -0,0 +1,331 @@
/*
* SearchResult.swift
*
* Search result model for iOS search service.
*/
import Foundation
/// Search result type
enum SearchResultType: String, Codable, CaseIterable {
case article
case feed
case notification
case bookmark
}
/// Search result highlight configuration
struct SearchResultHighlight: Codable {
/// The original text
let original: String
/// Highlighted text
let highlighted: String
/// Indices of highlighted ranges
let ranges: [(start: Int, end: Int)]
/// Matched terms
let matchedTerms: [String]
private enum CodingKeys: String, CodingKey {
case original, highlighted, ranges, matchedTerms
}
}
/// Search result item
class SearchResult: Codable, Equatable {
/// Unique identifier
var id: String?
/// Type of search result
var type: SearchResultType
/// Main title
var title: String?
/// Description
var description: String?
/// Full content
var content: String?
/// Link URL
var link: String?
/// Feed title (for feed results)
var feedTitle: String?
/// Published date
var published: String?
/// Updated date
var updated: String?
/// Author
var author: String?
/// Categories
var categories: [String]?
/// Enclosure URL
var enclosureUrl: String?
/// Enclosure type
var enclosureType: String?
/// Enclosure length
var enclosureLength: Double?
/// Search relevance score (0.0 to 1.0)
var score: Double = 0.0
/// Highlighted text
var highlightedText: String? {
guard let content = content else { return nil }
return highlightText(content, query: nil) // Highlight all text
}
/// Initialize with values
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0,
highlightedText: String? = nil
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
self.highlightedText = highlightedText
}
/// Initialize with values (without highlightedText)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
/// Initialize with values (for Core Data)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
/// Initialize with values (for GRDB)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
/// Highlight text with query
func highlightText(_ text: String, query: String?) -> String? {
var highlighted = text
if let query = query, !query.isEmpty {
let queryWords = query.components(separatedBy: .whitespaces)
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
guard !word.isEmpty else { continue }
let lowerWord = word.lowercased()
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
if let regex = regex {
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in ranges {
if let range = Range(match.range, in: text) {
// Replace with HTML span
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
}
}
}
}
}
return highlighted
}
/// Highlight text with ranges
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
var result = text
// Sort ranges by start position (descending) to process from end
let sortedRanges = ranges.sorted { $0.start > $1.start }
for range in sortedRanges {
if let range = Range(range, in: text) {
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
}
}
return result
}
/// Initialize with values (simple version)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0,
published: Date? = nil
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published.map { $0.iso8601 }
self.updated = updated.map { $0.iso8601 }
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
}
/// Equality check
func == (lhs: SearchResult, rhs: SearchResult) -> Bool {
lhs.id == rhs.id &&
lhs.type == rhs.type &&
lhs.title == rhs.title &&
lhs.description == rhs.description &&
lhs.content == rhs.content &&
lhs.link == rhs.link &&
lhs.feedTitle == rhs.feedTitle &&
lhs.published == rhs.published &&
lhs.updated == rhs.updated &&
lhs.author == rhs.author &&
lhs.categories == rhs.categories &&
lhs.enclosureUrl == rhs.enclosureUrl &&
lhs.enclosureType == rhs.enclosureType &&
lhs.enclosureLength == rhs.enclosureLength &&
lhs.score == rhs.score
}

View File

@@ -0,0 +1,572 @@
/*
* CoreDataDatabase.swift
*
* Core Data database wrapper with FTS support.
*/
import Foundation
import CoreData
/// Core Data stack
class CoreDataStack: NSObject {
static let shared = CoreDataStack()
private let persistentContainer: NSPersistentContainer
private init() {
persistentContainer = NSPersistentContainer(name: "RSSuper")
persistentContainer.loadPersistentStores {
($0, _ ) in
return NSPersistentStoreFault()
}
}
var managedObjectContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
func saveContext() async throws {
try await managedObjectContext.save()
}
func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws {
try await task(managedObjectContext)
try await saveContext()
}
}
/// CoreDataDatabase - Core Data wrapper with FTS support
class CoreDataDatabase: NSObject {
private let stack: CoreDataStack
/// Create a new core data database
init() {
self.stack = CoreDataStack.shared
super.init()
}
/// Perform a task on the context
func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws {
try await task(stack.managedObjectContext)
try await stack.saveContext()
}
}
/// CoreDataFeedItemStore - Feed item store with FTS
class CoreDataFeedItemStore: NSObject {
private let db: CoreDataDatabase
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Insert a feed item
func insertFeedItem(_ item: FeedItem) async throws {
try await db.performTask { context in
let managedObject = FeedItem(context: context)
managedObject.id = item.id
managedObject.subscriptionId = item.subscriptionId
managedObject.title = item.title
managedObject.link = item.link
managedObject.description = item.description
managedObject.content = item.content
managedObject.author = item.author
managedObject.published = item.published.map { $0.iso8601 }
managedObject.updated = item.updated.map { $0.iso8601 }
managedObject.categories = item.categories?.joined(separator: ",")
managedObject.enclosureUrl = item.enclosureUrl
managedObject.enclosureType = item.enclosureType
managedObject.enclosureLength = item.enclosureLength
managedObject.guid = item.guid
managedObject.isRead = item.isRead
managedObject.isStarred = item.isStarred
// Update FTS index
try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content)
}
}
/// Insert multiple feed items
func insertFeedItems(_ items: [FeedItem]) async throws {
try await db.performTask { context in
for item in items {
let managedObject = FeedItem(context: context)
managedObject.id = item.id
managedObject.subscriptionId = item.subscriptionId
managedObject.title = item.title
managedObject.link = item.link
managedObject.description = item.description
managedObject.content = item.content
managedObject.author = item.author
managedObject.published = item.published.map { $0.iso8601 }
managedObject.updated = item.updated.map { $0.iso8601 }
managedObject.categories = item.categories?.joined(separator: ",")
managedObject.enclosureUrl = item.enclosureUrl
managedObject.enclosureType = item.enclosureType
managedObject.enclosureLength = item.enclosureLength
managedObject.guid = item.guid
managedObject.isRead = item.isRead
managedObject.isStarred = item.isStarred
// Update FTS index
try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content)
}
}
}
/// Get feed items by subscription ID
func getFeedItems(_ subscriptionId: String?) async throws -> [FeedItem] {
let results: [FeedItem] = try await db.performTask { context in
var items: [FeedItem] = []
let predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId ?? "")
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = predicate
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
fetchRequest.limit = 1000
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
items.append(managedObjectToItem(managedObject))
}
} catch {
print("Failed to fetch feed items: \(error.localizedDescription)")
}
return items
}
return results
}
/// Get feed item by ID
func getFeedItemById(_ id: String) async throws -> FeedItem? {
let result: FeedItem? = try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
do {
let managedObjects = try context.fetch(fetchRequest)
return managedObjects.first.map { managedObjectToItem($0) }
} catch {
print("Failed to fetch feed item: \(error.localizedDescription)")
return nil
}
}
return result
}
/// Delete feed item by ID
func deleteFeedItem(_ id: String) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to delete feed item: \(error.localizedDescription)")
}
}
}
/// Delete feed items by subscription ID
func deleteFeedItems(_ subscriptionId: String) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to delete feed items: \(error.localizedDescription)")
}
}
}
/// Clean up old feed items
func cleanupOldItems(keepCount: Int = 100) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
fetchRequest.limit = keepCount
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to cleanup old feed items: \(error.localizedDescription)")
}
}
}
/// Update FTS index for a feed item
private func updateFTS(context: NSManagedObjectContext, feedItemId: String, title: String?, link: String?, description: String?, content: String?) async throws {
try await db.performTask { context in
let feedItem = FeedItem(context: context)
// Update text attributes for FTS
feedItem.title = title
feedItem.link = link
feedItem.description = description
feedItem.content = content
// Trigger FTS update
do {
try context.performSyncBlock()
} catch {
print("FTS update failed: \(error.localizedDescription)")
}
}
}
}
/// CoreDataSearchHistoryStore - Search history store
class CoreDataSearchHistoryStore: NSObject {
private let db: CoreDataDatabase
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Record a search query
func recordSearchHistory(query: SearchQuery, resultCount: Int) async throws -> Int {
try await db.performTask { context in
let historyEntry = SearchHistoryEntry(context: context)
historyEntry.query = query
historyEntry.resultCount = resultCount
historyEntry.createdAt = Date()
// Save and trigger FTS update
try context.save()
try context.performSyncBlock()
return resultCount
}
}
/// Get search history
func getSearchHistory(limit: Int = 50) async throws -> [SearchQuery] {
let results: [SearchQuery] = try await db.performTask { context in
var queries: [SearchQuery] = []
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.limit = UInt32(limit)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
queries.append(managedObjectToQuery(managedObject))
}
} catch {
print("Failed to fetch search history: \(error.localizedDescription)")
}
return queries
}
return results
}
/// Get recent searches (last 24 hours)
func getRecentSearches(limit: Int = 20) async throws -> [SearchQuery] {
let results: [SearchQuery] = try await db.performTask { context in
var queries: [SearchQuery] = []
let now = Date()
let yesterday = Calendar.current.startOfDay(in: now)
let threshold = yesterday.timeIntervalSince1970
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.predicate = NSPredicate(format: "createdAt >= %f", threshold)
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.limit = UInt32(limit)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
queries.append(managedObjectToQuery(managedObject))
}
} catch {
print("Failed to fetch recent searches: \(error.localizedDescription)")
}
return queries
}
return results
}
/// Delete a search history entry by ID
func deleteSearchHistoryEntry(id: Int) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.predicate = NSPredicate(format: "id == %d", id)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to delete search history entry: \(error.localizedDescription)")
}
}
}
/// Clear all search history
func clearSearchHistory() async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to clear search history: \(error.localizedDescription)")
}
}
}
/// Clean up old search history entries
func cleanupOldSearchHistory(limit: Int = 100) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.limit = UInt32(limit)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to cleanup old search history: \(error.localizedDescription)")
}
}
}
}
/// CoreDataFullTextSearch - FTS5 search implementation
class CoreDataFullTextSearch: NSObject {
private let db: CoreDataDatabase
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Search using FTS5
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search using FTS5 with custom limit
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search with fuzzy matching
func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// For FTS5, we can use the boolean mode with fuzzy operators
// FTS5 supports prefix matching and phrase queries
// Convert query to FTS5 boolean format
let ftsQuery = fullTextSearch.buildFTSQuery(query)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search with highlighting
func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
// Apply highlighting
results.forEach { result in
result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query)
}
return results
}
/// Build FTS5 query from user input
/// Supports fuzzy matching with prefix operators
func buildFTSQuery(_ query: String) -> String {
var sb = StringBuilder()
let words = query.components(separatedBy: .whitespaces)
for (index, word) in words.enumerated() {
let word = word.trimmingCharacters(in: .whitespaces)
if word.isEmpty { continue }
if index > 0 { sb.append(" AND ") }
// Use * for prefix matching in FTS5
sb.append("\"")
sb.append(word)
sb.append("*")
sb.append("\"")
}
return sb.str
}
/// Highlight text with query
func highlightText(_ text: String, query: String) -> String? {
var highlighted = text
if !query.isEmpty {
let queryWords = query.components(separatedBy: .whitespaces)
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
guard !word.isEmpty else { continue }
let lowerWord = word.lowercased()
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
if let regex = regex {
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in ranges {
if let range = Range(match.range, in: text) {
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
}
}
}
}
}
return highlighted
}
/// Highlight text with ranges
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
var result = text
// Sort ranges by start position (descending) to process from end
let sortedRanges = ranges.sorted { $0.start > $1.start }
for range in sortedRanges {
if let range = Range(range, in: text) {
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
}
}
return result
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
}
/// CoreDataFeedItemStore extension for FTS search
extend(CoreDataFeedItemStore) {
/// Search using FTS5
func searchFTS(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
}
/// CoreDataSearchHistoryStore extension
extend(CoreDataSearchHistoryStore) {
/// Record a search query
func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int {
try await recordSearchHistory(query: query, resultCount: resultCount)
searchRecorded?(query, resultCount)
// Clean up old entries if needed
try await cleanupOldEntries(limit: maxEntries)
return resultCount
}
}

View File

@@ -0,0 +1,190 @@
/*
* FeedItemStore.swift
*
* CRUD operations for feed items with FTS search support.
*/
import Foundation
import CoreData
/// FeedItemStore - Manages feed item persistence with FTS search
class FeedItemStore: NSObject {
private let db: CoreDataDatabase
/// Signal emitted when an item is added
var itemAdded: ((FeedItem) -> Void)?
/// Signal emitted when an item is updated
var itemUpdated: ((FeedItem) -> Void)?
/// Signal emitted when an item is deleted
var itemDeleted: ((String) -> Void)?
/// Create a new feed item store
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Add a new feed item
func add(_ item: FeedItem) async throws -> FeedItem {
try await db.insertFeedItem(item)
itemAdded?(item)
return item
}
/// Add multiple items in a batch
func addBatch(_ items: [FeedItem]) async throws {
try await db.insertFeedItems(items)
}
/// Get an item by ID
func get_BY_ID(_ id: String) async throws -> FeedItem? {
return try await db.getFeedItemById(id)
}
/// Get items by subscription ID
func get_BY_SUBSCRIPTION(_ subscriptionId: String) async throws -> [FeedItem] {
return try await db.getFeedItems(subscriptionId)
}
/// Get all items
func get_ALL() async throws -> [FeedItem] {
return try await db.getFeedItems(nil)
}
/// Search items using FTS
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
return try await searchFTS(query: query, filters: filters, limit: limit)
}
/// Search items using FTS with custom limit
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.search(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try rankResults(query: query, results: results)
return results
}
/// Apply search filters to a search result
func applyFilters(_ result: SearchResult, filters: SearchFilters) -> Bool {
// Date filters
if let dateFrom = filters.dateFrom, result.published != nil {
let published = result.published.map { Date(string: $0) } ?? Date.distantPast
if published < dateFrom {
return false
}
}
if let dateTo = filters.dateTo, result.published != nil {
let published = result.published.map { Date(string: $0) } ?? Date.distantFuture
if published > dateTo {
return false
}
}
// Feed ID filters
if let feedIds = filters.feedIds, !feedIds.isEmpty {
// For now, we can't filter by feedId without additional lookup
// This would require joining with feed_subscriptions
}
// Author filters - not directly supported in current schema
// Would require adding author to FTS index
// Content type filters - not directly supported
// Would require adding enclosure_type to FTS index
return true
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
/// Mark an item as read
func markAsRead(_ id: String) async throws {
try await db.markFeedItemAsRead(id)
}
/// Mark an item as unread
func markAsUnread(_ id: String) async throws {
try await db.markFeedItemAsUnread(id)
}
/// Mark an item as starred
func markAsStarred(_ id: String) async throws {
try await db.markFeedItemAsStarred(id)
}
/// Unmark an item from starred
func unmarkStarred(_ id: String) async throws {
try await db.unmarkFeedItemAsStarred(id)
}
/// Get unread items
func get_UNREAD() async throws -> [FeedItem] {
return try await db.getFeedItems(nil).filter { $0.isRead == false }
}
/// Get starred items
func get_STARRED() async throws -> [FeedItem] {
return try await db.getFeedItems(nil).filter { $0.isStarred == true }
}
/// Delete an item by ID
func delete(_ id: String) async throws {
try await db.deleteFeedItem(id)
itemDeleted?(id)
}
/// Delete items by subscription ID
func deleteBySubscription(_ subscriptionId: String) async throws {
try await db.deleteFeedItems(subscriptionId)
}
/// Delete old items (keep last N items per subscription)
func cleanupOldItems(keepCount: Int = 100) async throws {
try await db.cleanupOldItems(keepCount: keepCount)
}
}

View File

@@ -0,0 +1,221 @@
/*
* FullTextSearch.swift
*
* Full-Text Search implementation using Core Data FTS5.
*/
import Foundation
import CoreData
/// FullTextSearch - FTS5 search implementation for Core Data
class FullTextSearch: NSObject {
private let db: CoreDataDatabase
/// Create a new full text search
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Search using FTS5
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search using FTS5 with custom limit
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search with fuzzy matching
func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// For FTS5, we can use the boolean mode with fuzzy operators
// FTS5 supports prefix matching and phrase queries
// Convert query to FTS5 boolean format
let ftsQuery = fullTextSearch.buildFTSQuery(query)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Build FTS5 query from user input
/// Supports fuzzy matching with prefix operators
func buildFTSQuery(_ query: String) -> String {
var sb = StringBuilder()
let words = query.components(separatedBy: .whitespaces)
for (index, word) in words.enumerated() {
let word = word.trimmingCharacters(in: .whitespaces)
if word.isEmpty { continue }
if index > 0 { sb.append(" AND ") }
// Use * for prefix matching in FTS5
// This allows matching partial words
sb.append("\"")
sb.append(word)
sb.append("*")
sb.append("\"")
}
return sb.str
}
/// Search with highlighting
func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
// Apply highlighting
results.forEach { result in
result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query)
}
return results
}
/// Highlight text with query
func highlightText(_ text: String, query: String) -> String? {
var highlighted = text
if !query.isEmpty {
let queryWords = query.components(separatedBy: .whitespaces)
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
guard !word.isEmpty else { continue }
let lowerWord = word.lowercased()
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
if let regex = regex {
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in ranges {
if let range = Range(match.range, in: text) {
// Replace with HTML span
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
}
}
}
}
}
return highlighted
}
/// Highlight text with ranges
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
var result = text
// Sort ranges by start position (descending) to process from end
let sortedRanges = ranges.sorted { $0.start > $1.start }
for range in sortedRanges {
if let range = Range(range, in: text) {
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
}
}
return result
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
}
/// StringBuilder helper
class StringBuilder {
var str: String = ""
mutating func append(_ value: String) {
str.append(value)
}
mutating func append(_ value: Int) {
str.append(String(value))
}
}
/// Regex escape helper
func regexEscape(_ string: String) -> String {
return string.replacingOccurrences(of: ".", with: ".")
.replacingOccurrences(of: "+", with: "+")
.replacingOccurrences(of: "?", with: "?")
.replacingOccurrences(of: "*", with: "*")
.replacingOccurrences(of: "^", with: "^")
.replacingOccurrences(of: "$", with: "$")
.replacingOccurrences(of: "(", with: "(")
.replacingOccurrences(of: ")", with: ")")
.replacingOccurrences(of: "[", with: "[")
.replacingOccurrences(of: "]", with: "]")
.replacingOccurrences(of: "{", with: "{")
.replacingOccurrences(of: "}", with: "}")
.replacingOccurrences(of: "|", with: "|")
.replacingOccurrences(of: "\\", with: "\\\\")
}

View File

@@ -0,0 +1,65 @@
/*
* SearchHistoryStore.swift
*
* CRUD operations for search history.
*/
import Foundation
import CoreData
/// SearchHistoryStore - Manages search history persistence
class SearchHistoryStore: NSObject {
private let db: CoreDataDatabase
/// Maximum number of history entries to keep
var maxEntries: Int = 100
/// Signal emitted when a search is recorded
var searchRecorded: ((SearchQuery, Int) -> Void)?
/// Signal emitted when history is cleared
var historyCleared: (() -> Void)?
/// Create a new search history store
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Record a search query
func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int {
try await db.recordSearchHistory(query: query, resultCount: resultCount)
searchRecorded?(query, resultCount)
// Clean up old entries if needed
try await cleanupOldEntries()
return resultCount
}
/// Get search history
func getHistory(limit: Int = 50) async throws -> [SearchQuery] {
return try await db.getSearchHistory(limit: limit)
}
/// Get recent searches (last 24 hours)
func getRecent(limit: Int = 20) async throws -> [SearchQuery] {
return try await db.getRecentSearches(limit: limit)
}
/// Delete a search history entry by ID
func deleteHistoryEntry(id: Int) async throws {
try await db.deleteSearchHistoryEntry(id: id)
}
/// Clear all search history
func clearHistory() async throws {
try await db.clearSearchHistory()
historyCleared?()
}
/// Clear old search history entries
private func cleanupOldEntries() async throws {
try await db.cleanupOldSearchHistory(limit: maxEntries)
}
}

View File

@@ -0,0 +1,252 @@
/*
* SearchService.swift
*
* Full-text search service with history tracking and fuzzy matching.
*/
import Foundation
import Combine
import CoreData
/// SearchService - Manages search operations with history tracking
class SearchService: NSObject {
private let db: CoreDataDatabase
private let historyStore: SearchHistoryStore
/// Maximum number of results to return
var maxResults: Int = 50
/// Maximum number of history entries to keep
var maxHistory: Int = 100
/// Search results publisher
private let resultsPublisher = CurrentValueSubject<SearchResult?, Never>(nil)
/// Search history publisher
private let historyPublisher = CurrentValueSubject<SearchHistoryEntry?, Never>(nil)
/// Signals
var searchPerformed: ((SearchQuery, SearchResult) -> Void)?
var searchRecorded: ((SearchQuery, Int) -> Void)?
var historyCleared: (() -> Void)?
/// Create a new search service
init(db: CoreDataDatabase) {
self.db = db
self.historyStore = SearchHistoryStore(db: db)
self.historyStore.maxEntries = maxHistory
// Connect to history store signals
historyStore.searchRecorded { query, count in
self.searchRecorded?(query, count)
self.historyPublisher.send(query)
}
historyStore.historyCleared { [weak self] in
self?.historyCleared?()
}
}
/// Perform a search
func search(_ query: String, filters: SearchFilters? = nil) async throws -> [SearchResult] {
let itemStore = FeedItemStore(db: db)
// Perform FTS search
var results = try await itemStore.searchFTS(query: query, filters: filters, limit: maxResults)
// Rank results by relevance
results = try rankResults(query: query, results: results)
// Record in history
let searchQuery = SearchQuery(
query: query,
page: 0,
pageSize: maxResults,
filters: filters,
sortOrder: .relevance
)
try await historyStore.recordSearch(searchQuery, resultCount: results.count)
searchPerformed?(searchQuery, results.first!)
resultsPublisher.send(results.first)
return results
}
/// Perform a search with custom page size
func searchWithPage(_ query: String, page: Int, pageSize: Int, filters: SearchFilters? = nil) async throws -> [SearchResult] {
let itemStore = FeedItemStore(db: db)
var results = try await itemStore.searchFTS(query: query, filters: filters, limit: pageSize)
// Rank results by relevance
results = try rankResults(query: query, results: results)
// Record in history
let searchQuery = SearchQuery(
query: query,
page: page,
pageSize: pageSize,
filters: filters,
sortOrder: .relevance
)
try await historyStore.recordSearch(searchQuery, resultCount: results.count)
searchPerformed?(searchQuery, results.first!)
resultsPublisher.send(results.first)
return results
}
/// Get search history
func getHistory(limit: Int = 50) async throws -> [SearchQuery] {
return try await historyStore.getHistory(limit: limit)
}
/// Get recent searches (last 24 hours)
func getRecent() async throws -> [SearchQuery] {
return try await historyStore.getRecent(limit: 20)
}
/// Delete a search history entry by ID
func deleteHistoryEntry(id: Int) async throws {
try await historyStore.deleteHistoryEntry(id: id)
}
/// Clear all search history
func clearHistory() async throws {
try await historyStore.clearHistory()
historyCleared?()
}
/// Get search suggestions based on recent queries
func getSuggestions(_ prefix: String, limit: Int = 10) async throws -> [String] {
let history = try await historyStore.getHistory(limit: limit * 2)
var suggestions: Set<String> = []
for entry in history {
if entry.query.hasPrefix(prefix) && entry.query != prefix {
suggestions.insert(entry.query)
if suggestions.count >= limit {
break
}
}
}
return Array(suggestions)
}
/// Get search suggestions from current results
func getResultSuggestions(_ results: [SearchResult], field: String) -> [String] {
var suggestions: Set<String> = []
var resultList: [String] = []
for result in results {
switch field {
case "title":
if let title = result.title, !title.isEmpty {
suggestions.insert(title)
}
case "feed":
if let feedTitle = result.feedTitle, !feedTitle.isEmpty {
suggestions.insert(feedTitle)
}
default:
break
}
}
var iter = suggestions.iterator()
var key: String?
while (key = iter.nextValue()) {
resultList.append(key!)
}
return resultList
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
/// Search suggestions from recent queries
var suggestionsSubject: Published<[String]> {
return Published(
publisher: Publishers.CombineLatest(
Publishers.Everything($0.suggestionsSubject),
Publishers.Everything($0.historyPublisher)
) { suggestions, history in
var result: [String] = suggestions
for query in history {
result += query.query.components(separatedBy: "\n")
}
return result.sorted()
}
)
}
}
/// Search history entry
class SearchHistoryEntry: Codable, Equatable {
let query: SearchQuery
let resultCount: Int
let createdAt: Date
var description: String {
guard !query.query.isEmpty else { return "Search" }
return query.query
}
init(query: SearchQuery, resultCount: Int = 0, createdAt: Date = Date()) {
self.query = query
self.resultCount = resultCount
self.createdAt = createdAt
}
init(query: SearchQuery, resultCount: Int) {
self.query = query
self.resultCount = resultCount
self.createdAt = Date()
}
}
extension SearchHistoryEntry: Equatable {
static func == (lhs: SearchHistoryEntry, rhs: SearchHistoryEntry) -> Bool {
lhs.query == rhs.query && lhs.resultCount == rhs.resultCount
}
}