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: [
|
products: [
|
||||||
.library(name: "Models", targets: ["Models"]),
|
.library(name: "Models", targets: ["Models"]),
|
||||||
.library(name: "MailStore", targets: ["MailStore"]),
|
.library(name: "MailStore", targets: ["MailStore"]),
|
||||||
|
.library(name: "TaskStore", targets: ["TaskStore"]),
|
||||||
.library(name: "IMAPClient", targets: ["IMAPClient"]),
|
.library(name: "IMAPClient", targets: ["IMAPClient"]),
|
||||||
.library(name: "SMTPClient", targets: ["SMTPClient"]),
|
.library(name: "SMTPClient", targets: ["SMTPClient"]),
|
||||||
.library(name: "SyncEngine", targets: ["SyncEngine"]),
|
.library(name: "SyncEngine", targets: ["SyncEngine"]),
|
||||||
@@ -46,9 +47,17 @@ let package = Package(
|
|||||||
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "TaskStore",
|
||||||
|
dependencies: [
|
||||||
|
"Models",
|
||||||
|
"MailStore",
|
||||||
|
.product(name: "GRDB", package: "GRDB.swift"),
|
||||||
|
]
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "SyncEngine",
|
name: "SyncEngine",
|
||||||
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore"]
|
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore"]
|
||||||
),
|
),
|
||||||
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
|
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
|
||||||
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
|
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
|
||||||
@@ -59,6 +68,7 @@ let package = Package(
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient", "Models"]),
|
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient", "Models"]),
|
||||||
|
.testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]),
|
||||||
.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "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"])
|
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
|
return migrator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,4 +316,265 @@ public final class MailStore: Sendable {
|
|||||||
public var databaseReader: any DatabaseReader {
|
public var databaseReader: any DatabaseReader {
|
||||||
dbWriter
|
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