add v0.4 models, schema, query extensions for GTD tasks, labels, deferrals
New model types: TaskStatus, TaskSummary, LabelInfo, ItemSummary. GRDB records: TaskRecord, LabelRecord, ItemLabelRecord, DeferralRecord. Database migrations v3_task, v3_label, v3_deferral with indexes. MailStore query extensions for labels, item-labels, deferrals, task cache. TaskStore module wrapping MailStore. Tests for all v3 tables and records. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ let package = Package(
|
||||
products: [
|
||||
.library(name: "Models", targets: ["Models"]),
|
||||
.library(name: "MailStore", targets: ["MailStore"]),
|
||||
.library(name: "TaskStore", targets: ["TaskStore"]),
|
||||
.library(name: "IMAPClient", targets: ["IMAPClient"]),
|
||||
.library(name: "SMTPClient", targets: ["SMTPClient"]),
|
||||
.library(name: "SyncEngine", targets: ["SyncEngine"]),
|
||||
@@ -46,9 +47,17 @@ let package = Package(
|
||||
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "TaskStore",
|
||||
dependencies: [
|
||||
"Models",
|
||||
"MailStore",
|
||||
.product(name: "GRDB", package: "GRDB.swift"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "SyncEngine",
|
||||
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore"]
|
||||
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore"]
|
||||
),
|
||||
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
|
||||
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
|
||||
@@ -59,6 +68,7 @@ let package = Package(
|
||||
]
|
||||
),
|
||||
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient", "Models"]),
|
||||
.testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]),
|
||||
.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -127,6 +127,65 @@ public enum DatabaseSetup {
|
||||
try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"])
|
||||
}
|
||||
|
||||
migrator.registerMigration("v3_task") { db in
|
||||
try db.create(table: "task") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.belongsTo("account", onDelete: .cascade).notNull()
|
||||
t.column("summary", .text).notNull()
|
||||
t.column("description", .text)
|
||||
t.column("status", .text).notNull().defaults(to: "NEEDS-ACTION")
|
||||
t.column("priority", .integer).notNull().defaults(to: 0)
|
||||
t.column("dueDate", .text)
|
||||
t.column("deferUntil", .text)
|
||||
t.column("createdAt", .text).notNull()
|
||||
t.column("updatedAt", .text).notNull()
|
||||
t.column("filePath", .text)
|
||||
t.column("linkedMessageId", .text)
|
||||
t.column("isSomeday", .boolean).notNull().defaults(to: false)
|
||||
}
|
||||
try db.create(index: "idx_task_status", on: "task", columns: ["status"])
|
||||
try db.create(index: "idx_task_deferUntil", on: "task", columns: ["deferUntil"])
|
||||
try db.create(index: "idx_task_dueDate", on: "task", columns: ["dueDate"])
|
||||
try db.create(index: "idx_task_linkedMessageId", on: "task", columns: ["linkedMessageId"])
|
||||
}
|
||||
|
||||
migrator.registerMigration("v3_label") { db in
|
||||
try db.create(table: "label") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.belongsTo("account", onDelete: .cascade).notNull()
|
||||
t.column("name", .text).notNull()
|
||||
t.column("isProject", .boolean).notNull().defaults(to: false)
|
||||
t.column("color", .text)
|
||||
}
|
||||
try db.create(
|
||||
index: "idx_label_accountId_name",
|
||||
on: "label",
|
||||
columns: ["accountId", "name"],
|
||||
unique: true
|
||||
)
|
||||
|
||||
try db.create(table: "itemLabel") { t in
|
||||
t.column("labelId", .text).notNull()
|
||||
.references("label", onDelete: .cascade)
|
||||
t.column("itemType", .text).notNull()
|
||||
t.column("itemId", .text).notNull()
|
||||
t.primaryKey(["labelId", "itemType", "itemId"])
|
||||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v3_deferral") { db in
|
||||
try db.create(table: "deferral") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("messageId", .text).notNull()
|
||||
.references("message", column: "id", onDelete: .cascade)
|
||||
t.column("deferUntil", .text)
|
||||
t.column("originalMailbox", .text)
|
||||
t.column("createdAt", .text).notNull()
|
||||
t.uniqueKey(["messageId"])
|
||||
}
|
||||
try db.create(index: "idx_deferral_deferUntil", on: "deferral", columns: ["deferUntil"])
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
|
||||
@@ -316,4 +316,265 @@ public final class MailStore: Sendable {
|
||||
public var databaseReader: any DatabaseReader {
|
||||
dbWriter
|
||||
}
|
||||
|
||||
/// Access the underlying database writer (for TaskStore and other modules)
|
||||
public var databaseWriter: any DatabaseWriter {
|
||||
dbWriter
|
||||
}
|
||||
|
||||
// MARK: - Labels
|
||||
|
||||
public func insertLabel(_ label: LabelRecord) throws {
|
||||
try dbWriter.write { db in
|
||||
try label.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func labels(accountId: String) throws -> [LabelRecord] {
|
||||
try dbWriter.read { db in
|
||||
try LabelRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.order(Column("name"))
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func projectLabels(accountId: String) throws -> [LabelRecord] {
|
||||
try dbWriter.read { db in
|
||||
try LabelRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("isProject") == true)
|
||||
.order(Column("name"))
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteLabel(id: String) throws {
|
||||
try dbWriter.write { db in
|
||||
_ = try LabelRecord.deleteOne(db, key: id)
|
||||
}
|
||||
}
|
||||
|
||||
public func labelByName(_ name: String, accountId: String) throws -> LabelRecord? {
|
||||
try dbWriter.read { db in
|
||||
try LabelRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("name") == name)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item Labels
|
||||
|
||||
public func attachLabel(labelId: String, itemType: String, itemId: String) throws {
|
||||
try dbWriter.write { db in
|
||||
try ItemLabelRecord(labelId: labelId, itemType: itemType, itemId: itemId).save(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func detachLabel(labelId: String, itemType: String, itemId: String) throws {
|
||||
try dbWriter.write { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM itemLabel WHERE labelId = ? AND itemType = ? AND itemId = ?",
|
||||
arguments: [labelId, itemType, itemId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func labelsForItem(itemType: String, itemId: String) throws -> [LabelRecord] {
|
||||
try dbWriter.read { db in
|
||||
try LabelRecord.fetchAll(db, sql: """
|
||||
SELECT l.* FROM label l
|
||||
JOIN itemLabel il ON il.labelId = l.id
|
||||
WHERE il.itemType = ? AND il.itemId = ?
|
||||
ORDER BY l.name
|
||||
""", arguments: [itemType, itemId])
|
||||
}
|
||||
}
|
||||
|
||||
public func itemsWithLabel(labelId: String) throws -> [ItemLabelRecord] {
|
||||
try dbWriter.read { db in
|
||||
try ItemLabelRecord
|
||||
.filter(Column("labelId") == labelId)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deferrals (email only)
|
||||
|
||||
public func insertDeferral(_ deferral: DeferralRecord) throws {
|
||||
try dbWriter.write { db in
|
||||
try deferral.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteDeferral(id: String) throws {
|
||||
try dbWriter.write { db in
|
||||
_ = try DeferralRecord.deleteOne(db, key: id)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteDeferralForMessage(messageId: String) throws {
|
||||
try dbWriter.write { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM deferral WHERE messageId = ?",
|
||||
arguments: [messageId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func deferralForMessage(messageId: String) throws -> DeferralRecord? {
|
||||
try dbWriter.read { db in
|
||||
try DeferralRecord
|
||||
.filter(Column("messageId") == messageId)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func expiredDeferrals(beforeDate: String) throws -> [DeferralRecord] {
|
||||
try dbWriter.read { db in
|
||||
try DeferralRecord
|
||||
.filter(Column("deferUntil") != nil)
|
||||
.filter(Column("deferUntil") <= beforeDate)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func somedayDeferrals() throws -> [DeferralRecord] {
|
||||
try dbWriter.read { db in
|
||||
try DeferralRecord
|
||||
.filter(Column("deferUntil") == nil)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task cache queries
|
||||
|
||||
public func insertTask(_ task: TaskRecord) throws {
|
||||
try dbWriter.write { db in
|
||||
try task.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateTask(_ task: TaskRecord) throws {
|
||||
try dbWriter.write { db in
|
||||
try task.update(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteTask(id: String) throws {
|
||||
try dbWriter.write { db in
|
||||
_ = try TaskRecord.deleteOne(db, key: id)
|
||||
}
|
||||
}
|
||||
|
||||
public func task(id: String) throws -> TaskRecord? {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord.fetchOne(db, key: id)
|
||||
}
|
||||
}
|
||||
|
||||
public func inboxTasks(accountId: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("status") == "NEEDS-ACTION")
|
||||
.filter(Column("deferUntil") == nil)
|
||||
.filter(Column("isSomeday") == false)
|
||||
.order(Column("createdAt").desc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func todayTasks(accountId: String, todayDate: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("status") == "NEEDS-ACTION" || Column("status") == "IN-PROCESS")
|
||||
.filter(Column("dueDate") != nil)
|
||||
.filter(Column("dueDate") <= todayDate)
|
||||
.filter(Column("isSomeday") == false)
|
||||
.order(Column("priority").desc, Column("dueDate").asc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func upcomingTasks(accountId: String, afterDate: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("status") == "NEEDS-ACTION" || Column("status") == "IN-PROCESS")
|
||||
.filter(Column("dueDate") != nil)
|
||||
.filter(Column("dueDate") > afterDate)
|
||||
.filter(Column("isSomeday") == false)
|
||||
.order(Column("dueDate").asc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func somedayTasks(accountId: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("isSomeday") == true)
|
||||
.filter(Column("status") == "NEEDS-ACTION")
|
||||
.order(Column("createdAt").desc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func archivedTasks(accountId: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord
|
||||
.filter(Column("accountId") == accountId)
|
||||
.filter(Column("status") == "COMPLETED" || Column("status") == "CANCELLED")
|
||||
.order(Column("updatedAt").desc)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func tasksLinkedToThread(threadId: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord.fetchAll(db, sql: """
|
||||
SELECT t.* FROM task t
|
||||
WHERE t.linkedMessageId IN (
|
||||
SELECT m.messageId FROM message m
|
||||
JOIN threadMessage tm ON tm.messageId = m.id
|
||||
WHERE tm.threadId = ?
|
||||
)
|
||||
ORDER BY t.createdAt ASC
|
||||
""", arguments: [threadId])
|
||||
}
|
||||
}
|
||||
|
||||
public func expiredDeferredTasks(beforeDate: String) throws -> [TaskRecord] {
|
||||
try dbWriter.read { db in
|
||||
try TaskRecord
|
||||
.filter(Column("deferUntil") != nil)
|
||||
.filter(Column("deferUntil") <= beforeDate)
|
||||
.filter(Column("status") == "NEEDS-ACTION")
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func clearTaskDeferral(id: String) throws {
|
||||
try dbWriter.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE task SET deferUntil = NULL WHERE id = ?",
|
||||
arguments: [id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteAllTasks() throws {
|
||||
try dbWriter.write { db in
|
||||
_ = try TaskRecord.deleteAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteAllItemLabelsForTasks() throws {
|
||||
try dbWriter.write { db in
|
||||
try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import GRDB
|
||||
|
||||
public struct DeferralRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
public static let databaseTableName = "deferral"
|
||||
|
||||
public var id: String
|
||||
public var messageId: String
|
||||
public var deferUntil: String?
|
||||
public var originalMailbox: String?
|
||||
public var createdAt: String
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
messageId: String,
|
||||
deferUntil: String? = nil,
|
||||
originalMailbox: String? = nil,
|
||||
createdAt: String
|
||||
) {
|
||||
self.id = id
|
||||
self.messageId = messageId
|
||||
self.deferUntil = deferUntil
|
||||
self.originalMailbox = originalMailbox
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import GRDB
|
||||
|
||||
public struct ItemLabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
public static let databaseTableName = "itemLabel"
|
||||
|
||||
public var labelId: String
|
||||
public var itemType: String
|
||||
public var itemId: String
|
||||
|
||||
public init(labelId: String, itemType: String, itemId: String) {
|
||||
self.labelId = labelId
|
||||
self.itemType = itemType
|
||||
self.itemId = itemId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import GRDB
|
||||
|
||||
public struct LabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
public static let databaseTableName = "label"
|
||||
|
||||
public var id: String
|
||||
public var accountId: String
|
||||
public var name: String
|
||||
public var isProject: Bool
|
||||
public var color: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
accountId: String,
|
||||
name: String,
|
||||
isProject: Bool = false,
|
||||
color: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.accountId = accountId
|
||||
self.name = name
|
||||
self.isProject = isProject
|
||||
self.color = color
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public struct TaskRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
public static let databaseTableName = "task"
|
||||
|
||||
public var id: String
|
||||
public var accountId: String
|
||||
public var summary: String
|
||||
public var description: String?
|
||||
public var status: String
|
||||
public var priority: Int
|
||||
public var dueDate: String?
|
||||
public var deferUntil: String?
|
||||
public var createdAt: String
|
||||
public var updatedAt: String
|
||||
public var filePath: String?
|
||||
public var linkedMessageId: String?
|
||||
public var isSomeday: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
accountId: String,
|
||||
summary: String,
|
||||
description: String? = nil,
|
||||
status: String = "NEEDS-ACTION",
|
||||
priority: Int = 0,
|
||||
dueDate: String? = nil,
|
||||
deferUntil: String? = nil,
|
||||
createdAt: String,
|
||||
updatedAt: String,
|
||||
filePath: String? = nil,
|
||||
linkedMessageId: String? = nil,
|
||||
isSomeday: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.accountId = accountId
|
||||
self.summary = summary
|
||||
self.description = description
|
||||
self.status = status
|
||||
self.priority = priority
|
||||
self.dueDate = dueDate
|
||||
self.deferUntil = deferUntil
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.filePath = filePath
|
||||
self.linkedMessageId = linkedMessageId
|
||||
self.isSomeday = isSomeday
|
||||
}
|
||||
}
|
||||
48
Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift
Normal file
48
Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
public enum ItemSummary: Sendable, Identifiable, Equatable {
|
||||
case email(MessageSummary)
|
||||
case task(TaskSummary)
|
||||
|
||||
public var id: String {
|
||||
switch self {
|
||||
case .email(let msg): "email-\(msg.id)"
|
||||
case .task(let task): "task-\(task.id)"
|
||||
}
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .email(let msg): msg.subject ?? "(no subject)"
|
||||
case .task(let task): task.summary
|
||||
}
|
||||
}
|
||||
|
||||
public var date: Date {
|
||||
switch self {
|
||||
case .email(let msg): msg.date
|
||||
case .task(let task): task.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public var dueDate: Date? {
|
||||
switch self {
|
||||
case .email: nil
|
||||
case .task(let task): task.dueDate
|
||||
}
|
||||
}
|
||||
|
||||
public var isDeferred: Bool {
|
||||
switch self {
|
||||
case .email: false
|
||||
case .task(let task): task.deferUntil != nil
|
||||
}
|
||||
}
|
||||
|
||||
public var isSomeday: Bool {
|
||||
switch self {
|
||||
case .email: false
|
||||
case .task(let task): task.isSomeday
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift
Normal file
13
Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
public struct LabelInfo: Sendable, Identifiable, Equatable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var isProject: Bool
|
||||
public var color: String?
|
||||
|
||||
public init(id: String, name: String, isProject: Bool = false, color: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.isProject = isProject
|
||||
self.color = color
|
||||
}
|
||||
}
|
||||
6
Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift
Normal file
6
Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
public enum TaskStatus: String, Sendable, Codable {
|
||||
case needsAction = "NEEDS-ACTION"
|
||||
case inProcess = "IN-PROCESS"
|
||||
case completed = "COMPLETED"
|
||||
case cancelled = "CANCELLED"
|
||||
}
|
||||
36
Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift
Normal file
36
Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
public struct TaskSummary: Sendable, Identifiable, Equatable {
|
||||
public var id: String
|
||||
public var accountId: String
|
||||
public var summary: String
|
||||
public var description: String?
|
||||
public var status: TaskStatus
|
||||
public var priority: Int
|
||||
public var dueDate: Date?
|
||||
public var deferUntil: Date?
|
||||
public var createdAt: Date
|
||||
public var linkedMessageId: String?
|
||||
public var categories: [String]
|
||||
public var isSomeday: Bool
|
||||
|
||||
public init(
|
||||
id: String, accountId: String, summary: String, description: String? = nil,
|
||||
status: TaskStatus = .needsAction, priority: Int = 0, dueDate: Date? = nil,
|
||||
deferUntil: Date? = nil, createdAt: Date = Date(), linkedMessageId: String? = nil,
|
||||
categories: [String] = [], isSomeday: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.accountId = accountId
|
||||
self.summary = summary
|
||||
self.description = description
|
||||
self.status = status
|
||||
self.priority = priority
|
||||
self.dueDate = dueDate
|
||||
self.deferUntil = deferUntil
|
||||
self.createdAt = createdAt
|
||||
self.linkedMessageId = linkedMessageId
|
||||
self.categories = categories
|
||||
self.isSomeday = isSomeday
|
||||
}
|
||||
}
|
||||
13
Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift
Normal file
13
Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
@_exported import MailStore
|
||||
import Models
|
||||
|
||||
/// TaskStore provides task-specific business logic on top of MailStore's
|
||||
/// database records and queries. The underlying TaskRecord and its CRUD
|
||||
/// operations live in MailStore to share the same database connection.
|
||||
public struct TaskStore: Sendable {
|
||||
public let mailStore: MailStore
|
||||
|
||||
public init(mailStore: MailStore) {
|
||||
self.mailStore = mailStore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import Testing
|
||||
import GRDB
|
||||
@testable import MailStore
|
||||
|
||||
@Suite("V3 Labels, Deferrals & Tasks")
|
||||
struct LabelDeferralTests {
|
||||
func makeStore() throws -> MailStore {
|
||||
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
|
||||
}
|
||||
|
||||
func makeAccount(_ store: MailStore) throws {
|
||||
try store.insertAccount(AccountRecord(
|
||||
id: "acc1", name: "Test", email: "test@example.com",
|
||||
imapHost: "imap.example.com", imapPort: 993
|
||||
))
|
||||
}
|
||||
|
||||
func makeMailboxAndMessage(_ store: MailStore) throws {
|
||||
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: "msg1@example.com", inReplyTo: nil, refs: nil,
|
||||
subject: "Test", fromAddress: "alice@example.com", fromName: "Alice",
|
||||
toAddresses: nil, ccAddresses: nil,
|
||||
date: "2026-03-14T10:00:00Z", snippet: nil,
|
||||
bodyText: nil, bodyHtml: nil,
|
||||
isRead: false, isFlagged: false, size: 100
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Schema verification
|
||||
|
||||
@Test("v3 tables exist after migration")
|
||||
func v3TablesExist() throws {
|
||||
let store = try makeStore()
|
||||
let tables = try store.databaseWriter.read { db in
|
||||
try String.fetchAll(db, sql: """
|
||||
SELECT name FROM sqlite_master WHERE type='table'
|
||||
AND name IN ('task', 'label', 'itemLabel', 'deferral')
|
||||
ORDER BY name
|
||||
""")
|
||||
}
|
||||
#expect(tables == ["deferral", "itemLabel", "label", "task"])
|
||||
}
|
||||
|
||||
@Test("task table has expected columns")
|
||||
func taskColumns() throws {
|
||||
let store = try makeStore()
|
||||
let columns = try store.databaseWriter.read { db in
|
||||
try db.columns(in: "task").map(\.name)
|
||||
}
|
||||
let expected = [
|
||||
"id", "accountId", "summary", "description", "status",
|
||||
"priority", "dueDate", "deferUntil", "createdAt", "updatedAt",
|
||||
"filePath", "linkedMessageId", "isSomeday",
|
||||
]
|
||||
for col in expected {
|
||||
#expect(columns.contains(col), "missing column: \(col)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LabelRecord round-trip
|
||||
|
||||
@Test("LabelRecord insert and fetch round-trip")
|
||||
func labelRoundTrip() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
let label = LabelRecord(
|
||||
id: "lbl1", accountId: "acc1", name: "urgent",
|
||||
isProject: false, color: "#ff0000"
|
||||
)
|
||||
try store.insertLabel(label)
|
||||
|
||||
let labels = try store.labels(accountId: "acc1")
|
||||
#expect(labels.count == 1)
|
||||
#expect(labels[0].name == "urgent")
|
||||
#expect(labels[0].color == "#ff0000")
|
||||
#expect(labels[0].isProject == false)
|
||||
}
|
||||
|
||||
@Test("labelByName finds label by account and name")
|
||||
func labelByName() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
try store.insertLabel(LabelRecord(
|
||||
id: "lbl1", accountId: "acc1", name: "work"
|
||||
))
|
||||
|
||||
let found = try store.labelByName("work", accountId: "acc1")
|
||||
#expect(found?.id == "lbl1")
|
||||
|
||||
let notFound = try store.labelByName("personal", accountId: "acc1")
|
||||
#expect(notFound == nil)
|
||||
}
|
||||
|
||||
@Test("projectLabels returns only project labels")
|
||||
func projectLabels() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
try store.insertLabel(LabelRecord(
|
||||
id: "lbl1", accountId: "acc1", name: "context", isProject: false
|
||||
))
|
||||
try store.insertLabel(LabelRecord(
|
||||
id: "lbl2", accountId: "acc1", name: "my-project", isProject: true
|
||||
))
|
||||
|
||||
let projects = try store.projectLabels(accountId: "acc1")
|
||||
#expect(projects.count == 1)
|
||||
#expect(projects[0].name == "my-project")
|
||||
}
|
||||
|
||||
@Test("deleteLabel removes label and cascades to itemLabel")
|
||||
func deleteLabelCascade() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
try store.insertLabel(LabelRecord(
|
||||
id: "lbl1", accountId: "acc1", name: "test"
|
||||
))
|
||||
try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
|
||||
try store.deleteLabel(id: "lbl1")
|
||||
|
||||
let labels = try store.labels(accountId: "acc1")
|
||||
#expect(labels.isEmpty)
|
||||
|
||||
let items = try store.itemsWithLabel(labelId: "lbl1")
|
||||
#expect(items.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - ItemLabelRecord round-trip
|
||||
|
||||
@Test("ItemLabelRecord attach and fetch round-trip")
|
||||
func itemLabelRoundTrip() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
try store.insertLabel(LabelRecord(
|
||||
id: "lbl1", accountId: "acc1", name: "important"
|
||||
))
|
||||
try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
|
||||
|
||||
let labels = try store.labelsForItem(itemType: "email", itemId: "m1")
|
||||
#expect(labels.count == 1)
|
||||
#expect(labels[0].id == "lbl1")
|
||||
|
||||
let items = try store.itemsWithLabel(labelId: "lbl1")
|
||||
#expect(items.count == 1)
|
||||
#expect(items[0].itemId == "m1")
|
||||
}
|
||||
|
||||
@Test("detachLabel removes item-label link")
|
||||
func detachLabel() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
try store.insertLabel(LabelRecord(
|
||||
id: "lbl1", accountId: "acc1", name: "test"
|
||||
))
|
||||
try store.attachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
|
||||
try store.detachLabel(labelId: "lbl1", itemType: "email", itemId: "m1")
|
||||
|
||||
let labels = try store.labelsForItem(itemType: "email", itemId: "m1")
|
||||
#expect(labels.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - DeferralRecord round-trip
|
||||
|
||||
@Test("DeferralRecord insert and fetch round-trip")
|
||||
func deferralRoundTrip() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
try makeMailboxAndMessage(store)
|
||||
|
||||
let deferral = DeferralRecord(
|
||||
id: "def1", messageId: "m1",
|
||||
deferUntil: "2026-03-15T09:00:00Z",
|
||||
originalMailbox: "mb1",
|
||||
createdAt: "2026-03-14T10:00:00Z"
|
||||
)
|
||||
try store.insertDeferral(deferral)
|
||||
|
||||
let fetched = try store.deferralForMessage(messageId: "m1")
|
||||
#expect(fetched != nil)
|
||||
#expect(fetched?.deferUntil == "2026-03-15T09:00:00Z")
|
||||
#expect(fetched?.originalMailbox == "mb1")
|
||||
}
|
||||
|
||||
@Test("deferral cascade on message delete")
|
||||
func deferralCascadeOnMessageDelete() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
try makeMailboxAndMessage(store)
|
||||
|
||||
try store.insertDeferral(DeferralRecord(
|
||||
id: "def1", messageId: "m1",
|
||||
deferUntil: "2026-03-15T09:00:00Z",
|
||||
createdAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
|
||||
try store.deleteMessage(id: "m1")
|
||||
|
||||
let fetched = try store.deferralForMessage(messageId: "m1")
|
||||
#expect(fetched == nil)
|
||||
}
|
||||
|
||||
@Test("expiredDeferrals returns deferrals before given date")
|
||||
func expiredDeferrals() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
try makeMailboxAndMessage(store)
|
||||
|
||||
// Add second message for second deferral
|
||||
try store.insertMessages([
|
||||
MessageRecord(
|
||||
id: "m2", accountId: "acc1", mailboxId: "mb1", uid: 2,
|
||||
messageId: "msg2@example.com", inReplyTo: nil, refs: nil,
|
||||
subject: "Future", fromAddress: nil, fromName: nil,
|
||||
toAddresses: nil, ccAddresses: nil,
|
||||
date: "2026-03-14T10:00:00Z", snippet: nil,
|
||||
bodyText: nil, bodyHtml: nil,
|
||||
isRead: false, isFlagged: false, size: 0
|
||||
),
|
||||
])
|
||||
|
||||
try store.insertDeferral(DeferralRecord(
|
||||
id: "def1", messageId: "m1",
|
||||
deferUntil: "2026-03-14T08:00:00Z",
|
||||
createdAt: "2026-03-13T10:00:00Z"
|
||||
))
|
||||
try store.insertDeferral(DeferralRecord(
|
||||
id: "def2", messageId: "m2",
|
||||
deferUntil: "2026-03-16T08:00:00Z",
|
||||
createdAt: "2026-03-13T10:00:00Z"
|
||||
))
|
||||
|
||||
let expired = try store.expiredDeferrals(beforeDate: "2026-03-15T00:00:00Z")
|
||||
#expect(expired.count == 1)
|
||||
#expect(expired[0].id == "def1")
|
||||
}
|
||||
|
||||
@Test("somedayDeferrals returns deferrals with nil deferUntil")
|
||||
func somedayDeferrals() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
try makeMailboxAndMessage(store)
|
||||
|
||||
try store.insertDeferral(DeferralRecord(
|
||||
id: "def1", messageId: "m1",
|
||||
deferUntil: nil,
|
||||
createdAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
|
||||
let someday = try store.somedayDeferrals()
|
||||
#expect(someday.count == 1)
|
||||
#expect(someday[0].id == "def1")
|
||||
}
|
||||
|
||||
// MARK: - TaskRecord round-trip
|
||||
|
||||
@Test("TaskRecord insert and fetch round-trip")
|
||||
func taskRoundTrip() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
let task = TaskRecord(
|
||||
id: "t1", accountId: "acc1", summary: "Buy groceries",
|
||||
description: "Milk, eggs, bread",
|
||||
status: "NEEDS-ACTION", priority: 5,
|
||||
dueDate: "2026-03-15",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z",
|
||||
linkedMessageId: "msg1@example.com"
|
||||
)
|
||||
try store.insertTask(task)
|
||||
|
||||
let fetched = try store.task(id: "t1")
|
||||
#expect(fetched != nil)
|
||||
#expect(fetched?.summary == "Buy groceries")
|
||||
#expect(fetched?.description == "Milk, eggs, bread")
|
||||
#expect(fetched?.priority == 5)
|
||||
#expect(fetched?.linkedMessageId == "msg1@example.com")
|
||||
}
|
||||
|
||||
@Test("inboxTasks returns only active non-deferred non-someday tasks")
|
||||
func inboxTasks() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
// Inbox task
|
||||
try store.insertTask(TaskRecord(
|
||||
id: "t1", accountId: "acc1", summary: "Inbox task",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
// Deferred task
|
||||
try store.insertTask(TaskRecord(
|
||||
id: "t2", accountId: "acc1", summary: "Deferred",
|
||||
deferUntil: "2026-03-20T10:00:00Z",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
// Someday task
|
||||
try store.insertTask(TaskRecord(
|
||||
id: "t3", accountId: "acc1", summary: "Someday",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z",
|
||||
isSomeday: true
|
||||
))
|
||||
// Completed task
|
||||
try store.insertTask(TaskRecord(
|
||||
id: "t4", accountId: "acc1", summary: "Done",
|
||||
status: "COMPLETED",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
|
||||
let inbox = try store.inboxTasks(accountId: "acc1")
|
||||
#expect(inbox.count == 1)
|
||||
#expect(inbox[0].id == "t1")
|
||||
}
|
||||
|
||||
@Test("deleteAllTasks removes all tasks")
|
||||
func deleteAllTasks() throws {
|
||||
let store = try makeStore()
|
||||
try makeAccount(store)
|
||||
|
||||
try store.insertTask(TaskRecord(
|
||||
id: "t1", accountId: "acc1", summary: "Task 1",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
try store.insertTask(TaskRecord(
|
||||
id: "t2", accountId: "acc1", summary: "Task 2",
|
||||
createdAt: "2026-03-14T10:00:00Z",
|
||||
updatedAt: "2026-03-14T10:00:00Z"
|
||||
))
|
||||
|
||||
try store.deleteAllTasks()
|
||||
let remaining = try store.task(id: "t1")
|
||||
#expect(remaining == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Testing
|
||||
@testable import TaskStore
|
||||
@testable import MailStore
|
||||
|
||||
@Suite("TaskStore")
|
||||
struct TaskStoreTests {
|
||||
@Test("TaskStore wraps MailStore")
|
||||
func taskStoreInit() throws {
|
||||
let mailStore = try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
|
||||
let taskStore = TaskStore(mailStore: mailStore)
|
||||
#expect(taskStore.mailStore === mailStore)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user