Phase 8: Ablage, AirDrop und Calendarr

Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine
eigene Anmeldung, kein Netz, kein Schreiben zurück.

Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49
Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes
Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht
keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu
behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin
abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine
ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang
nicht abgedeckt ist.

Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung.
Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt
darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie
geöffnet zu haben knipst den Apple-Kalender nicht aus.

Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp
liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot,
und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt
die Fläche UTType.item statt public.file-url an: solche Anhänge kommen
als Dateiversprechen, und loadFileRepresentation löst beides ein. Die
übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort
sofort gesichert statt später auf dem MainActor.

Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig
aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit
Zeitschaltuhr.

Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel
in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer
im Hauptbundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-11 10:16:19 +02:00
parent 28b1b4270e
commit 8308536227
25 changed files with 2917 additions and 868 deletions

View File

@@ -0,0 +1,69 @@
import Foundation
/// Woher die Termine kommen sollen.
public enum CalendarSourceKind: String, CaseIterable, Sendable {
case appleCalendar
case calendarr
case both
public var localizedName: String {
switch self {
case .appleCalendar: String(localized: "calendar.source.apple", bundle: .module)
case .calendarr: String(localized: "calendar.source.calendarr", bundle: .module)
case .both: String(localized: "calendar.source.both", bundle: .module)
}
}
/// Fasst die Zustände mehrerer Quellen zu einem zusammen.
///
/// Solange **eine** Quelle liefert, wird geliefert. Calendarr nie geöffnet
/// zu haben darf den Apple-Kalender nicht mit ausknipsen sonst wäre
/// beide" schlechter als jede einzelne Quelle für sich.
public static func merge(_ states: [CalendarSourceState]) -> CalendarSourceState {
guard !states.isEmpty else { return .ready(.empty) }
let windows = states.compactMap { state -> EventWindow? in
if case .ready(let window) = state { return window }
return nil
}
// Keine einzige Quelle liefert: dann ist der erste Grund die Antwort.
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
// als wäre nichts los.
guard !windows.isEmpty else { return states[0] }
return .ready(.merged(windows))
}
}
public extension EventWindow {
/// Führt mehrere Fenster zu einem zusammen.
static func merged(_ windows: [EventWindow]) -> EventWindow {
guard !windows.isEmpty else { return .empty }
guard windows.count > 1 else { return windows[0] }
let events = windows.flatMap(\.events).sorted { $0.start < $1.start }
// Die Abdeckung ist der **Schnitt**, nicht die Vereinigung: ein Tag
// gilt nur als bekannt, wenn ihn jede beteiligte Quelle abdeckt. Deckt
// Calendarr den März nicht ab, ist der März unbekannt auch wenn der
// Apple-Kalender dafür Auskunft geben könnte. Sonst zeigte das Raster
// einen halben März als frei", der in Wahrheit nur halb bekannt ist.
var coverage: DateInterval?
for window in windows {
// `nil` heißt unbegrenzt und schränkt daher nichts ein.
guard let next = window.coverage else { continue }
coverage = coverage.map { current in
let start = max(current.start, next.start)
let end = min(current.end, next.end)
// Ohne Überschneidung bleibt nichts übrig und das ist die
// richtige Antwort, nicht ein beliebiger der beiden Zeiträume.
return DateInterval(start: start, end: max(start, end))
} ?? next
}
// Der älteste Stand begrenzt die Verlässlichkeit des Ganzen. Den
// neueren zu zeigen behauptete eine Aktualität, die nicht alle haben.
let asOf = windows.compactMap(\.asOf).min()
return EventWindow(events: events, coverage: coverage, asOf: asOf)
}
}

View File

@@ -0,0 +1,106 @@
import SwiftUI
/// Die Wahl der Terminquelle samt ehrlicher Auskunft darüber, was Calendarr
/// gerade liefert.
///
/// Der Zustandstext ist kein Beiwerk: bleibt das Widget leer, ist genau hier
/// die Antwort auf das Warum. Ohne ihn sieht Calendarr nie geöffnet" genauso
/// aus wie keine Termine".
public struct CalendarSourceSettings: View {
@Bindable private var model: CalendarModel
@State private var calendarrState: CalendarSourceState?
public init(model: CalendarModel) {
self.model = model
}
public var body: some View {
Form {
Section {
Picker(selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
} label: {
Text("calendar.source.title", bundle: .module)
}
.pickerStyle(.radioGroup)
}
if model.kind != .appleCalendar {
Section {
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
statusText
Text("calendar.source.readOnly", bundle: .module)
.font(.caption)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
} label: {
Text(verbatim: "Calendarr")
}
}
}
}
.formStyle(.grouped)
.task(id: model.kind) { await loadCalendarrState() }
}
@ViewBuilder
private var statusText: some View {
switch calendarrState {
case .ready(let window):
VStack(alignment: .leading, spacing: 2) {
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
bundle: .module)
if let coverage = window.coverage {
// Die Grenze der Abdeckung gehört sichtbar hin: dahinter
// zeigt Onyx bewusst unbekannt" statt frei", und das
// soll niemand für einen Fehler halten.
Text("calendar.source.status.coverage \(coverage.end.formatted(date: .abbreviated, time: .omitted))",
bundle: .module)
.foregroundStyle(.secondary)
}
}
.font(.callout)
case .neverWritten:
notice("calendar.source.status.neverWritten")
case .loggedOut:
notice("calendar.source.status.loggedOut")
case .incompatible:
notice("calendar.source.status.incompatible")
case .unreadable(let reason):
Text("calendar.source.status.unreadable \(reason)", bundle: .module)
.font(.callout).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
case .denied, .none:
ProgressView().controlSize(.small)
}
}
private func notice(_ key: String.LocalizationValue) -> some View {
Text(String(localized: key, bundle: .module))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
private func asOfText(_ date: Date?) -> String {
date?.formatted(date: .abbreviated, time: .shortened) ?? ""
}
private func loadCalendarrState() async {
guard model.kind != .appleCalendar else { return }
// Bewusst über die Quelle selbst und nicht über das Modell: hier soll
// der Zustand von Calendarr allein stehen, nicht die Mischung aus
// beiden Quellen. Sonst verdeckt ein funktionierender Apple-Kalender,
// dass Calendarr gar nichts liefert.
let source = CalendarrSource()
let now = Date()
calendarrState = await source.load(
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
end: now.addingTimeInterval(60 * 86_400)))
}
}

View File

@@ -14,18 +14,76 @@ public final class CalendarModel {
public private(set) var state: CalendarSourceState = .ready(.empty)
public private(set) var isLoading = false
private let source: CalendarSource
/// Welche Quellen gefragt werden. Umschalten lädt sofort neu.
public var kind: CalendarSourceKind {
didSet {
guard kind != oldValue else { return }
defaults.set(kind.rawValue, forKey: Self.kindKey)
observeCalendarrIfNeeded()
refresh()
}
}
private static let kindKey = "calendar.source"
private let appleSource: CalendarSource
private let calendarrSource: CalendarSource
private let defaults: UserDefaults
private var calendar: Calendar
private var refreshTask: Task<Void, Never>?
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
private var calendarrObserver: DarwinObserver?
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
self.source = source
public init(appleSource: CalendarSource = EventKitSource(),
calendarrSource: CalendarSource = CalendarrSource(),
defaults: UserDefaults = .standard,
calendar: Calendar = .autoupdatingCurrent) {
self.appleSource = appleSource
self.calendarrSource = calendarrSource
self.defaults = defaults
self.calendar = calendar
self.kind = defaults.string(forKey: Self.kindKey)
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
observeCalendarrIfNeeded()
}
/// Die gerade befragten Quellen.
private var activeSources: [CalendarSource] {
switch kind {
case .appleCalendar: [appleSource]
case .calendarr: [calendarrSource]
case .both: [appleSource, calendarrSource]
}
}
/// Ob gelesen werden darf. Fragt nicht nach nur zum Anzeigen.
public var hasAccess: Bool { source.isAuthorized }
public var authorization: CalendarAuthorization { source.authorization }
///
/// **Eine** berechtigte Quelle genügt: bei beide" darf ein gesperrter
/// Apple-Kalender nicht dazu führen, dass auch Calendarr stumm bleibt.
public var hasAccess: Bool { activeSources.contains(where: \.isAuthorized) }
/// Der Berechtigungszustand, der die Oberfläche interessiert also der des
/// Apple-Kalenders, denn nur der kann überhaupt einen Dialog auslösen.
public var authorization: CalendarAuthorization {
kind == .calendarr ? .notRequired : appleSource.authorization
}
// MARK: - Calendarr-Benachrichtigung
/// Calendarr meldet neue Schnappschüsse per Darwin-Nachricht deshalb muss
/// Onyx die Datei nicht abfragen, sondern erfährt Änderungen.
///
/// Nur angemeldet, solange Calendarr überhaupt gefragt wird. Wer nur den
/// Apple-Kalender nutzt, soll von Calendarr auch nicht geweckt werden.
private func observeCalendarrIfNeeded() {
let wanted = kind != .appleCalendar
guard wanted != (calendarrObserver != nil) else { return }
calendarrObserver = wanted
? DarwinObserver(name: CalendarrSource.changeNotification) { [weak self] in
Task { @MainActor in self?.refresh() }
}
: nil
}
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
///
@@ -36,9 +94,11 @@ public final class CalendarModel {
@discardableResult
public func requestAccessNow() async -> Bool {
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
let granted = await source.requestAccess()
let granted = await appleSource.requestAccess()
log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
if granted { refresh() } else { state = .denied }
// Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft
// Calendarr mit, hat Onyx weiterhin Termine zu zeigen.
refresh()
return granted
}
@@ -56,20 +116,23 @@ public final class CalendarModel {
isLoading = true
defer { isLoading = false }
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
// passiert die Anfrage muss aus dem Einstellungsfenster kommen.
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
guard source.isAuthorized else {
state = .denied
return
}
let start = calendar.date(byAdding: .day, value: -14,
to: calendar.startOfDay(for: date))!
let end = calendar.date(byAdding: .day, value: 60, to: start)!
let interval = DateInterval(start: start, end: end)
var states: [CalendarSourceState] = []
for source in activeSources {
guard !Task.isCancelled else { return }
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
// passiert die Anfrage muss aus dem Einstellungsfenster kommen.
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
states.append(source.isAuthorized ? await source.load(interval) : .denied)
}
guard !Task.isCancelled else { return }
state = await source.load(DateInterval(start: start, end: end))
state = CalendarSourceKind.merge(states)
}
}

View File

@@ -0,0 +1,222 @@
import Foundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
/// Liest den Schnappschuss, den Calendarr in den Gruppencontainer legt.
///
/// Streng lesend. Onyx meldet sich nie selbst an, holt nichts aus dem Netz und
/// schreibt nie in den Container zurück der Schnappschuss gehört Calendarr,
/// und zwei Schreiber auf einer Datei wären ein Fehler, den man erst bemerkt,
/// wenn Termine verschwinden.
public enum CalendarrSnapshotReader {
/// Wie weit nach vorne der Schnappschuss reicht.
///
/// Steht nicht in der Datei, sondern ist zugesagtes Verhalten des
/// Schreibers. Läge der Wert zu niedrig, würde Onyx zu viel ausgrauen
/// unschön, aber ehrlich. Läge er zu hoch, würde es leere Tage als frei"
/// behaupten, für die nie jemand Daten geschrieben hat. Von beiden Fehlern
/// ist nur der erste vertretbar, deshalb wird hier nicht großzügig gerundet.
public static let coverageDays = 49
/// Höchste Formatfassung, deren Bedeutung Onyx kennt.
public static let maxSupportedVersion = 1
/// Der Zeitraum, über den der Schnappschuss etwas aussagt.
///
/// Beginnt am Tag des Schreibens, nicht früher: Calendarr schreibt nach
/// vorne. Ein vergangener Tag ohne Termine kann war nichts" heißen oder
/// wurde nie geschrieben" das ist von außen nicht unterscheidbar, also
/// wird es auch nicht behauptet.
public static func coverage(writtenAt: Date, calendar: Calendar) -> DateInterval {
let start = calendar.startOfDay(for: writtenAt)
let end = calendar.date(byAdding: .day, value: coverageDays, to: start) ?? start
return DateInterval(start: start, end: end)
}
/// Wertet Schnappschuss und Kalenderliste aus. Rein ohne Dateisystem,
/// damit jeder Zustand ohne installierte Calendarr-App prüfbar bleibt.
public static func state(fromSnapshot snapshot: Data?,
calendars: Data?,
calendar: Calendar = .autoupdatingCurrent) -> CalendarSourceState {
// Keine Datei heißt nicht kaputt", sondern Calendarr war noch nie
// offen". Dafür gibt es einen eigenen, hilfreicheren Hinweis.
guard let snapshot else { return .neverWritten }
let raw: RawSnapshot
do {
raw = try JSONDecoder().decode(RawSnapshot.self, from: snapshot)
} catch {
log.error("Schnappschuss unlesbar: \(error.localizedDescription, privacy: .public)")
return .unreadable(reason: error.localizedDescription)
}
let version = raw.version ?? 1
guard version <= maxSupportedVersion else {
return .incompatible(found: version, maxSupported: maxSupportedVersion)
}
let parser = DateParser()
let writtenAt = raw.writtenAtDate(parser)
// Abgemeldet: die vorhandenen Termine sind alt und sehen trotzdem
// aktuell aus. Genau deshalb werden sie verworfen statt angezeigt.
if raw.loggedOut == true { return .loggedOut(asOf: writtenAt) }
let titles = calendarTitles(from: calendars)
let events = (raw.events ?? [])
.compactMap { $0.asOnyxEvent(titles: titles, parser: parser) }
.sorted { $0.start < $1.start }
return .ready(EventWindow(
events: events,
coverage: writtenAt.map { coverage(writtenAt: $0, calendar: calendar) },
asOf: writtenAt))
}
/// Die Kalenderliste als eigene Datei sie trägt die Namen, die im
/// Schnappschuss selbst nur als Kennung stehen.
public static func calendars(from data: Data?) -> [OnyxCalendar] {
guard let data,
let raw = try? JSONDecoder().decode([RawCalendar].self, from: data)
else { return [] }
return raw.map { OnyxCalendar(id: $0.id, title: $0.name, colorHex: $0.colorHex) }
}
private static func calendarTitles(from data: Data?) -> [String: String] {
Dictionary(calendars(from: data).map { ($0.id, $0.title) },
uniquingKeysWith: { first, _ in first })
}
// MARK: - Rohformat
private struct RawSnapshot: Decodable {
let version: Int?
let loggedOut: Bool?
let writtenAt: String?
let events: [RawEvent]?
func writtenAtDate(_ parser: DateParser) -> Date? {
writtenAt.flatMap(parser.date(from:))
}
}
private struct RawCalendar: Decodable {
let id: String
let name: String
let colorHex: String?
}
private struct RawEvent: Decodable {
let id: String
let title: String
let start: String
let end: String
let isAllDay: Bool?
let location: String?
let calendarKey: String?
let colorHex: String?
func asOnyxEvent(titles: [String: String], parser: DateParser) -> OnyxEvent? {
guard let start = parser.date(from: start),
let end = parser.date(from: end) else { return nil }
let key = calendarKey ?? ""
return OnyxEvent(
id: id, title: title, start: start, end: end,
isAllDay: isAllDay ?? false,
colorHex: colorHex,
// Ein leerer Ort ist kein Ort sonst steht unter jedem Termin
// eine leere Zeile mit Stecknadel.
location: (location?.isEmpty ?? true) ? nil : location,
calendarID: key,
calendarTitle: titles[key] ?? key)
}
}
/// Calendarr schreibt ISO 8601 in UTC, mal mit und mal ohne Sekundenbruch.
///
/// Bewusst kein gemeinsamer statischer Formatierer: `ISO8601DateFormatter`
/// ist nicht `Sendable`, und ein `nonisolated(unsafe)` wäre hier eine
/// Behauptung, die niemand prüft. Einmal je Durchlauf gebaut und
/// durchgereicht kostet nichts und ist nachweislich richtig.
struct DateParser {
private let plain: ISO8601DateFormatter
private let withFraction: ISO8601DateFormatter
init() {
plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
}
func date(from string: String) -> Date? {
withFraction.date(from: string) ?? plain.date(from: string)
}
}
}
// MARK: - Quelle
/// Calendarr als Terminquelle.
public final class CalendarrSource: CalendarSource, @unchecked Sendable {
public let id = "calendarr"
/// Der Gruppencontainer, den Calendarr und Onyx teilen.
///
/// Onyx läuft ohne Sandbox der Container ist trotzdem erreichbar, weil
/// die Gruppe im Entitlement steht und beide Apps im selben Team signiert
/// sind. Siehe docs/spikes/D-appgroup.md.
public static let appGroup = "PP34X97WS3.group.com.scarriffleservices.calendarr"
/// Die Nachricht, mit der Calendarr auf einen neuen Schnappschuss hinweist.
public static let changeNotification = "com.scarriffleservices.calendarr.snapshot-changed"
private let containerURL: URL?
private let calendar: Calendar
public init(calendar: Calendar = .autoupdatingCurrent) {
self.containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)
self.calendar = calendar
if containerURL == nil {
log.error("Gruppencontainer \(Self.appGroup, privacy: .public) nicht erreichbar")
}
}
/// Calendarr braucht keinen Systemdialog der Container ist entweder da
/// oder die App ist nicht installiert.
public var authorization: CalendarAuthorization { .notRequired }
public var isAuthorized: Bool { true }
public func requestAccess() async -> Bool { true }
public func calendars() async -> [OnyxCalendar] {
CalendarrSnapshotReader.calendars(from: read("widget-calendars.json"))
}
/// Das angefragte Intervall wird zum Filtern benutzt, nicht zum Abfragen:
/// der Schnappschuss liegt fertig da, seine Reichweite bestimmt Calendarr.
public func load(_ interval: DateInterval) async -> CalendarSourceState {
guard containerURL != nil else {
return .unreadable(reason: "Gruppencontainer nicht erreichbar")
}
let state = CalendarrSnapshotReader.state(fromSnapshot: read("widget-cache.json"),
calendars: read("widget-calendars.json"),
calendar: calendar)
guard case .ready(let window) = state else { return state }
// Die Abdeckung bleibt die des Schnappschusses und wird nicht auf das
// angefragte Fenster eingeengt sonst würde ein schmaler Ausschnitt
// wie eine schmale Datenlage aussehen.
return .ready(EventWindow(
events: window.events.filter { $0.end >= interval.start && $0.start < interval.end },
coverage: window.coverage,
asOf: window.asOf))
}
private func read(_ name: String) -> Data? {
guard let url = containerURL?.appendingPathComponent(name) else { return nil }
return try? Data(contentsOf: url)
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
/// Hört auf eine Darwin-Nachricht und meldet sich beim Abräumen wieder ab.
///
/// Warum ein eigenes Objekt und nicht einfach ein Feld im Modell: das Modell
/// liegt auf dem MainActor, sein `deinit` aber nicht es dürfte den Beobachter
/// dort gar nicht mehr anfassen. Ein Objekt, dessen einzige Aufgabe die
/// Anmeldung ist, bindet das Abmelden an genau die richtige Lebensdauer.
/// Auf `nil` setzen genügt zum Abmelden.
///
/// `@unchecked Sendable` ist hier belegbar und keine Behauptung ins Blaue:
/// `handler` wird nach `init` nie mehr geschrieben, und der Rückruf liest ihn
/// nur. Andere veränderliche Zustände gibt es nicht.
final class DarwinObserver: @unchecked Sendable {
private let name: CFString
private let handler: @Sendable () -> Void
/// - Parameter handler: läuft auf dem Thread, der die Nachricht zustellt
/// nicht zwingend dem Hauptthread. Wer UI anfasst, hüpft selbst hinüber.
init(name: String, handler: @escaping @Sendable () -> Void) {
self.name = name as CFString
self.handler = handler
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
{ _, observer, _, _, _ in
guard let observer else { return }
// Unretained: der Beobachter wird beim Abmelden entfernt, also
// kann hier kein toter Zeiger ankommen und ein starker
// Verweis hielte das Objekt ewig am Leben.
Unmanaged<DarwinObserver>.fromOpaque(observer)
.takeUnretainedValue().handler()
},
self.name, nil, .deliverImmediately)
}
deinit {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
CFNotificationName(name), nil)
}
}

View File

@@ -1,159 +1,333 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
"sourceLanguage": "en",
"strings": {
"": {},
"%lld": {
"comment": "A day of the month. The argument is the day of the month.",
"isCommentAutoGenerated": true
},
"%lld" : {
"comment" : "A day of the month. The argument is the day of the month.",
"isCommentAutoGenerated" : true
},
"widget.calendar.allDay" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ganztägig"
"calendar.source.apple": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Apple Kalender"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "All day"
"en": {
"stringUnit": {
"state": "translated",
"value": "Apple Calendar"
}
}
}
},
"widget.calendar.day.unknown" : {
"comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Für diesen Tag liegen keine Daten vor"
"calendar.source.both": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Beide"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No data available for this day"
"en": {
"stringUnit": {
"state": "translated",
"value": "Both"
}
}
}
},
"widget.calendar.denied" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
"calendar.source.calendarr": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No calendar access.\nAllow it in System Settings."
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendarr"
}
}
}
},
"widget.calendar.incompatible" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
"calendar.source.readOnly": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Onyx is too old for this\nCalendarr version. Please update."
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx only reads. It never signs in and never writes to Calendarr."
}
}
}
},
"widget.calendar.loggedOut" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In Calendarr anmelden"
"calendar.source.status.coverage": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bekannt bis %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sign in to Calendarr"
"en": {
"stringUnit": {
"state": "translated",
"value": "Known through %@"
}
}
}
},
"widget.calendar.name" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kalender"
"calendar.source.status.incompatible": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Calendar"
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendarr writes a newer format. Please update Onyx."
}
}
}
},
"widget.calendar.neverWritten" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
"calendar.source.status.loggedOut": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Calendarr once\nso events become available."
"en": {
"stringUnit": {
"state": "translated",
"value": "Not signed in to Calendarr. Old events are not shown."
}
}
}
},
"widget.calendar.noUpcoming" : {
"comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nichts mehr für heute"
"calendar.source.status.neverWritten": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nothing left today"
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Calendarr once so it can write a snapshot."
}
}
}
},
"widget.calendar.unreadable" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termine nicht lesbar.\nKonfigurationsfehler."
"calendar.source.status.ready": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%@ Termine, Stand %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Events unreadable.\nConfiguration error."
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ events, as of %@"
}
}
}
},
"calendar.source.status.unreadable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schnappschuss unlesbar: %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Snapshot unreadable: %@"
}
}
}
},
"calendar.source.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Terminquelle"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendar source"
}
}
}
},
"widget.calendar.allDay": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ganztägig"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "All day"
}
}
}
},
"widget.calendar.day.unknown": {
"comment": "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Für diesen Tag liegen keine Daten vor"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No data available for this day"
}
}
}
},
"widget.calendar.denied": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No calendar access.\nAllow it in System Settings."
}
}
}
},
"widget.calendar.incompatible": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx is too old for this\nCalendarr version. Please update."
}
}
}
},
"widget.calendar.loggedOut": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In Calendarr anmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sign in to Calendarr"
}
}
}
},
"widget.calendar.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kalender"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendar"
}
}
}
},
"widget.calendar.neverWritten": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Calendarr once\nso events become available."
}
}
}
},
"widget.calendar.noUpcoming": {
"comment": "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nichts mehr für heute"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing left today"
}
}
}
},
"widget.calendar.unreadable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Termine nicht lesbar.\nKonfigurationsfehler."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Events unreadable.\nConfiguration error."
}
}
}
}
},
"version" : "1.1"
}
"version": "1.1"
}