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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user