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
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:
201
native-route/ios/RSSuper/CoreData/CoreDataModel.ent
Normal file
201
native-route/ios/RSSuper/CoreData/CoreDataModel.ent
Normal 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>
|
||||
98
native-route/ios/RSSuper/Models/SearchFilters.swift
Normal file
98
native-route/ios/RSSuper/Models/SearchFilters.swift
Normal 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
|
||||
}
|
||||
}
|
||||
212
native-route/ios/RSSuper/Models/SearchQuery.swift
Normal file
212
native-route/ios/RSSuper/Models/SearchQuery.swift
Normal 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
|
||||
}
|
||||
}
|
||||
331
native-route/ios/RSSuper/Models/SearchResult.swift
Normal file
331
native-route/ios/RSSuper/Models/SearchResult.swift
Normal 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
|
||||
}
|
||||
572
native-route/ios/RSSuper/Services/CoreDataDatabase.swift
Normal file
572
native-route/ios/RSSuper/Services/CoreDataDatabase.swift
Normal 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
|
||||
}
|
||||
}
|
||||
190
native-route/ios/RSSuper/Services/FeedItemStore.swift
Normal file
190
native-route/ios/RSSuper/Services/FeedItemStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
221
native-route/ios/RSSuper/Services/FullTextSearch.swift
Normal file
221
native-route/ios/RSSuper/Services/FullTextSearch.swift
Normal 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: "\\\\")
|
||||
}
|
||||
65
native-route/ios/RSSuper/Services/SearchHistoryStore.swift
Normal file
65
native-route/ios/RSSuper/Services/SearchHistoryStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
252
native-route/ios/RSSuper/Services/SearchService.swift
Normal file
252
native-route/ios/RSSuper/Services/SearchService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user