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>
271 lines
11 KiB
Swift
271 lines
11 KiB
Swift
import Foundation
|
||
import SwiftUI
|
||
|
||
/// Creator (or owner, in the group combined view) of an event.
|
||
/// `id` is nil for imported events.
|
||
struct EventPerson: Hashable {
|
||
let id: Int?
|
||
let displayName: String
|
||
|
||
static func from(_ json: Any?) -> EventPerson? {
|
||
guard let obj = json as? [String: Any],
|
||
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
||
let id: Int?
|
||
if let n = obj["id"] as? Int { id = n }
|
||
else if let s = obj["id"] as? String { id = Int(s) }
|
||
else { id = nil }
|
||
return EventPerson(id: id, displayName: name)
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// hard fetch failure (`CalendarStore.lastError`) — the request itself
|
||
/// succeeded, just not every source within it.
|
||
struct SyncError: Hashable {
|
||
let source: String
|
||
let calendarId: String? // nil on older server responses without calendar_id
|
||
let name: String
|
||
let message: String
|
||
|
||
static func from(json: [String: Any]) -> SyncError? {
|
||
guard
|
||
let source = json["source"] as? String,
|
||
let name = json["name"] as? String,
|
||
let message = json["message"] as? String
|
||
else { return nil }
|
||
let calendarId = json["calendar_id"].map { "\($0)" }
|
||
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
|
||
}
|
||
}
|
||
|
||
struct CalEvent: Identifiable, Hashable {
|
||
let id: String
|
||
let url: String
|
||
var title: String
|
||
var startDate: Date
|
||
var endDate: Date
|
||
var isAllDay: Bool
|
||
var location: String
|
||
var notes: String
|
||
var color: String?
|
||
var calendarId: String
|
||
var calendarName: String
|
||
var calendarColor: String
|
||
var source: String
|
||
var creator: EventPerson? = nil
|
||
var isPrivate: Bool = false
|
||
// Only set in the group combined view:
|
||
var owner: EventPerson? = nil
|
||
var isGroupEvent: Bool = false
|
||
var displayColor: String? = nil
|
||
// Server-decorated title for the group combined view (group icon / owner
|
||
// prefix); rendered in group mode while `title` stays raw for editing.
|
||
var displayTitle: String? = nil
|
||
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
||
var reminders: [Int] = []
|
||
// True for events from a calendar shared with the user read-only.
|
||
var readOnly: Bool = false
|
||
// 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 }
|
||
|
||
// Title to render: the server-decorated one (birthday age, group prefix) wins
|
||
// over the raw title, which is kept for editing.
|
||
var renderTitle: String { displayTitle ?? title }
|
||
|
||
static func from(json: [String: Any]) -> CalEvent? {
|
||
guard
|
||
let title = json["title"] as? String,
|
||
let startStr = json["start"] as? String,
|
||
let endStr = json["end"] as? String
|
||
else { return nil }
|
||
|
||
// id can be String (local UUID) or Int (CalDAV numeric)
|
||
let id: String
|
||
if let s = json["id"] as? String { id = s }
|
||
else if let n = json["id"] as? Int { id = String(n) }
|
||
else { return nil }
|
||
|
||
// A date-only string is an all-day event whether or not the flag says
|
||
// so — `parseDate` already treats it that way. Trusting the flag alone
|
||
// is what put "00:00" in front of birthdays everywhere they are shown.
|
||
let startIsDateOnly: Bool = {
|
||
let clean = startStr.trimmingCharacters(in: .whitespaces)
|
||
return clean.count == 10 && !clean.contains("T")
|
||
}()
|
||
let declaredAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly
|
||
let startDate = parseDate(startStr, allDay: declaredAllDay)
|
||
let endDate = parseDate(endStr, allDay: declaredAllDay)
|
||
guard let s = startDate, let e = endDate else { return nil }
|
||
|
||
// An event running midnight to midnight across one or more whole days
|
||
// is all-day whatever the payload claims. Without this, a six-day
|
||
// holiday sent without the flag renders as "00:00 – 00:00" — a time
|
||
// range that is technically true and tells the reader nothing.
|
||
let cal = Calendar.current
|
||
// Requiring the end to land exactly on midnight too was too strict: a
|
||
// holiday stored as 16th 00:00 to 21st 01:00 slipped through and showed
|
||
// up as a one-hour slot. Starting on a day boundary and running into a
|
||
// later day is enough.
|
||
let spansWholeDays = e > s
|
||
&& cal.startOfDay(for: s) == s
|
||
&& cal.startOfDay(for: e) > cal.startOfDay(for: s)
|
||
let isAllDay = declaredAllDay || spansWholeDays
|
||
|
||
return CalEvent(
|
||
id: id,
|
||
url: json["url"] as? String ?? "",
|
||
title: title,
|
||
startDate: s,
|
||
endDate: e,
|
||
isAllDay: isAllDay,
|
||
location: json["location"] as? String ?? "",
|
||
notes: json["description"] as? String ?? "",
|
||
color: (json["color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
||
calendarName: json["calendar_name"] as? String ?? "",
|
||
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
||
source: json["source"] as? String ?? "local",
|
||
creator: EventPerson.from(json["creator"]),
|
||
isPrivate: json["private"] as? Bool ?? false,
|
||
owner: EventPerson.from(json["owner"]),
|
||
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||
displayColor: (json["display_color"] 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)") } ?? [],
|
||
readOnly: json["read_only"] as? Bool ?? false,
|
||
isBirthday: json["is_birthday"] as? Bool ?? false,
|
||
attachmentCount: json["attachment_count"] as? Int ?? 0
|
||
)
|
||
}
|
||
}
|
||
|
||
private let isoFull: ISO8601DateFormatter = {
|
||
let f = ISO8601DateFormatter()
|
||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
return f
|
||
}()
|
||
|
||
private let isoBasic: ISO8601DateFormatter = {
|
||
let f = ISO8601DateFormatter()
|
||
f.formatOptions = [.withInternetDateTime]
|
||
return f
|
||
}()
|
||
|
||
private let dateOnly: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.dateFormat = "yyyy-MM-dd"
|
||
f.timeZone = .current
|
||
return f
|
||
}()
|
||
|
||
// Handles all date formats the backend may produce:
|
||
// "2026-05-17" "2026-05-17T10:00:00Z" "2026-05-17T10:00:00+02:00"
|
||
// "2026-05-17T10:00:00.000Z" "2026-05-17T10:00:00" "2026-05-17 10:00:00+00:00"
|
||
private let noTZFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "en_US_POSIX")
|
||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||
f.timeZone = TimeZone(abbreviation: "UTC")
|
||
return f
|
||
}()
|
||
|
||
private let spaceSepFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = Locale(identifier: "en_US_POSIX")
|
||
f.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
|
||
return f
|
||
}()
|
||
|
||
func parseDate(_ s: String, allDay: Bool) -> Date? {
|
||
let clean = s.trimmingCharacters(in: .whitespaces)
|
||
if allDay || (clean.count == 10 && !clean.contains("T")) {
|
||
return dateOnly.date(from: String(clean.prefix(10)))
|
||
}
|
||
// Try each formatter in order of likelihood
|
||
if let d = isoFull.date(from: clean) { return d }
|
||
if let d = isoBasic.date(from: clean) { return d }
|
||
// Python isoformat uses space separator: "2026-05-17 10:00:00+00:00"
|
||
if let d = spaceSepFormatter.date(from: clean) { return d }
|
||
// No timezone → treat as UTC
|
||
if let d = noTZFormatter.date(from: String(clean.prefix(19))) { return d }
|
||
// Last resort: just parse the date part
|
||
return dateOnly.date(from: String(clean.prefix(10)))
|
||
}
|
||
|
||
func formatISO(_ date: Date, allDay: Bool) -> String {
|
||
if allDay {
|
||
return dateOnly.string(from: date)
|
||
}
|
||
return isoBasic.string(from: date)
|
||
}
|
||
|
||
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
|
||
/// when the event is a birthday, followed by the render title (which carries the
|
||
/// server-computed age). Icon and text inherit the surrounding font/colour, so
|
||
/// the label matches whatever bar it's dropped into.
|
||
struct EventLabel: View {
|
||
let event: CalEvent
|
||
var body: some View {
|
||
if event.isBirthday {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "birthday.cake.fill").imageScale(.small)
|
||
Text(event.renderTitle)
|
||
}
|
||
} else {
|
||
Text(event.renderTitle)
|
||
}
|
||
}
|
||
}
|