Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Sources/VoiceMemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ struct ContentView: View {
init(settings: SettingsStore) {
self.settings = settings
_recorder = StateObject(wrappedValue: AudioRecorder(settings: settings))
_selectedRecordingMode = State(initialValue: RecordingModeItem(rawValue: settings.recordingMode.rawValue) ?? .mixed)
}

// Method to navigate to a task in history
Expand Down Expand Up @@ -154,14 +153,6 @@ struct ContentView: View {
.onChange(of: recorder.latestTask?.id) { _ in
Task { await historyStore.refresh() }
}
.onChange(of: selectedRecordingMode) { newValue in
if let newValue {
settings.recordingMode = SettingsStore.RecordingMode(rawValue: newValue.rawValue) ?? .mixed
}
}
.onChange(of: settings.recordingMode) { newValue in
selectedRecordingMode = RecordingModeItem(rawValue: newValue.rawValue) ?? .mixed
}
.alert("Import Failed", isPresented: Binding(
get: { importError != nil },
set: { if !$0 { importError = nil } }
Expand Down
7 changes: 7 additions & 0 deletions Sources/VoiceMemo/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>47.99.241.152</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<false/>
</dict>
</dict>
</dict>
</dict>
Expand Down
4 changes: 4 additions & 0 deletions Sources/VoiceMemo/Models/AppNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable {
case asr
case oss
case storage
case email
case logs

var id: String { rawValue }
Expand All @@ -98,6 +99,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable {
case .asr: return "ASR Service"
case .oss: return "Object Storage"
case .storage: return "Storage"
case .email: return "Email"
case .logs: return "Logs"
}
}
Expand All @@ -108,6 +110,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable {
case .asr: return "Configure Speech-to-Text providers and parameters."
case .oss: return "Configure Object Storage Service (OSS) settings."
case .storage: return "Manage data persistence and database connections."
case .email: return "Configure FastMail gateway and recipients."
case .logs: return "View and manage application logs."
}
}
Expand All @@ -118,6 +121,7 @@ enum SettingsCategory: String, Hashable, CaseIterable, Identifiable {
case .asr: return "waveform"
case .oss: return "server.rack"
case .storage: return "externaldrive"
case .email: return "envelope"
case .logs: return "doc.text"
}
}
Expand Down
62 changes: 62 additions & 0 deletions Sources/VoiceMemo/Models/MeetingTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,65 @@ extension MeetingTask {
}
}
}

extension MeetingTask {
func markdownSummary() -> String {
var md = "# \(title)\n\n"
md += "Date: \(createdAt)\n\n"

md += "## Task Info\n"
if let key = taskKey { md += "- Task Key: \(key)\n" }
if let status = apiStatus { md += "- Status: \(status)\n" }
if let error = statusText, !error.isEmpty { md += "- Message: \(error)\n" }
if let duration = bizDuration { md += "- Duration: \(duration / 1000)s\n" }
if let mp3 = outputMp3Path { md += "- Audio: [Download](\(mp3))\n" }
md += "\n"

if let summary = summary {
md += "## Summary\n\(summary)\n\n"
}

if let keyPoints = keyPoints {
md += "## Key Points\n\(keyPoints)\n\n"
}

if let actionItems = actionItems {
md += "## Action Items\n\(actionItems)\n\n"
}

if let transcript = derivedTranscriptText() {
md += "## Transcript\n\(transcript)\n"
}

return md
}

func derivedTranscriptText() -> String? {
if let transcript = transcript, !transcript.isEmpty {
return transcript
}

if let dataStr = transcriptData,
let data = dataStr.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let text = TranscriptParser.buildTranscriptText(from: json) {
return text
}
}

guard let raw = rawResponse,
let data = raw.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}

return TranscriptParser.buildTranscriptText(from: json)
}

func safeFilename() -> String {
let invalid = CharacterSet(charactersIn: "/:\\")
let parts = title.components(separatedBy: invalid)
let name = parts.joined(separator: "_").trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "meeting-summary" : name
}
}
101 changes: 101 additions & 0 deletions Sources/VoiceMemo/Services/EmailService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

enum EmailError: Error, LocalizedError {
case invalidURL
case missingConfiguration
case serverError(statusCode: Int)
case networkError(Error)
case invalidResponse

var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid Gateway URL"
case .missingConfiguration: return "Missing email configuration (URL, Token, or Recipient)"
case .serverError(let code): return "Email server returned error: \(code)"
case .networkError(let error): return "Network error: \(error.localizedDescription)"
case .invalidResponse: return "Invalid response from server"
}
}
}

class EmailService {
private let settings: SettingsStore

init(settings: SettingsStore) {
self.settings = settings
}

func sendEmail(subject: String, body: String, attachmentPath: String?) async throws {
let gatewayUrlString = settings.fastmailUrl
let token = settings.getFastmailToken()
let recipient = settings.recipientEmail

guard !gatewayUrlString.isEmpty,
let token = token, !token.isEmpty,
!recipient.isEmpty else {
throw EmailError.missingConfiguration
}

guard let url = URL(string: gatewayUrlString.trimmingCharacters(in: .whitespacesAndNewlines))?.appendingPathComponent("/api/v1/send") else {
throw EmailError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

var httpBody = Data()

func append(_ string: String) {
if let data = string.data(using: .utf8) {
httpBody.append(data)
}
}

func appendField(name: String, value: String) {
append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
append("\(value)\r\n")
}

appendField(name: "to", value: recipient)
appendField(name: "subject", value: subject)
appendField(name: "body", value: body)

if let attachmentPath = attachmentPath, FileManager.default.fileExists(atPath: attachmentPath) {
let fileUrl = URL(fileURLWithPath: attachmentPath)
let filename = fileUrl.lastPathComponent
let mimeType = "text/markdown"

if let fileData = try? Data(contentsOf: fileUrl) {
append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"attachments\"; filename=\"\(filename)\"\r\n")
append("Content-Type: \(mimeType)\r\n\r\n")
httpBody.append(fileData)
append("\r\n")
}
}

append("--\(boundary)--\r\n")
request.httpBody = httpBody

do {
let (_, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw EmailError.invalidResponse
}

if !(200...299).contains(httpResponse.statusCode) {
throw EmailError.serverError(statusCode: httpResponse.statusCode)
}
} catch let error as EmailError {
throw error
} catch {
throw EmailError.networkError(error)
}
}
}
65 changes: 46 additions & 19 deletions Sources/VoiceMemo/Services/MeetingPipelineManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,86 +116,113 @@ class MeetingPipelineManager: ObservableObject {
// MARK: - Board & Execution Logic

private func executeChain(nodes: [PipelineNode]) async {
// 1. Hydrate Board from Task
let taskSnapshot = await MainActor.run { self.task }
var board = createBoard(from: taskSnapshot)
let services = ServiceProvider(ossService: ossService, transcriptionService: transcriptionService)

await MainActor.run { self.isProcessing = true }

var isChainCompleted = true

for node in nodes {
// Update UI status to "Running"
await updateStatus(node.step, isFailed: false)

var success = false
var retryCount = 0

while !success {
do {
// 2. Run Node (Pure execution on Board)
try await node.run(board: &board, services: services)

// 3. Persist State (Sync Board back to Task)
await persistState(from: board, channelId: 0, completedStep: node.step)
success = true

} catch {
// Handle PipelineError with type safety
if let pipelineError = error as? PipelineError {
switch pipelineError {
case .taskRunning:
retryCount += 1
if retryCount > PipelineConstants.maxPollingRetries {
await updateStatus(.failed, step: node.step, error: "Polling timeout", isFailed: true)
isChainCompleted = false
return
}
try? await Task.sleep(nanoseconds: PipelineConstants.pollingInterval)
continue
case .channelNotFound(let id):
await updateStatus(.failed, step: node.step, error: "Channel \(id) not found", isFailed: true)
isChainCompleted = false
return
case .inputMissing(let msg):
await updateStatus(.failed, step: node.step, error: "Input missing: \(msg)", isFailed: true)
isChainCompleted = false
return
case .transcodeFailed:
await updateStatus(.failed, step: node.step, error: "Transcoding failed", isFailed: true)
isChainCompleted = false
return
case .cloudError(let msg):
await updateStatus(.failed, step: node.step, error: "Cloud service error: \(msg)", isFailed: true)
isChainCompleted = false
return
case .taskFailed(let msg):
await updateStatus(.failed, step: node.step, error: "Task failed: \(msg)", isFailed: true)
isChainCompleted = false
return
}
}

if let transcriptionError = error as? TranscriptionError {
await updateStatus(.failed, step: node.step, error: formatTranscriptionError(transcriptionError), isFailed: true)
await updateStatus(.failed, step: node.step, error: "Transcription Error: \(transcriptionError.localizedDescription)", isFailed: true)
isChainCompleted = false
return
}

// Backward compatibility: handle non-PipelineError NSError
let nsError = error as NSError
if nsError.code == 202 {
retryCount += 1
if retryCount > PipelineConstants.maxPollingRetries {
await updateStatus(.failed, step: node.step, error: "Polling timeout", isFailed: true)
return
}
try? await Task.sleep(nanoseconds: PipelineConstants.pollingInterval)
continue
}

// Unknown error
await updateStatus(.failed, step: node.step, error: error.localizedDescription, isFailed: true)
isChainCompleted = false
return
}
}
}

await MainActor.run { self.isProcessing = false }

if isChainCompleted && settings.enableEmailNotification && task.status == .completed {
await sendEmailNotification()
}
}

private func sendEmailNotification() async {
await MainActor.run { self.isProcessing = true }

let mdContent = task.markdownSummary()
let filename = task.safeFilename().appending(".md")
let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(filename)

do {
try mdContent.write(to: tempUrl, atomically: true, encoding: .utf8)

let emailService = EmailService(settings: settings)
try await emailService.sendEmail(
subject: "Meeting Summary: \(task.title)",
body: "Please find the attached meeting summary.",
attachmentPath: tempUrl.path
)
settings.log("Email sent successfully to \(settings.recipientEmail)")
} catch {
settings.log("Failed to send email: \(error.localizedDescription)")
await MainActor.run {
self.errorMessage = "Email failed: \(error.localizedDescription)"
}
}

try? FileManager.default.removeItem(at: tempUrl)

await MainActor.run { self.isProcessing = false }
}



// MARK: - Hydration & Persistence

private func createBoard(from task: MeetingTask) -> PipelineBoard {
Expand Down
Loading