feat(ios): Anhaenge an Terminen

attachmentCount im Event-Parser, EventAttachment als Modell, API-Methoden
fuer Liste, Upload, Download, Thumbnail und Loeschen. Der Multipart-Body
ist von importICS uebernommen.

Die Detailansicht laedt die Liste beim Oeffnen und teilt eine Datei ueber
das System-Share-Sheet; der Editor staged neue Dateien und wendet sie
erst nach dem Speichern an, weil ein neuer Termin vorher keine UID hat.

EventAttachment liegt bewusst in CalEvent.swift statt in einer eigenen
Datei: eine hier angelegte .swift-Datei waere in keinem Xcode-Target
Mitglied und wuerde schlicht nicht mitkompiliert -- das faellt erst auf
dem Mac auf.

Die URL fuer das Share-Sheet steckt in einem kleinen Identifiable-Wrapper,
weil URL das nicht ist und eine nachtraegliche Konformitaet auf einen
Stdlib-Typ modulweit sichtbar waere.

Nicht kompiliert: auf diesem Rechner gibt es kein Xcode. Muss auf dem Mac
gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 10:32:01 +02:00
co-authored by Claude Opus 5
parent cd8aba74b6
commit 3b649ec310
5 changed files with 307 additions and 2 deletions
+48 -1
View File
@@ -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 /// A partial-sync failure reported alongside an otherwise-successful
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g. /// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
/// expired credentials) even though it's still enabled. Distinct from a /// 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 // True for events from a birthday calendar — clients show a cake icon and the
// server bakes the age into `displayTitle`. // server bakes the age into `displayTitle`.
var isBirthday: Bool = false 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. // Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor } 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 }, 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)") } ?? [], reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
readOnly: json["read_only"] as? Bool ?? false, 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
) )
} }
} }
@@ -259,6 +259,10 @@ private let strings: [String: [String: String]] = [
"event.title_placeholder": "Titel", "event.title_placeholder": "Titel",
"event.allday": "Ganztägig", "event.allday": "Ganztägig",
"event.private": "Privat", "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.start": "Start",
"event.end": "Ende", "event.end": "Ende",
"event.location": "Ort", "event.location": "Ort",
@@ -652,6 +656,10 @@ private let strings: [String: [String: String]] = [
"event.title_placeholder": "Title", "event.title_placeholder": "Title",
"event.allday": "All-day", "event.allday": "All-day",
"event.private": "Private", "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.start": "Start",
"event.end": "End", "event.end": "End",
"event.location": "Location", "event.location": "Location",
@@ -673,6 +673,82 @@ class CalendarrAPI {
// MARK: – iCal import / export // MARK: – iCal import / export
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors). /// 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]) { 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 } guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
let fileData = try Data(contentsOf: fileURL) let fileData = try Data(contentsOf: fileURL)
@@ -1,5 +1,12 @@
import SwiftUI 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 { struct EventDetailSheet: View {
let event: CalEvent let event: CalEvent
let api: CalendarrAPI let api: CalendarrAPI
@@ -15,6 +22,10 @@ struct EventDetailSheet: View {
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var isDeleting = false @State private var isDeleting = false
@State private var showCopySheet = 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 = { private let timeFmt: DateFormatter = {
let f = 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 { if !store.writableCalendars.isEmpty {
Section { Section {
Button { Button {
@@ -170,8 +218,41 @@ struct EventDetailSheet: View {
await onDone(nil, true) 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 { private func deleteEvent() async {
isDeleting = true isDeleting = true
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct EventEditorSheet: View { struct EventEditorSheet: View {
let api: CalendarrAPI let api: CalendarrAPI
@@ -23,6 +24,12 @@ struct EventEditorSheet: View {
@State private var isPrivate = false @State private var isPrivate = false
@State private var reminders: [Int] = [] @State private var reminders: [Int] = []
@State private var isSaving = false @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<Int> = []
@State private var showFileImporter = false
@State private var error = "" @State private var error = ""
private var isEditing: Bool { editingEvent != nil } private var isEditing: Bool { editingEvent != nil }
@@ -114,6 +121,44 @@ struct EventEditorSheet: View {
} }
} }
.disabled(remindersDisabled) .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)) { Section(L10n.t("event.color_section", appLang)) {
@@ -161,6 +206,15 @@ struct EventEditorSheet: View {
} }
} }
.onAppear { setup() } .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 .onChange(of: startDate) { oldStart, newStart in
guard newStart >= endDate else { return } guard newStart >= endDate else { return }
let duration = endDate.timeIntervalSince(oldStart) 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() { private func setup() {
if let ev = editingEvent { if let ev = editingEvent {
title = ev.title title = ev.title
@@ -232,6 +322,7 @@ struct EventEditorSheet: View {
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end, try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal, isAllDay: isAllDay, location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders) isPrivate: isPrivate, reminders: reminders)
await applyAttachmentChanges(uid: ev.id)
case "homeassistant": case "homeassistant":
// No update API exists – delete the old event and recreate with new data. // No update API exists – delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -249,10 +340,12 @@ struct EventEditorSheet: View {
} else { } else {
switch cal.source { switch cal.source {
case "local": 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, start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal, location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders) isPrivate: isPrivate, reminders: reminders)
// Only now does a uid exist to attach the staged files to.
await applyAttachmentChanges(uid: created.id)
case "google": case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title, try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay, start: start, end: end, isAllDay: isAllDay,