1049ca2675
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2185 lines
63 KiB
Markdown
2185 lines
63 KiB
Markdown
# Magnum Opus v0.4 — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add local task management (VTODO files) and GTD triage (defer, file, discard, complete) to the email client. Emails and tasks appear as a unified stream — same perspectives, same triage actions.
|
||
|
||
**Architecture:** TaskStore module manages VTODO `.ics` files on disk with a SQLite cache. Emails and tasks are unified via `ItemSummary` enum. Deferrals for emails use a `deferral` table; task deferrals use the `task.deferUntil` column (derived from VTODO `DTSTART`). Labels with an `isProject` flag provide project grouping. SyncEngine resurfaces deferred items on each sync cycle.
|
||
|
||
**Tech Stack:** Swift 6 (strict concurrency), SwiftUI, GRDB.swift, iCalendar/VTODO (in-house parser)
|
||
|
||
**Design Document:** `docs/plans/2026-03-14-v0.4-gtd-tasks-design.md`
|
||
|
||
**Branch:** `feature/v0.4-gtd-tasks` from `main`.
|
||
|
||
---
|
||
|
||
## File Structure (changes from v0.3)
|
||
|
||
```
|
||
MagnumOpus/
|
||
├── Packages/
|
||
│ └── MagnumOpusCore/
|
||
│ ├── Package.swift ← EDIT: add TaskStore target
|
||
│ ├── Sources/
|
||
│ │ ├── Models/
|
||
│ │ │ ├── TaskSummary.swift ← NEW
|
||
│ │ │ ├── TaskStatus.swift ← NEW
|
||
│ │ │ ├── LabelInfo.swift ← NEW
|
||
│ │ │ └── ItemSummary.swift ← NEW
|
||
│ │ │
|
||
│ │ ├── TaskStore/ ← NEW module
|
||
│ │ │ ├── VTODOParser.swift ← NEW: iCalendar line parser
|
||
│ │ │ ├── VTODOFormatter.swift ← NEW: TaskRecord → .ics string
|
||
│ │ │ ├── TaskStore.swift ← NEW: file I/O + cache
|
||
│ │ │ └── TaskRecord.swift ← NEW: GRDB record
|
||
│ │ │
|
||
│ │ ├── MailStore/
|
||
│ │ │ ├── DatabaseSetup.swift ← EDIT: add v3 migrations
|
||
│ │ │ ├── MailStore.swift ← EDIT: add label/deferral queries
|
||
│ │ │ └── Records/
|
||
│ │ │ ├── LabelRecord.swift ← NEW
|
||
│ │ │ ├── ItemLabelRecord.swift ← NEW
|
||
│ │ │ └── DeferralRecord.swift ← NEW
|
||
│ │ │
|
||
│ │ └── SyncEngine/
|
||
│ │ └── SyncCoordinator.swift ← EDIT: add resurfacing check
|
||
│ │
|
||
│ └── Tests/
|
||
│ ├── TaskStoreTests/ ← NEW
|
||
│ │ ├── VTODOParserTests.swift
|
||
│ │ ├── VTODOFormatterTests.swift
|
||
│ │ └── TaskStoreTests.swift
|
||
│ └── MailStoreTests/
|
||
│ └── LabelDeferralTests.swift ← NEW
|
||
│
|
||
├── Apps/
|
||
│ └── MagnumOpus/
|
||
│ ├── ViewModels/
|
||
│ │ ├── MailViewModel.swift ← EDIT: perspectives, GTD triage
|
||
│ │ └── TaskEditViewModel.swift ← NEW
|
||
│ └── Views/
|
||
│ ├── SidebarView.swift ← EDIT: new perspectives
|
||
│ ├── ThreadListView.swift ← EDIT: unified items, GTD keys
|
||
│ ├── ThreadDetailView.swift ← EDIT: linked tasks inline
|
||
│ ├── DeferPicker.swift ← NEW
|
||
│ ├── LabelPicker.swift ← NEW
|
||
│ └── TaskEditView.swift ← NEW
|
||
```
|
||
|
||
**Dependency graph:** `SyncEngine` → `IMAPClient` + `MailStore` + `SMTPClient` + `TaskStore` → `Models`.
|
||
|
||
---
|
||
|
||
## Chunk 1: Models & Schema
|
||
|
||
New model types, database records, and migrations. No file I/O or UI — pure data layer.
|
||
|
||
### Task 1: New Model Types
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift`
|
||
|
||
- [ ] **Step 1: Create TaskStatus enum**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift`:
|
||
|
||
```swift
|
||
public enum TaskStatus: String, Sendable, Codable {
|
||
case needsAction = "NEEDS-ACTION"
|
||
case inProcess = "IN-PROCESS"
|
||
case completed = "COMPLETED"
|
||
case cancelled = "CANCELLED"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create TaskSummary**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift`:
|
||
|
||
```swift
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create LabelInfo**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift`:
|
||
|
||
```swift
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create ItemSummary**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
|
||
public enum ItemSummary: Sendable, Identifiable, Equatable {
|
||
case email(MessageSummary)
|
||
case task(TaskSummary)
|
||
|
||
public var id: String {
|
||
switch self {
|
||
case .email(let msg): return "email-\(msg.id)"
|
||
case .task(let task): return "task-\(task.id)"
|
||
}
|
||
}
|
||
|
||
public var title: String {
|
||
switch self {
|
||
case .email(let msg): return msg.subject ?? "(no subject)"
|
||
case .task(let task): return task.summary
|
||
}
|
||
}
|
||
|
||
public var date: Date? {
|
||
switch self {
|
||
case .email(let msg): return msg.date
|
||
case .task(let task): return task.createdAt
|
||
}
|
||
}
|
||
|
||
public var dueDate: Date? {
|
||
switch self {
|
||
case .email: return nil
|
||
case .task(let task): return task.dueDate
|
||
}
|
||
}
|
||
|
||
public var isDeferred: Bool {
|
||
switch self {
|
||
case .email: return false // checked via deferral table
|
||
case .task(let task): return task.deferUntil != nil
|
||
}
|
||
}
|
||
|
||
public var isSomeday: Bool {
|
||
switch self {
|
||
case .email: return false // checked via deferral table
|
||
case .task(let task): return task.isSomeday
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify Models compile**
|
||
|
||
```bash
|
||
swift build --package-path Packages/MagnumOpusCore --target Models
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add Packages/MagnumOpusCore/Sources/Models/
|
||
git commit -m "add v0.4 model types: TaskStatus, TaskSummary, LabelInfo, ItemSummary"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Database Records & Migrations
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/TaskRecord.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Package.swift`
|
||
|
||
- [ ] **Step 1: Add TaskStore target to Package.swift**
|
||
|
||
Edit `Packages/MagnumOpusCore/Package.swift`:
|
||
|
||
```swift
|
||
// In products:
|
||
.library(name: "TaskStore", targets: ["TaskStore"]),
|
||
|
||
// In targets:
|
||
.target(
|
||
name: "TaskStore",
|
||
dependencies: [
|
||
"Models",
|
||
"MailStore",
|
||
.product(name: "GRDB", package: "GRDB.swift"),
|
||
]
|
||
),
|
||
|
||
// Add TaskStore to SyncEngine dependencies:
|
||
.target(
|
||
name: "SyncEngine",
|
||
dependencies: ["Models", "IMAPClient", "MailStore", "SMTPClient", "TaskStore"]
|
||
),
|
||
|
||
// Add test target:
|
||
.testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]),
|
||
```
|
||
|
||
Create placeholder: `mkdir -p Packages/MagnumOpusCore/Sources/TaskStore`
|
||
|
||
- [ ] **Step 2: Create TaskRecord**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/TaskStore/TaskRecord.swift`:
|
||
|
||
```swift
|
||
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,
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create LabelRecord**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift`:
|
||
|
||
```swift
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create ItemLabelRecord**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift`:
|
||
|
||
```swift
|
||
import GRDB
|
||
|
||
public struct ItemLabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||
public static let databaseTableName = "itemLabel"
|
||
|
||
public var labelId: String
|
||
public var itemType: String // "email" or "task"
|
||
public var itemId: String
|
||
|
||
public init(labelId: String, itemType: String, itemId: String) {
|
||
self.labelId = labelId
|
||
self.itemType = itemType
|
||
self.itemId = itemId
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create DeferralRecord**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift`:
|
||
|
||
```swift
|
||
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? // ISO8601, NULL = someday
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add v3 migrations to DatabaseSetup.swift**
|
||
|
||
Edit `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift` — add three new migrations after existing v2 ones:
|
||
|
||
```swift
|
||
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).notNull()
|
||
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_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", onDelete: .cascade)
|
||
t.column("deferUntil", .text)
|
||
t.column("originalMailbox", .text)
|
||
t.column("createdAt", .text).notNull()
|
||
t.uniqueKey(["messageId"])
|
||
}
|
||
try db.create(index: "idx_deferral_until", on: "deferral", columns: ["deferUntil"])
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Add migration and record round-trip tests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
import Foundation
|
||
@testable import MailStore
|
||
|
||
@Suite("V3 Migrations & Records")
|
||
struct LabelDeferralTests {
|
||
@Test("v3 migrations create task, label, itemLabel, deferral tables")
|
||
func v3TablesExist() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.read { db in
|
||
let tables = try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table'")
|
||
#expect(tables.contains("task"))
|
||
#expect(tables.contains("label"))
|
||
#expect(tables.contains("itemLabel"))
|
||
#expect(tables.contains("deferral"))
|
||
}
|
||
}
|
||
|
||
@Test("task table has expected columns")
|
||
func taskColumns() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.read { db in
|
||
let columns = try db.columns(in: "task").map(\.name)
|
||
#expect(columns.contains("summary"))
|
||
#expect(columns.contains("status"))
|
||
#expect(columns.contains("deferUntil"))
|
||
#expect(columns.contains("isSomeday"))
|
||
#expect(columns.contains("linkedMessageId"))
|
||
#expect(columns.contains("filePath"))
|
||
}
|
||
}
|
||
|
||
@Test("LabelRecord round-trip")
|
||
func labelRoundTrip() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
try LabelRecord(id: "l1", accountId: "a1", name: "Work", isProject: true).insert(db)
|
||
}
|
||
let loaded = try db.read { db in try LabelRecord.fetchOne(db, key: "l1") }
|
||
#expect(loaded?.name == "Work")
|
||
#expect(loaded?.isProject == true)
|
||
}
|
||
|
||
@Test("ItemLabelRecord round-trip")
|
||
func itemLabelRoundTrip() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
try LabelRecord(id: "l1", accountId: "a1", name: "Work").insert(db)
|
||
try ItemLabelRecord(labelId: "l1", itemType: "email", itemId: "msg1").insert(db)
|
||
}
|
||
let loaded = try db.read { db in
|
||
try ItemLabelRecord.fetchAll(db, sql: "SELECT * FROM itemLabel WHERE itemId = 'msg1'")
|
||
}
|
||
#expect(loaded.count == 1)
|
||
#expect(loaded.first?.itemType == "email")
|
||
}
|
||
|
||
@Test("DeferralRecord round-trip with unique constraint")
|
||
func deferralRoundTrip() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
try db.execute(sql: """
|
||
INSERT INTO mailbox (id, accountId, name, uidValidity, uidNext) VALUES ('mb1', 'a1', 'INBOX', 1, 1)
|
||
""")
|
||
try db.execute(sql: """
|
||
INSERT INTO message (id, accountId, mailboxId, uid, date, isRead, isFlagged, size)
|
||
VALUES ('m1', 'a1', 'mb1', 1, '2026-03-14', 0, 0, 100)
|
||
""")
|
||
try DeferralRecord(id: "d1", messageId: "m1", deferUntil: "2026-03-20T00:00:00Z", originalMailbox: "INBOX", createdAt: "2026-03-14T10:00:00Z").insert(db)
|
||
}
|
||
let loaded = try db.read { db in try DeferralRecord.fetchOne(db, key: "d1") }
|
||
#expect(loaded?.deferUntil == "2026-03-20T00:00:00Z")
|
||
#expect(loaded?.originalMailbox == "INBOX")
|
||
}
|
||
|
||
@Test("deferral cascade deletes when message deleted")
|
||
func deferralCascade() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
try db.execute(sql: """
|
||
INSERT INTO mailbox (id, accountId, name, uidValidity, uidNext) VALUES ('mb1', 'a1', 'INBOX', 1, 1)
|
||
""")
|
||
try db.execute(sql: """
|
||
INSERT INTO message (id, accountId, mailboxId, uid, date, isRead, isFlagged, size)
|
||
VALUES ('m1', 'a1', 'mb1', 1, '2026-03-14', 0, 0, 100)
|
||
""")
|
||
try DeferralRecord(id: "d1", messageId: "m1", createdAt: "2026-03-14T10:00:00Z").insert(db)
|
||
try db.execute(sql: "DELETE FROM message WHERE id = 'm1'")
|
||
}
|
||
let count = try db.read { db in try DeferralRecord.fetchCount(db) }
|
||
#expect(count == 0)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Verify build and run tests**
|
||
|
||
```bash
|
||
swift build --package-path Packages/MagnumOpusCore
|
||
swift test --package-path Packages/MagnumOpusCore --filter LabelDeferralTests
|
||
```
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add v0.4 schema: task, label, itemLabel, deferral tables with migrations and tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: MailStore Query Extensions
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift`
|
||
|
||
- [ ] **Step 1: Add label CRUD to MailStore**
|
||
|
||
```swift
|
||
// 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").asc)
|
||
.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").asc)
|
||
.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)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add itemLabel junction CRUD**
|
||
|
||
```swift
|
||
// 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).insert(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 label.* FROM label
|
||
JOIN itemLabel ON itemLabel.labelId = label.id
|
||
WHERE itemLabel.itemType = ? AND itemLabel.itemId = ?
|
||
ORDER BY label.name ASC
|
||
""", arguments: [itemType, itemId])
|
||
}
|
||
}
|
||
|
||
public func itemsWithLabel(labelId: String) throws -> [(itemType: String, itemId: String)] {
|
||
try dbWriter.read { db in
|
||
let rows = try Row.fetchAll(db, sql: """
|
||
SELECT itemType, itemId FROM itemLabel WHERE labelId = ?
|
||
""", arguments: [labelId])
|
||
return rows.map { (itemType: $0["itemType"], itemId: $0["itemId"]) }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add deferral CRUD**
|
||
|
||
```swift
|
||
// 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.fetchAll(db, sql: """
|
||
SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= ?
|
||
""", arguments: [beforeDate])
|
||
}
|
||
}
|
||
|
||
public func somedayDeferrals() throws -> [DeferralRecord] {
|
||
try dbWriter.read { db in
|
||
try DeferralRecord
|
||
.filter(Column("deferUntil") == nil)
|
||
.fetchAll(db)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add task cache queries to MailStore**
|
||
|
||
The task table lives in the same SQLite database. Add queries for task records:
|
||
|
||
```swift
|
||
// MARK: - Task Cache (used by perspectives)
|
||
|
||
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.fetchAll(db, sql: """
|
||
SELECT * FROM task
|
||
WHERE accountId = ? AND status = 'NEEDS-ACTION'
|
||
AND deferUntil IS NULL AND isSomeday = 0
|
||
ORDER BY createdAt DESC
|
||
""", arguments: [accountId])
|
||
}
|
||
}
|
||
|
||
public func todayTasks(accountId: String, todayDate: String) throws -> [TaskRecord] {
|
||
try dbWriter.read { db in
|
||
try TaskRecord.fetchAll(db, sql: """
|
||
SELECT * FROM task
|
||
WHERE accountId = ? AND status IN ('NEEDS-ACTION', 'IN-PROCESS')
|
||
AND dueDate = ?
|
||
""", arguments: [accountId, todayDate])
|
||
}
|
||
}
|
||
|
||
public func upcomingTasks(accountId: String, afterDate: String) throws -> [TaskRecord] {
|
||
try dbWriter.read { db in
|
||
try TaskRecord.fetchAll(db, sql: """
|
||
SELECT * FROM task
|
||
WHERE accountId = ? AND status IN ('NEEDS-ACTION', 'IN-PROCESS')
|
||
AND dueDate > ?
|
||
ORDER BY dueDate ASC
|
||
""", arguments: [accountId, afterDate])
|
||
}
|
||
}
|
||
|
||
public func somedayTasks(accountId: String) throws -> [TaskRecord] {
|
||
try dbWriter.read { db in
|
||
try TaskRecord.fetchAll(db, sql: """
|
||
SELECT * FROM task WHERE accountId = ? AND isSomeday = 1
|
||
ORDER BY createdAt DESC
|
||
""", arguments: [accountId])
|
||
}
|
||
}
|
||
|
||
public func archivedTasks(accountId: String) throws -> [TaskRecord] {
|
||
try dbWriter.read { db in
|
||
try TaskRecord.fetchAll(db, sql: """
|
||
SELECT * FROM task WHERE accountId = ? AND status IN ('COMPLETED', 'CANCELLED')
|
||
ORDER BY updatedAt DESC
|
||
""", arguments: [accountId])
|
||
}
|
||
}
|
||
|
||
public func tasksLinkedToThread(threadId: String) throws -> [TaskRecord] {
|
||
try dbWriter.read { db in
|
||
try TaskRecord.fetchAll(db, sql: """
|
||
SELECT task.* FROM task
|
||
WHERE task.linkedMessageId IN (
|
||
SELECT message.messageId FROM message
|
||
JOIN threadMessage ON threadMessage.messageId = message.id
|
||
WHERE threadMessage.threadId = ?
|
||
)
|
||
ORDER BY task.createdAt ASC
|
||
""", arguments: [threadId])
|
||
}
|
||
}
|
||
|
||
public func expiredDeferredTasks(beforeDate: String) throws -> [TaskRecord] {
|
||
try dbWriter.read { db in
|
||
try TaskRecord.fetchAll(db, sql: """
|
||
SELECT * FROM task
|
||
WHERE deferUntil IS NOT NULL AND deferUntil <= ? AND isSomeday = 0
|
||
""", arguments: [beforeDate])
|
||
}
|
||
}
|
||
|
||
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'")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build and all tests pass**
|
||
|
||
```bash
|
||
swift build --package-path Packages/MagnumOpusCore
|
||
swift test --package-path Packages/MagnumOpusCore
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add MailStore queries: labels, item-labels, deferrals, task cache perspectives"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: TaskStore Module
|
||
|
||
VTODO parser, formatter, and file-backed task store with cache management.
|
||
|
||
### Task 4: VTODO Parser
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift`
|
||
- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift`
|
||
|
||
- [ ] **Step 1: Create VTODOParser**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
|
||
public enum VTODOParser {
|
||
/// Parse an iCalendar string containing a VTODO into a dictionary of properties.
|
||
public static func parse(_ icsContent: String) -> [String: String]? {
|
||
// Unfold: lines starting with space/tab are continuations
|
||
let unfolded = icsContent
|
||
.replacingOccurrences(of: "\r\n ", with: "")
|
||
.replacingOccurrences(of: "\r\n\t", with: "")
|
||
.replacingOccurrences(of: "\n ", with: "")
|
||
.replacingOccurrences(of: "\n\t", with: "")
|
||
|
||
let lines = unfolded.components(separatedBy: .newlines)
|
||
|
||
// Find VTODO block
|
||
guard let startIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "BEGIN:VTODO" }),
|
||
let endIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "END:VTODO" }),
|
||
startIdx < endIdx
|
||
else { return nil }
|
||
|
||
var properties: [String: String] = [:]
|
||
for i in (startIdx + 1)..<endIdx {
|
||
let line = lines[i].trimmingCharacters(in: .whitespaces)
|
||
guard !line.isEmpty else { continue }
|
||
|
||
// Split on first colon — property name may include parameters (e.g., DTSTART;VALUE=DATE:20260315)
|
||
guard let colonIdx = line.firstIndex(of: ":") else { continue }
|
||
let rawName = String(line[..<colonIdx])
|
||
let value = String(line[line.index(after: colonIdx)...])
|
||
|
||
// Strip parameters from name (e.g., "DTSTART;VALUE=DATE" → "DTSTART")
|
||
let name = rawName.split(separator: ";").first.map(String.init) ?? rawName
|
||
// Unescape iCalendar text values
|
||
properties[name] = unescapeText(value)
|
||
}
|
||
|
||
return properties
|
||
}
|
||
|
||
/// Unescape iCalendar text values
|
||
public static func unescapeText(_ text: String) -> String {
|
||
text.replacingOccurrences(of: "\\n", with: "\n")
|
||
.replacingOccurrences(of: "\\,", with: ",")
|
||
.replacingOccurrences(of: "\\;", with: ";")
|
||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||
}
|
||
|
||
/// Parse an iCalendar date string (DATE or DATE-TIME format)
|
||
public static func parseDate(_ value: String) -> Date? {
|
||
// DATE-TIME: 20260315T090000Z
|
||
let dtFormatter = DateFormatter()
|
||
dtFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||
dtFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
||
dtFormatter.timeZone = TimeZone(identifier: "UTC")
|
||
if let date = dtFormatter.date(from: value) { return date }
|
||
|
||
// DATE-TIME without Z (local time)
|
||
dtFormatter.dateFormat = "yyyyMMdd'T'HHmmss"
|
||
dtFormatter.timeZone = .current
|
||
if let date = dtFormatter.date(from: value) { return date }
|
||
|
||
// DATE: 20260315
|
||
dtFormatter.dateFormat = "yyyyMMdd"
|
||
dtFormatter.timeZone = .current
|
||
if let date = dtFormatter.date(from: value) { return date }
|
||
|
||
return nil
|
||
}
|
||
|
||
/// Format a Date into iCalendar DATE-TIME format (UTC)
|
||
public static func formatDate(_ date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
/// Format a Date into iCalendar DATE format (no time)
|
||
public static func formatDateOnly(_ date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "yyyyMMdd"
|
||
formatter.timeZone = .current
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create VTODOParserTests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
import Foundation
|
||
@testable import TaskStore
|
||
|
||
@Suite("VTODOParser")
|
||
struct VTODOParserTests {
|
||
let sampleVTODO = """
|
||
BEGIN:VCALENDAR
|
||
VERSION:2.0
|
||
PRODID:-//MagnumOpus//v0.4//EN
|
||
BEGIN:VTODO
|
||
UID:test-123
|
||
DTSTAMP:20260314T100000Z
|
||
CREATED:20260314T100000Z
|
||
LAST-MODIFIED:20260314T120000Z
|
||
SUMMARY:Buy groceries
|
||
DESCRIPTION:Milk\\, eggs\\, bread
|
||
STATUS:NEEDS-ACTION
|
||
PRIORITY:3
|
||
DTSTART;VALUE=DATE:20260320
|
||
DUE;VALUE=DATE:20260325
|
||
CATEGORIES:Shopping,Personal
|
||
ATTACH:mid:abc-123@example.com
|
||
END:VTODO
|
||
END:VCALENDAR
|
||
"""
|
||
|
||
@Test("parses all VTODO properties")
|
||
func parseProperties() {
|
||
let props = VTODOParser.parse(sampleVTODO)
|
||
#expect(props != nil)
|
||
#expect(props?["UID"] == "test-123")
|
||
#expect(props?["SUMMARY"] == "Buy groceries")
|
||
#expect(props?["STATUS"] == "NEEDS-ACTION")
|
||
#expect(props?["PRIORITY"] == "3")
|
||
#expect(props?["CATEGORIES"] == "Shopping,Personal")
|
||
#expect(props?["ATTACH"] == "mid:abc-123@example.com")
|
||
}
|
||
|
||
@Test("parses DATE format")
|
||
func parseDateOnly() {
|
||
let date = VTODOParser.parseDate("20260315")
|
||
#expect(date != nil)
|
||
let cal = Calendar.current
|
||
#expect(cal.component(.year, from: date!) == 2026)
|
||
#expect(cal.component(.month, from: date!) == 3)
|
||
#expect(cal.component(.day, from: date!) == 15)
|
||
}
|
||
|
||
@Test("parses DATE-TIME format with Z")
|
||
func parseDateTimeUTC() {
|
||
let date = VTODOParser.parseDate("20260315T090000Z")
|
||
#expect(date != nil)
|
||
}
|
||
|
||
@Test("parses DATE-TIME format without Z")
|
||
func parseDateTimeLocal() {
|
||
let date = VTODOParser.parseDate("20260315T090000")
|
||
#expect(date != nil)
|
||
}
|
||
|
||
@Test("returns nil for invalid content")
|
||
func invalidContent() {
|
||
#expect(VTODOParser.parse("not ical") == nil)
|
||
#expect(VTODOParser.parse("BEGIN:VCALENDAR\nEND:VCALENDAR") == nil)
|
||
}
|
||
|
||
@Test("handles line unfolding")
|
||
func lineUnfolding() {
|
||
let folded = """
|
||
BEGIN:VCALENDAR
|
||
BEGIN:VTODO
|
||
UID:test
|
||
DESCRIPTION:This is a very long description that has been
|
||
folded across multiple lines
|
||
END:VTODO
|
||
END:VCALENDAR
|
||
"""
|
||
let props = VTODOParser.parse(folded)
|
||
#expect(props?["DESCRIPTION"] == "This is a very long description that has been folded across multiple lines")
|
||
}
|
||
|
||
@Test("date round-trip")
|
||
func dateRoundTrip() {
|
||
let now = Date()
|
||
let formatted = VTODOParser.formatDate(now)
|
||
let parsed = VTODOParser.parseDate(formatted)
|
||
#expect(parsed != nil)
|
||
// Within 1 second tolerance (formatting drops sub-seconds)
|
||
#expect(abs(now.timeIntervalSince(parsed!)) < 1.0)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run parser tests**
|
||
|
||
```bash
|
||
swift test --package-path Packages/MagnumOpusCore --filter VTODOParserTests
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add VTODO parser with line unfolding, date parsing, tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: VTODO Formatter
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift`
|
||
- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift`
|
||
|
||
- [ ] **Step 1: Create VTODOFormatter**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
|
||
public enum VTODOFormatter {
|
||
/// Format a TaskRecord into an iCalendar VTODO string
|
||
public static func format(
|
||
uid: String,
|
||
summary: String,
|
||
description: String? = nil,
|
||
status: String = "NEEDS-ACTION",
|
||
priority: Int = 0,
|
||
dueDate: Date? = nil,
|
||
deferUntil: Date? = nil,
|
||
categories: [String] = [],
|
||
linkedMessageId: String? = nil,
|
||
isSomeday: Bool = false,
|
||
createdAt: Date,
|
||
updatedAt: Date
|
||
) -> String {
|
||
var lines: [String] = []
|
||
lines.append("BEGIN:VCALENDAR")
|
||
lines.append("VERSION:2.0")
|
||
lines.append("PRODID:-//MagnumOpus//v0.4//EN")
|
||
lines.append("BEGIN:VTODO")
|
||
lines.append("UID:\(uid)")
|
||
lines.append("DTSTAMP:\(VTODOParser.formatDate(updatedAt))")
|
||
lines.append("CREATED:\(VTODOParser.formatDate(createdAt))")
|
||
lines.append("LAST-MODIFIED:\(VTODOParser.formatDate(updatedAt))")
|
||
lines.append("SUMMARY:\(escapeText(summary))")
|
||
|
||
if let desc = description, !desc.isEmpty {
|
||
lines.append("DESCRIPTION:\(escapeText(desc))")
|
||
}
|
||
|
||
lines.append("STATUS:\(status)")
|
||
lines.append("PRIORITY:\(priority)")
|
||
|
||
if let defer_ = deferUntil {
|
||
lines.append("DTSTART;VALUE=DATE:\(VTODOParser.formatDateOnly(defer_))")
|
||
}
|
||
|
||
if let due = dueDate {
|
||
lines.append("DUE;VALUE=DATE:\(VTODOParser.formatDateOnly(due))")
|
||
}
|
||
|
||
if !categories.isEmpty {
|
||
lines.append("CATEGORIES:\(categories.joined(separator: ","))")
|
||
}
|
||
|
||
if let msgId = linkedMessageId {
|
||
lines.append("ATTACH:mid:\(msgId)")
|
||
}
|
||
|
||
if isSomeday {
|
||
lines.append("X-MAGNUM-SOMEDAY:TRUE")
|
||
}
|
||
|
||
lines.append("END:VTODO")
|
||
lines.append("END:VCALENDAR")
|
||
|
||
// Fold lines longer than 75 octets per RFC 5545
|
||
return lines.map(foldLine).joined(separator: "\r\n") + "\r\n"
|
||
}
|
||
|
||
static func escapeText(_ text: String) -> String {
|
||
text.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: ",", with: "\\,")
|
||
.replacingOccurrences(of: ";", with: "\\;")
|
||
.replacingOccurrences(of: "\n", with: "\\n")
|
||
}
|
||
|
||
static func foldLine(_ line: String) -> String {
|
||
let maxOctets = 75
|
||
let utf8 = Array(line.utf8)
|
||
guard utf8.count > maxOctets else { return line }
|
||
|
||
var result = ""
|
||
var offset = 0
|
||
var isFirst = true
|
||
while offset < utf8.count {
|
||
let limit = isFirst ? maxOctets : maxOctets - 1 // continuation has leading space
|
||
let end = min(offset + limit, utf8.count)
|
||
let chunk = utf8[offset..<end]
|
||
if isFirst {
|
||
result += String(bytes: chunk, encoding: .utf8) ?? ""
|
||
isFirst = false
|
||
} else {
|
||
result += "\r\n " + (String(bytes: chunk, encoding: .utf8) ?? "")
|
||
}
|
||
offset = end
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create VTODOFormatterTests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
import Foundation
|
||
@testable import TaskStore
|
||
|
||
@Suite("VTODOFormatter")
|
||
struct VTODOFormatterTests {
|
||
@Test("formats minimal VTODO")
|
||
func minimalFormat() {
|
||
let now = Date()
|
||
let result = VTODOFormatter.format(
|
||
uid: "test-1",
|
||
summary: "Buy milk",
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
#expect(result.contains("BEGIN:VCALENDAR"))
|
||
#expect(result.contains("BEGIN:VTODO"))
|
||
#expect(result.contains("UID:test-1"))
|
||
#expect(result.contains("SUMMARY:Buy milk"))
|
||
#expect(result.contains("STATUS:NEEDS-ACTION"))
|
||
#expect(result.contains("PRIORITY:0"))
|
||
#expect(result.contains("END:VTODO"))
|
||
#expect(result.contains("END:VCALENDAR"))
|
||
}
|
||
|
||
@Test("includes optional fields when set")
|
||
func optionalFields() {
|
||
let now = Date()
|
||
let due = Calendar.current.date(byAdding: .day, value: 7, to: now)!
|
||
let result = VTODOFormatter.format(
|
||
uid: "test-2",
|
||
summary: "Review PR",
|
||
description: "Check the auth changes",
|
||
status: "IN-PROCESS",
|
||
priority: 1,
|
||
dueDate: due,
|
||
categories: ["Work", "Code"],
|
||
linkedMessageId: "msg-abc@example.com",
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
#expect(result.contains("DESCRIPTION:Check the auth changes"))
|
||
#expect(result.contains("STATUS:IN-PROCESS"))
|
||
#expect(result.contains("PRIORITY:1"))
|
||
#expect(result.contains("DUE;VALUE=DATE:"))
|
||
#expect(result.contains("CATEGORIES:Work,Code"))
|
||
#expect(result.contains("ATTACH:mid:msg-abc@example.com"))
|
||
}
|
||
|
||
@Test("includes someday flag")
|
||
func somedayFlag() {
|
||
let now = Date()
|
||
let result = VTODOFormatter.format(
|
||
uid: "test-3",
|
||
summary: "Learn piano",
|
||
isSomeday: true,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
#expect(result.contains("X-MAGNUM-SOMEDAY:TRUE"))
|
||
}
|
||
|
||
@Test("escapes special characters")
|
||
func specialChars() {
|
||
let now = Date()
|
||
let result = VTODOFormatter.format(
|
||
uid: "test-4",
|
||
summary: "Meeting, 3pm; room 5",
|
||
description: "Line 1\nLine 2",
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
#expect(result.contains("SUMMARY:Meeting\\, 3pm\\; room 5"))
|
||
#expect(result.contains("DESCRIPTION:Line 1\\nLine 2"))
|
||
}
|
||
|
||
@Test("round-trip: format then parse")
|
||
func roundTrip() {
|
||
let now = Date()
|
||
let formatted = VTODOFormatter.format(
|
||
uid: "rt-1",
|
||
summary: "Round trip test",
|
||
status: "NEEDS-ACTION",
|
||
priority: 5,
|
||
categories: ["Test"],
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
let parsed = VTODOParser.parse(formatted)
|
||
#expect(parsed?["UID"] == "rt-1")
|
||
#expect(parsed?["SUMMARY"] == "Round trip test")
|
||
#expect(parsed?["STATUS"] == "NEEDS-ACTION")
|
||
#expect(parsed?["PRIORITY"] == "5")
|
||
#expect(parsed?["CATEGORIES"] == "Test")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run formatter tests**
|
||
|
||
```bash
|
||
swift test --package-path Packages/MagnumOpusCore --filter VTODOFormatterTests
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add VTODO formatter with escaping, line folding, round-trip tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: TaskStore (File I/O + Cache)
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift`
|
||
- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift`
|
||
|
||
- [ ] **Step 1: Create TaskStore**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
import GRDB
|
||
|
||
public struct TaskStore: Sendable {
|
||
private let taskDirectory: URL
|
||
private let dbWriter: any DatabaseWriter
|
||
|
||
public init(taskDirectory: URL, dbWriter: any DatabaseWriter) {
|
||
self.taskDirectory = taskDirectory
|
||
self.dbWriter = dbWriter
|
||
}
|
||
|
||
// MARK: - Write
|
||
|
||
/// Create or update a task: write .ics file + update SQLite cache
|
||
public func writeTask(
|
||
id: String,
|
||
accountId: String,
|
||
summary: String,
|
||
description: String? = nil,
|
||
status: TaskStatus = .needsAction,
|
||
priority: Int = 0,
|
||
dueDate: Date? = nil,
|
||
deferUntil: Date? = nil,
|
||
linkedMessageId: String? = nil,
|
||
categories: [String] = [],
|
||
isSomeday: Bool = false
|
||
) throws {
|
||
let now = Date()
|
||
let filePath = taskFilePath(id: id)
|
||
|
||
// Preserve original createdAt if updating an existing task
|
||
let existingCreatedAt: Date? = try? dbWriter.read { db in
|
||
try TaskRecord.fetchOne(db, key: id)
|
||
}.flatMap { record in
|
||
ISO8601DateFormatter().date(from: record.createdAt)
|
||
}
|
||
let createdAt = existingCreatedAt ?? now
|
||
|
||
// Ensure directory exists
|
||
try FileManager.default.createDirectory(at: taskDirectory, withIntermediateDirectories: true)
|
||
|
||
// Write .ics file
|
||
let icsContent = VTODOFormatter.format(
|
||
uid: id,
|
||
summary: summary,
|
||
description: description,
|
||
status: status.rawValue,
|
||
priority: priority,
|
||
dueDate: dueDate,
|
||
deferUntil: deferUntil,
|
||
categories: categories,
|
||
linkedMessageId: linkedMessageId,
|
||
isSomeday: isSomeday,
|
||
createdAt: createdAt,
|
||
updatedAt: now
|
||
)
|
||
try icsContent.write(to: filePath, atomically: true, encoding: .utf8)
|
||
|
||
// Update cache
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
let record = TaskRecord(
|
||
id: id,
|
||
accountId: accountId,
|
||
summary: summary,
|
||
description: description,
|
||
status: status.rawValue,
|
||
priority: priority,
|
||
dueDate: dueDate.map { isoFormatter.string(from: $0) },
|
||
deferUntil: deferUntil.map { isoFormatter.string(from: $0) },
|
||
createdAt: isoFormatter.string(from: createdAt),
|
||
updatedAt: isoFormatter.string(from: now),
|
||
filePath: filePath.path,
|
||
linkedMessageId: linkedMessageId,
|
||
isSomeday: isSomeday
|
||
)
|
||
try dbWriter.write { db in
|
||
try record.save(db) // insert or update
|
||
}
|
||
}
|
||
|
||
/// Update task status (and rewrite .ics file)
|
||
public func updateStatus(id: String, status: TaskStatus) throws {
|
||
guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { return }
|
||
record.status = status.rawValue
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
record.updatedAt = isoFormatter.string(from: Date())
|
||
try dbWriter.write { db in try record.update(db) }
|
||
try rewriteFile(for: record)
|
||
}
|
||
|
||
/// Update task defer date (and rewrite .ics file)
|
||
public func updateDeferral(id: String, deferUntil: Date?, isSomeday: Bool) throws {
|
||
guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { return }
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
record.deferUntil = deferUntil.map { isoFormatter.string(from: $0) }
|
||
record.isSomeday = isSomeday
|
||
record.updatedAt = isoFormatter.string(from: Date())
|
||
try dbWriter.write { db in try record.update(db) }
|
||
try rewriteFile(for: record)
|
||
}
|
||
|
||
/// Delete task: remove .ics file + cache record
|
||
public func deleteTask(id: String) throws {
|
||
let filePath = taskFilePath(id: id)
|
||
try? FileManager.default.removeItem(at: filePath)
|
||
_ = try dbWriter.write { db in try TaskRecord.deleteOne(db, key: id) }
|
||
}
|
||
|
||
// MARK: - Cache Rebuild
|
||
|
||
/// Scan task directory, rebuild SQLite cache from .ics files
|
||
public func rebuildCache(accountId: String) throws {
|
||
// Clear existing cache
|
||
_ = try dbWriter.write { db in
|
||
try TaskRecord.filter(Column("accountId") == accountId).deleteAll(db)
|
||
try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'")
|
||
}
|
||
|
||
let fm = FileManager.default
|
||
guard fm.fileExists(atPath: taskDirectory.path) else { return }
|
||
|
||
let files = try fm.contentsOfDirectory(at: taskDirectory, includingPropertiesForKeys: nil)
|
||
.filter { $0.pathExtension == "ics" }
|
||
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
|
||
for file in files {
|
||
let content = try String(contentsOf: file, encoding: .utf8)
|
||
guard let props = VTODOParser.parse(content) else { continue }
|
||
guard let uid = props["UID"] else { continue }
|
||
|
||
let record = TaskRecord(
|
||
id: uid,
|
||
accountId: accountId,
|
||
summary: props["SUMMARY"] ?? "(untitled)",
|
||
description: props["DESCRIPTION"],
|
||
status: props["STATUS"] ?? "NEEDS-ACTION",
|
||
priority: Int(props["PRIORITY"] ?? "0") ?? 0,
|
||
dueDate: props["DUE"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) },
|
||
deferUntil: props["DTSTART"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) },
|
||
createdAt: props["CREATED"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) }
|
||
?? isoFormatter.string(from: Date()),
|
||
updatedAt: props["LAST-MODIFIED"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) }
|
||
?? isoFormatter.string(from: Date()),
|
||
filePath: file.path,
|
||
linkedMessageId: props["ATTACH"]?.hasPrefix("mid:") == true
|
||
? String(props["ATTACH"]!.dropFirst(4)) : nil,
|
||
isSomeday: props["X-MAGNUM-SOMEDAY"] == "TRUE"
|
||
)
|
||
try dbWriter.write { db in try record.insert(db) }
|
||
|
||
// Rebuild labels from CATEGORIES
|
||
if let cats = props["CATEGORIES"], !cats.isEmpty {
|
||
let categoryNames = cats.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||
try dbWriter.write { db in
|
||
for catName in categoryNames {
|
||
// Find or create label
|
||
var label = try LabelRecord.filter(Column("accountId") == accountId)
|
||
.filter(Column("name") == catName)
|
||
.fetchOne(db)
|
||
if label == nil {
|
||
label = LabelRecord(id: UUID().uuidString, accountId: accountId, name: catName, isProject: true)
|
||
try label!.insert(db)
|
||
}
|
||
try ItemLabelRecord(labelId: label!.id, itemType: "task", itemId: uid).insert(db)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private func taskFilePath(id: String) -> URL {
|
||
taskDirectory.appendingPathComponent("\(id).ics")
|
||
}
|
||
|
||
private func rewriteFile(for record: TaskRecord) throws {
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
let icsContent = VTODOFormatter.format(
|
||
uid: record.id,
|
||
summary: record.summary,
|
||
description: record.description,
|
||
status: record.status,
|
||
priority: record.priority,
|
||
dueDate: record.dueDate.flatMap { isoFormatter.date(from: $0) },
|
||
deferUntil: record.deferUntil.flatMap { isoFormatter.date(from: $0) },
|
||
categories: [], // Will be populated from itemLabel junction if needed
|
||
linkedMessageId: record.linkedMessageId,
|
||
isSomeday: record.isSomeday,
|
||
createdAt: isoFormatter.date(from: record.createdAt) ?? Date(),
|
||
updatedAt: isoFormatter.date(from: record.updatedAt) ?? Date()
|
||
)
|
||
let filePath = URL(fileURLWithPath: record.filePath)
|
||
try icsContent.write(to: filePath, atomically: true, encoding: .utf8)
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: `TaskStore` needs `import MailStore` for `LabelRecord` and `ItemLabelRecord` access in `rebuildCache`. Add this import. Alternatively, if circular dependencies are an issue, the rebuild logic that touches labels can live in the SyncEngine (which imports both). Adjust Package.swift if needed — TaskStore may need to depend on MailStore, or the label-rebuild logic moves to SyncEngine.
|
||
|
||
- [ ] **Step 2: Create TaskStoreTests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
import Foundation
|
||
@testable import TaskStore
|
||
@testable import MailStore
|
||
import Models
|
||
|
||
@Suite("TaskStore")
|
||
struct TaskStoreTests {
|
||
func makeTestDir() throws -> URL {
|
||
let dir = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("MagnumOpusTests-\(UUID().uuidString)")
|
||
.appendingPathComponent("tasks")
|
||
return dir
|
||
}
|
||
|
||
func makeStore() throws -> (TaskStore, any DatabaseWriter) {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
let dir = try makeTestDir()
|
||
let store = TaskStore(taskDirectory: dir, dbWriter: db)
|
||
// Seed account
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
}
|
||
return (store, db)
|
||
}
|
||
|
||
@Test("writeTask creates .ics file and cache record")
|
||
func writeTask() throws {
|
||
let (store, db) = try makeStore()
|
||
try store.writeTask(id: "t1", accountId: "a1", summary: "Buy milk")
|
||
|
||
// Verify cache
|
||
let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
|
||
#expect(record?.summary == "Buy milk")
|
||
#expect(record?.status == "NEEDS-ACTION")
|
||
|
||
// Verify file
|
||
let filePath = record!.filePath
|
||
#expect(FileManager.default.fileExists(atPath: filePath))
|
||
let content = try String(contentsOfFile: filePath, encoding: .utf8)
|
||
#expect(content.contains("SUMMARY:Buy milk"))
|
||
}
|
||
|
||
@Test("updateStatus changes cache and file")
|
||
func updateStatus() throws {
|
||
let (store, db) = try makeStore()
|
||
try store.writeTask(id: "t1", accountId: "a1", summary: "Test")
|
||
try store.updateStatus(id: "t1", status: .completed)
|
||
|
||
let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
|
||
#expect(record?.status == "COMPLETED")
|
||
|
||
let content = try String(contentsOfFile: record!.filePath, encoding: .utf8)
|
||
#expect(content.contains("STATUS:COMPLETED"))
|
||
}
|
||
|
||
@Test("deleteTask removes file and cache")
|
||
func deleteTask() throws {
|
||
let (store, db) = try makeStore()
|
||
try store.writeTask(id: "t1", accountId: "a1", summary: "Test")
|
||
let filePath = try db.read { db in try TaskRecord.fetchOne(db, key: "t1")!.filePath }
|
||
|
||
try store.deleteTask(id: "t1")
|
||
|
||
let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
|
||
#expect(record == nil)
|
||
#expect(!FileManager.default.fileExists(atPath: filePath))
|
||
}
|
||
|
||
@Test("rebuildCache restores tasks from .ics files")
|
||
func rebuildCache() throws {
|
||
let (store, db) = try makeStore()
|
||
|
||
// Create tasks
|
||
try store.writeTask(id: "t1", accountId: "a1", summary: "Task One", categories: ["Work"])
|
||
try store.writeTask(id: "t2", accountId: "a1", summary: "Task Two")
|
||
|
||
// Clear cache (simulate corruption)
|
||
_ = try db.write { db in try TaskRecord.deleteAll(db) }
|
||
|
||
// Rebuild
|
||
try store.rebuildCache(accountId: "a1")
|
||
|
||
// Verify restored
|
||
let tasks = try db.read { db in try TaskRecord.fetchAll(db) }
|
||
#expect(tasks.count == 2)
|
||
#expect(tasks.contains(where: { $0.summary == "Task One" }))
|
||
#expect(tasks.contains(where: { $0.summary == "Task Two" }))
|
||
}
|
||
|
||
@Test("updateDeferral changes defer date")
|
||
func updateDeferral() throws {
|
||
let (store, db) = try makeStore()
|
||
try store.writeTask(id: "t1", accountId: "a1", summary: "Test")
|
||
|
||
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||
try store.updateDeferral(id: "t1", deferUntil: tomorrow, isSomeday: false)
|
||
|
||
let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") }
|
||
#expect(record?.deferUntil != nil)
|
||
#expect(record?.isSomeday == false)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run TaskStore tests**
|
||
|
||
```bash
|
||
swift test --package-path Packages/MagnumOpusCore --filter TaskStoreTests
|
||
```
|
||
|
||
- [ ] **Step 4: Run all tests to check for regressions**
|
||
|
||
```bash
|
||
swift test --package-path Packages/MagnumOpusCore
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add TaskStore: file I/O, cache management, rebuild from .ics files"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 3: Deferral Resurfacing & SyncEngine
|
||
|
||
### Task 7: Deferral Resurfacing in SyncCoordinator
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift`
|
||
|
||
- [ ] **Step 1: Add resurfacing check to performSync**
|
||
|
||
Add a `resurfaceDeferrals()` method to SyncCoordinator that:
|
||
1. Gets current ISO8601 date string
|
||
2. Queries `expiredDeferrals(beforeDate:)` from MailStore → delete each deferral record
|
||
3. Queries `expiredDeferredTasks(beforeDate:)` from MailStore → clear deferUntil for each task
|
||
4. If TaskStore is available, rewrite the .ics file for each resurfaced task
|
||
|
||
Call `resurfaceDeferrals()` at the start of `performSync()`, before flushing the action queue.
|
||
|
||
```swift
|
||
// Add to SyncCoordinator:
|
||
private let taskStore: TaskStore?
|
||
|
||
// Update init:
|
||
public init(
|
||
accountConfig: AccountConfig,
|
||
imapClient: any IMAPClientProtocol,
|
||
store: MailStore,
|
||
actionQueue: ActionQueue? = nil,
|
||
taskStore: TaskStore? = nil
|
||
) { ... }
|
||
|
||
// Add method:
|
||
private func resurfaceDeferrals() {
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
let now = isoFormatter.string(from: Date())
|
||
|
||
do {
|
||
// Resurface emails
|
||
let expiredDeferrals = try store.expiredDeferrals(beforeDate: now)
|
||
for deferral in expiredDeferrals {
|
||
try store.deleteDeferral(id: deferral.id)
|
||
}
|
||
|
||
// Resurface tasks
|
||
let expiredTasks = try store.expiredDeferredTasks(beforeDate: now)
|
||
for task in expiredTasks {
|
||
try store.clearTaskDeferral(id: task.id)
|
||
try taskStore?.updateDeferral(id: task.id, deferUntil: nil, isSomeday: false)
|
||
}
|
||
} catch {
|
||
// Resurfacing is non-fatal — log and continue with sync
|
||
print("[SyncCoordinator] resurfacing failed: \(error)")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Call resurfaceDeferrals in performSync**
|
||
|
||
```swift
|
||
private func performSync() async throws {
|
||
// Resurface deferred items
|
||
resurfaceDeferrals()
|
||
|
||
// Flush pending actions
|
||
if let queue = actionQueue {
|
||
await queue.flush()
|
||
}
|
||
|
||
// ... existing IMAP sync code ...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify build and existing tests pass**
|
||
|
||
```bash
|
||
swift build --package-path Packages/MagnumOpusCore
|
||
swift test --package-path Packages/MagnumOpusCore
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add deferral resurfacing to SyncCoordinator: emails and tasks"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 4: App UI — Perspectives & Task Views
|
||
|
||
### Task 8: Sidebar Perspectives
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
|
||
- Edit: `Apps/MagnumOpus/Views/SidebarView.swift`
|
||
|
||
- [ ] **Step 1: Update Perspective enum in MailViewModel**
|
||
|
||
Replace the existing Perspective enum with:
|
||
|
||
```swift
|
||
enum Perspective: String, CaseIterable, Identifiable {
|
||
case inbox
|
||
case today
|
||
case upcoming
|
||
case projects
|
||
case someday
|
||
case archive
|
||
|
||
var id: String { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .inbox: return "Inbox"
|
||
case .today: return "Today"
|
||
case .upcoming: return "Upcoming"
|
||
case .projects: return "Projects"
|
||
case .someday: return "Someday"
|
||
case .archive: return "Archive"
|
||
}
|
||
}
|
||
|
||
var systemImage: String {
|
||
switch self {
|
||
case .inbox: return "tray"
|
||
case .today: return "star"
|
||
case .upcoming: return "calendar"
|
||
case .projects: return "folder"
|
||
case .someday: return "archivebox"
|
||
case .archive: return "checkmark.circle"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add unified item loading per perspective**
|
||
|
||
Add methods to MailViewModel that load `[ItemSummary]` for each perspective, combining emails and tasks from MailStore queries. Each perspective uses the appropriate queries defined in Task 3.
|
||
|
||
- [ ] **Step 3: Update SidebarView with new perspectives**
|
||
|
||
Update SidebarView to show all 6 perspectives with icons and unread/item counts. The Projects perspective shows project labels as sub-items.
|
||
|
||
- [ ] **Step 4: Verify app compiles**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add sidebar perspectives: inbox, today, upcoming, projects, someday, archive"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Task Creation Views
|
||
|
||
**Files:**
|
||
- Create: `Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift`
|
||
- Create: `Apps/MagnumOpus/Views/TaskEditView.swift`
|
||
|
||
- [ ] **Step 1: Create TaskEditViewModel**
|
||
|
||
`@Observable @MainActor` class with:
|
||
- `title: String`, `body: String`, `dueDate: Date?`, `hasDueDate: Bool`
|
||
- `linkedMessageId: String?` (set when creating from email)
|
||
- `save() throws` → calls `TaskStore.writeTask()`
|
||
- Constructor takes `accountConfig`, `taskStore`, optional `linkedMessageId` and pre-filled `title`
|
||
|
||
- [ ] **Step 2: Create TaskEditView**
|
||
|
||
Compact form view:
|
||
- Title TextField (required)
|
||
- Body TextEditor (optional, expandable)
|
||
- Due date toggle + DatePicker
|
||
- Save button (⌘⏎), Cancel button
|
||
- Sheet presentation on iOS, popover on macOS
|
||
|
||
- [ ] **Step 3: Wire task creation to keyboard shortcuts**
|
||
|
||
In ContentView or appropriate parent:
|
||
- `⌘⇧N` → open TaskEditView (standalone)
|
||
- `t` (when thread selected) → open TaskEditView with linkedMessageId and pre-filled title
|
||
|
||
- [ ] **Step 4: Verify app compiles**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add task creation: TaskEditViewModel, TaskEditView, keyboard shortcuts"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Defer Picker & Label Picker
|
||
|
||
**Files:**
|
||
- Create: `Apps/MagnumOpus/Views/DeferPicker.swift`
|
||
- Create: `Apps/MagnumOpus/Views/LabelPicker.swift`
|
||
|
||
- [ ] **Step 1: Create DeferPicker**
|
||
|
||
Compact popover with:
|
||
- "Tomorrow" button → defer to tomorrow
|
||
- "Next Week" button → defer to next Monday
|
||
- "Pick Date" → inline DatePicker
|
||
- "Someday" button → defer to someday
|
||
|
||
```swift
|
||
struct DeferPicker: View {
|
||
let onDefer: (Date?) -> Void // nil = someday
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var showDatePicker = false
|
||
@State private var customDate = Date()
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
Button("Tomorrow") {
|
||
onDefer(Calendar.current.date(byAdding: .day, value: 1, to: Date()))
|
||
dismiss()
|
||
}
|
||
Button("Next Week") {
|
||
onDefer(nextMonday())
|
||
dismiss()
|
||
}
|
||
if showDatePicker {
|
||
DatePicker("", selection: $customDate, displayedComponents: .date)
|
||
.datePickerStyle(.graphical)
|
||
Button("Defer to Selected Date") {
|
||
onDefer(customDate)
|
||
dismiss()
|
||
}
|
||
} else {
|
||
Button("Pick Date...") { showDatePicker = true }
|
||
}
|
||
Divider()
|
||
Button("Someday") {
|
||
onDefer(nil)
|
||
dismiss()
|
||
}
|
||
}
|
||
.padding()
|
||
#if os(macOS)
|
||
.frame(width: 250)
|
||
#endif
|
||
}
|
||
|
||
private func nextMonday() -> Date {
|
||
let cal = Calendar.current
|
||
let today = Date()
|
||
let weekday = cal.component(.weekday, from: today)
|
||
// Monday = 2 in Calendar
|
||
let daysUntilMonday = (9 - weekday) % 7
|
||
return cal.date(byAdding: .day, value: daysUntilMonday == 0 ? 7 : daysUntilMonday, to: today)!
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create LabelPicker**
|
||
|
||
Compact popover with:
|
||
- Searchable list of existing project labels
|
||
- Type-to-create: "Create project: <name>" option when no match
|
||
- On select: callback with label
|
||
|
||
```swift
|
||
struct LabelPicker: View {
|
||
let labels: [LabelInfo]
|
||
let onSelect: (LabelInfo) -> Void
|
||
let onCreate: (String) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var searchText = ""
|
||
|
||
var filtered: [LabelInfo] {
|
||
if searchText.isEmpty { return labels }
|
||
return labels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||
}
|
||
|
||
var showCreateOption: Bool {
|
||
!searchText.isEmpty && !labels.contains(where: { $0.name.lowercased() == searchText.lowercased() })
|
||
}
|
||
|
||
var body: some View {
|
||
VStack {
|
||
TextField("Search or create...", text: $searchText)
|
||
.textFieldStyle(.roundedBorder)
|
||
.padding(.horizontal)
|
||
|
||
List {
|
||
if showCreateOption {
|
||
Button("Create project: \(searchText)") {
|
||
onCreate(searchText)
|
||
dismiss()
|
||
}
|
||
}
|
||
ForEach(filtered) { label in
|
||
Button(label.name) {
|
||
onSelect(label)
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#if os(macOS)
|
||
.frame(width: 250, height: 300)
|
||
#endif
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify app compiles**
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add DeferPicker, LabelPicker views for GTD triage"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: GTD Triage Actions in MailViewModel
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
|
||
- Edit: `Apps/MagnumOpus/Views/ThreadListView.swift`
|
||
|
||
- [ ] **Step 1: Add GTD triage methods to MailViewModel**
|
||
|
||
```swift
|
||
// MARK: - GTD Triage
|
||
|
||
func deferSelectedItem(until date: Date?) async {
|
||
// If date is nil → someday
|
||
guard let item = selectedItem else { return }
|
||
switch item {
|
||
case .email(let msg):
|
||
let deferral = DeferralRecord(
|
||
id: UUID().uuidString,
|
||
messageId: msg.id,
|
||
deferUntil: date.map { ISO8601DateFormatter().string(from: $0) },
|
||
originalMailbox: selectedMailbox?.name,
|
||
createdAt: ISO8601DateFormatter().string(from: Date())
|
||
)
|
||
try? store?.insertDeferral(deferral)
|
||
case .task(let task):
|
||
try? taskStore?.updateDeferral(id: task.id, deferUntil: date, isSomeday: date == nil)
|
||
}
|
||
autoAdvance()
|
||
}
|
||
|
||
func fileSelectedItem(to label: LabelInfo) async {
|
||
guard let item = selectedItem else { return }
|
||
let (itemType, itemId) = itemTypeAndId(item)
|
||
try? store?.attachLabel(labelId: label.id, itemType: itemType, itemId: itemId)
|
||
// For tasks, also update VTODO CATEGORIES
|
||
if case .task(let task) = item {
|
||
let allLabels = (try? store?.labelsForItem(itemType: "task", itemId: task.id)) ?? []
|
||
let catNames = allLabels.map(\.name)
|
||
// Rewrite .ics with updated categories via TaskStore
|
||
// (TaskStore.rewriteFile would need a categories parameter, or
|
||
// read labels from DB during rewrite. Simplest: re-call writeTask
|
||
// with current values + updated categories.)
|
||
}
|
||
autoAdvance()
|
||
}
|
||
|
||
func discardSelectedItem() async {
|
||
guard let item = selectedItem else { return }
|
||
switch item {
|
||
case .email:
|
||
await archiveSelectedThread() // existing v0.3 action
|
||
case .task(let task):
|
||
try? taskStore?.updateStatus(id: task.id, status: .cancelled)
|
||
}
|
||
autoAdvance()
|
||
}
|
||
|
||
func completeSelectedItem() async {
|
||
guard let item = selectedItem else { return }
|
||
switch item {
|
||
case .email:
|
||
await archiveSelectedThread()
|
||
case .task(let task):
|
||
try? taskStore?.updateStatus(id: task.id, status: .completed)
|
||
}
|
||
autoAdvance()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add GTD keyboard shortcuts to ThreadListView**
|
||
|
||
Add toolbar buttons and keyboard shortcuts for the new GTD actions:
|
||
- `d` → show DeferPicker
|
||
- `⇧D` → defer to someday immediately
|
||
- `p` → show LabelPicker
|
||
- `⌘⏎` → complete item
|
||
|
||
Update `⌫` behavior: in GTD perspectives, call `discardSelectedItem()` instead of `deleteSelectedThread()`.
|
||
|
||
- [ ] **Step 3: Verify app compiles**
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "add GTD triage actions: defer, file to project, discard, complete"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Thread Detail — Linked Tasks
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/Views/ThreadDetailView.swift`
|
||
|
||
- [ ] **Step 1: Query linked tasks for the current thread**
|
||
|
||
When displaying a thread, also query `store.tasksLinkedToThread(threadId:)` and interleave task items in the timeline:
|
||
|
||
```swift
|
||
// In thread detail, after loading messages:
|
||
let linkedTasks = try? store.tasksLinkedToThread(threadId: thread.id)
|
||
```
|
||
|
||
- [ ] **Step 2: Render tasks inline with distinct style**
|
||
|
||
Show tasks with:
|
||
- Checkbox icon (filled if completed, empty if active)
|
||
- Task summary as title
|
||
- Status badge (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||
- Tapping a task could toggle its status
|
||
|
||
- [ ] **Step 3: Verify app compiles**
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "show linked tasks inline in thread detail view"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 5: Integration & Polish
|
||
|
||
### Task 13: End-to-End Wiring
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
|
||
- Edit: `Apps/MagnumOpus/ContentView.swift`
|
||
|
||
- [ ] **Step 1: Initialize TaskStore in MailViewModel.setup()**
|
||
|
||
```swift
|
||
// In setup(config:credentials:):
|
||
let taskDir = Self.taskDirectory(accountId: config.id)
|
||
// Note: MailStore needs a public `databaseWriter` accessor added. Currently `dbWriter`
|
||
// is internal. Add `public var databaseWriter: any DatabaseWriter { dbWriter }` to MailStore.
|
||
let taskStore = TaskStore(taskDirectory: taskDir, dbWriter: store.databaseWriter)
|
||
self.taskStore = taskStore
|
||
```
|
||
|
||
- [ ] **Step 2: Pass TaskStore to SyncCoordinator**
|
||
|
||
```swift
|
||
let coordinator = SyncCoordinator(
|
||
accountConfig: config,
|
||
imapClient: imapClient,
|
||
store: store,
|
||
actionQueue: queue,
|
||
taskStore: taskStore
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Wire TaskEditView presentation**
|
||
|
||
Connect `⌘⇧N` and `t` shortcuts to TaskEditView presentation in ContentView.
|
||
|
||
- [ ] **Step 4: Verify full flow works**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "wire end-to-end: TaskStore in MailViewModel, SyncCoordinator, task creation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Final Tests & Cleanup
|
||
|
||
- [ ] **Step 1: Run all package tests**
|
||
|
||
```bash
|
||
swift test --package-path Packages/MagnumOpusCore
|
||
```
|
||
|
||
Fix any failures.
|
||
|
||
- [ ] **Step 2: Verify app builds for macOS and iOS**
|
||
|
||
- [ ] **Step 3: Remove any leftover placeholder files**
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix test failures, verify builds for macOS and iOS"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Version Bump
|
||
|
||
- [ ] **Step 1: Bump CalVer to 2026.03.14+1**
|
||
|
||
Update version in relevant config files.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "bump calver to 2026.03.14+1, v0.4: GTD tasks, unified triage, deferrals, labels"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Chunk | Tasks | Focus |
|
||
|-------|-------|-------|
|
||
| 1: Models & Schema | 1–3 | Model types, records, migrations, MailStore queries |
|
||
| 2: TaskStore Module | 4–6 | VTODO parser, formatter, file I/O, cache rebuild |
|
||
| 3: Deferral & Sync | 7 | Resurfacing in SyncCoordinator |
|
||
| 4: App UI | 8–12 | Perspectives, task creation, defer/label pickers, GTD triage, thread detail |
|
||
| 5: Integration | 13–15 | Wiring, tests, version bump |
|
||
|
||
**Parallelizable:** Chunks 1 and 2 (schema + TaskStore) are partially independent — the VTODO parser/formatter (Tasks 4-5) have no schema dependency and can run alongside Task 2. Chunk 3 depends on both. Chunk 4 depends on everything. Chunk 5 depends on everything.
|