import Foundation import ManaCore /// Resultat von `CardEditorPayload.build` — was an `CardsAPI.createCard` /// oder `updateCard` durchgereicht wird. struct CardEditorPayload { let fields: [String: String] let mediaRefs: [String]? } /// Snapshot der CardEditor-Felder zum Submit-Zeitpunkt. Ein Wert-Typ, /// damit `buildPayload` außerhalb der View testbar ist und der View- /// Struct kompakt bleibt. struct CardEditorPayloadInputs { let type: CardType let front: String let back: String let clozeText: String let typingAnswer: String let multipleChoiceAnswer: String let occlusionImageData: Data? let occlusionMimeType: String let occlusionRegions: [MaskRegion] let occlusionNote: String let existingImageRef: String? let audioFileURL: URL? let existingAudioRef: String? let existingMediaRefs: [String] } enum CardEditorPayloadError: LocalizedError { case missingImage case missingAudio var errorDescription: String? { switch self { case .missingImage: "Bitte ein Bild wählen." case .missingAudio: "Bitte eine Audio-Datei wählen." } } } enum CardEditorPayloadBuilder { /// Baut den Payload für `POST /cards` bzw. `PATCH /cards/:id`. /// Lädt für Image-Occlusion / Audio-Front bei Bedarf neue Media /// hoch; sonst wird der bestehende `*_ref` aus der Card weiterverwendet. static func build(inputs: CardEditorPayloadInputs, api: CardsAPI) async throws -> CardEditorPayload { switch inputs.type { case .basic, .basicReverse: CardEditorPayload( fields: CardFieldsBuilder.basic(front: inputs.front, back: inputs.back), mediaRefs: nil ) case .cloze: CardEditorPayload( fields: CardFieldsBuilder.cloze(text: inputs.clozeText), mediaRefs: nil ) case .typing: CardEditorPayload( fields: CardFieldsBuilder.typing(front: inputs.front, answer: inputs.typingAnswer), mediaRefs: nil ) case .multipleChoice: CardEditorPayload( fields: CardFieldsBuilder.multipleChoice( front: inputs.front, answer: inputs.multipleChoiceAnswer ), mediaRefs: nil ) case .imageOcclusion: try await buildImageOcclusionPayload(inputs: inputs, api: api) case .audioFront: try await buildAudioFrontPayload(inputs: inputs, api: api) } } private static func buildImageOcclusionPayload( inputs: CardEditorPayloadInputs, api: CardsAPI ) async throws -> CardEditorPayload { let imageRef: String var refs = inputs.existingMediaRefs if let newData = inputs.occlusionImageData { let media = try await api.uploadMedia( data: newData, filename: "occlusion.\(inputs.occlusionMimeType.contains("png") ? "png" : "jpg")", mimeType: inputs.occlusionMimeType ) imageRef = media.id refs = [media.id] } else if let ref = inputs.existingImageRef { imageRef = ref } else { throw CardEditorPayloadError.missingImage } return CardEditorPayload( fields: CardFieldsBuilder.imageOcclusion( imageRef: imageRef, regions: inputs.occlusionRegions, note: inputs.occlusionNote.isEmpty ? nil : inputs.occlusionNote ), mediaRefs: refs ) } private static func buildAudioFrontPayload( inputs: CardEditorPayloadInputs, api: CardsAPI ) async throws -> CardEditorPayload { let audioRef: String var refs = inputs.existingMediaRefs if let url = inputs.audioFileURL { let didStart = url.startAccessingSecurityScopedResource() defer { if didStart { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) let media = try await api.uploadMedia( data: data, filename: url.lastPathComponent, mimeType: audioMimeType(for: url) ) audioRef = media.id refs = [media.id] } else if let ref = inputs.existingAudioRef { audioRef = ref } else { throw CardEditorPayloadError.missingAudio } return CardEditorPayload( fields: CardFieldsBuilder.audioFront(audioRef: audioRef, back: inputs.back), mediaRefs: refs ) } private static func audioMimeType(for url: URL) -> String { switch url.pathExtension.lowercased() { case "mp3": "audio/mpeg" case "wav": "audio/wav" case "m4a", "mp4": "audio/mp4" case "ogg", "oga": "audio/ogg" default: "audio/mpeg" } } }