add FTS5 search, thread summaries, reactive observation streams
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import GRDB
|
||||
|
||||
public final class MailStore: Sendable {
|
||||
private let dbWriter: any DatabaseWriter
|
||||
let dbWriter: any DatabaseWriter
|
||||
|
||||
public init(dbWriter: any DatabaseWriter) {
|
||||
self.dbWriter = dbWriter
|
||||
|
||||
112
Packages/MagnumOpusCore/Sources/MailStore/Queries.swift
Normal file
112
Packages/MagnumOpusCore/Sources/MailStore/Queries.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Models
|
||||
|
||||
extension MailStore {
|
||||
/// Full-text search across subject, sender, and body via FTS5
|
||||
public func search(query: String) throws -> [MessageRecord] {
|
||||
try dbWriter.read { db in
|
||||
guard let pattern = FTS5Pattern(matchingAllPrefixesIn: query) else { return [] }
|
||||
return try MessageRecord.fetchAll(db, sql: """
|
||||
SELECT message.* FROM message
|
||||
JOIN messageFts ON messageFts.rowid = message.rowid
|
||||
WHERE messageFts MATCH ?
|
||||
""", arguments: [pattern])
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread summaries with unread count and latest sender info, ordered by lastDate DESC
|
||||
public func threadSummaries(accountId: String) throws -> [ThreadSummary] {
|
||||
try dbWriter.read { db in
|
||||
try Self.threadSummariesFromDB(db, accountId: accountId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observe thread summaries reactively — UI updates automatically on DB change.
|
||||
public func observeThreadSummaries(accountId: String) -> AsyncValueObservation<[ThreadSummary]> {
|
||||
ValueObservation.tracking { db -> [ThreadSummary] in
|
||||
try Self.threadSummariesFromDB(db, accountId: accountId)
|
||||
}.values(in: dbWriter)
|
||||
}
|
||||
|
||||
/// Observe messages in a thread reactively
|
||||
public func observeMessages(threadId: String) -> AsyncValueObservation<[MessageSummary]> {
|
||||
ValueObservation.tracking { db -> [MessageSummary] in
|
||||
let records = try MessageRecord.fetchAll(db, sql: """
|
||||
SELECT m.* FROM message m
|
||||
JOIN threadMessage tm ON tm.messageId = m.id
|
||||
WHERE tm.threadId = ?
|
||||
ORDER BY m.date ASC
|
||||
""", arguments: [threadId])
|
||||
return records.map(Self.toMessageSummary)
|
||||
}.values(in: dbWriter)
|
||||
}
|
||||
|
||||
// MARK: - Internal helpers
|
||||
|
||||
static func threadSummariesFromDB(_ db: Database, accountId: String) throws -> [ThreadSummary] {
|
||||
let sql = """
|
||||
SELECT
|
||||
t.id, t.accountId, t.subject, t.lastDate, t.messageCount,
|
||||
(SELECT COUNT(*) FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.isRead = 0) as unreadCount,
|
||||
(SELECT GROUP_CONCAT(DISTINCT m.fromName) FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.fromName IS NOT NULL) as senders,
|
||||
(SELECT m.snippet FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id ORDER BY m.date DESC LIMIT 1) as snippet
|
||||
FROM thread t
|
||||
WHERE t.accountId = ?
|
||||
ORDER BY t.lastDate DESC
|
||||
"""
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: [accountId])
|
||||
return rows.map { row in
|
||||
ThreadSummary(
|
||||
id: row["id"],
|
||||
accountId: row["accountId"],
|
||||
subject: row["subject"],
|
||||
lastDate: parseDate(row["lastDate"] as String? ?? "") ?? Date.distantPast,
|
||||
messageCount: row["messageCount"],
|
||||
unreadCount: row["unreadCount"],
|
||||
senders: row["senders"] ?? "",
|
||||
snippet: row["snippet"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private static let isoFormatterWithFractional: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated(unsafe) private static let isoFormatter: ISO8601DateFormatter = {
|
||||
ISO8601DateFormatter()
|
||||
}()
|
||||
|
||||
static func parseDate(_ iso: String) -> Date? {
|
||||
isoFormatterWithFractional.date(from: iso) ?? isoFormatter.date(from: iso)
|
||||
}
|
||||
|
||||
static func toMessageSummary(_ record: MessageRecord) -> MessageSummary {
|
||||
MessageSummary(
|
||||
id: record.id,
|
||||
messageId: record.messageId,
|
||||
threadId: nil,
|
||||
from: record.fromAddress.map { EmailAddress.parse($0) },
|
||||
to: parseAddressList(record.toAddresses),
|
||||
cc: parseAddressList(record.ccAddresses),
|
||||
subject: record.subject,
|
||||
date: parseDate(record.date) ?? Date.distantPast,
|
||||
snippet: record.snippet,
|
||||
bodyText: record.bodyText,
|
||||
bodyHtml: record.bodyHtml,
|
||||
isRead: record.isRead,
|
||||
isFlagged: record.isFlagged,
|
||||
hasAttachments: false
|
||||
)
|
||||
}
|
||||
|
||||
static func parseAddressList(_ json: String?) -> [EmailAddress] {
|
||||
guard let json, let data = json.data(using: .utf8) else { return [] }
|
||||
struct Addr: Codable { var name: String?; var address: String }
|
||||
guard let addrs = try? JSONDecoder().decode([Addr].self, from: data) else { return [] }
|
||||
return addrs.map { EmailAddress(name: $0.name, address: $0.address) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Testing
|
||||
import GRDB
|
||||
@testable import MailStore
|
||||
@testable import Models
|
||||
|
||||
@Suite("MailStore Search & Queries")
|
||||
struct SearchTests {
|
||||
func makeStore() throws -> MailStore {
|
||||
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
|
||||
}
|
||||
|
||||
func seedData(_ store: MailStore) throws {
|
||||
try store.insertAccount(AccountRecord(
|
||||
id: "acc1", name: "Personal", email: "me@example.com",
|
||||
imapHost: "imap.example.com", imapPort: 993
|
||||
))
|
||||
try store.upsertMailbox(MailboxRecord(
|
||||
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
|
||||
))
|
||||
try store.insertMessages([
|
||||
MessageRecord(
|
||||
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
|
||||
messageId: "msg001@example.com", inReplyTo: nil, refs: nil,
|
||||
subject: "Quarterly planning meeting", fromAddress: "alice@example.com",
|
||||
fromName: "Alice Johnson", toAddresses: nil, ccAddresses: nil,
|
||||
date: "2024-03-08T10:00:00Z", snippet: "Let's discuss Q2 goals",
|
||||
bodyText: "Let's discuss Q2 goals and roadmap priorities.", bodyHtml: nil,
|
||||
isRead: false, isFlagged: false, size: 1024
|
||||
),
|
||||
MessageRecord(
|
||||
id: "m2", accountId: "acc1", mailboxId: "mb1", uid: 2,
|
||||
messageId: "msg002@example.com", inReplyTo: nil, refs: nil,
|
||||
subject: "Invoice #4521", fromAddress: "billing@vendor.com",
|
||||
fromName: "Billing Dept", toAddresses: nil, ccAddresses: nil,
|
||||
date: "2024-03-07T09:00:00Z", snippet: "Please find attached",
|
||||
bodyText: "Your invoice for March is attached.", bodyHtml: nil,
|
||||
isRead: true, isFlagged: false, size: 2048
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("FTS5 search finds messages by subject")
|
||||
func searchBySubject() throws {
|
||||
let store = try makeStore()
|
||||
try seedData(store)
|
||||
let results = try store.search(query: "quarterly")
|
||||
#expect(results.count == 1)
|
||||
#expect(results[0].id == "m1")
|
||||
}
|
||||
|
||||
@Test("FTS5 search finds messages by body text")
|
||||
func searchByBody() throws {
|
||||
let store = try makeStore()
|
||||
try seedData(store)
|
||||
let results = try store.search(query: "roadmap")
|
||||
#expect(results.count == 1)
|
||||
#expect(results[0].id == "m1")
|
||||
}
|
||||
|
||||
@Test("FTS5 search finds messages by sender name")
|
||||
func searchBySender() throws {
|
||||
let store = try makeStore()
|
||||
try seedData(store)
|
||||
let results = try store.search(query: "alice")
|
||||
#expect(results.count == 1)
|
||||
#expect(results[0].fromName == "Alice Johnson")
|
||||
}
|
||||
|
||||
@Test("FTS5 search returns empty for no matches")
|
||||
func searchNoMatch() throws {
|
||||
let store = try makeStore()
|
||||
try seedData(store)
|
||||
let results = try store.search(query: "nonexistent")
|
||||
#expect(results.isEmpty)
|
||||
}
|
||||
|
||||
@Test("thread summaries include unread count and senders")
|
||||
func threadSummaries() throws {
|
||||
let store = try makeStore()
|
||||
try seedData(store)
|
||||
let reconstructor = ThreadReconstructor(store: store)
|
||||
let messages = try store.messages(mailboxId: "mb1")
|
||||
try reconstructor.processMessages(messages)
|
||||
let summaries = try store.threadSummaries(accountId: "acc1")
|
||||
#expect(summaries.count == 2)
|
||||
// First thread (most recent) should be "Quarterly planning meeting"
|
||||
#expect(summaries[0].subject == "Quarterly planning meeting")
|
||||
#expect(summaries[0].unreadCount == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user