Add personal data tools, 2nd Brain trust toggle, and research agents
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts framework, and MapKit. Write actions (create event/reminder, complete reminder) gate through an approval sheet. Four hardened-runtime entitlements added to oAI.entitlements; Info.plist usage strings added for all services. Personal Data section shows a β badge while Contacts is hidden. 2nd Brain always-trust: inline toggle on the Agent Skills row for the skill named "2nd Brain" skips the bash approval dialog when the command contains .brain_helper.py, gated by three runtime checks in MCPService. Research agents: spawn_research_agents tool runs up to 5 concurrent read-only sub-agents (read_file, list_directory, search_files, web_search — no write, no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
//
|
||||
// EventKitService.swift
|
||||
// oAI
|
||||
//
|
||||
// Calendar and Reminders integration via EventKit
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import EventKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Shared tri-state authorization status for the Settings UI across all Personal Data services.
|
||||
/// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` — states where the OS
|
||||
/// will not show a request dialog again; the user must go to System Settings manually.
|
||||
enum PersonalDataAccessState {
|
||||
case notDetermined
|
||||
case denied
|
||||
case granted
|
||||
}
|
||||
|
||||
@Observable
|
||||
class EventKitService {
|
||||
static let shared = EventKitService()
|
||||
|
||||
private let store = EKEventStore()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var calendarAuthStatus: EKAuthorizationStatus {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
return status
|
||||
}
|
||||
|
||||
var reminderAuthStatus: EKAuthorizationStatus {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
return status
|
||||
}
|
||||
|
||||
var calendarAuthorized: Bool {
|
||||
calendarAuthStatus == .fullAccess
|
||||
}
|
||||
|
||||
var reminderAuthorized: Bool {
|
||||
reminderAuthStatus == .fullAccess
|
||||
}
|
||||
|
||||
var calendarAccessState: PersonalDataAccessState {
|
||||
switch calendarAuthStatus {
|
||||
case .fullAccess: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools)
|
||||
}
|
||||
}
|
||||
|
||||
var reminderAccessState: PersonalDataAccessState {
|
||||
switch reminderAuthStatus {
|
||||
case .fullAccess: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestCalendarAccess() async -> Bool {
|
||||
let before = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
do {
|
||||
let granted = try await store.requestFullAccessToEvents()
|
||||
let after = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return granted
|
||||
} catch {
|
||||
let after = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestReminderAccess() async -> Bool {
|
||||
let before = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
do {
|
||||
let granted = try await store.requestFullAccessToReminders()
|
||||
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return granted
|
||||
} catch {
|
||||
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: EKAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .fullAccess: return "fullAccess"
|
||||
case .writeOnly: return "writeOnly"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] {
|
||||
var tools: [Tool] = []
|
||||
|
||||
if calendarEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "calendar_list_calendars",
|
||||
description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).",
|
||||
properties: [:],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "calendar_list_events",
|
||||
description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.",
|
||||
properties: [
|
||||
"start_date": prop("string", "Start of the date range (ISO8601)"),
|
||||
"end_date": prop("string", "End of the date range (ISO8601)"),
|
||||
"calendar_name": prop("string", "Optional: only list events from this calendar")
|
||||
],
|
||||
required: ["start_date", "end_date"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "calendar_create_event",
|
||||
description: "Create a new calendar event. Requires user approval before it is actually created.",
|
||||
properties: [
|
||||
"title": prop("string", "Event title"),
|
||||
"start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"),
|
||||
"end_date": prop("string", "End date/time (ISO8601)"),
|
||||
"calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"),
|
||||
"location": prop("string", "Optional: event location text"),
|
||||
"notes": prop("string", "Optional: event notes"),
|
||||
"all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"),
|
||||
"alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert")
|
||||
],
|
||||
required: ["title", "start_date", "end_date"]
|
||||
))
|
||||
}
|
||||
|
||||
if remindersEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "reminders_list_lists",
|
||||
description: "List all reminder lists available on this Mac.",
|
||||
properties: [:],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_list",
|
||||
description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.",
|
||||
properties: [
|
||||
"list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"),
|
||||
"include_completed": prop("boolean", "Optional: include completed reminders (default: false)")
|
||||
],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_create",
|
||||
description: "Create a new reminder. Requires user approval before it is actually created.",
|
||||
properties: [
|
||||
"title": prop("string", "Reminder title"),
|
||||
"list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"),
|
||||
"due_date": prop("string", "Optional: due date/time (ISO8601)"),
|
||||
"priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]),
|
||||
"notes": prop("string", "Optional: reminder notes")
|
||||
],
|
||||
required: ["title"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_complete",
|
||||
description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.",
|
||||
properties: [
|
||||
"reminder_id": prop("string", "The reminder's identifier, from reminders_list")
|
||||
],
|
||||
required: ["reminder_id"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
// MARK: - Read Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing EventKit tool: \(name)")
|
||||
let args = Self.parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "calendar_list_calendars":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
return listCalendars()
|
||||
|
||||
case "calendar_list_events":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||
return ["error": "Missing or invalid parameter: start_date"]
|
||||
}
|
||||
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||
return ["error": "Missing or invalid parameter: end_date"]
|
||||
}
|
||||
let calendarName = args["calendar_name"] as? String
|
||||
return listEvents(start: start, end: end, calendarName: calendarName)
|
||||
|
||||
case "reminders_list_lists":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
return listReminderLists()
|
||||
|
||||
case "reminders_list":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
let listName = args["list_name"] as? String
|
||||
let includeCompleted = args["include_completed"] as? Bool ?? false
|
||||
return await listReminders(listName: listName, includeCompleted: includeCompleted)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown EventKit tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Write Tool Execution (called only after approval)
|
||||
|
||||
func executeWriteTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing EventKit write tool: \(name)")
|
||||
let args = Self.parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "calendar_create_event":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
return createEvent(args: args)
|
||||
|
||||
case "reminders_create":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
return createReminder(args: args)
|
||||
|
||||
case "reminders_complete":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
guard let reminderId = args["reminder_id"] as? String else {
|
||||
return ["error": "Missing required parameter: reminder_id"]
|
||||
}
|
||||
return completeReminder(reminderId: reminderId)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown EventKit write tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Approval Summary
|
||||
|
||||
/// Human-readable description shown in the approval sheet before a write tool runs.
|
||||
func approvalSummary(forTool name: String, arguments: String) -> String {
|
||||
let args = Self.parseArgs(arguments)
|
||||
switch name {
|
||||
case "calendar_create_event":
|
||||
let title = args["title"] as? String ?? "Untitled event"
|
||||
let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date()
|
||||
let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start
|
||||
return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))"
|
||||
case "reminders_create":
|
||||
let title = args["title"] as? String ?? "Untitled reminder"
|
||||
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||
return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))"
|
||||
}
|
||||
return "Create reminder \"\(title)\""
|
||||
case "reminders_complete":
|
||||
return "Mark reminder as completed"
|
||||
default:
|
||||
return "Perform action: \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar Read Implementations
|
||||
|
||||
private func listCalendars() -> [String: Any] {
|
||||
let calendars = store.calendars(for: .event).map { cal -> [String: Any] in
|
||||
[
|
||||
"name": cal.title,
|
||||
"type": calendarTypeDescription(cal),
|
||||
"allows_modifications": cal.allowsContentModifications
|
||||
]
|
||||
}
|
||||
return ["calendars": calendars]
|
||||
}
|
||||
|
||||
private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] {
|
||||
guard end > start else { return ["error": "end_date must be after start_date"] }
|
||||
guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else {
|
||||
return ["error": "Date range too large — limit to 1 year or less"]
|
||||
}
|
||||
|
||||
var calendars = store.calendars(for: .event)
|
||||
if let calendarName {
|
||||
calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }
|
||||
if calendars.isEmpty {
|
||||
return ["error": "No calendar found named '\(calendarName)'"]
|
||||
}
|
||||
}
|
||||
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
|
||||
let events = store.events(matching: predicate)
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
.prefix(200)
|
||||
.map { event -> [String: Any] in
|
||||
var item: [String: Any] = [
|
||||
"id": event.eventIdentifier ?? "",
|
||||
"title": event.title ?? "Untitled",
|
||||
"start": Self.isoFormatter.string(from: event.startDate),
|
||||
"end": Self.isoFormatter.string(from: event.endDate),
|
||||
"all_day": event.isAllDay,
|
||||
"calendar": event.calendar?.title ?? ""
|
||||
]
|
||||
if let location = event.location, !location.isEmpty {
|
||||
item["location"] = location
|
||||
}
|
||||
if let notes = event.notes, !notes.isEmpty {
|
||||
item["notes"] = String(notes.prefix(500))
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return ["count": events.count, "events": Array(events)]
|
||||
}
|
||||
|
||||
private func createEvent(args: [String: Any]) -> [String: Any] {
|
||||
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||
return ["error": "Missing required parameter: title"]
|
||||
}
|
||||
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||
return ["error": "Missing or invalid parameter: start_date"]
|
||||
}
|
||||
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||
return ["error": "Missing or invalid parameter: end_date"]
|
||||
}
|
||||
guard end >= start else {
|
||||
return ["error": "end_date must not be before start_date"]
|
||||
}
|
||||
|
||||
let event = EKEvent(eventStore: store)
|
||||
event.title = title
|
||||
event.startDate = start
|
||||
event.endDate = end
|
||||
event.isAllDay = args["all_day"] as? Bool ?? false
|
||||
|
||||
if let calendarName = args["calendar_name"] as? String,
|
||||
let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) {
|
||||
event.calendar = calendar
|
||||
} else if let defaultCalendar = store.defaultCalendarForNewEvents {
|
||||
event.calendar = defaultCalendar
|
||||
} else {
|
||||
guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else {
|
||||
return ["error": "No writable calendar available"]
|
||||
}
|
||||
event.calendar = fallback
|
||||
}
|
||||
|
||||
if let location = args["location"] as? String { event.location = location }
|
||||
if let notes = args["notes"] as? String { event.notes = notes }
|
||||
if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) {
|
||||
event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60)))
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(event, span: .thisEvent, commit: true)
|
||||
return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to create event: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reminders Read Implementations
|
||||
|
||||
private func listReminderLists() -> [String: Any] {
|
||||
let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in
|
||||
["name": cal.title, "allows_modifications": cal.allowsContentModifications]
|
||||
}
|
||||
return ["lists": lists]
|
||||
}
|
||||
|
||||
private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] {
|
||||
var lists = store.calendars(for: .reminder)
|
||||
if let listName {
|
||||
lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame }
|
||||
if lists.isEmpty {
|
||||
return ["error": "No reminder list found named '\(listName)'"]
|
||||
}
|
||||
}
|
||||
|
||||
let predicate = store.predicateForReminders(in: lists)
|
||||
let reminders: [EKReminder] = await withCheckedContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { results in
|
||||
continuation.resume(returning: results ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = reminders
|
||||
.filter { includeCompleted || !$0.isCompleted }
|
||||
.sorted { lhs, rhs in
|
||||
let l = lhs.dueDateComponents?.date ?? .distantFuture
|
||||
let r = rhs.dueDateComponents?.date ?? .distantFuture
|
||||
return l < r
|
||||
}
|
||||
.prefix(200)
|
||||
.map { reminder -> [String: Any] in
|
||||
var item: [String: Any] = [
|
||||
"id": reminder.calendarItemIdentifier,
|
||||
"title": reminder.title ?? "Untitled",
|
||||
"completed": reminder.isCompleted,
|
||||
"list": reminder.calendar?.title ?? ""
|
||||
]
|
||||
if let due = reminder.dueDateComponents?.date {
|
||||
item["due"] = Self.isoFormatter.string(from: due)
|
||||
}
|
||||
if reminder.priority > 0 {
|
||||
item["priority"] = priorityDescription(reminder.priority)
|
||||
}
|
||||
if let notes = reminder.notes, !notes.isEmpty {
|
||||
item["notes"] = String(notes.prefix(500))
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return ["count": filtered.count, "reminders": Array(filtered)]
|
||||
}
|
||||
|
||||
private func createReminder(args: [String: Any]) -> [String: Any] {
|
||||
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||
return ["error": "Missing required parameter: title"]
|
||||
}
|
||||
|
||||
let reminder = EKReminder(eventStore: store)
|
||||
reminder.title = title
|
||||
|
||||
if let listName = args["list_name"] as? String,
|
||||
let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) {
|
||||
reminder.calendar = list
|
||||
} else if let defaultList = store.defaultCalendarForNewReminders() {
|
||||
reminder.calendar = defaultList
|
||||
} else {
|
||||
guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else {
|
||||
return ["error": "No writable reminder list available"]
|
||||
}
|
||||
reminder.calendar = fallback
|
||||
}
|
||||
|
||||
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||
reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due)
|
||||
}
|
||||
if let notes = args["notes"] as? String { reminder.notes = notes }
|
||||
if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) }
|
||||
|
||||
do {
|
||||
try store.save(reminder, commit: true)
|
||||
return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("reminders_create failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to create reminder: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func completeReminder(reminderId: String) -> [String: Any] {
|
||||
guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else {
|
||||
return ["error": "No reminder found with id '\(reminderId)'"]
|
||||
}
|
||||
item.isCompleted = true
|
||||
item.completionDate = Date()
|
||||
|
||||
do {
|
||||
try store.save(item, commit: true)
|
||||
return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("reminders_complete failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to complete reminder: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calendarTypeDescription(_ cal: EKCalendar) -> String {
|
||||
switch cal.type {
|
||||
case .local: return "local"
|
||||
case .calDAV: return "caldav"
|
||||
case .exchange: return "exchange"
|
||||
case .subscription: return "subscription"
|
||||
case .birthday: return "birthday"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityDescription(_ value: Int) -> String {
|
||||
switch value {
|
||||
case 1...4: return "high"
|
||||
case 5: return "medium"
|
||||
case 6...9: return "low"
|
||||
default: return "none"
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityValue(_ description: String) -> Int {
|
||||
switch description.lowercased() {
|
||||
case "high": return 1
|
||||
case "medium": return 5
|
||||
case "low": return 9
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||
}
|
||||
|
||||
nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return [:]
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
nonisolated static func permissionError(domain: String) -> [String: Any] {
|
||||
["error": "\(domain) permission not granted. Grant access in Settings > MCP."]
|
||||
}
|
||||
|
||||
nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated static let displayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated static func parseDate(_ string: String) -> Date? {
|
||||
if let date = isoFormatter.date(from: string) { return date }
|
||||
|
||||
let isoNoTimezone = ISO8601DateFormatter()
|
||||
isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = isoNoTimezone.date(from: string) { return date }
|
||||
|
||||
let localDateTime = DateFormatter()
|
||||
localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
if let date = localDateTime.date(from: string) { return date }
|
||||
|
||||
let dateOnly = DateFormatter()
|
||||
dateOnly.dateFormat = "yyyy-MM-dd"
|
||||
if let date = dateOnly.date(from: string) { return date }
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user