diff --git a/ios/Calendarr iOS/Models/CalEvent.swift b/ios/Calendarr iOS/Models/CalEvent.swift index db2298f..851bb9b 100644 --- a/ios/Calendarr iOS/Models/CalEvent.swift +++ b/ios/Calendarr iOS/Models/CalEvent.swift @@ -18,6 +18,48 @@ struct EventPerson: Hashable { } } +/// A file attached to an event. +/// +/// Deliberately declared here rather than in a file of its own: a new .swift +/// file added outside Xcode is not a member of any target and simply would not +/// compile in, which is invisible until someone opens the project on a Mac. +struct EventAttachment: Identifiable, Hashable { + let id: Int + let filename: String + let contentType: String + let sizeBytes: Int + let hasThumbnail: Bool + let uploadedBy: EventPerson? + + var isImage: Bool { contentType.hasPrefix("image/") } + + /// SF Symbol for the file kind — no network, unlike the thumbnail. + var symbolName: String { + if contentType == "application/pdf" { return "doc.richtext" } + if isImage { return "photo" } + return "doc.text" + } + + var displaySize: String { + ByteCountFormatter.string(fromByteCount: Int64(sizeBytes), countStyle: .file) + } + + static func from(json: [String: Any]) -> EventAttachment? { + guard + let id = json["id"] as? Int, + let filename = json["filename"] as? String + else { return nil } + return EventAttachment( + id: id, + filename: filename, + contentType: json["content_type"] as? String ?? "application/octet-stream", + sizeBytes: json["size_bytes"] as? Int ?? 0, + hasThumbnail: json["has_thumb"] as? Bool ?? false, + uploadedBy: EventPerson.from(json["uploaded_by"]) + ) + } +} + /// A partial-sync failure reported alongside an otherwise-successful /// `/api/caldav/events` response: one specific calendar didn't sync (e.g. /// expired credentials) even though it's still enabled. Distinct from a @@ -70,6 +112,10 @@ struct CalEvent: Identifiable, Hashable { // True for events from a birthday calendar — clients show a cake icon and the // server bakes the age into `displayTitle`. var isBirthday: Bool = false + // How many files hang off this event. The list payload carries only the + // count; the files themselves are fetched when the detail sheet opens. + // Absent on non-local sources and on busy-masked private events, so 0. + var attachmentCount: Int = 0 // Group view supplies a server-resolved colour; otherwise per-event then calendar colour. var effectiveColor: String { displayColor ?? color ?? calendarColor } @@ -139,7 +185,8 @@ struct CalEvent: Identifiable, Hashable { displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 }, reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [], readOnly: json["read_only"] as? Bool ?? false, - isBirthday: json["is_birthday"] as? Bool ?? false + isBirthday: json["is_birthday"] as? Bool ?? false, + attachmentCount: json["attachment_count"] as? Int ?? 0 ) } } diff --git a/ios/Calendarr iOS/Models/Localization.swift b/ios/Calendarr iOS/Models/Localization.swift index c35e1d6..757a1ce 100644 --- a/ios/Calendarr iOS/Models/Localization.swift +++ b/ios/Calendarr iOS/Models/Localization.swift @@ -259,6 +259,10 @@ private let strings: [String: [String: String]] = [ "event.title_placeholder": "Titel", "event.allday": "Ganztägig", "event.private": "Privat", + "event.attachments": "Anhänge", + "event.attachment_add": "Datei hinzufügen", + "event.attachment_hint": "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.", + "event.attachment_failed": "Anhänge konnten nicht vollständig gespeichert werden.", "event.start": "Start", "event.end": "Ende", "event.location": "Ort", @@ -652,6 +656,10 @@ private let strings: [String: [String: String]] = [ "event.title_placeholder": "Title", "event.allday": "All-day", "event.private": "Private", + "event.attachments": "Attachments", + "event.attachment_add": "Add file", + "event.attachment_hint": "Max. 10 MB per file, 10 files. PDF, images, text.", + "event.attachment_failed": "Attachments could not be saved completely.", "event.start": "Start", "event.end": "End", "event.location": "Location", diff --git a/ios/Calendarr iOS/Services/CalendarrAPI.swift b/ios/Calendarr iOS/Services/CalendarrAPI.swift index 1407333..0f4bd46 100644 --- a/ios/Calendarr iOS/Services/CalendarrAPI.swift +++ b/ios/Calendarr iOS/Services/CalendarrAPI.swift @@ -673,6 +673,82 @@ class CalendarrAPI { // MARK: – iCal import / export /// Import a .ics file into a local calendar. Returns (imported, skipped, errors). + + // MARK: - Attachments + + /// Files on an event. Only local events can have any; the server answers + /// 404 for anything else, which surfaces as `serverError`. + func listAttachments(eventUid: String) async throws -> [EventAttachment] { + let path = "/api/local/events/\(eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid)/attachments" + let data = try await request(path) + let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] ?? [] + return arr.compactMap { EventAttachment.from(json: $0) } + } + + /// Upload one file. The body is built by hand, exactly like `importICS`. + func uploadAttachment(eventUid: String, fileURL: URL) async throws -> EventAttachment { + // A URL from `.fileImporter` is security-scoped: without this the read + // fails outside the app's own container. + let scoped = fileURL.startAccessingSecurityScopedResource() + defer { if scoped { fileURL.stopAccessingSecurityScopedResource() } } + let fileData = try Data(contentsOf: fileURL) + return try await uploadAttachment(eventUid: eventUid, + data: fileData, + filename: fileURL.lastPathComponent) + } + + func uploadAttachment(eventUid: String, data fileData: Data, filename: String) async throws -> EventAttachment { + let encodedUid = eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid + guard let url = URL(string: baseURL + "/api/local/events/\(encodedUid)/attachments") else { + throw APIError.invalidURL + } + let boundary = "Boundary-\(UUID().uuidString)" + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var bodyData = Data() + let name = filename.isEmpty ? "anhang" : filename + bodyData.append("--\(boundary)\r\n".data(using: .utf8)!) + bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\n".data(using: .utf8)!) + // The server sniffs the bytes and ignores this header, but something + // has to be sent for the part to be well formed. + bodyData.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) + bodyData.append(fileData) + bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + req.httpBody = bodyData + + let (data, response) = try await URLSession.shared.data(for: req) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + if status == 401 { throw APIError.unauthorized } + if status >= 400 { + let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String + ?? "Fehler \(status)" + throw APIError.serverError(msg) + } + guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let att = EventAttachment.from(json: json) else { + throw APIError.serverError("Unerwartete Antwort") + } + return att + } + + func deleteAttachment(id: Int) async throws { + _ = try await request("/api/local/attachments/\(id)", method: "DELETE") + } + + /// Raw bytes, for saving or sharing. + func downloadAttachment(id: Int) async throws -> Data { + try await request("/api/local/attachments/\(id)") + } + + /// Small JPEG preview, or nil when there is none. Never throws: a missing + /// thumbnail just means the row shows its type icon. + func attachmentThumbnail(id: Int) async -> Data? { + try? await request("/api/local/attachments/\(id)/thumb") + } + func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) { guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL } let fileData = try Data(contentsOf: fileURL) diff --git a/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index 7e80c0f..a60b8f0 100644 --- a/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -1,5 +1,12 @@ import SwiftUI +/// `.sheet(item:)` needs Identifiable and URL is not. A retroactive conformance +/// on a stdlib type would be visible module-wide, so wrap it instead. +private struct SharedFile: Identifiable { + let id = UUID() + let url: URL +} + struct EventDetailSheet: View { let event: CalEvent let api: CalendarrAPI @@ -15,6 +22,10 @@ struct EventDetailSheet: View { @State private var showDeleteConfirm = false @State private var isDeleting = false @State private var showCopySheet = false + @State private var attachments: [EventAttachment] = [] + @State private var thumbnails: [Int: UIImage] = [:] + @State private var loadingAttachments = false + @State private var sharedFile: SharedFile? private let timeFmt: DateFormatter = { let f = DateFormatter() @@ -111,6 +122,43 @@ struct EventDetailSheet: View { } } + // Attachments exist on local events only; the payload carries + // just the count, so the list is fetched when the sheet opens. + if event.source == "local" && event.attachmentCount > 0 { + Section(L10n.t("event.attachments", appLang)) { + if loadingAttachments && attachments.isEmpty { + ProgressView() + } + ForEach(attachments) { att in + Button { + Task { await shareAttachment(att) } + } label: { + HStack(spacing: 10) { + if let image = thumbnails[att.id] { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: att.symbolName) + .frame(width: 32) + .foregroundStyle(.secondary) + } + Text(att.filename) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(att.displaySize) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + } + if !store.writableCalendars.isEmpty { Section { Button { @@ -170,9 +218,42 @@ struct EventDetailSheet: View { await onDone(nil, true) } } + .task(id: event.id) { await loadAttachments() } + .sheet(item: $sharedFile) { shared in + ActivityView(items: [shared.url]) + } } } + private func loadAttachments() async { + guard event.source == "local", event.attachmentCount > 0 else { + attachments = [] + return + } + loadingAttachments = true + defer { loadingAttachments = false } + guard let loaded = try? await api.listAttachments(eventUid: event.id) else { return } + attachments = loaded + // Thumbnails are small server-rendered JPEGs, so decoding them here is + // cheap and needs no image-loading dependency. + for att in loaded where att.hasThumbnail { + if let data = await api.attachmentThumbnail(id: att.id), + let image = UIImage(data: data) { + thumbnails[att.id] = image + } + } + } + + /// Download to a temporary file and hand it to the share sheet, so the user + /// can save it to Files or open it in another app. + private func shareAttachment(_ att: EventAttachment) async { + guard let data = try? await api.downloadAttachment(id: att.id) else { return } + let name = att.filename.isEmpty ? "anhang" : att.filename + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) + guard (try? data.write(to: url)) != nil else { return } + sharedFile = SharedFile(url: url) + } + private func deleteEvent() async { isDeleting = true do { diff --git a/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index e067a83..6b5ef33 100644 --- a/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers struct EventEditorSheet: View { let api: CalendarrAPI @@ -23,6 +24,12 @@ struct EventEditorSheet: View { @State private var isPrivate = false @State private var reminders: [Int] = [] @State private var isSaving = false + // Files picked before the event exists are staged and uploaded after it is + // created; existing ones are removed only on save, so Cancel really cancels. + @State private var stagedFiles: [URL] = [] + @State private var existingAttachments: [EventAttachment] = [] + @State private var removedAttachmentIds: Set = [] + @State private var showFileImporter = false @State private var error = "" private var isEditing: Bool { editingEvent != nil } @@ -114,6 +121,44 @@ struct EventEditorSheet: View { } } .disabled(remindersDisabled) + + Section(L10n.t("event.attachments", appLang)) { + ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in + HStack { + Image(systemName: att.symbolName).foregroundStyle(.secondary) + Text(att.filename).lineLimit(1).truncationMode(.middle) + Spacer() + Text(att.displaySize).font(.caption).foregroundStyle(.secondary) + Button(role: .destructive) { + removedAttachmentIds.insert(att.id) + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.borderless) + } + } + ForEach(stagedFiles, id: \.self) { url in + HStack { + Image(systemName: "doc.badge.plus").foregroundStyle(Color.accentColor) + Text(url.lastPathComponent).lineLimit(1).truncationMode(.middle) + Spacer() + Button(role: .destructive) { + stagedFiles.removeAll { $0 == url } + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.borderless) + } + } + Button { + showFileImporter = true + } label: { + Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip") + } + .disabled(attachmentSlotsUsed >= 10) + } footer: { + Text(L10n.t("event.attachment_hint", appLang)) + } } Section(L10n.t("event.color_section", appLang)) { @@ -161,6 +206,15 @@ struct EventEditorSheet: View { } } .onAppear { setup() } + .task { await loadExistingAttachments() } + .fileImporter(isPresented: $showFileImporter, + allowedContentTypes: Self.allowedAttachmentTypes, + allowsMultipleSelection: true) { result in + guard case .success(let urls) = result else { return } + for url in urls where attachmentSlotsUsed < 10 { + stagedFiles.append(url) + } + } .onChange(of: startDate) { oldStart, newStart in guard newStart >= endDate else { return } let duration = endDate.timeIntervalSince(oldStart) @@ -169,6 +223,42 @@ struct EventEditorSheet: View { } } + /// Mirrors the server allowlist. `.webP` needs iOS 14, which is well below + /// the deployment target; markdown has no system-provided type. + private static var allowedAttachmentTypes: [UTType] { + var types: [UTType] = [.pdf, .png, .jpeg, .gif, .plainText, .commaSeparatedText] + if let webp = UTType("org.webmproject.webp") { types.append(webp) } + if let md = UTType("net.daringfireball.markdown") { types.append(md) } + return types + } + + private var attachmentSlotsUsed: Int { + existingAttachments.filter { !removedAttachmentIds.contains($0.id) }.count + stagedFiles.count + } + + private func loadExistingAttachments() async { + guard let ev = editingEvent, ev.source == "local", ev.attachmentCount > 0 else { return } + existingAttachments = (try? await api.listAttachments(eventUid: ev.id)) ?? [] + } + + /// Applied after the event itself was saved. A failure here leaves the + /// event in place — report it rather than claiming everything worked. + private func applyAttachmentChanges(uid: String) async { + var failed = 0 + for id in removedAttachmentIds { + do { try await api.deleteAttachment(id: id) } catch { failed += 1 } + } + for url in stagedFiles { + do { _ = try await api.uploadAttachment(eventUid: uid, fileURL: url) } + catch { failed += 1 } + } + if failed > 0 { + error = L10n.t("event.attachment_failed", appLang) + } + stagedFiles = [] + removedAttachmentIds = [] + } + private func setup() { if let ev = editingEvent { title = ev.title @@ -232,6 +322,7 @@ struct EventEditorSheet: View { try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end, isAllDay: isAllDay, location: location, description: notes, color: colorVal, isPrivate: isPrivate, reminders: reminders) + await applyAttachmentChanges(uid: ev.id) case "homeassistant": // No update API exists – delete the old event and recreate with new data. let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") @@ -249,10 +340,12 @@ struct EventEditorSheet: View { } else { switch cal.source { case "local": - _ = try await api.createLocalEvent(calendarId: cal.numericId, title: title, + let created = try await api.createLocalEvent(calendarId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, location: location, description: notes, color: colorVal, isPrivate: isPrivate, reminders: reminders) + // Only now does a uid exist to attach the staged files to. + await applyAttachmentChanges(uid: created.id) case "google": try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay,