Compare commits
31
Commits
a1f15e33aa
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67602dbc2 | ||
|
|
9bf7d3797d | ||
|
|
aa795fde76 | ||
|
|
28c24bc3cd | ||
|
|
a153eea8f9 | ||
|
|
93297319a3 | ||
|
|
2bdf134d9c | ||
|
|
96cbbb0dc5 | ||
|
|
25f015a15b | ||
|
|
c811486ea7 | ||
|
|
2db4b42fa1 | ||
|
|
75ae038529 | ||
|
|
5977ad0928 | ||
|
|
10686cf383 | ||
|
|
4816a33980 | ||
|
|
9f864617c2 | ||
|
|
ab0c64a9e1 | ||
|
|
dccf8671b2 | ||
|
|
8bc32faa7f | ||
|
|
aaecfc435d | ||
|
|
6b7317b4e0 | ||
|
|
cd6a4698aa | ||
|
|
9ea1a8705d | ||
|
|
d70cdca4e2 | ||
|
|
2e9989eab9 | ||
|
|
50446c8de5 | ||
|
|
66a51cc6fc | ||
|
|
19454379e5 | ||
|
|
cbec421367 | ||
|
|
d5a9dad769 | ||
|
|
ab2328969e |
@@ -0,0 +1,86 @@
|
|||||||
|
# Calendarr — Arbeitsweise (WICHTIG)
|
||||||
|
|
||||||
|
Dieses Repo ist ein **Monorepo**. Alles liegt hier, es gibt keine getrennten App-Repos mehr.
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ FastAPI + SQLite
|
||||||
|
frontend/ Web-Frontend
|
||||||
|
ios/ SwiftUI-App, Widgets, Watch-App, Komplikationen
|
||||||
|
android/ Kotlin/Compose-App
|
||||||
|
CalendarrKit/ Swift-Paket (CalendarrCore) — geteilt von iOS-App, Widgets und Watch
|
||||||
|
docs/ DESIGN.md u.a.
|
||||||
|
```
|
||||||
|
|
||||||
|
Gearbeitet wird auf Branch **`beta`**. Der Nutzer deployt daraus auf den Testserver
|
||||||
|
und merged später selbst `beta` → `master`.
|
||||||
|
|
||||||
|
## Diese Maschine = macOS-Entwicklungsrechner. Was hier NICHT geht:
|
||||||
|
|
||||||
|
- **KEINE Server-Tests, kein Server-Start.** Nicht `pytest` ausführen, keine venvs
|
||||||
|
aufsetzen, den Server nicht lokal starten. Backend-Änderungen werden per
|
||||||
|
Code-Review abgesichert; getestet wird ausschließlich auf dem Testserver.
|
||||||
|
- **Apps lassen sich hier nur bauen, nicht ausführen.** UI-, Verhaltens- und
|
||||||
|
Visual-Tests macht der Nutzer auf Gerät bzw. Simulator. Nicht über das Aussehen
|
||||||
|
spekulieren — bei Unsicherheit nachfragen.
|
||||||
|
- **Geräteabhängige Bugs** sind hier nicht reproduzierbar. Die Watch-Komplikationen
|
||||||
|
und der Liquid-Glass-Titel-Bug sind Beispiele dafür.
|
||||||
|
|
||||||
|
## Build-Verifikation (das geht, und nur das)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS inkl. Widgets und Watch-App
|
||||||
|
cd ios && xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" \
|
||||||
|
-destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
||||||
|
|
||||||
|
# Mac Catalyst — separat prüfen, der Build bricht sonst unbemerkt
|
||||||
|
cd ios && xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" \
|
||||||
|
-destination 'platform=macOS,variant=Mac Catalyst' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
||||||
|
|
||||||
|
# CalendarrKit — gegen die native macOS-SDK, das fängt UIKit-Importe ab
|
||||||
|
cd CalendarrKit && swift build && swift test
|
||||||
|
|
||||||
|
# Android
|
||||||
|
cd android && ./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Xcode liegt unter `/Applications/Xcode.app`. Watch-Builds brauchen eine Xcode-Version,
|
||||||
|
deren watchOS-SDK zum Gerät passt.
|
||||||
|
|
||||||
|
## CalendarrKit
|
||||||
|
|
||||||
|
Liegt als `git subtree` in der Wurzel. Das Xcode-Projekt referenziert es relativ als
|
||||||
|
`../CalendarrKit` — von `ios/` aus zeigt das genau hierher. **Diesen Pfad nicht ändern
|
||||||
|
und das Verzeichnis nicht verschieben**, sonst baut iOS nicht mehr.
|
||||||
|
|
||||||
|
`CalendarrCore` ist bewusst **Foundation-only** — kein SwiftUI, kein UIKit, kein AppKit.
|
||||||
|
`swift build` gegen die macOS-SDK ist das, was einen versehentlichen UIKit-Import
|
||||||
|
auffallen lässt, bevor ein Consumer darüber stolpert. Das Wire-Format hat eigene
|
||||||
|
Regeln, siehe `CalendarrKit/README.md`.
|
||||||
|
|
||||||
|
## Plattform-Parität (WICHTIG)
|
||||||
|
|
||||||
|
Feature-Logik gehört möglichst auf den **Server**, nicht pro Client dupliziert.
|
||||||
|
Beispiel: die Gruppentermin-Dekoration liefert der Server als `display_title` im
|
||||||
|
`/combined`-Endpoint — Clients rendern nur. Sichtbare Features müssen auf **allen**
|
||||||
|
Clients umgesetzt werden (Web, iOS, Android), nicht nur einem.
|
||||||
|
|
||||||
|
Ausnahme sind plattformeigene Erweiterungen wie Watch-App und Komplikationen; ein
|
||||||
|
Wear-OS-Gegenstück ist ein eigenes Projekt.
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- **Keine Emojis als Icons** — überall echte Vektor-Icons (`Image(systemName:)` auf
|
||||||
|
Apple-Plattformen, Icon-Set bzw. inline SVG sonst). Emojis sehen auf jeder
|
||||||
|
Plattform anders aus, was der Paritäts-Anspruch nicht verträgt.
|
||||||
|
- Commit-Messages auf **Englisch**, Kommunikation mit dem Nutzer auf **Deutsch**.
|
||||||
|
- Nach jeder abgeschlossenen Einheit committen und pushen.
|
||||||
|
- Fehler nicht mit `try?` verschlucken. Eine Quelle, die scheitert, muss sagen warum —
|
||||||
|
sonst sieht sie aus wie eine Quelle ohne Inhalt. Das hat schon zweimal Bugs
|
||||||
|
verborgen (`KeychainStore`, `loadWritableCalendars`).
|
||||||
|
|
||||||
|
## Altlasten
|
||||||
|
|
||||||
|
`~/Projects_Coding/Calendar Stack/` enthält die früheren Einzel-Repos
|
||||||
|
(`Calendarr iOS`, `Calendarr Android`, `CalendarrKit`, `Calendarr server`).
|
||||||
|
Sie sind **veraltet** und enthalten nichts, was hier fehlt; das iOS-Remote ist
|
||||||
|
archiviert. Dort nicht mehr arbeiten.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "CalendarrKit",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v17),
|
||||||
|
// String form, not `.v26`: that enum case needs PackageDescription 6.2,
|
||||||
|
// and raising swift-tools-version for one constant would change
|
||||||
|
// defaults across the whole manifest.
|
||||||
|
.watchOS("26.0"),
|
||||||
|
.macOS(.v14),
|
||||||
|
.macCatalyst(.v17),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Foundation only — deliberately no SwiftUI, no UIKit, no AppKit, so it
|
||||||
|
// compiles for iOS, Mac Catalyst and native macOS from one source.
|
||||||
|
.library(name: "CalendarrCore", targets: ["CalendarrCore"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "CalendarrCore",
|
||||||
|
// Matches the apps, which build in Swift 5 language mode. Note that
|
||||||
|
// this package deliberately does NOT set defaultIsolation(MainActor),
|
||||||
|
// even though the Calendarr iOS app target does: the widget extension
|
||||||
|
// and the read-only consumer app have no MainActor default, and making
|
||||||
|
// the package MainActor-by-default would force `await` at every
|
||||||
|
// SnapshotStore.read() call site.
|
||||||
|
swiftSettings: [.swiftLanguageMode(.v5)]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "CalendarrCoreTests",
|
||||||
|
dependencies: ["CalendarrCore"],
|
||||||
|
swiftSettings: [.swiftLanguageMode(.v5)]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# CalendarrKit
|
||||||
|
|
||||||
|
Shared code between Calendarr (iOS / Mac Catalyst) and other apps from the same
|
||||||
|
developer team that want to **read** the user's calendar.
|
||||||
|
|
||||||
|
`CalendarrCore` depends on Foundation only — no SwiftUI, no UIKit, no AppKit — so
|
||||||
|
it compiles for iOS, Mac Catalyst and native macOS from one source. That
|
||||||
|
constraint is load-bearing: `swift build` against the native macOS SDK is what
|
||||||
|
catches a UIKit import before it reaches a consumer, because a Mac Catalyst build
|
||||||
|
would compile it happily.
|
||||||
|
|
||||||
|
## What a reading app gets
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
|
let store = SnapshotStore() // uses CalendarrAppGroup.current
|
||||||
|
|
||||||
|
switch store.read() {
|
||||||
|
case .ok(let snap):
|
||||||
|
snap.events // [SnapshotEvent]
|
||||||
|
snap.theme // the colours Calendarr is currently rendering with
|
||||||
|
snap.writtenAt // "as of 14:32"
|
||||||
|
snap.coverageStart // see "Coverage" below
|
||||||
|
snap.coverageEnd
|
||||||
|
case .neverWritten: // Calendarr has never run here → "open Calendarr once"
|
||||||
|
case .loggedOut: // signed out → "sign in to Calendarr", discard old events
|
||||||
|
case .incompatible: // Calendarr is newer than this package → "update the app"
|
||||||
|
case .unreadable(let why): // almost always a wrong App Group; log `why`
|
||||||
|
}
|
||||||
|
|
||||||
|
store.readCalendars() // [SnapshotCalendar] for a filter UI
|
||||||
|
```
|
||||||
|
|
||||||
|
React to changes without polling:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let token = SnapshotChangeNotifier.observe { /* hop to your actor, read again */ }
|
||||||
|
// keep `token` alive; releasing it removes the observer
|
||||||
|
```
|
||||||
|
|
||||||
|
Also reload on window activation — the notification is a best-effort signal, and
|
||||||
|
missing one should leave you slightly stale, never wrong.
|
||||||
|
|
||||||
|
## Coverage — the part that bites
|
||||||
|
|
||||||
|
The snapshot is not the whole calendar. Outside `coverageStart..<coverageEnd` it
|
||||||
|
carries **no information**, which is not the same as "no events". Rendering an
|
||||||
|
empty March because it is outside the window produces a screen that looks correct
|
||||||
|
and is false. Use `snapshot.covers(date)` and grey out what you cannot speak for.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Window | ~7 days back, ~42 days ahead, frozen at write time |
|
||||||
|
| Cap | 500 events, sorted by start |
|
||||||
|
| Filters | hidden and "banished" calendars are already excluded — you see the user's filtered view |
|
||||||
|
| Scope | personal only; group overlays are never published, deliberately |
|
||||||
|
| Dropped | notes, url, source, creator, privacy, read-only, reminders, recurrence |
|
||||||
|
|
||||||
|
What remains is enough to *display* a calendar and deliberately not enough to
|
||||||
|
edit one or reason about permissions.
|
||||||
|
|
||||||
|
## Setup for a consuming app
|
||||||
|
|
||||||
|
Same developer team (`PP34X97WS3`) is required — that is what makes the container
|
||||||
|
and the keychain group shared.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>com.apple.security.app-sandbox</key><true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array><string>PP34X97WS3.group.com.scarriffleservices.calendarr</string></array>
|
||||||
|
```
|
||||||
|
|
||||||
|
The Team ID prefix is mandatory on macOS and forbidden on iOS. `CalendarrAppGroup.current`
|
||||||
|
resolves the right one; it must match the entitlement byte for byte, or
|
||||||
|
`containerURL(…)` returns nil and every read silently yields nothing.
|
||||||
|
|
||||||
|
Never sign in from a consuming app. Credentials, 2FA and token storage belong to
|
||||||
|
Calendarr alone.
|
||||||
|
|
||||||
|
## Wire format rules
|
||||||
|
|
||||||
|
`widget-cache.json`, `widget-calendars.json` and `session.json` live in the group
|
||||||
|
container and are read by apps that ship on independent schedules.
|
||||||
|
|
||||||
|
1. Filenames do not change within a major version, so an older build keeps reading
|
||||||
|
after the writer updates.
|
||||||
|
2. Readers refuse `schemaVersion > maxSupportedSchema` instead of guessing.
|
||||||
|
3. Writers only ever *add* optional fields. Never rename, remove or retype one.
|
||||||
|
4. A breaking change means a new major version *and* a new filename, with the old
|
||||||
|
file still written for one full release cycle.
|
||||||
|
5. The package version is the contract: `CalendarrKit 1.x` ⇔ snapshot schema 2.
|
||||||
|
Depend on `.upToNextMajor(from: "1.0.0")`.
|
||||||
|
|
||||||
|
Rule 3 is why the six colour values sit flat at the top level instead of nested
|
||||||
|
under `theme`, which would read better: a v1 reader requires them flat, so nesting
|
||||||
|
would have broken every older build on the first v2 write. `theme` is a computed
|
||||||
|
view over them. `SnapshotSchemaTests` pins this down in both directions.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build # native macOS — catches UIKit leaks
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The App Group the Calendarr apps share.
|
||||||
|
///
|
||||||
|
/// The identifier registered in the Apple Developer portal never changes. What
|
||||||
|
/// changes is the string the *runtime* expects: macOS — including Mac Catalyst —
|
||||||
|
/// requires the Team ID prefix, and iOS forbids it.
|
||||||
|
///
|
||||||
|
/// Getting this wrong fails silently. `containerURL(forSecurityApplication‑
|
||||||
|
/// GroupIdentifier:)` returns nil, every read and write no-ops, and widgets show
|
||||||
|
/// placeholder content forever with no error raised anywhere. `current` must
|
||||||
|
/// therefore stay byte-identical to `com.apple.security.application-groups` in
|
||||||
|
/// the entitlements file used for the platform being built.
|
||||||
|
public enum CalendarrAppGroup {
|
||||||
|
/// As registered in the Apple Developer portal.
|
||||||
|
public static let unprefixed = "group.com.scarriffleservices.calendarr"
|
||||||
|
|
||||||
|
/// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time.
|
||||||
|
public static let teamID = "PP34X97WS3"
|
||||||
|
|
||||||
|
/// The value to hand to `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`.
|
||||||
|
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||||
|
public static let current = "\(teamID).\(unprefixed)"
|
||||||
|
#else
|
||||||
|
public static let current = unprefixed
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Keychain access group shared across apps from this team. Requires
|
||||||
|
/// `keychain-access-groups` in the entitlements of every app that uses it.
|
||||||
|
public static let sharedKeychainGroup = "\(teamID).com.scarriffleservices.calendarr.shared"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Stable identifier for one calendar, used to tie a snapshot event back to the
|
||||||
|
/// calendar it came from.
|
||||||
|
///
|
||||||
|
/// This exists because the server reports `calendar_id` inconsistently: a raw
|
||||||
|
/// numeric for CalDAV sources, but `"<source>-<id>"` for local, iCal, Google and
|
||||||
|
/// Home Assistant (e.g. `"local-3"`). Any consumer that filters by calendar has
|
||||||
|
/// to normalise the two forms identically, or filtering silently does nothing
|
||||||
|
/// for the prefixed sources — which is exactly the bug this rule was written to
|
||||||
|
/// fix. Keeping the rule in one shared place is why it lives here rather than
|
||||||
|
/// being reimplemented per app.
|
||||||
|
public enum CalendarKey {
|
||||||
|
/// Build a key, stripping a redundant `"<source>-"` prefix from the id.
|
||||||
|
public static func make(source: String, calendarId: String) -> String {
|
||||||
|
var id = calendarId
|
||||||
|
let prefix = "\(source)-"
|
||||||
|
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
|
||||||
|
return "\(source):\(id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a key back into its source and numeric id.
|
||||||
|
/// Returns nil for malformed keys or non-numeric ids.
|
||||||
|
public static func parse(_ key: String) -> (source: String, id: Int)? {
|
||||||
|
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||||
|
let source = String(key[..<colon])
|
||||||
|
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
|
||||||
|
return (source, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// When a complication needs to be redrawn.
|
||||||
|
///
|
||||||
|
/// A watch-face complication has a small refresh budget, so the timeline is
|
||||||
|
/// pre-computed from data the watch already holds instead of being refreshed on
|
||||||
|
/// a schedule: an entry on every event boundary means the complication changes
|
||||||
|
/// in the exact minute the next appointment does, without a network call.
|
||||||
|
public enum ComplicationTimeline {
|
||||||
|
/// Dates at which a complication should re-render.
|
||||||
|
///
|
||||||
|
/// Always begins with `now` so the first entry renders immediately. Event
|
||||||
|
/// starts and ends within `horizon` become entries.
|
||||||
|
///
|
||||||
|
/// The first `fineGrainedWindow` then gets a mark every minute, and the rest
|
||||||
|
/// of the horizon one every hour. The fine-grained part is what lets a
|
||||||
|
/// complication show a *compact* countdown — "23m" rather than "1:23:45" —
|
||||||
|
/// which is the only kind that fits an accessoryCorner or a circular gauge.
|
||||||
|
/// A live `Text(style: .timer)` needs no entries at all but is eight
|
||||||
|
/// characters wide, and overlaps the curved label it sits next to.
|
||||||
|
///
|
||||||
|
/// The result is sorted, deduplicated to the second, and capped at
|
||||||
|
/// `maxEntries`, keeping the earliest.
|
||||||
|
public static func entryDates(from now: Date,
|
||||||
|
in snapshot: CalendarrSnapshot,
|
||||||
|
horizon: TimeInterval = 24 * 3600,
|
||||||
|
fineGrainedWindow: TimeInterval = 3600,
|
||||||
|
maxEntries: Int = 150) -> [Date] {
|
||||||
|
let end = now.addingTimeInterval(horizon)
|
||||||
|
var seen: Set<Int> = []
|
||||||
|
var dates: [Date] = []
|
||||||
|
|
||||||
|
func add(_ date: Date) {
|
||||||
|
guard date >= now, date <= end else { return }
|
||||||
|
// Deduplicate to whole seconds: two events that meet exactly would
|
||||||
|
// otherwise produce two entries a float apart.
|
||||||
|
let key = Int(date.timeIntervalSince1970.rounded())
|
||||||
|
guard seen.insert(key).inserted else { return }
|
||||||
|
dates.append(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(now)
|
||||||
|
for event in snapshot.events {
|
||||||
|
add(event.start)
|
||||||
|
add(event.end)
|
||||||
|
}
|
||||||
|
let fineEnd = min(end, now.addingTimeInterval(fineGrainedWindow))
|
||||||
|
var minute = now.addingTimeInterval(60)
|
||||||
|
while minute <= fineEnd {
|
||||||
|
add(minute)
|
||||||
|
minute = minute.addingTimeInterval(60)
|
||||||
|
}
|
||||||
|
var hourly = now.addingTimeInterval(3600)
|
||||||
|
while hourly <= end {
|
||||||
|
add(hourly)
|
||||||
|
hourly = hourly.addingTimeInterval(3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(dates.sorted().prefix(maxEntries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Non-secret session facts published alongside the snapshot.
|
||||||
|
///
|
||||||
|
/// This exists so a consumer can answer "which server, and is anyone signed in?"
|
||||||
|
/// without reading Calendarr's own `UserDefaults`, which lives in a different
|
||||||
|
/// sandbox. The auth token is deliberately *not* here — it belongs in the shared
|
||||||
|
/// keychain group, not in a plain file in the container.
|
||||||
|
public struct SharedSession: Codable, Sendable {
|
||||||
|
public static let currentSchema = 1
|
||||||
|
public static let maxSupportedSchema = 1
|
||||||
|
|
||||||
|
public let schemaVersion: Int
|
||||||
|
public let baseURL: String
|
||||||
|
public let username: String
|
||||||
|
public let isLoggedIn: Bool
|
||||||
|
public let writtenAt: Date
|
||||||
|
|
||||||
|
public init(baseURL: String, username: String, isLoggedIn: Bool, writtenAt: Date) {
|
||||||
|
self.schemaVersion = Self.currentSchema
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.username = username
|
||||||
|
self.isLoggedIn = isLoggedIn
|
||||||
|
self.writtenAt = writtenAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||||
|
baseURL = try c.decode(String.self, forKey: .baseURL)
|
||||||
|
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||||
|
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
|
||||||
|
writtenAt = try c.decodeIfPresent(Date.self, forKey: .writtenAt) ?? Date(timeIntervalSince1970: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion, baseURL, username, isLoggedIn, writtenAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server URL as a `URL`, or nil when unset or malformed.
|
||||||
|
public var url: URL? { baseURL.isEmpty ? nil : URL(string: baseURL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SharedSessionStore: Sendable {
|
||||||
|
private static let filename = "session.json"
|
||||||
|
|
||||||
|
public let appGroupID: String
|
||||||
|
|
||||||
|
public init(appGroupID: String = CalendarrAppGroup.current) {
|
||||||
|
self.appGroupID = appGroupID
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fileURL: URL? {
|
||||||
|
FileManager.default
|
||||||
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)?
|
||||||
|
.appendingPathComponent(Self.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nil when Calendarr has never published, or the file is unreadable or newer
|
||||||
|
/// than this reader understands.
|
||||||
|
public func read() -> SharedSession? {
|
||||||
|
guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
guard let session = try? decoder.decode(SharedSession.self, from: data),
|
||||||
|
session.schemaVersion <= SharedSession.maxSupportedSchema else { return nil }
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Writer)
|
||||||
|
public func write(_ session: SharedSession) throws {
|
||||||
|
guard let url = fileURL else {
|
||||||
|
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
try encoder.encode(session).write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Writer)
|
||||||
|
public func clear() {
|
||||||
|
guard let url = fileURL else { return }
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Cross-process "the snapshot changed" broadcast.
|
||||||
|
///
|
||||||
|
/// Two sandboxed apps cannot see each other's `NotificationCenter`, and neither
|
||||||
|
/// can poll the container cheaply. Darwin notifications are the one signal that
|
||||||
|
/// crosses the process boundary without an extra entitlement, a running
|
||||||
|
/// connection, or a shared framework.
|
||||||
|
///
|
||||||
|
/// The payload is always empty — Darwin notifications carry no data. The signal
|
||||||
|
/// means only "read again". If it is ever missed, a consumer that also reloads
|
||||||
|
/// on window activation degrades to a slightly stale view rather than a wrong one.
|
||||||
|
public enum SnapshotChangeNotifier {
|
||||||
|
public static let name = "com.scarriffleservices.calendarr.snapshot-changed"
|
||||||
|
|
||||||
|
/// Announce that the snapshot was rewritten. Called by `SnapshotStore`.
|
||||||
|
public static func post() {
|
||||||
|
CFNotificationCenterPostNotification(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
CFNotificationName(name as CFString),
|
||||||
|
nil, nil, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start listening. The handler runs on an arbitrary thread, so hop to
|
||||||
|
/// whatever actor your UI needs before touching state.
|
||||||
|
///
|
||||||
|
/// Returns a token you must keep alive; deinit removes the observer.
|
||||||
|
public static func observe(_ handler: @escaping @Sendable () -> Void) -> ObservationToken {
|
||||||
|
ObservationToken(handler: handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class ObservationToken: @unchecked Sendable {
|
||||||
|
private let handler: @Sendable () -> Void
|
||||||
|
|
||||||
|
fileprivate init(handler: @escaping @Sendable () -> Void) {
|
||||||
|
self.handler = handler
|
||||||
|
// Darwin notifications hand back no context pointer we can rely on
|
||||||
|
// across process boundaries, so route through an unretained pointer
|
||||||
|
// to this token and look it up in the callback.
|
||||||
|
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
CFNotificationCenterAddObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
observer,
|
||||||
|
{ _, observer, _, _, _ in
|
||||||
|
guard let observer else { return }
|
||||||
|
Unmanaged<ObservationToken>.fromOpaque(observer)
|
||||||
|
.takeUnretainedValue().handler()
|
||||||
|
},
|
||||||
|
SnapshotChangeNotifier.name as CFString,
|
||||||
|
nil,
|
||||||
|
.deliverImmediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
CFNotificationCenterRemoveObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
CFNotificationName(SnapshotChangeNotifier.name as CFString),
|
||||||
|
nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension CalendarrSnapshot {
|
||||||
|
/// Keep only the listed calendars. An empty set means "every calendar",
|
||||||
|
/// which is what an unconfigured widget or complication asks for.
|
||||||
|
///
|
||||||
|
/// Filtering narrows the events but deliberately not the window: the
|
||||||
|
/// snapshot still speaks for the same range, it just has fewer calendars
|
||||||
|
/// in it.
|
||||||
|
public func filtered(toCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
|
||||||
|
guard !keys.isEmpty else { return self }
|
||||||
|
return replacingEvents(events.filter { keys.contains($0.calendarKey) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the listed calendars, keeping everything else. An empty set is a
|
||||||
|
/// no-op.
|
||||||
|
public func filtered(excludingCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
|
||||||
|
guard !keys.isEmpty else { return self }
|
||||||
|
return replacingEvents(events.filter { !keys.contains($0.calendarKey) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replacingEvents(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: writtenAt,
|
||||||
|
coverageStart: coverageStart,
|
||||||
|
coverageEnd: coverageEnd,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
writerVersion: writerVersion,
|
||||||
|
events: events,
|
||||||
|
theme: theme,
|
||||||
|
language: language)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Coverage policy
|
||||||
|
|
||||||
|
/// How much of the calendar the writer publishes. Shared so the writer and the
|
||||||
|
/// v1-file reconstruction below can never disagree about it.
|
||||||
|
public enum SnapshotCoverage {
|
||||||
|
/// Days before today. The current week must stay complete for widgets that
|
||||||
|
/// render Monday–today.
|
||||||
|
public static let daysBehind = 7
|
||||||
|
/// Days after today.
|
||||||
|
public static let daysAhead = 42
|
||||||
|
/// Hard cap on events written, applied after sorting by start date.
|
||||||
|
public static let maxEvents = 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event
|
||||||
|
|
||||||
|
/// One event as published to the shared container.
|
||||||
|
///
|
||||||
|
/// Deliberately lossy: notes, URL, source, creator, privacy, read-only status,
|
||||||
|
/// reminders and recurrence rules are all dropped. This is enough to *display* a
|
||||||
|
/// calendar and deliberately not enough to edit one or reason about permissions.
|
||||||
|
public struct SnapshotEvent: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let start: Date
|
||||||
|
public let end: Date
|
||||||
|
public let isAllDay: Bool
|
||||||
|
public let colorHex: String
|
||||||
|
public let location: String
|
||||||
|
/// Ties the event to a calendar. See `CalendarKey`.
|
||||||
|
public let calendarKey: String
|
||||||
|
|
||||||
|
public init(id: String, title: String, start: Date, end: Date,
|
||||||
|
isAllDay: Bool, colorHex: String, location: String, calendarKey: String) {
|
||||||
|
self.id = id; self.title = title; self.start = start; self.end = end
|
||||||
|
self.isAllDay = isAllDay; self.colorHex = colorHex
|
||||||
|
self.location = location; self.calendarKey = calendarKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lenient: caches written before calendarKey existed simply lack the field.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
title = try c.decode(String.self, forKey: .title)
|
||||||
|
start = try c.decode(Date.self, forKey: .start)
|
||||||
|
end = try c.decode(Date.self, forKey: .end)
|
||||||
|
isAllDay = try c.decode(Bool.self, forKey: .isAllDay)
|
||||||
|
colorHex = try c.decode(String.self, forKey: .colorHex)
|
||||||
|
location = try c.decode(String.self, forKey: .location)
|
||||||
|
calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Calendar
|
||||||
|
|
||||||
|
/// A calendar available for filtering, published alongside the events so a
|
||||||
|
/// consumer can build a picker without a network call.
|
||||||
|
public struct SnapshotCalendar: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
/// A `CalendarKey`, matching `SnapshotEvent.calendarKey`.
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let colorHex: String
|
||||||
|
|
||||||
|
public init(id: String, name: String, colorHex: String) {
|
||||||
|
self.id = id; self.name = name; self.colorHex = colorHex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Theme
|
||||||
|
|
||||||
|
/// The colours the app is currently rendering with, so a consumer can match it
|
||||||
|
/// without reading the app's own preferences.
|
||||||
|
public struct SnapshotTheme: Sendable, Hashable {
|
||||||
|
public let today: String
|
||||||
|
public let text: String
|
||||||
|
public let background: String
|
||||||
|
public let line: String
|
||||||
|
public let primary: String
|
||||||
|
public let accent: String
|
||||||
|
|
||||||
|
public init(today: String, text: String, background: String,
|
||||||
|
line: String, primary: String, accent: String) {
|
||||||
|
self.today = today; self.text = text; self.background = background
|
||||||
|
self.line = line; self.primary = primary; self.accent = accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Snapshot
|
||||||
|
|
||||||
|
/// The published calendar snapshot.
|
||||||
|
///
|
||||||
|
/// ## Wire compatibility
|
||||||
|
///
|
||||||
|
/// The six colour values are stored **flat** at the top level rather than nested
|
||||||
|
/// under `theme`, even though `theme` is the nicer thing to read in code. That is
|
||||||
|
/// deliberate: v1 files carry them flat and a v1 reader requires them flat, so
|
||||||
|
/// nesting would break older readers on the very first v2 write. Within a major
|
||||||
|
/// version this format only ever gains optional fields — old readers ignore what
|
||||||
|
/// they do not know, new readers default what is missing. `theme` is a computed
|
||||||
|
/// view over the stored values, so callers get the clean shape anyway.
|
||||||
|
public struct CalendarrSnapshot: Codable, Sendable {
|
||||||
|
/// Bump only for a breaking change, and see the rules in the README.
|
||||||
|
public static let currentSchema = 2
|
||||||
|
/// Files claiming a higher schema are refused rather than mis-decoded.
|
||||||
|
public static let maxSupportedSchema = 2
|
||||||
|
|
||||||
|
public let schemaVersion: Int
|
||||||
|
public let writtenAt: Date
|
||||||
|
/// Start of the published window. Outside `coverageStart..<coverageEnd` the
|
||||||
|
/// snapshot carries *no information* — which is not the same as "no events".
|
||||||
|
public let coverageStart: Date
|
||||||
|
public let coverageEnd: Date
|
||||||
|
/// False once the user signs out. Distinguishes "signed out" from "never ran".
|
||||||
|
public let isLoggedIn: Bool
|
||||||
|
/// CFBundleShortVersionString of whoever wrote the file. Diagnostics only.
|
||||||
|
public let writerVersion: String
|
||||||
|
public let events: [SnapshotEvent]
|
||||||
|
public let language: String
|
||||||
|
|
||||||
|
// Stored flat for wire compatibility — read them via `theme`.
|
||||||
|
let todayColorHex: String
|
||||||
|
let textColorHex: String
|
||||||
|
let backgroundColorHex: String
|
||||||
|
let lineColorHex: String
|
||||||
|
let primaryColorHex: String
|
||||||
|
let accentColorHex: String
|
||||||
|
|
||||||
|
public var theme: SnapshotTheme {
|
||||||
|
SnapshotTheme(today: todayColorHex, text: textColorHex,
|
||||||
|
background: backgroundColorHex, line: lineColorHex,
|
||||||
|
primary: primaryColorHex, accent: accentColorHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `date` falls inside the published window, i.e. when an absence
|
||||||
|
/// of events for that date genuinely means "nothing scheduled".
|
||||||
|
public func covers(_ date: Date) -> Bool {
|
||||||
|
date >= coverageStart && date < coverageEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(writtenAt: Date,
|
||||||
|
coverageStart: Date,
|
||||||
|
coverageEnd: Date,
|
||||||
|
isLoggedIn: Bool,
|
||||||
|
writerVersion: String,
|
||||||
|
events: [SnapshotEvent],
|
||||||
|
theme: SnapshotTheme,
|
||||||
|
language: String) {
|
||||||
|
self.schemaVersion = Self.currentSchema
|
||||||
|
self.writtenAt = writtenAt
|
||||||
|
self.coverageStart = coverageStart
|
||||||
|
self.coverageEnd = coverageEnd
|
||||||
|
self.isLoggedIn = isLoggedIn
|
||||||
|
self.writerVersion = writerVersion
|
||||||
|
self.events = events
|
||||||
|
self.language = language
|
||||||
|
self.todayColorHex = theme.today
|
||||||
|
self.textColorHex = theme.text
|
||||||
|
self.backgroundColorHex = theme.background
|
||||||
|
self.lineColorHex = theme.line
|
||||||
|
self.primaryColorHex = theme.primary
|
||||||
|
self.accentColorHex = theme.accent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lenient decoder. Everything added after v1 is optional here, and a v1
|
||||||
|
/// file's coverage window is reconstructed from the policy that produced it.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||||
|
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
|
||||||
|
events = try c.decode([SnapshotEvent].self, forKey: .events)
|
||||||
|
language = try c.decode(String.self, forKey: .language)
|
||||||
|
|
||||||
|
todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
|
||||||
|
textColorHex = try c.decode(String.self, forKey: .textColorHex)
|
||||||
|
backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
|
||||||
|
lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
|
||||||
|
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
|
||||||
|
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
|
||||||
|
|
||||||
|
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
|
||||||
|
writerVersion = try c.decodeIfPresent(String.self, forKey: .writerVersion) ?? "unknown"
|
||||||
|
|
||||||
|
// v1 had no explicit window; it always wrote the same one, measured from
|
||||||
|
// the start of the day it was written.
|
||||||
|
let cal = Calendar(identifier: .gregorian)
|
||||||
|
let dayStart = cal.startOfDay(for: writtenAt)
|
||||||
|
coverageStart = try c.decodeIfPresent(Date.self, forKey: .coverageStart)
|
||||||
|
?? cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? dayStart
|
||||||
|
coverageEnd = try c.decodeIfPresent(Date.self, forKey: .coverageEnd)
|
||||||
|
?? cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? dayStart
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion, writtenAt, coverageStart, coverageEnd, isLoggedIn, writerVersion
|
||||||
|
case events, language
|
||||||
|
case todayColorHex, textColorHex, backgroundColorHex, lineColorHex
|
||||||
|
case primaryColorHex, accentColorHex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read result
|
||||||
|
|
||||||
|
/// Why a read produced no usable snapshot. The distinction matters for the UI:
|
||||||
|
/// "never ran", "signed out" and "genuinely empty" call for different messages.
|
||||||
|
public enum SnapshotReadResult: Sendable {
|
||||||
|
case ok(CalendarrSnapshot)
|
||||||
|
/// The user signed out of Calendarr. Show a sign-in prompt, not stale events.
|
||||||
|
case loggedOut(writtenAt: Date)
|
||||||
|
/// Calendarr has never published on this machine. Ask the user to open it once.
|
||||||
|
case neverWritten
|
||||||
|
/// Written by a newer Calendarr than this reader understands. Refuse rather
|
||||||
|
/// than guess; ask the user to update.
|
||||||
|
case incompatible(foundSchema: Int, maxSupported: Int)
|
||||||
|
/// Corrupt file, or — much more likely — a misconfigured App Group.
|
||||||
|
case unreadable(String)
|
||||||
|
|
||||||
|
public var snapshot: CalendarrSnapshot? {
|
||||||
|
if case .ok(let s) = self { return s }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SnapshotStoreError: Error, LocalizedError {
|
||||||
|
case containerUnavailable(appGroupID: String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .containerUnavailable(let id):
|
||||||
|
return "App Group container unavailable for \(id). Check that "
|
||||||
|
+ "com.apple.security.application-groups in this target's "
|
||||||
|
+ "entitlements matches exactly, and that macOS builds use the "
|
||||||
|
+ "Team ID prefix."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads — and, for Calendarr itself, writes — the shared calendar snapshot.
|
||||||
|
///
|
||||||
|
/// Writing is gated behind `@_spi(Writer)` so a read-only consumer cannot
|
||||||
|
/// corrupt the shared cache even by accident. Calendarr imports the package as
|
||||||
|
/// `@_spi(Writer) import CalendarrCore`; everyone else gets read access only.
|
||||||
|
public struct SnapshotStore: Sendable {
|
||||||
|
private static let cacheFilename = "widget-cache.json"
|
||||||
|
private static let calendarsFilename = "widget-calendars.json"
|
||||||
|
|
||||||
|
public let appGroupID: String
|
||||||
|
|
||||||
|
public init(appGroupID: String = CalendarrAppGroup.current) {
|
||||||
|
self.appGroupID = appGroupID
|
||||||
|
}
|
||||||
|
|
||||||
|
private var containerURL: URL? {
|
||||||
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encoder() -> JSONEncoder {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decoder() -> JSONDecoder {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading
|
||||||
|
|
||||||
|
/// Load the published snapshot.
|
||||||
|
///
|
||||||
|
/// Never returns a bare nil: every failure mode is named, because they call
|
||||||
|
/// for different things in the UI and because a silent nil is precisely how
|
||||||
|
/// a misconfigured App Group used to hide itself.
|
||||||
|
public func read() -> SnapshotReadResult {
|
||||||
|
guard let container = containerURL else {
|
||||||
|
return .unreadable(SnapshotStoreError
|
||||||
|
.containerUnavailable(appGroupID: appGroupID).localizedDescription)
|
||||||
|
}
|
||||||
|
let url = container.appendingPathComponent(Self.cacheFilename)
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
// No cache. Signing out deletes the events — keeping them would leak
|
||||||
|
// the previous user's calendar — so the session file is what tells
|
||||||
|
// "signed out" apart from "never ran here". Answering that in one
|
||||||
|
// call keeps every consumer from reimplementing the distinction.
|
||||||
|
if let session = SharedSessionStore(appGroupID: appGroupID).read(),
|
||||||
|
!session.isLoggedIn {
|
||||||
|
return .loggedOut(writtenAt: session.writtenAt)
|
||||||
|
}
|
||||||
|
return .neverWritten
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
do { data = try Data(contentsOf: url) }
|
||||||
|
catch { return .unreadable("Could not read \(Self.cacheFilename): \(error.localizedDescription)") }
|
||||||
|
|
||||||
|
// Check the version before decoding: a newer writer may have changed the
|
||||||
|
// meaning of a field we think we understand, so refusing beats guessing.
|
||||||
|
if let probe = try? Self.decoder().decode(SchemaProbe.self, from: data) {
|
||||||
|
let found = probe.schemaVersion ?? 1
|
||||||
|
if found > CalendarrSnapshot.maxSupportedSchema {
|
||||||
|
return .incompatible(foundSchema: found,
|
||||||
|
maxSupported: CalendarrSnapshot.maxSupportedSchema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let snapshot = try Self.decoder().decode(CalendarrSnapshot.self, from: data)
|
||||||
|
guard snapshot.isLoggedIn else { return .loggedOut(writtenAt: snapshot.writtenAt) }
|
||||||
|
return .ok(snapshot)
|
||||||
|
} catch {
|
||||||
|
return .unreadable("Could not decode \(Self.cacheFilename): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calendars available for filtering. Empty when nothing is published.
|
||||||
|
public func readCalendars() -> [SnapshotCalendar] {
|
||||||
|
guard let url = containerURL?.appendingPathComponent(Self.calendarsFilename),
|
||||||
|
let data = try? Data(contentsOf: url) else { return [] }
|
||||||
|
return (try? Self.decoder().decode([SnapshotCalendar].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads only the version field, so an unsupported file is never fully decoded.
|
||||||
|
private struct SchemaProbe: Decodable { let schemaVersion: Int? }
|
||||||
|
|
||||||
|
// MARK: - Writing (Calendarr only)
|
||||||
|
|
||||||
|
@_spi(Writer)
|
||||||
|
public func write(_ snapshot: CalendarrSnapshot) throws {
|
||||||
|
guard let container = containerURL else {
|
||||||
|
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
|
||||||
|
}
|
||||||
|
let data = try Self.encoder().encode(snapshot)
|
||||||
|
try data.write(to: container.appendingPathComponent(Self.cacheFilename), options: .atomic)
|
||||||
|
SnapshotChangeNotifier.post()
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Writer)
|
||||||
|
public func writeCalendars(_ calendars: [SnapshotCalendar]) throws {
|
||||||
|
guard let container = containerURL else {
|
||||||
|
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
|
||||||
|
}
|
||||||
|
let data = try Self.encoder().encode(calendars)
|
||||||
|
try data.write(to: container.appendingPathComponent(Self.calendarsFilename), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove everything published. Call this on sign-out and on server reset —
|
||||||
|
/// the container outlives the session, so without it the next reader keeps
|
||||||
|
/// rendering the previous user's calendar.
|
||||||
|
@_spi(Writer)
|
||||||
|
public func clear() {
|
||||||
|
guard let container = containerURL else { return }
|
||||||
|
for name in [Self.cacheFilename, Self.calendarsFilename] {
|
||||||
|
try? FileManager.default.removeItem(at: container.appendingPathComponent(name))
|
||||||
|
}
|
||||||
|
SnapshotChangeNotifier.post()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How much of the calendar travels to the watch.
|
||||||
|
///
|
||||||
|
/// Deliberately narrower than `SnapshotCoverage`: the payload crosses
|
||||||
|
/// WatchConnectivity on every publish, and a watch complication never needs six
|
||||||
|
/// weeks of history to answer "what is next".
|
||||||
|
public enum WatchCoverage {
|
||||||
|
/// Days before today. One is enough for "still running" all-day events.
|
||||||
|
public static let daysBehind = 1
|
||||||
|
/// Days after today.
|
||||||
|
public static let daysAhead = 14
|
||||||
|
/// Hard cap on events sent, applied after sorting by start date.
|
||||||
|
public static let maxEvents = 120
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CalendarrSnapshot {
|
||||||
|
/// A narrower copy of this snapshot, suitable for sending to the watch.
|
||||||
|
///
|
||||||
|
/// The coverage window shrinks together with the events, and never widens
|
||||||
|
/// past what the writer already claimed. If `WatchCoverage.maxEvents`
|
||||||
|
/// truncates the list, `coverageEnd` is pulled back to the first event we
|
||||||
|
/// had to drop: everything before that point is provably complete, and
|
||||||
|
/// beyond it the watch must not pretend to know anything.
|
||||||
|
public func trimmedForWatch(now: Date = Date(),
|
||||||
|
calendar: Calendar = Calendar(identifier: .gregorian)) -> CalendarrSnapshot {
|
||||||
|
let dayStart = calendar.startOfDay(for: now)
|
||||||
|
let windowStart = max(coverageStart,
|
||||||
|
calendar.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart) ?? dayStart)
|
||||||
|
let windowEnd = min(coverageEnd,
|
||||||
|
calendar.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart) ?? dayStart)
|
||||||
|
|
||||||
|
guard windowEnd > windowStart else {
|
||||||
|
return Self.rebuilt(from: self, start: windowStart, end: windowStart, events: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let inWindow = events
|
||||||
|
.filter { $0.start < windowEnd && $0.end > windowStart }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
guard inWindow.count > WatchCoverage.maxEvents else {
|
||||||
|
return Self.rebuilt(from: self, start: windowStart, end: windowEnd, events: inWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
let kept = Array(inWindow.prefix(WatchCoverage.maxEvents))
|
||||||
|
// The first dropped event marks the edge of what we can prove. Its start
|
||||||
|
// becomes the new end of coverage; the last kept event is dropped too
|
||||||
|
// when it starts at that same instant, which keeps the invariant
|
||||||
|
// "every event we carry starts before coverageEnd" exactly true.
|
||||||
|
let provenEnd = max(windowStart, min(windowEnd, inWindow[WatchCoverage.maxEvents].start))
|
||||||
|
return Self.rebuilt(from: self, start: windowStart, end: provenEnd,
|
||||||
|
events: kept.filter { $0.start < provenEnd && $0.end > windowStart })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rebuilt(from source: CalendarrSnapshot,
|
||||||
|
start: Date, end: Date,
|
||||||
|
events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: source.writtenAt,
|
||||||
|
coverageStart: start,
|
||||||
|
coverageEnd: end,
|
||||||
|
isLoggedIn: source.isLoggedIn,
|
||||||
|
writerVersion: source.writerVersion,
|
||||||
|
events: events,
|
||||||
|
theme: source.theme,
|
||||||
|
language: source.language)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Identifies the appointment a complication is currently showing.
|
||||||
|
///
|
||||||
|
/// The start date is part of the identity on purpose: a moved event keeps its
|
||||||
|
/// id, and the complication is wrong until it is told.
|
||||||
|
public struct NextEventIdentity: Equatable, Sendable, Codable {
|
||||||
|
public let id: String
|
||||||
|
public let start: Date
|
||||||
|
|
||||||
|
public init(id: String, start: Date) {
|
||||||
|
self.id = id
|
||||||
|
self.start = start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WatchPushPolicy {
|
||||||
|
/// The first event that has not ended yet — which is what every "next
|
||||||
|
/// appointment" complication renders, including one that is running now.
|
||||||
|
public static func nextEventIdentity(in snapshot: CalendarrSnapshot,
|
||||||
|
at now: Date) -> NextEventIdentity? {
|
||||||
|
snapshot.events
|
||||||
|
.filter { $0.end > now }
|
||||||
|
.min { $0.start < $1.start }
|
||||||
|
.map { NextEventIdentity(id: $0.id, start: $0.start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this change is worth one of the day's limited complication
|
||||||
|
/// pushes. Only a changed next appointment is — everything else reaches the
|
||||||
|
/// watch on the next application-context delivery.
|
||||||
|
public static func shouldPushComplicationUpdate(previous: NextEventIdentity?,
|
||||||
|
current: NextEventIdentity?) -> Bool {
|
||||||
|
previous != current
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Dictionary keys used on the WatchConnectivity wire. Stable within a major
|
||||||
|
/// version, like the snapshot filenames.
|
||||||
|
public enum WatchTransportKey {
|
||||||
|
public static let schema = "schema"
|
||||||
|
public static let snapshot = "snapshot"
|
||||||
|
public static let calendars = "calendars"
|
||||||
|
public static let session = "session"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WatchTransportError: Error, Equatable, LocalizedError {
|
||||||
|
case missingSnapshot
|
||||||
|
case unsupportedSchema(found: Int, maxSupported: Int)
|
||||||
|
case malformed(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingSnapshot:
|
||||||
|
return "Watch payload carried no snapshot."
|
||||||
|
case .unsupportedSchema(let found, let max):
|
||||||
|
return "Watch payload schema \(found) is newer than this build understands (\(max))."
|
||||||
|
case .malformed(let why):
|
||||||
|
return "Watch payload could not be decoded: \(why)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the phone sends to the watch: a trimmed snapshot, the calendar list for
|
||||||
|
/// a filter UI, and the non-secret session facts so the watch can tell "signed
|
||||||
|
/// out" from "never received anything".
|
||||||
|
///
|
||||||
|
/// The auth token is deliberately absent. The watch never talks to the server,
|
||||||
|
/// so there is nothing for it to authenticate with and nothing to leak.
|
||||||
|
public struct WatchTransportPayload: Sendable {
|
||||||
|
/// Bump only for a breaking change to the dictionary shape.
|
||||||
|
public static let currentSchema = 1
|
||||||
|
/// Payloads claiming a higher schema are refused rather than mis-decoded.
|
||||||
|
public static let maxSupportedSchema = 1
|
||||||
|
|
||||||
|
public let snapshot: CalendarrSnapshot
|
||||||
|
public let calendars: [SnapshotCalendar]
|
||||||
|
public let session: SharedSession?
|
||||||
|
|
||||||
|
public init(snapshot: CalendarrSnapshot,
|
||||||
|
calendars: [SnapshotCalendar],
|
||||||
|
session: SharedSession?) {
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.calendars = calendars
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encoder() -> JSONEncoder {
|
||||||
|
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decoder() -> JSONDecoder {
|
||||||
|
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode for `WCSession`. Values are `Data` and `Int` only, because
|
||||||
|
/// WatchConnectivity accepts property-list types and rejects everything
|
||||||
|
/// else at runtime rather than at compile time.
|
||||||
|
public func encoded() throws -> [String: Any] {
|
||||||
|
let e = Self.encoder()
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
WatchTransportKey.schema: Self.currentSchema,
|
||||||
|
WatchTransportKey.snapshot: try e.encode(snapshot),
|
||||||
|
WatchTransportKey.calendars: try e.encode(calendars),
|
||||||
|
]
|
||||||
|
if let session { dict[WatchTransportKey.session] = try e.encode(session) }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decode(_ dict: [String: Any]) throws -> WatchTransportPayload {
|
||||||
|
let found = dict[WatchTransportKey.schema] as? Int ?? 1
|
||||||
|
guard found <= maxSupportedSchema else {
|
||||||
|
throw WatchTransportError.unsupportedSchema(found: found, maxSupported: maxSupportedSchema)
|
||||||
|
}
|
||||||
|
guard let snapshotData = dict[WatchTransportKey.snapshot] as? Data else {
|
||||||
|
throw WatchTransportError.missingSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
let d = decoder()
|
||||||
|
let snapshot: CalendarrSnapshot
|
||||||
|
do { snapshot = try d.decode(CalendarrSnapshot.self, from: snapshotData) }
|
||||||
|
catch { throw WatchTransportError.malformed("snapshot: \(error)") }
|
||||||
|
|
||||||
|
var calendars: [SnapshotCalendar] = []
|
||||||
|
if let data = dict[WatchTransportKey.calendars] as? Data {
|
||||||
|
calendars = (try? d.decode([SnapshotCalendar].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var session: SharedSession?
|
||||||
|
if let data = dict[WatchTransportKey.session] as? Data {
|
||||||
|
session = try? d.decode(SharedSession.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return WatchTransportPayload(snapshot: snapshot, calendars: calendars, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
/// The server reports `calendar_id` two different ways, and normalising them
|
||||||
|
/// inconsistently is what made per-calendar filtering silently do nothing for
|
||||||
|
/// some sources. These pin the rule down.
|
||||||
|
struct CalendarKeyTests {
|
||||||
|
|
||||||
|
@Test("CalDAV ids arrive bare and are used as-is")
|
||||||
|
func bareNumericId() {
|
||||||
|
#expect(CalendarKey.make(source: "caldav", calendarId: "12") == "caldav:12")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Prefixed ids from the other sources get the prefix stripped")
|
||||||
|
func strippedPrefix() {
|
||||||
|
#expect(CalendarKey.make(source: "local", calendarId: "local-3") == "local:3")
|
||||||
|
#expect(CalendarKey.make(source: "google", calendarId: "google-5") == "google:5")
|
||||||
|
#expect(CalendarKey.make(source: "ical", calendarId: "ical-7") == "ical:7")
|
||||||
|
#expect(CalendarKey.make(source: "homeassistant",
|
||||||
|
calendarId: "homeassistant-9") == "homeassistant:9")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Both spellings of the same calendar produce the same key")
|
||||||
|
func bothFormsAgree() {
|
||||||
|
#expect(CalendarKey.make(source: "local", calendarId: "local-3")
|
||||||
|
== CalendarKey.make(source: "local", calendarId: "3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Only a leading prefix is stripped, and only once")
|
||||||
|
func stripsOnlyLeadingPrefix() {
|
||||||
|
// A prefix appearing later in the id must survive untouched.
|
||||||
|
#expect(CalendarKey.make(source: "local", calendarId: "3-local") == "local:3-local")
|
||||||
|
// Two prefixes: only the first goes.
|
||||||
|
#expect(CalendarKey.make(source: "local", calendarId: "local-local-3") == "local:local-3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("parse round-trips make")
|
||||||
|
func roundTrip() {
|
||||||
|
let key = CalendarKey.make(source: "local", calendarId: "local-42")
|
||||||
|
let parsed = CalendarKey.parse(key)
|
||||||
|
#expect(parsed?.source == "local")
|
||||||
|
#expect(parsed?.id == 42)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("parse rejects what it cannot represent")
|
||||||
|
func rejectsMalformed() {
|
||||||
|
#expect(CalendarKey.parse("nocolon") == nil)
|
||||||
|
#expect(CalendarKey.parse("local:abc") == nil) // non-numeric id
|
||||||
|
#expect(CalendarKey.parse("local:") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
struct ComplicationTimelineTests {
|
||||||
|
|
||||||
|
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||||
|
|
||||||
|
private static func event(_ id: String, startOffset: TimeInterval,
|
||||||
|
duration: TimeInterval) -> SnapshotEvent {
|
||||||
|
SnapshotEvent(id: id, title: id,
|
||||||
|
start: now.addingTimeInterval(startOffset),
|
||||||
|
end: now.addingTimeInterval(startOffset + duration),
|
||||||
|
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: now,
|
||||||
|
coverageStart: now.addingTimeInterval(-86400),
|
||||||
|
coverageEnd: now.addingTimeInterval(14 * 86400),
|
||||||
|
isLoggedIn: true, writerVersion: "1.0", events: events,
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The first entry is always now, so the complication renders immediately")
|
||||||
|
func startsAtNow() {
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
|
||||||
|
#expect(dates.first == Self.now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Entries are sorted and free of duplicates")
|
||||||
|
func sortedAndDeduplicated() {
|
||||||
|
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800),
|
||||||
|
Self.event("b", startOffset: 5400, duration: 1800)])
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
|
||||||
|
|
||||||
|
#expect(dates == dates.sorted())
|
||||||
|
#expect(Set(dates).count == dates.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Event starts and ends inside the horizon become entries")
|
||||||
|
func includesEventBoundaries() {
|
||||||
|
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)])
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
|
||||||
|
|
||||||
|
#expect(dates.contains(Self.now.addingTimeInterval(3600)))
|
||||||
|
#expect(dates.contains(Self.now.addingTimeInterval(5400)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Boundaries outside the horizon are ignored")
|
||||||
|
func ignoresBoundariesBeyondHorizon() {
|
||||||
|
let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)])
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600)
|
||||||
|
|
||||||
|
#expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false)
|
||||||
|
#expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Boundaries already in the past are ignored")
|
||||||
|
func ignoresPastBoundaries() {
|
||||||
|
let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)])
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
|
||||||
|
|
||||||
|
#expect(dates.allSatisfy { $0 >= Self.now })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty calendar still gets hourly entries across the horizon")
|
||||||
|
func fillsHourlyWhenEmpty() {
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]),
|
||||||
|
horizon: 24 * 3600)
|
||||||
|
#expect(dates.count >= 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The first hour gets minute-granular entries, so a compact countdown stays honest")
|
||||||
|
func minuteGranularFirstHour() {
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
|
||||||
|
let firstHour = dates.filter { $0 <= Self.now.addingTimeInterval(3600) }
|
||||||
|
|
||||||
|
// 60 minute marks plus `now` itself.
|
||||||
|
#expect(firstHour.count >= 60)
|
||||||
|
#expect(firstHour.contains(Self.now.addingTimeInterval(60)))
|
||||||
|
#expect(firstHour.contains(Self.now.addingTimeInterval(1800)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Past the first hour the entries thin out to hourly")
|
||||||
|
func hourlyAfterFirstHour() {
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
|
||||||
|
let later = dates.filter { $0 > Self.now.addingTimeInterval(3600) }
|
||||||
|
|
||||||
|
// 23 further hours, not 23 * 60 minutes.
|
||||||
|
#expect(later.count < 40)
|
||||||
|
#expect(later.contains(Self.now.addingTimeInterval(2 * 3600)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The entry count is capped, keeping the earliest entries")
|
||||||
|
func capsEntries() {
|
||||||
|
var events: [SnapshotEvent] = []
|
||||||
|
for i in 0..<300 {
|
||||||
|
events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30))
|
||||||
|
}
|
||||||
|
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events),
|
||||||
|
maxEntries: 50)
|
||||||
|
|
||||||
|
#expect(dates.count == 50)
|
||||||
|
#expect(dates.first == Self.now)
|
||||||
|
#expect(dates == dates.sorted())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
struct SnapshotFilterTests {
|
||||||
|
|
||||||
|
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||||
|
|
||||||
|
private static func event(_ id: String, key: String) -> SnapshotEvent {
|
||||||
|
SnapshotEvent(id: id, title: id, start: now, end: now.addingTimeInterval(3600),
|
||||||
|
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: now,
|
||||||
|
coverageStart: now.addingTimeInterval(-86400),
|
||||||
|
coverageEnd: now.addingTimeInterval(86400),
|
||||||
|
isLoggedIn: true, writerVersion: "1.0", events: events,
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty allow-list means every calendar, not no calendar")
|
||||||
|
func emptyAllowListKeepsEverything() {
|
||||||
|
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
|
||||||
|
#expect(snap.filtered(toCalendarKeys: []).events.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An allow-list keeps only the listed calendars")
|
||||||
|
func allowListNarrows() {
|
||||||
|
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
|
||||||
|
#expect(snap.filtered(toCalendarKeys: ["local:1"]).events.map(\.id) == ["a"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A deny-list removes only the listed calendars")
|
||||||
|
func denyListNarrows() {
|
||||||
|
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
|
||||||
|
#expect(snap.filtered(excludingCalendarKeys: ["local:1"]).events.map(\.id) == ["b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Filtering narrows the events but never the window")
|
||||||
|
func windowIsUntouched() {
|
||||||
|
let snap = Self.snapshot([Self.event("a", key: "local:1")])
|
||||||
|
let filtered = snap.filtered(toCalendarKeys: ["nothing:0"])
|
||||||
|
|
||||||
|
#expect(filtered.events.isEmpty)
|
||||||
|
#expect(filtered.coverageStart == snap.coverageStart)
|
||||||
|
#expect(filtered.coverageEnd == snap.coverageEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
/// The wire format is a contract between apps that ship independently, so both
|
||||||
|
/// directions of compatibility need a test: a new reader must understand an old
|
||||||
|
/// file, and an old reader must survive a new one.
|
||||||
|
struct SnapshotSchemaTests {
|
||||||
|
|
||||||
|
private static func decoder() -> JSONDecoder {
|
||||||
|
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
|
||||||
|
}
|
||||||
|
private static func encoder() -> JSONEncoder {
|
||||||
|
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real v1 file: no schemaVersion, no coverage window, no isLoggedIn,
|
||||||
|
/// colours flat at the top level.
|
||||||
|
private static let v1Fixture = """
|
||||||
|
{
|
||||||
|
"writtenAt": "2026-08-10T12:00:00Z",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"title": "Zahnarzt",
|
||||||
|
"start": "2026-08-11T09:00:00Z",
|
||||||
|
"end": "2026-08-11T10:00:00Z",
|
||||||
|
"isAllDay": false,
|
||||||
|
"colorHex": "#58B900",
|
||||||
|
"location": "Bern",
|
||||||
|
"calendarKey": "local:3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"todayColorHex": "#6FB669",
|
||||||
|
"textColorHex": "#FFFFFF",
|
||||||
|
"backgroundColorHex": "#000000",
|
||||||
|
"lineColorHex": "#3B3B3D",
|
||||||
|
"primaryColorHex": "#58B900",
|
||||||
|
"accentColorHex": "#45A148",
|
||||||
|
"language": "de"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("A v1 file decodes, and its missing fields get sane defaults")
|
||||||
|
func decodesV1() throws {
|
||||||
|
let snap = try Self.decoder().decode(
|
||||||
|
CalendarrSnapshot.self, from: Data(Self.v1Fixture.utf8))
|
||||||
|
|
||||||
|
#expect(snap.schemaVersion == 1)
|
||||||
|
#expect(snap.events.count == 1)
|
||||||
|
#expect(snap.events[0].calendarKey == "local:3")
|
||||||
|
#expect(snap.language == "de")
|
||||||
|
#expect(snap.theme.today == "#6FB669")
|
||||||
|
#expect(snap.theme.accent == "#45A148")
|
||||||
|
|
||||||
|
// v1 never said whether anyone was signed in; assuming yes preserves the
|
||||||
|
// old behaviour of simply rendering whatever was cached.
|
||||||
|
#expect(snap.isLoggedIn)
|
||||||
|
#expect(snap.writerVersion == "unknown")
|
||||||
|
|
||||||
|
// The window is reconstructed from the policy v1 always used.
|
||||||
|
let cal = Calendar(identifier: .gregorian)
|
||||||
|
let dayStart = cal.startOfDay(for: snap.writtenAt)
|
||||||
|
#expect(snap.coverageStart == cal.date(byAdding: .day, value: -7, to: dayStart))
|
||||||
|
#expect(snap.coverageEnd == cal.date(byAdding: .day, value: 42, to: dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Older still: written before per-calendar filtering existed, so the events
|
||||||
|
/// carry no calendarKey at all.
|
||||||
|
private static let v1FixtureNoCalendarKey = """
|
||||||
|
{
|
||||||
|
"writtenAt": "2026-08-10T12:00:00Z",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "abc",
|
||||||
|
"title": "Zahnarzt",
|
||||||
|
"start": "2026-08-11T09:00:00Z",
|
||||||
|
"end": "2026-08-11T10:00:00Z",
|
||||||
|
"isAllDay": false,
|
||||||
|
"colorHex": "#58B900",
|
||||||
|
"location": "Bern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"todayColorHex": "#6FB669",
|
||||||
|
"textColorHex": "#FFFFFF",
|
||||||
|
"backgroundColorHex": "#000000",
|
||||||
|
"lineColorHex": "#3B3B3D",
|
||||||
|
"language": "de"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("A v1 file that predates calendarKey and the newer colours still decodes")
|
||||||
|
func decodesV1WithoutCalendarKey() throws {
|
||||||
|
let snap = try Self.decoder().decode(
|
||||||
|
CalendarrSnapshot.self, from: Data(Self.v1FixtureNoCalendarKey.utf8))
|
||||||
|
#expect(snap.events[0].calendarKey == "")
|
||||||
|
// primary/accent were added later too, so they fall back to defaults.
|
||||||
|
#expect(snap.theme.primary == "#4285f4")
|
||||||
|
#expect(snap.theme.accent == "#ea4335")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("v2 round-trips without losing anything")
|
||||||
|
func roundTripsV2() throws {
|
||||||
|
let start = Date(timeIntervalSince1970: 1_780_000_000)
|
||||||
|
let original = CalendarrSnapshot(
|
||||||
|
writtenAt: start,
|
||||||
|
coverageStart: start.addingTimeInterval(-7 * 86_400),
|
||||||
|
coverageEnd: start.addingTimeInterval(42 * 86_400),
|
||||||
|
isLoggedIn: true,
|
||||||
|
writerVersion: "3.5",
|
||||||
|
events: [SnapshotEvent(id: "x", title: "Termin", start: start,
|
||||||
|
end: start.addingTimeInterval(3600),
|
||||||
|
isAllDay: false, colorHex: "#58B900",
|
||||||
|
location: "", calendarKey: "local:1")],
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "ch")
|
||||||
|
|
||||||
|
let decoded = try Self.decoder().decode(
|
||||||
|
CalendarrSnapshot.self, from: Self.encoder().encode(original))
|
||||||
|
|
||||||
|
#expect(decoded.schemaVersion == CalendarrSnapshot.currentSchema)
|
||||||
|
#expect(decoded.writerVersion == "3.5")
|
||||||
|
#expect(decoded.language == "ch")
|
||||||
|
#expect(decoded.coverageStart == original.coverageStart)
|
||||||
|
#expect(decoded.coverageEnd == original.coverageEnd)
|
||||||
|
#expect(decoded.theme == original.theme)
|
||||||
|
#expect(decoded.events == original.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason the colours are stored flat rather than nested under `theme`:
|
||||||
|
/// a v1 reader requires them at the top level, so nesting them would have
|
||||||
|
/// broken every older build on the first v2 write.
|
||||||
|
@Test("v2 keeps the colour keys where a v1 reader expects them")
|
||||||
|
func v2StaysReadableByV1() throws {
|
||||||
|
let start = Date(timeIntervalSince1970: 1_780_000_000)
|
||||||
|
let snap = CalendarrSnapshot(
|
||||||
|
writtenAt: start, coverageStart: start, coverageEnd: start,
|
||||||
|
isLoggedIn: true, writerVersion: "3.5", events: [],
|
||||||
|
theme: SnapshotTheme(today: "#a", text: "#b", background: "#c",
|
||||||
|
line: "#d", primary: "#e", accent: "#f"),
|
||||||
|
language: "de")
|
||||||
|
|
||||||
|
let object = try JSONSerialization.jsonObject(
|
||||||
|
with: Self.encoder().encode(snap)) as? [String: Any]
|
||||||
|
|
||||||
|
for key in ["todayColorHex", "textColorHex", "backgroundColorHex",
|
||||||
|
"lineColorHex", "primaryColorHex", "accentColorHex",
|
||||||
|
"writtenAt", "events", "language"] {
|
||||||
|
#expect(object?[key] != nil, "v1 readers require \(key) at the top level")
|
||||||
|
}
|
||||||
|
#expect(object?["theme"] == nil, "theme must stay a computed view, not a stored key")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("covers() separates 'no events' from 'no information'")
|
||||||
|
func coverageWindow() {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_780_000_000)
|
||||||
|
let snap = CalendarrSnapshot(
|
||||||
|
writtenAt: now,
|
||||||
|
coverageStart: now.addingTimeInterval(-86_400),
|
||||||
|
coverageEnd: now.addingTimeInterval(86_400),
|
||||||
|
isLoggedIn: true, writerVersion: "3.5", events: [],
|
||||||
|
theme: SnapshotTheme(today: "", text: "", background: "",
|
||||||
|
line: "", primary: "", accent: ""),
|
||||||
|
language: "de")
|
||||||
|
|
||||||
|
#expect(snap.covers(now))
|
||||||
|
#expect(snap.covers(now.addingTimeInterval(-86_399)))
|
||||||
|
#expect(!snap.covers(now.addingTimeInterval(-90_000)))
|
||||||
|
#expect(!snap.covers(snap.coverageEnd)) // half-open: end is excluded
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
struct WatchPushPolicyTests {
|
||||||
|
|
||||||
|
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||||
|
|
||||||
|
private static func event(_ id: String, startOffset: TimeInterval,
|
||||||
|
duration: TimeInterval = 3600) -> SnapshotEvent {
|
||||||
|
SnapshotEvent(id: id, title: id,
|
||||||
|
start: now.addingTimeInterval(startOffset),
|
||||||
|
end: now.addingTimeInterval(startOffset + duration),
|
||||||
|
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: now,
|
||||||
|
coverageStart: now.addingTimeInterval(-86400),
|
||||||
|
coverageEnd: now.addingTimeInterval(14 * 86400),
|
||||||
|
isLoggedIn: true, writerVersion: "1.0", events: events,
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The next event is the first one that has not ended yet")
|
||||||
|
func picksFirstUnfinished() {
|
||||||
|
let snap = Self.snapshot([Self.event("past", startOffset: -7200),
|
||||||
|
Self.event("running", startOffset: -600),
|
||||||
|
Self.event("later", startOffset: 3600)])
|
||||||
|
|
||||||
|
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty calendar has no next event")
|
||||||
|
func emptyHasNoIdentity() {
|
||||||
|
#expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Events are considered in start order regardless of input order")
|
||||||
|
func sortsBeforePicking() {
|
||||||
|
let snap = Self.snapshot([Self.event("late", startOffset: 7200),
|
||||||
|
Self.event("soon", startOffset: 1800)])
|
||||||
|
|
||||||
|
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unchanged next event does not spend a push")
|
||||||
|
func unchangedDoesNotPush() {
|
||||||
|
let identity = NextEventIdentity(id: "a", start: Self.now)
|
||||||
|
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A different event, a moved event, or a first event all spend a push")
|
||||||
|
func changesPush() {
|
||||||
|
let a = NextEventIdentity(id: "a", start: Self.now)
|
||||||
|
let b = NextEventIdentity(id: "b", start: Self.now)
|
||||||
|
let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600))
|
||||||
|
|
||||||
|
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b))
|
||||||
|
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved))
|
||||||
|
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Losing the last event spends a push, so the watch stops showing it")
|
||||||
|
func clearingPushes() {
|
||||||
|
let a = NextEventIdentity(id: "a", start: Self.now)
|
||||||
|
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Two empty calendars in a row do not push")
|
||||||
|
func stayingEmptyDoesNotPush() {
|
||||||
|
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
/// The phone and the watch ship on independent schedules, so the transport gets
|
||||||
|
/// the same treatment as the file format: refuse a newer schema rather than
|
||||||
|
/// mis-decode it.
|
||||||
|
struct WatchTransportTests {
|
||||||
|
|
||||||
|
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||||
|
|
||||||
|
private static func snapshot() -> CalendarrSnapshot {
|
||||||
|
CalendarrSnapshot(writtenAt: now,
|
||||||
|
coverageStart: now.addingTimeInterval(-86400),
|
||||||
|
coverageEnd: now.addingTimeInterval(14 * 86400),
|
||||||
|
isLoggedIn: true, writerVersion: "1.2",
|
||||||
|
events: [SnapshotEvent(id: "a", title: "Zahnarzt",
|
||||||
|
start: now.addingTimeInterval(3600),
|
||||||
|
end: now.addingTimeInterval(7200),
|
||||||
|
isAllDay: false, colorHex: "#4285f4",
|
||||||
|
location: "Bern", calendarKey: "local:3")],
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A payload survives a round trip")
|
||||||
|
func roundTrips() throws {
|
||||||
|
let payload = WatchTransportPayload(
|
||||||
|
snapshot: Self.snapshot(),
|
||||||
|
calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")],
|
||||||
|
session: SharedSession(baseURL: "https://cal.example.com", username: "guido",
|
||||||
|
isLoggedIn: true, writtenAt: Self.now))
|
||||||
|
|
||||||
|
let decoded = try WatchTransportPayload.decode(try payload.encoded())
|
||||||
|
|
||||||
|
#expect(decoded.snapshot.events.map(\.id) == ["a"])
|
||||||
|
#expect(decoded.snapshot.language == "de")
|
||||||
|
#expect(decoded.calendars.map(\.name) == ["Privat"])
|
||||||
|
#expect(decoded.session?.username == "guido")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A payload with no session record still decodes")
|
||||||
|
func sessionIsOptional() throws {
|
||||||
|
let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil)
|
||||||
|
let decoded = try WatchTransportPayload.decode(try payload.encoded())
|
||||||
|
|
||||||
|
#expect(decoded.session == nil)
|
||||||
|
#expect(decoded.calendars.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The encoded form only contains property-list types")
|
||||||
|
func encodesToPlistTypes() throws {
|
||||||
|
let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
|
||||||
|
|
||||||
|
#expect(dict[WatchTransportKey.schema] is Int)
|
||||||
|
#expect(dict[WatchTransportKey.snapshot] is Data)
|
||||||
|
#expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A newer schema is refused rather than guessed at")
|
||||||
|
func refusesNewerSchema() throws {
|
||||||
|
var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
|
||||||
|
dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1
|
||||||
|
|
||||||
|
#expect(throws: WatchTransportError.unsupportedSchema(
|
||||||
|
found: WatchTransportPayload.maxSupportedSchema + 1,
|
||||||
|
maxSupported: WatchTransportPayload.maxSupportedSchema)) {
|
||||||
|
try WatchTransportPayload.decode(dict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A payload without a snapshot is rejected")
|
||||||
|
func rejectsMissingSnapshot() {
|
||||||
|
let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema]
|
||||||
|
|
||||||
|
#expect(throws: WatchTransportError.missingSnapshot) {
|
||||||
|
try WatchTransportPayload.decode(dict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import CalendarrCore
|
||||||
|
|
||||||
|
/// The watch payload is a narrower snapshot, not a different one. These tests
|
||||||
|
/// pin down the part that is easy to get wrong: the coverage window has to
|
||||||
|
/// shrink with the events, or the watch renders an empty week it knows nothing
|
||||||
|
/// about.
|
||||||
|
struct WatchTrimmingTests {
|
||||||
|
|
||||||
|
private static let cal = Calendar(identifier: .gregorian)
|
||||||
|
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
|
||||||
|
|
||||||
|
private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent {
|
||||||
|
let dayStart = cal.startOfDay(for: now)
|
||||||
|
let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)!
|
||||||
|
let start = cal.date(byAdding: .hour, value: hour, to: day)!
|
||||||
|
return SnapshotEvent(id: id, title: "Event \(id)",
|
||||||
|
start: start, end: start.addingTimeInterval(3600),
|
||||||
|
isAllDay: false, colorHex: "#4285f4",
|
||||||
|
location: "", calendarKey: "local:1")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
|
||||||
|
let dayStart = cal.startOfDay(for: now)
|
||||||
|
return CalendarrSnapshot(
|
||||||
|
writtenAt: now,
|
||||||
|
coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!,
|
||||||
|
coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!,
|
||||||
|
isLoggedIn: true,
|
||||||
|
writerVersion: "1.0",
|
||||||
|
events: events,
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The window narrows to the watch policy, not just the events")
|
||||||
|
func narrowsWindow() {
|
||||||
|
let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
|
||||||
|
#expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart))
|
||||||
|
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Events outside the watch window are dropped")
|
||||||
|
func dropsEventsOutsideWindow() {
|
||||||
|
let inside = Self.event("in", dayOffset: 3)
|
||||||
|
let outside = Self.event("out", dayOffset: 30)
|
||||||
|
let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.events.map(\.id) == ["in"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An event still running at the window start is kept")
|
||||||
|
func keepsRunningEvent() {
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)!
|
||||||
|
let running = SnapshotEvent(id: "running", title: "Urlaub",
|
||||||
|
start: start,
|
||||||
|
end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!,
|
||||||
|
isAllDay: true, colorHex: "#4285f4",
|
||||||
|
location: "", calendarKey: "local:1")
|
||||||
|
let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.events.map(\.id) == ["running"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Metadata survives trimming unchanged")
|
||||||
|
func preservesMetadata() {
|
||||||
|
let original = Self.snapshot([Self.event("a", dayOffset: 1)])
|
||||||
|
let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.language == "de")
|
||||||
|
#expect(trimmed.isLoggedIn == true)
|
||||||
|
#expect(trimmed.writerVersion == "1.0")
|
||||||
|
#expect(trimmed.theme.accent == "#6")
|
||||||
|
#expect(trimmed.writtenAt == original.writtenAt)
|
||||||
|
#expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("When the event cap truncates, coverageEnd pulls back to what we can prove")
|
||||||
|
func capPullsBackCoverage() {
|
||||||
|
var many: [SnapshotEvent] = []
|
||||||
|
for day in 0..<14 {
|
||||||
|
for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) }
|
||||||
|
}
|
||||||
|
#expect(many.count > WatchCoverage.maxEvents)
|
||||||
|
|
||||||
|
let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.events.count <= WatchCoverage.maxEvents)
|
||||||
|
#expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd })
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)!
|
||||||
|
#expect(trimmed.coverageEnd < fullEnd)
|
||||||
|
#expect(trimmed.coverageEnd > trimmed.coverageStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Trimming never widens a window the writer already narrowed")
|
||||||
|
func neverWidens() {
|
||||||
|
let dayStart = Self.cal.startOfDay(for: Self.now)
|
||||||
|
let narrow = CalendarrSnapshot(
|
||||||
|
writtenAt: Self.now,
|
||||||
|
coverageStart: dayStart,
|
||||||
|
coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!,
|
||||||
|
isLoggedIn: true, writerVersion: "1.0", events: [],
|
||||||
|
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
|
||||||
|
line: "#4", primary: "#5", accent: "#6"),
|
||||||
|
language: "de")
|
||||||
|
|
||||||
|
let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal)
|
||||||
|
|
||||||
|
#expect(trimmed.coverageStart == dayStart)
|
||||||
|
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ Configuration is done via environment variables. The `install.sh` script generat
|
|||||||
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
|
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
|
||||||
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
|
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
|
||||||
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
|
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
|
||||||
|
| `MAIL_POLL_MINUTES` | `5` | How often the watched mailbox is read |
|
||||||
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
|
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
|
||||||
|
|
||||||
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
|
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
|
||||||
@@ -239,6 +240,26 @@ The server runs two periodic jobs in-process:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record |
|
| `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record |
|
||||||
| `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due |
|
| `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due |
|
||||||
|
| `mail.invites` | 5 min | Reads the watched mailbox for invitations *(idle until one is configured)* |
|
||||||
|
|
||||||
|
#### Invitations by mail
|
||||||
|
|
||||||
|
With a mailbox configured, invitations sent to it appear under **Requests** and
|
||||||
|
become calendar entries only once someone accepts them. Nothing a mail contains
|
||||||
|
is written to a calendar on its own.
|
||||||
|
|
||||||
|
An invitation is matched to a user by its `ATTENDEE` address, never by the
|
||||||
|
sender — compared against that user's `email` plus any additional addresses.
|
||||||
|
That matters when mail is forwarded: the invitation still names the original
|
||||||
|
address, not the mailbox it arrived in, so put the address people actually
|
||||||
|
invite into the user's profile.
|
||||||
|
|
||||||
|
The mailbox is set up under **Settings → Invitations** by an administrator: IMAP
|
||||||
|
host, user, password, folder and how often to check. One mailbox per instance —
|
||||||
|
it is a shared forwarding target, not a per-user account. The password is stored
|
||||||
|
the same way CalDAV, Google and Home Assistant credentials are, and no endpoint
|
||||||
|
returns it. Worth knowing before you point this at a real account: unlike those
|
||||||
|
three, an IMAP password opens an entire mailbox.
|
||||||
|
|
||||||
Attachments are normally cleaned up the moment an event, calendar or user is
|
Attachments are normally cleaned up the moment an event, calendar or user is
|
||||||
deleted; the sweep is the safety net for what gets past that — an external
|
deleted; the sweep is the safety net for what gets past that — an external
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ class CalendarRepository @Inject constructor(
|
|||||||
val body = jsonBody("reminders_enabled" to enabled)
|
val body = jsonBody("reminders_enabled" to enabled)
|
||||||
when (source) {
|
when (source) {
|
||||||
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
|
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
|
||||||
"local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess()
|
"local" -> api.setLocalCalendarReminders(calendarId, body).ensureSuccess()
|
||||||
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
|
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
|
||||||
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
|
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
|
||||||
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
|
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
|
||||||
|
|||||||
@@ -192,6 +192,11 @@ interface CalendarrApi {
|
|||||||
@PUT("api/local/calendars/{id}/color")
|
@PUT("api/local/calendars/{id}/color")
|
||||||
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
|
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
|
||||||
|
|
||||||
|
// Reminder-only update that works for owners AND share recipients
|
||||||
|
// (recipients get their own per-user mute; owners flip the calendar's flag).
|
||||||
|
@PUT("api/local/calendars/{id}/reminders")
|
||||||
|
suspend fun setLocalCalendarReminders(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
|
||||||
|
|
||||||
@DELETE("api/local/calendars/{id}")
|
@DELETE("api/local/calendars/{id}")
|
||||||
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
|
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -490,6 +490,11 @@ class CalendarViewModel @Inject constructor(
|
|||||||
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
|
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
|
||||||
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
|
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
|
||||||
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
|
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
|
||||||
|
// Needed for the reminder flag, which exists on all five sources. A
|
||||||
|
// failed fetch yields an empty list, which simply reconciles nothing —
|
||||||
|
// never a wrongly cleared mute.
|
||||||
|
val local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
|
||||||
|
val ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList())
|
||||||
|
|
||||||
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
|
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
|
||||||
fun apply(source: String, id: Int, serverHidden: Boolean) {
|
fun apply(source: String, id: Int, serverHidden: Boolean) {
|
||||||
@@ -500,9 +505,25 @@ class CalendarViewModel @Inject constructor(
|
|||||||
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
|
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
|
||||||
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
|
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
|
||||||
|
|
||||||
if (banished == settingsStore.banishedCalendarKeys) return false
|
// Reminders were only ever pushed app -> server, so a mute set on the
|
||||||
settingsStore.banishedCalendarKeys = banished
|
// web or on iOS never arrived here.
|
||||||
_state.update { it.copy(banishedKeys = banished) }
|
val muted = settingsStore.reminderDisabledCalendarKeys.toMutableSet()
|
||||||
|
fun applyReminders(source: String, id: Int, enabled: Boolean) {
|
||||||
|
val key = calendarKey(source, id.toString())
|
||||||
|
if (enabled) muted.remove(key) else muted.add(key)
|
||||||
|
}
|
||||||
|
local.forEach { applyReminders("local", it.id, it.remindersEnabled) }
|
||||||
|
ical.forEach { applyReminders("ical", it.id, it.remindersEnabled) }
|
||||||
|
caldav.forEach { acc -> acc.calendars?.forEach { applyReminders("caldav", it.id, it.remindersEnabled) } }
|
||||||
|
google.forEach { acc -> acc.calendars?.forEach { applyReminders("google", it.id, it.remindersEnabled) } }
|
||||||
|
ha.forEach { acc -> acc.calendars?.forEach { applyReminders("homeassistant", it.id, it.remindersEnabled) } }
|
||||||
|
|
||||||
|
val banishedChanged = banished != settingsStore.banishedCalendarKeys
|
||||||
|
val mutedChanged = muted != settingsStore.reminderDisabledCalendarKeys
|
||||||
|
if (!banishedChanged && !mutedChanged) return false
|
||||||
|
if (banishedChanged) settingsStore.banishedCalendarKeys = banished
|
||||||
|
if (mutedChanged) settingsStore.reminderDisabledCalendarKeys = muted
|
||||||
|
_state.update { it.copy(banishedKeys = banished, reminderDisabledKeys = muted) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,8 +473,10 @@ private fun ReminderRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors the server allowlist. "*/*" is deliberately absent — the picker
|
// Mirrors the server allowlist. The catch-all wildcard type is deliberately
|
||||||
* should not offer files the upload would reject. */
|
// absent — the picker should not offer files the upload would reject.
|
||||||
|
// (Written as line comments: spelling that wildcard inside a block comment
|
||||||
|
// closes it early, which is what used to break this file.)
|
||||||
private val ATTACHMENT_MIME_TYPES = arrayOf(
|
private val ATTACHMENT_MIME_TYPES = arrayOf(
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif",
|
"image/png", "image/jpeg", "image/webp", "image/gif",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Repo: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git
|
|||||||
Builds:
|
Builds:
|
||||||
- versionName: 1.1.0
|
- versionName: 1.1.0
|
||||||
versionCode: 2
|
versionCode: 2
|
||||||
commit: v1.1.0
|
commit: dd347a07f1c7c5fc55cc8bfb7fdfc5c5c020793f
|
||||||
subdir: app
|
subdir: app
|
||||||
gradle:
|
gradle:
|
||||||
- yes
|
- yes
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
|
||||||
|
toolchainVendor=JETBRAINS
|
||||||
|
toolchainVersion=21
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -5,6 +5,9 @@ pluginManagement {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
||||||
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import caldav
|
import caldav
|
||||||
from icalendar import Calendar, Event, vDatetime, vRecur
|
from icalendar import Calendar, Event, vDatetime
|
||||||
|
|
||||||
|
# One parser for both directions: this file used to carry its own copy with
|
||||||
|
# the same UNTIL defect.
|
||||||
|
from ical_io import _rrule_str_to_vrecur as _parse_rrule_str
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -306,20 +310,6 @@ def delete_event(url: str, username: str, password: str, event_url: str,
|
|||||||
resource.delete()
|
resource.delete()
|
||||||
|
|
||||||
|
|
||||||
def _parse_rrule_str(rrule_str: str) -> vRecur:
|
|
||||||
"""Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
|
|
||||||
params = {}
|
|
||||||
for part in rrule_str.split(";"):
|
|
||||||
if "=" not in part:
|
|
||||||
continue
|
|
||||||
key, val = part.split("=", 1)
|
|
||||||
if "," in val:
|
|
||||||
params[key] = val.split(",")
|
|
||||||
else:
|
|
||||||
params[key] = val
|
|
||||||
return vRecur(params)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(s: str) -> datetime:
|
def _parse_dt(s: str) -> datetime:
|
||||||
s = s.replace("Z", "+00:00")
|
s = s.replace("Z", "+00:00")
|
||||||
dt = datetime.fromisoformat(s)
|
dt = datetime.fromisoformat(s)
|
||||||
|
|||||||
+67
-10
@@ -60,8 +60,50 @@ def _organizer_name(component) -> str | None:
|
|||||||
return raw or None
|
return raw or None
|
||||||
|
|
||||||
|
|
||||||
|
def _cal_address(prop) -> str | None:
|
||||||
|
"""The bare lowercase address of an ORGANIZER/ATTENDEE property."""
|
||||||
|
if prop is None:
|
||||||
|
return None
|
||||||
|
raw = str(prop).strip()
|
||||||
|
if raw.lower().startswith("mailto:"):
|
||||||
|
raw = raw[7:]
|
||||||
|
raw = raw.strip().strip("<>").lower()
|
||||||
|
return raw or None
|
||||||
|
|
||||||
|
|
||||||
|
def _attendees(component) -> list[dict]:
|
||||||
|
"""The ATTENDEE list with the parameters an iTIP reply needs.
|
||||||
|
|
||||||
|
icalendar hands back a single property when there is one attendee and a
|
||||||
|
list when there are several, so both shapes have to be normalised.
|
||||||
|
"""
|
||||||
|
raw = component.get("ATTENDEE")
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
items = raw if isinstance(raw, list) else [raw]
|
||||||
|
out = []
|
||||||
|
for prop in items:
|
||||||
|
email = _cal_address(prop)
|
||||||
|
if not email:
|
||||||
|
continue
|
||||||
|
params = getattr(prop, "params", {}) or {}
|
||||||
|
out.append({
|
||||||
|
"email": email,
|
||||||
|
"cn": str(params.get("CN")) if params.get("CN") else None,
|
||||||
|
"partstat": str(params.get("PARTSTAT") or "NEEDS-ACTION").upper(),
|
||||||
|
"role": str(params.get("ROLE") or "REQ-PARTICIPANT").upper(),
|
||||||
|
"rsvp": str(params.get("RSVP") or "").upper() == "TRUE",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def parse_ics(raw: bytes) -> dict:
|
def parse_ics(raw: bytes) -> dict:
|
||||||
"""Parse .ics bytes into {"events": [dict, ...], "errors": [str, ...]}.
|
"""Parse .ics bytes into {"method": str|None, "events": [...], "errors": [...]}.
|
||||||
|
|
||||||
|
``method`` sits on the VCALENDAR, not on a VEVENT, so it is read outside the
|
||||||
|
component loop — it is the only reliable way to tell an invitation
|
||||||
|
(METHOD:REQUEST) from a plain calendar file. Every key added for iTIP is
|
||||||
|
additive: existing callers read the ones they already knew.
|
||||||
|
|
||||||
Raises ValueError if the payload is not a parseable calendar at all.
|
Raises ValueError if the payload is not a parseable calendar at all.
|
||||||
"""
|
"""
|
||||||
@@ -127,21 +169,33 @@ def parse_ics(raw: bytes) -> dict:
|
|||||||
"rrule": _rrule_to_str(component),
|
"rrule": _rrule_to_str(component),
|
||||||
"exdate": _exdate_to_csv(component),
|
"exdate": _exdate_to_csv(component),
|
||||||
"organizer": _organizer_name(component),
|
"organizer": _organizer_name(component),
|
||||||
|
"organizer_email": _cal_address(component.get("ORGANIZER")),
|
||||||
|
"attendees": _attendees(component),
|
||||||
|
"sequence": int(component.get("SEQUENCE", 0) or 0),
|
||||||
|
# A single occurrence of a series. Applying one as if it were
|
||||||
|
# the whole series would silently replace every other date.
|
||||||
|
"recurrence_id": (str(component.get("RECURRENCE-ID").dt)
|
||||||
|
if component.get("RECURRENCE-ID") is not None else None),
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Skipping malformed VEVENT: %s", exc)
|
logger.warning("Skipping malformed VEVENT: %s", exc)
|
||||||
errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
|
errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
|
||||||
return {"events": events, "errors": errors}
|
method = str(cal.get("METHOD") or "").upper() or None
|
||||||
|
return {"method": method, "events": events, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
|
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
|
||||||
params = {}
|
"""Parse an RRULE string into a vRecur with correctly typed values.
|
||||||
for part in rrule_str.split(";"):
|
|
||||||
if "=" not in part:
|
Hand-splitting on ";" and "=" leaves every value a str, which icalendar
|
||||||
continue
|
accepts silently here and then rejects inside ``to_ical()``: vRecur.types
|
||||||
key, val = part.split("=", 1)
|
maps UNTIL to vDDDTypes, and that demands a date. A single "repeats until
|
||||||
params[key] = val.split(",") if "," in val else val
|
<date>" event therefore made the whole calendar fail to serialise — a 500
|
||||||
return vRecur(params)
|
on REPORT, on GET and on export, while PROPFIND kept working because it
|
||||||
|
never builds ICS. Letting icalendar parse its own format gives UNTIL as a
|
||||||
|
datetime and COUNT/INTERVAL as ints.
|
||||||
|
"""
|
||||||
|
return vRecur(vRecur.from_ical(rrule_str))
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(s: str) -> datetime:
|
def _parse_iso(s: str) -> datetime:
|
||||||
@@ -198,7 +252,10 @@ def build_ics(calendar, events, *, name_cache: dict | None = None,
|
|||||||
if ev.color:
|
if ev.color:
|
||||||
item.add("x-calendarr-color", ev.color)
|
item.add("x-calendarr-color", ev.color)
|
||||||
if ev.rrule:
|
if ev.rrule:
|
||||||
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
|
try:
|
||||||
|
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Skipping unparsable RRULE %r on event %s", ev.rrule, ev.id)
|
||||||
|
|
||||||
# ORGANIZER from the creator (local user or imported name).
|
# ORGANIZER from the creator (local user or imported name).
|
||||||
organizer_name = None
|
organizer_name = None
|
||||||
|
|||||||
@@ -158,7 +158,11 @@ def build_local_event_dict(
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
d["display_title"] = display
|
d["display_title"] = display
|
||||||
if not d["reminders"] and cal.birthday_notify_days_before is not None:
|
# Honour the calendar's mute: injecting a reminder here would hand the
|
||||||
|
# schedulers something to fire for a calendar the user silenced.
|
||||||
|
if (not d["reminders"]
|
||||||
|
and cal.birthday_notify_days_before is not None
|
||||||
|
and getattr(cal, "reminders_enabled", True)):
|
||||||
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
|
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
|
||||||
if owner is not None:
|
if owner is not None:
|
||||||
d["owner"] = owner
|
d["owner"] = owner
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""Pull iTIP invitations out of a watched mailbox.
|
||||||
|
|
||||||
|
A mailbox is a trust boundary this app did not have before: anyone who knows the
|
||||||
|
address can hand the server an iCalendar file. So the rule throughout is that a
|
||||||
|
mail can only ever create a row in ``event_invites``. No calendar read path
|
||||||
|
touches that table — an invitation becomes an event when a human accepts it, and
|
||||||
|
not before.
|
||||||
|
|
||||||
|
The mailbox is configured in the UI and stored in ``mailbox_accounts`` — one row
|
||||||
|
per instance, since it is a shared forwarding target rather than a per-user
|
||||||
|
account. The password is kept as given, like every other external account here;
|
||||||
|
no endpoint returns it.
|
||||||
|
|
||||||
|
Single process assumed, same as the rest of the scheduler (see scheduler.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import imaplib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email import message_from_bytes
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import dav_util
|
||||||
|
import ical_io
|
||||||
|
import models
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# An invitation is a few kilobytes. These caps exist so one oversized mail
|
||||||
|
# cannot exhaust memory, and so a flooded mailbox cannot monopolise a run.
|
||||||
|
MAX_MESSAGE_BYTES = 1 * 1024 * 1024
|
||||||
|
MAX_ICS_BYTES = 256 * 1024
|
||||||
|
MAX_MESSAGES_PER_RUN = 25
|
||||||
|
MAX_CAL_PARTS = 3
|
||||||
|
|
||||||
|
# Checked before an address is ever put into a mail header. Rejecting the
|
||||||
|
# characters that could break a header out is cheaper than trusting the
|
||||||
|
# formatter, and EmailMessage refuses embedded newlines on top of it.
|
||||||
|
_ADDR_RE = re.compile(r"^[^@\s<>\",;:]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
|
||||||
|
|
||||||
|
_HANDLED_METHODS = ("REQUEST", "CANCEL")
|
||||||
|
|
||||||
|
|
||||||
|
def get_account(db: Session):
|
||||||
|
"""The configured mailbox, or None. Disabled counts as not configured."""
|
||||||
|
return (db.query(models.MailboxAccount)
|
||||||
|
.filter(models.MailboxAccount.enabled.is_(True))
|
||||||
|
.order_by(models.MailboxAccount.id).first())
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured(db: Session) -> bool:
|
||||||
|
return get_account(db) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_address(addr: str | None) -> str | None:
|
||||||
|
if not addr:
|
||||||
|
return None
|
||||||
|
addr = addr.strip().lower()
|
||||||
|
return addr if _ADDR_RE.match(addr) else None
|
||||||
|
|
||||||
|
|
||||||
|
def user_for_address(db: Session, addr: str | None):
|
||||||
|
"""The user who owns this address, or None.
|
||||||
|
|
||||||
|
Reads the dedicated table and `users.email` both, so someone whose
|
||||||
|
invitation address is already in their profile needs no extra setup.
|
||||||
|
"""
|
||||||
|
addr = _valid_address(addr)
|
||||||
|
if not addr:
|
||||||
|
return None
|
||||||
|
row = (db.query(models.UserEmailAddress)
|
||||||
|
.filter(models.UserEmailAddress.address == addr).first())
|
||||||
|
if row:
|
||||||
|
return row.user
|
||||||
|
return (db.query(models.User)
|
||||||
|
.filter(func.lower(models.User.email) == addr).first())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ingest ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _match_attendee(db: Session, item: dict):
|
||||||
|
"""Which user this invitation is for, read from the ATTENDEE list.
|
||||||
|
|
||||||
|
Never from the From header: a mail can claim any sender it likes, and that
|
||||||
|
header is what an attacker controls. The ATTENDEE list is what the organiser
|
||||||
|
put in the payload, matched against addresses a user has proven are theirs.
|
||||||
|
"""
|
||||||
|
# Our own invitation coming back to us is not an invitation to accept.
|
||||||
|
if user_for_address(db, item.get("organizer_email")):
|
||||||
|
return None, None
|
||||||
|
for att in item.get("attendees") or ():
|
||||||
|
user = user_for_address(db, att.get("email"))
|
||||||
|
if user:
|
||||||
|
return user, _valid_address(att.get("email"))
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_request(db: Session, user, attendee: str, item: dict, raw_ics: str) -> None:
|
||||||
|
inv = (db.query(models.EventInvite)
|
||||||
|
.filter(models.EventInvite.user_id == user.id,
|
||||||
|
models.EventInvite.uid == item["uid"]).first())
|
||||||
|
sequence = int(item.get("sequence") or 0)
|
||||||
|
if inv and sequence <= inv.sequence and inv.status != "cancelled":
|
||||||
|
return # Forwarded mail arrives twice; the same SEQUENCE is the same news.
|
||||||
|
|
||||||
|
if inv is None:
|
||||||
|
inv = models.EventInvite(user_id=user.id, uid=item["uid"])
|
||||||
|
db.add(inv)
|
||||||
|
|
||||||
|
inv.sequence = sequence
|
||||||
|
inv.title = (item.get("title") or "(ohne Titel)")[:255]
|
||||||
|
inv.start = item["start"]
|
||||||
|
inv.end = item["end"]
|
||||||
|
inv.all_day = bool(item.get("all_day"))
|
||||||
|
inv.location = (item.get("location") or None) and item["location"][:500]
|
||||||
|
inv.description = (item.get("description") or None) and item["description"][:8000]
|
||||||
|
inv.rrule = item.get("rrule")
|
||||||
|
inv.organizer_email = _valid_address(item.get("organizer_email"))
|
||||||
|
inv.organizer_name = (item.get("organizer") or None) and item["organizer"][:255]
|
||||||
|
inv.attendee_email = attendee
|
||||||
|
inv.raw_ics = raw_ics
|
||||||
|
inv.received_at = datetime.now(timezone.utc)
|
||||||
|
# A changed invitation is a new decision, so an already answered one goes
|
||||||
|
# back to pending. The event created earlier stays until the user answers
|
||||||
|
# again — this never rewrites a calendar entry behind their back.
|
||||||
|
inv.status = "pending"
|
||||||
|
inv.reply_state = None
|
||||||
|
db.commit()
|
||||||
|
logger.info("Einladung %s für Nutzer %s gespeichert (SEQUENCE %s)",
|
||||||
|
item["uid"], user.id, sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_cancel(db: Session, user, item: dict) -> None:
|
||||||
|
inv = (db.query(models.EventInvite)
|
||||||
|
.filter(models.EventInvite.user_id == user.id,
|
||||||
|
models.EventInvite.uid == item["uid"]).first())
|
||||||
|
if inv is None:
|
||||||
|
return
|
||||||
|
drop_event(db, inv)
|
||||||
|
inv.status = "cancelled"
|
||||||
|
db.commit()
|
||||||
|
logger.info("Einladung %s zurückgezogen", item["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def drop_event(db: Session, inv) -> None:
|
||||||
|
"""Remove the event an acceptance created, if it still exists."""
|
||||||
|
if not inv.event_id:
|
||||||
|
return
|
||||||
|
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == inv.event_id).first()
|
||||||
|
if ev:
|
||||||
|
cal = (db.query(models.LocalCalendar)
|
||||||
|
.filter(models.LocalCalendar.id == ev.calendar_id).first())
|
||||||
|
db.delete(ev)
|
||||||
|
if cal:
|
||||||
|
dav_util.bump_dav(cal)
|
||||||
|
inv.event_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_ics(db: Session, raw: bytes) -> bool:
|
||||||
|
"""Store one invitation from an iCalendar payload. True when something was."""
|
||||||
|
if len(raw) > MAX_ICS_BYTES:
|
||||||
|
logger.info("Kalenderteil zu groß (%s Bytes), übersprungen", len(raw))
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = ical_io.parse_ics(raw)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
method = (parsed.get("method") or "").upper()
|
||||||
|
if method not in _HANDLED_METHODS:
|
||||||
|
return False # REPLY/COUNTER/PUBLISH: we are never the organiser.
|
||||||
|
|
||||||
|
text = raw.decode("utf-8", "replace")
|
||||||
|
for item in parsed.get("events") or ():
|
||||||
|
if item.get("recurrence_id"):
|
||||||
|
# One occurrence of a series. Applying it as the whole series would
|
||||||
|
# silently move every other date.
|
||||||
|
logger.info("Einzeltermin einer Serie übersprungen (UID %s)", item.get("uid"))
|
||||||
|
continue
|
||||||
|
user, attendee = _match_attendee(db, item)
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
if method == "CANCEL":
|
||||||
|
_apply_cancel(db, user, item)
|
||||||
|
else:
|
||||||
|
_apply_request(db, user, attendee, item, text)
|
||||||
|
return True # One invitation per mail; never a bulk import.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mailbox ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(acc) -> imaplib.IMAP4:
|
||||||
|
box = (imaplib.IMAP4_SSL(acc.imap_host, acc.imap_port, timeout=30) if acc.imap_ssl
|
||||||
|
else imaplib.IMAP4(acc.imap_host, acc.imap_port, timeout=30))
|
||||||
|
box.login(acc.imap_user, acc.imap_password)
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_parts(msg) -> list[bytes]:
|
||||||
|
"""Every iCalendar payload in a mail, capped.
|
||||||
|
|
||||||
|
Senders disagree on the content type: a proper invitation is text/calendar,
|
||||||
|
but plenty of clients attach it as application/ics or just a .ics file.
|
||||||
|
"""
|
||||||
|
out: list[bytes] = []
|
||||||
|
for part in msg.walk():
|
||||||
|
if len(out) >= MAX_CAL_PARTS:
|
||||||
|
break
|
||||||
|
ctype = (part.get_content_type() or "").lower()
|
||||||
|
name = (part.get_filename() or "").lower()
|
||||||
|
if ctype in ("text/calendar", "application/ics") or name.endswith(".ics"):
|
||||||
|
try:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if payload:
|
||||||
|
out.append(payload)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_message(db: Session, box: imaplib.IMAP4, uid: bytes) -> None:
|
||||||
|
typ, size_data = box.uid("FETCH", uid, "(RFC822.SIZE)")
|
||||||
|
if typ == "OK" and size_data and size_data[0]:
|
||||||
|
m = re.search(rb"RFC822\.SIZE (\d+)", size_data[0])
|
||||||
|
if m and int(m.group(1)) > MAX_MESSAGE_BYTES:
|
||||||
|
logger.info("Mail %s zu groß (%s Bytes), übersprungen", uid, m.group(1))
|
||||||
|
return
|
||||||
|
|
||||||
|
# PEEK: a plain FETCH sets \Seen on the server even if we crash afterwards,
|
||||||
|
# which would lose the invitation entirely.
|
||||||
|
typ, data = box.uid("FETCH", uid, "(BODY.PEEK[])")
|
||||||
|
if typ != "OK" or not data or not data[0]:
|
||||||
|
return
|
||||||
|
msg = message_from_bytes(data[0][1])
|
||||||
|
|
||||||
|
for payload in _calendar_parts(msg):
|
||||||
|
if ingest_ics(db, payload):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_invites(db: Session) -> None:
|
||||||
|
"""Scheduler job: read new mail and store any invitations it holds.
|
||||||
|
|
||||||
|
Records what happened on the account so the UI can show a mailbox that
|
||||||
|
stopped working, rather than letting invitations quietly never arrive.
|
||||||
|
"""
|
||||||
|
acc = get_account(db)
|
||||||
|
if acc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_run(db, acc)
|
||||||
|
acc.last_error = None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Postfach %s nicht erreichbar", acc.imap_host)
|
||||||
|
acc.last_error = f"{type(exc).__name__}: {exc}"[:500]
|
||||||
|
acc.last_run = datetime.now(timezone.utc)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(db: Session, acc) -> None:
|
||||||
|
box = _connect(acc)
|
||||||
|
try:
|
||||||
|
box.select(acc.imap_folder or "INBOX")
|
||||||
|
# UID commands, not sequence numbers: those shift when a message is
|
||||||
|
# expunged mid-run and we would read the wrong mail.
|
||||||
|
typ, data = box.uid("SEARCH", None, "UNSEEN")
|
||||||
|
if typ != "OK":
|
||||||
|
logger.warning("IMAP SEARCH fehlgeschlagen: %s", typ)
|
||||||
|
return
|
||||||
|
uids = (data[0] or b"").split()[:MAX_MESSAGES_PER_RUN]
|
||||||
|
for uid in uids:
|
||||||
|
try:
|
||||||
|
_handle_message(db, box, uid)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Einladungsmail %s nicht verarbeitbar", uid)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
# Advertising and broken mail get marked too, or every run
|
||||||
|
# retries them forever. Nothing is ever deleted.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
box.uid("STORE", uid, "+FLAGS", "\\Seen")
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
box.logout()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accepting ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_event(db: Session, inv, cal) -> models.LocalEvent:
|
||||||
|
"""Create or update the event an acceptance produces.
|
||||||
|
|
||||||
|
The event gets a fresh UID. LocalEvent.uid is globally unique, so reusing
|
||||||
|
the organiser's would let a mail collide with — and refuse to coexist
|
||||||
|
alongside — an event that is already there. The organiser's UID goes to
|
||||||
|
external_uid, which is what the rest of the app already uses for
|
||||||
|
"this came from somewhere else".
|
||||||
|
"""
|
||||||
|
ev = None
|
||||||
|
if inv.event_id:
|
||||||
|
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == inv.event_id).first()
|
||||||
|
if ev is None:
|
||||||
|
ev = models.LocalEvent(uid=str(uuid.uuid4()),
|
||||||
|
external_uid=f"invite:{inv.uid}",
|
||||||
|
creator_id=inv.user_id)
|
||||||
|
db.add(ev)
|
||||||
|
ev.calendar_id = cal.id
|
||||||
|
ev.title = inv.title
|
||||||
|
ev.start = inv.start
|
||||||
|
ev.end = inv.end
|
||||||
|
ev.all_day = bool(inv.all_day)
|
||||||
|
ev.location = inv.location
|
||||||
|
ev.description = inv.description
|
||||||
|
ev.rrule = inv.rrule
|
||||||
|
ev.creator_name_external = inv.organizer_name
|
||||||
|
ev.etag = dav_util.new_tag()
|
||||||
|
dav_util.bump_dav(cal)
|
||||||
|
db.flush()
|
||||||
|
inv.event_id = ev.id
|
||||||
|
return ev
|
||||||
+24
-1
@@ -18,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|||||||
|
|
||||||
import scheduler
|
import scheduler
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_router, profile_router, settings_router, users_router
|
from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, invites_router, local_router, oidc_router, profile_router, settings_router, users_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -383,6 +383,28 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── Theme inheritance ────────────────────────────────────────────
|
||||||
|
# These five colours used to carry a hardcoded column default, so every
|
||||||
|
# user row held a concrete value and the admin instance default could
|
||||||
|
# never apply. Clear the ones that still hold exactly that old default
|
||||||
|
# so they inherit again. A user who deliberately picked the same colour
|
||||||
|
# also inherits now — which is the same colour, until an admin changes
|
||||||
|
# the instance default.
|
||||||
|
for col, old_default in (("primary_color", "#4285f4"),
|
||||||
|
("accent_color", "#ea4335"),
|
||||||
|
("today_color", "#4285f4"),
|
||||||
|
("month_divider_color", "#7090c0"),
|
||||||
|
("month_label_color", "#7090c0")):
|
||||||
|
try:
|
||||||
|
conn.execute(text(
|
||||||
|
f"UPDATE user_settings SET {col} = NULL "
|
||||||
|
f"WHERE lower({col}) = lower(:old)"
|
||||||
|
), {"old": old_default})
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
|
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
|
||||||
@@ -438,6 +460,7 @@ app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
|||||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||||
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
|
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
|
||||||
|
app.include_router(invites_router.router, prefix="/api/invites", tags=["invites"])
|
||||||
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||||
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||||
app.include_router(dav_router.router, tags=["dav"])
|
app.include_router(dav_router.router, tags=["dav"])
|
||||||
|
|||||||
+134
-5
@@ -95,16 +95,19 @@ class UserSettings(Base):
|
|||||||
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
||||||
default_view = Column(String(20), default="month")
|
default_view = Column(String(20), default="month")
|
||||||
week_start_day = Column(String(10), default="monday")
|
week_start_day = Column(String(10), default="monday")
|
||||||
primary_color = Column(String(7), default="#4285f4")
|
# NULL = inherit the admin instance default (see admin_router). A baked-in
|
||||||
accent_color = Column(String(7), default="#ea4335")
|
# column default would mean every user always *has* an own colour, so the
|
||||||
today_color = Column(String(7), default="#4285f4")
|
# instance default could never apply — which is exactly what happened.
|
||||||
|
primary_color = Column(String(7), nullable=True)
|
||||||
|
accent_color = Column(String(7), nullable=True)
|
||||||
|
today_color = Column(String(7), nullable=True)
|
||||||
dim_past_events = Column(Boolean, default=False)
|
dim_past_events = Column(Boolean, default=False)
|
||||||
text_contrast = Column(Integer, default=3)
|
text_contrast = Column(Integer, default=3)
|
||||||
line_contrast = Column(Integer, default=3)
|
line_contrast = Column(Integer, default=3)
|
||||||
hour_height = Column(Integer, default=60)
|
hour_height = Column(Integer, default=60)
|
||||||
language = Column(String(5), default="de")
|
language = Column(String(5), default="de")
|
||||||
month_divider_color = Column(String(7), default="#7090c0")
|
month_divider_color = Column(String(7), nullable=True) # NULL = inherit
|
||||||
month_label_color = Column(String(7), default="#7090c0")
|
month_label_color = Column(String(7), nullable=True) # NULL = inherit
|
||||||
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
|
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
|
||||||
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
|
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
|
||||||
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
|
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
|
||||||
@@ -395,6 +398,114 @@ class HomeAssistantCalendar(Base):
|
|||||||
# ── Collaboration: sharing & groups (local calendars only) ────────────────
|
# ── Collaboration: sharing & groups (local calendars only) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class UserEmailAddress(Base):
|
||||||
|
"""An address under which this user appears in an ATTENDEE list.
|
||||||
|
|
||||||
|
A list rather than a column: the mail arrives at a forwarding mailbox while
|
||||||
|
the invitation still names the original address, and people hand out more
|
||||||
|
than one. Globally unique so nobody can claim someone else's address and
|
||||||
|
push events into their calendar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_email_addresses"
|
||||||
|
__table_args__ = (UniqueConstraint("address", name="uq_user_email_address"),)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
# Always lowercase and without the "mailto:" prefix.
|
||||||
|
address = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxAccount(Base):
|
||||||
|
"""The mailbox watched for invitations, configured in the UI.
|
||||||
|
|
||||||
|
One row per instance: this is a shared inbox that invitations are forwarded
|
||||||
|
to, not a per-user account. Administrators manage it.
|
||||||
|
|
||||||
|
The password is stored as given, the same as CalDAVAccount, GoogleAccount
|
||||||
|
and HomeAssistantAccount — IMAP needs the original to log in, so a hash is
|
||||||
|
no use. Worth knowing: unlike those three, this one opens an entire mailbox.
|
||||||
|
No endpoint ever returns it.
|
||||||
|
|
||||||
|
SMTP lives here too so replies to organisers have somewhere to come from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "mailbox_accounts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
enabled = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
imap_host = Column(String(255), nullable=False)
|
||||||
|
imap_port = Column(Integer, default=993, nullable=False)
|
||||||
|
imap_ssl = Column(Boolean, default=True, nullable=False)
|
||||||
|
imap_user = Column(String(255), nullable=False)
|
||||||
|
imap_password = Column(String(255), nullable=False)
|
||||||
|
imap_folder = Column(String(255), default="INBOX", nullable=False)
|
||||||
|
poll_minutes = Column(Integer, default=5, nullable=False)
|
||||||
|
|
||||||
|
smtp_host = Column(String(255), nullable=True)
|
||||||
|
smtp_port = Column(Integer, default=587, nullable=True)
|
||||||
|
smtp_ssl = Column(Boolean, default=False, nullable=True)
|
||||||
|
smtp_user = Column(String(255), nullable=True)
|
||||||
|
smtp_password = Column(String(255), nullable=True)
|
||||||
|
smtp_from = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Surfaced in the UI so a mailbox that stopped working says so, instead of
|
||||||
|
# invitations quietly never arriving.
|
||||||
|
last_run = Column(DateTime, nullable=True)
|
||||||
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EventInvite(Base):
|
||||||
|
"""An iTIP invitation (METHOD:REQUEST) that arrived by mail.
|
||||||
|
|
||||||
|
Raw *and* parsed. ``raw_ics`` is the only thing the organiser actually sent,
|
||||||
|
and the reply is built from it — a rebuilt DTSTART is what strict
|
||||||
|
counterparts reject. The parsed columns exist so clients can render a list
|
||||||
|
without parsing iCalendar, and use the same formats as LocalEvent.
|
||||||
|
|
||||||
|
Nothing here is visible in any calendar read path: an invitation becomes an
|
||||||
|
event only when a human accepts it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "event_invites"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "uid", name="uq_event_invite_user_uid"),)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
# The organiser's UID. Only ever matched together with user_id, so two
|
||||||
|
# users invited to the same meeting never collide.
|
||||||
|
uid = Column(String(255), nullable=False)
|
||||||
|
sequence = Column(Integer, default=0, nullable=False)
|
||||||
|
# pending | accepted | declined | cancelled
|
||||||
|
status = Column(String(12), default="pending", nullable=False)
|
||||||
|
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
start = Column(String(50), nullable=False)
|
||||||
|
end = Column(String(50), nullable=False)
|
||||||
|
all_day = Column(Boolean, default=False)
|
||||||
|
location = Column(String(500), nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
rrule = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
organizer_email = Column(String(255), nullable=True)
|
||||||
|
organizer_name = Column(String(255), nullable=True)
|
||||||
|
# The ATTENDEE address that matched this user. The reply must name exactly
|
||||||
|
# this one or the organiser will not recognise the answer.
|
||||||
|
attendee_email = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
raw_ics = Column(Text, nullable=False)
|
||||||
|
# The event created on acceptance, so an update or a CANCEL can find it.
|
||||||
|
event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True)
|
||||||
|
received_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
# None | sent | failed | skipped
|
||||||
|
reply_state = Column(String(12), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class CalendarShare(Base):
|
class CalendarShare(Base):
|
||||||
"""A local calendar shared with another Calendarr user."""
|
"""A local calendar shared with another Calendarr user."""
|
||||||
|
|
||||||
@@ -429,6 +540,24 @@ class CalendarColorPref(Base):
|
|||||||
color = Column(String(16), nullable=False)
|
color = Column(String(16), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarReminderPref(Base):
|
||||||
|
"""A user's personal reminder mute for a calendar they don't own — works for
|
||||||
|
any way a foreign calendar becomes visible (direct share, group calendar, or
|
||||||
|
a co-member's group-visible calendar). Absent = reminders on, regardless of
|
||||||
|
what the owner chose: muting is a personal decision and must not spread from
|
||||||
|
the owner to everyone a calendar reaches."""
|
||||||
|
|
||||||
|
__tablename__ = "calendar_reminder_prefs"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_reminder_pref"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
enabled = Column(Boolean, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
|||||||
return list(ids)
|
return list(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def reminder_prefs_for(db: Session, user_id: int) -> dict[int, bool]:
|
||||||
|
"""Map calendar_id -> the user's personal reminder flag for calendars they
|
||||||
|
don't own (any sharing path). Empty when the user set no overrides."""
|
||||||
|
return {
|
||||||
|
p.calendar_id: bool(p.enabled)
|
||||||
|
for p in db.query(models.CalendarReminderPref).filter(
|
||||||
|
models.CalendarReminderPref.user_id == user_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
|
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
|
||||||
"""Map calendar_id -> the user's personal colour for calendars they don't
|
"""Map calendar_id -> the user's personal colour for calendars they don't
|
||||||
own (any sharing path). Empty when the user set no overrides."""
|
own (any sharing path). Empty when the user set no overrides."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -248,6 +248,35 @@ def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_wait_until_listed(url: str, token: str, entity_id: str, data: dict) -> None:
|
||||||
|
"""Block until HA actually lists the event we just created.
|
||||||
|
|
||||||
|
HA confirms the create before its entity's event list reflects it, so a
|
||||||
|
client that refetches straight away misses the new event, the user assumes
|
||||||
|
it failed, retries — and ends up with a duplicate. Waiting here fixes every
|
||||||
|
client at once instead of each one guessing a delay.
|
||||||
|
|
||||||
|
Gives up quietly: the event exists either way, it may just need one more
|
||||||
|
refresh. Never turn a successful create into an error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start = datetime.fromisoformat(_ha_format_dt(data.get("start", "")))
|
||||||
|
end = datetime.fromisoformat(_ha_format_dt(data.get("end", "")))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
title = data.get("title", "")
|
||||||
|
# Widen the window so an all-day event or a timezone edge cannot hide it.
|
||||||
|
window_start, window_end = start - timedelta(days=1), end + timedelta(days=1)
|
||||||
|
for _ in range(8):
|
||||||
|
time.sleep(0.25)
|
||||||
|
try:
|
||||||
|
raw = _ha_get_events(url, token, entity_id, window_start, window_end)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if any(ev.get("summary") == title for ev in raw):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
|
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
|
||||||
"""Update an event. Tries calendar/event/update first; if the integration
|
"""Update an event. Tries calendar/event/update first; if the integration
|
||||||
doesn't support update (e.g. Google Calendar via HA), falls back to
|
doesn't support update (e.g. Google Calendar via HA), falls back to
|
||||||
@@ -697,10 +726,9 @@ def create_event(
|
|||||||
account = cal.account
|
account = cal.account
|
||||||
token = _get_valid_token(account, db)
|
token = _get_valid_token(account, db)
|
||||||
try:
|
try:
|
||||||
_ha_create_event(
|
payload = data.model_dump(exclude_none=True)
|
||||||
account.url, token, cal.entity_id,
|
_ha_create_event(account.url, token, cal.entity_id, payload)
|
||||||
data.model_dump(exclude_none=True),
|
_ha_wait_until_listed(account.url, token, cal.entity_id, payload)
|
||||||
)
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(500, f"HA event create failed: {exc}")
|
raise HTTPException(500, f"HA event create failed: {exc}")
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Invitations that arrived by mail: list them, accept or decline, dismiss.
|
||||||
|
|
||||||
|
The decision is the only thing that turns an invitation into a calendar entry.
|
||||||
|
Everything a mail can do on its own stops at the event_invites table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import mail_invites
|
||||||
|
import models
|
||||||
|
import permissions
|
||||||
|
from auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _invite_dict(inv: models.EventInvite) -> dict:
|
||||||
|
return {
|
||||||
|
"id": inv.id,
|
||||||
|
"uid": inv.uid,
|
||||||
|
"status": inv.status,
|
||||||
|
"title": inv.title,
|
||||||
|
"start": inv.start,
|
||||||
|
"end": inv.end,
|
||||||
|
"all_day": bool(inv.all_day),
|
||||||
|
"location": inv.location,
|
||||||
|
"description": inv.description,
|
||||||
|
"rrule": inv.rrule,
|
||||||
|
"organizer_email": inv.organizer_email,
|
||||||
|
"organizer_name": inv.organizer_name,
|
||||||
|
"attendee_email": inv.attendee_email,
|
||||||
|
"reply_state": inv.reply_state,
|
||||||
|
"received_at": inv.received_at.isoformat() if inv.received_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _own_invite(db: Session, user: models.User, invite_id: int) -> models.EventInvite:
|
||||||
|
inv = (db.query(models.EventInvite)
|
||||||
|
.filter(models.EventInvite.id == invite_id,
|
||||||
|
models.EventInvite.user_id == user.id).first())
|
||||||
|
if inv is None:
|
||||||
|
raise HTTPException(404, "Einladung nicht gefunden")
|
||||||
|
return inv
|
||||||
|
|
||||||
|
|
||||||
|
def _default_invite_calendar(db: Session, user: models.User) -> models.LocalCalendar:
|
||||||
|
"""Where an accepted invitation lands when the client names no calendar.
|
||||||
|
|
||||||
|
The user's own, non-birthday calendar with the lowest id — the same one they
|
||||||
|
would have picked by hand in almost every case. A birthday calendar is
|
||||||
|
excluded because the server injects reminders into those.
|
||||||
|
"""
|
||||||
|
cal = (db.query(models.LocalCalendar)
|
||||||
|
.filter(models.LocalCalendar.user_id == user.id,
|
||||||
|
models.LocalCalendar.is_birthday.isnot(True))
|
||||||
|
.order_by(models.LocalCalendar.id).first())
|
||||||
|
if cal:
|
||||||
|
return cal
|
||||||
|
cal = models.LocalCalendar(user_id=user.id, name="Einladungen", color="#4285f4")
|
||||||
|
db.add(cal)
|
||||||
|
db.flush()
|
||||||
|
return cal
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_invites(db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
"""Everything still awaiting a decision, plus withdrawn ones.
|
||||||
|
|
||||||
|
Cancelled invitations stay visible on purpose: a withdrawal the user never
|
||||||
|
sees is worse than one more row to dismiss.
|
||||||
|
"""
|
||||||
|
rows = (db.query(models.EventInvite)
|
||||||
|
.filter(models.EventInvite.user_id == current_user.id,
|
||||||
|
models.EventInvite.status.in_(("pending", "cancelled")))
|
||||||
|
.order_by(models.EventInvite.start).all())
|
||||||
|
return [_invite_dict(i) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRespond(BaseModel):
|
||||||
|
action: str # accept | decline
|
||||||
|
calendar_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{invite_id}/respond")
|
||||||
|
def respond(invite_id: int, data: InviteRespond,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
inv = _own_invite(db, current_user, invite_id)
|
||||||
|
if data.action not in ("accept", "decline"):
|
||||||
|
raise HTTPException(422, "action muss accept oder decline sein")
|
||||||
|
if inv.status == "cancelled":
|
||||||
|
raise HTTPException(409, "Die Einladung wurde zurückgezogen.")
|
||||||
|
|
||||||
|
cal = None
|
||||||
|
if data.action == "accept":
|
||||||
|
# The target is checked server-side; a client cannot name a calendar it
|
||||||
|
# may not write to.
|
||||||
|
cal = (permissions.accessible_local_calendar(db, current_user, data.calendar_id,
|
||||||
|
require_write=True)
|
||||||
|
if data.calendar_id else _default_invite_calendar(db, current_user))
|
||||||
|
mail_invites.upsert_event(db, inv, cal)
|
||||||
|
inv.status = "accepted"
|
||||||
|
else:
|
||||||
|
mail_invites.drop_event(db, inv)
|
||||||
|
inv.status = "declined"
|
||||||
|
|
||||||
|
# Committed before any mail is attempted: a broken SMTP must never roll back
|
||||||
|
# a decision the user already made.
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "status": inv.status, "reply_state": inv.reply_state,
|
||||||
|
"calendar_id": cal.id if cal else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{invite_id}")
|
||||||
|
def dismiss(invite_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
"""Clear an invitation from the list. An accepted event stays."""
|
||||||
|
inv = _own_invite(db, current_user, invite_id)
|
||||||
|
db.delete(inv)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def status(db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
acc = mail_invites.get_account(db)
|
||||||
|
return {
|
||||||
|
"imap_configured": acc is not None,
|
||||||
|
"smtp_configured": bool(acc and acc.smtp_host),
|
||||||
|
"last_run": acc.last_run.isoformat() if acc and acc.last_run else None,
|
||||||
|
"last_error": acc.last_error if acc else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mailbox configuration ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# One mailbox per instance: it is a shared forwarding target, not a per-user
|
||||||
|
# account, so only administrators may see or change it.
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: models.User) -> None:
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(403, "Nur für Administratoren")
|
||||||
|
|
||||||
|
|
||||||
|
def _mailbox_dict(acc) -> dict | None:
|
||||||
|
if acc is None:
|
||||||
|
return None
|
||||||
|
# Passwords are never returned, the same as the CalDAV account endpoints.
|
||||||
|
return {
|
||||||
|
"id": acc.id,
|
||||||
|
"enabled": bool(acc.enabled),
|
||||||
|
"imap_host": acc.imap_host,
|
||||||
|
"imap_port": acc.imap_port,
|
||||||
|
"imap_ssl": bool(acc.imap_ssl),
|
||||||
|
"imap_user": acc.imap_user,
|
||||||
|
"imap_folder": acc.imap_folder,
|
||||||
|
"poll_minutes": acc.poll_minutes,
|
||||||
|
"has_imap_password": bool(acc.imap_password),
|
||||||
|
"smtp_host": acc.smtp_host,
|
||||||
|
"smtp_port": acc.smtp_port,
|
||||||
|
"smtp_ssl": bool(acc.smtp_ssl),
|
||||||
|
"smtp_user": acc.smtp_user,
|
||||||
|
"smtp_from": acc.smtp_from,
|
||||||
|
"has_smtp_password": bool(acc.smtp_password),
|
||||||
|
"last_run": acc.last_run.isoformat() if acc.last_run else None,
|
||||||
|
"last_error": acc.last_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxUpdate(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
imap_host: str
|
||||||
|
imap_port: int = 993
|
||||||
|
imap_ssl: bool = True
|
||||||
|
imap_user: str
|
||||||
|
# Optional on edit: left out keeps the stored one, so changing a port does
|
||||||
|
# not mean retyping the password.
|
||||||
|
imap_password: Optional[str] = None
|
||||||
|
imap_folder: str = "INBOX"
|
||||||
|
poll_minutes: int = 5
|
||||||
|
smtp_host: Optional[str] = None
|
||||||
|
smtp_port: Optional[int] = 587
|
||||||
|
smtp_ssl: Optional[bool] = False
|
||||||
|
smtp_user: Optional[str] = None
|
||||||
|
smtp_password: Optional[str] = None
|
||||||
|
smtp_from: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mailbox")
|
||||||
|
def get_mailbox(db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
_require_admin(current_user)
|
||||||
|
return _mailbox_dict(
|
||||||
|
db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mailbox")
|
||||||
|
def set_mailbox(data: MailboxUpdate, db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
_require_admin(current_user)
|
||||||
|
acc = db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first()
|
||||||
|
if acc is None:
|
||||||
|
acc = models.MailboxAccount(imap_host="", imap_user="", imap_password="")
|
||||||
|
db.add(acc)
|
||||||
|
|
||||||
|
if not data.imap_password and not acc.imap_password:
|
||||||
|
raise HTTPException(422, "Passwort fehlt")
|
||||||
|
if not 1 <= data.poll_minutes <= 1440:
|
||||||
|
raise HTTPException(422, "Abrufintervall muss zwischen 1 und 1440 Minuten liegen")
|
||||||
|
|
||||||
|
acc.enabled = data.enabled
|
||||||
|
acc.imap_host = data.imap_host.strip()
|
||||||
|
acc.imap_port = data.imap_port
|
||||||
|
acc.imap_ssl = data.imap_ssl
|
||||||
|
acc.imap_user = data.imap_user.strip()
|
||||||
|
if data.imap_password:
|
||||||
|
acc.imap_password = data.imap_password
|
||||||
|
acc.imap_folder = (data.imap_folder or "INBOX").strip()
|
||||||
|
acc.poll_minutes = data.poll_minutes
|
||||||
|
acc.smtp_host = (data.smtp_host or "").strip() or None
|
||||||
|
acc.smtp_port = data.smtp_port
|
||||||
|
acc.smtp_ssl = bool(data.smtp_ssl)
|
||||||
|
acc.smtp_user = (data.smtp_user or "").strip() or None
|
||||||
|
if data.smtp_password:
|
||||||
|
acc.smtp_password = data.smtp_password
|
||||||
|
acc.smtp_from = (data.smtp_from or "").strip() or None
|
||||||
|
# Settings changed, so whatever went wrong before is no longer current.
|
||||||
|
acc.last_error = None
|
||||||
|
db.commit()
|
||||||
|
return _mailbox_dict(acc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mailbox")
|
||||||
|
def delete_mailbox(db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
_require_admin(current_user)
|
||||||
|
acc = db.query(models.MailboxAccount).order_by(models.MailboxAccount.id).first()
|
||||||
|
if acc:
|
||||||
|
db.delete(acc)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/poll")
|
||||||
|
def poll_now(db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user)):
|
||||||
|
"""Fetch right now instead of waiting for the scheduler."""
|
||||||
|
_require_admin(current_user)
|
||||||
|
if not mail_invites.is_configured(db):
|
||||||
|
raise HTTPException(400, "Kein Postfach konfiguriert")
|
||||||
|
mail_invites.fetch_invites(db)
|
||||||
|
acc = mail_invites.get_account(db)
|
||||||
|
return {"ok": True, "last_error": acc.last_error if acc else None}
|
||||||
@@ -15,6 +15,7 @@ import permissions
|
|||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from local_events_util import build_local_event_dict, resolve_creator
|
from local_events_util import build_local_event_dict, resolve_creator
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ class ShareCreate(BaseModel):
|
|||||||
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||||
shared_by: Optional[str] = None, permission: Optional[str] = None,
|
shared_by: Optional[str] = None, permission: Optional[str] = None,
|
||||||
color_override: Optional[str] = None,
|
color_override: Optional[str] = None,
|
||||||
|
reminders_override: Optional[bool] = None,
|
||||||
request: Optional[Request] = None) -> dict:
|
request: Optional[Request] = None) -> dict:
|
||||||
d = {
|
d = {
|
||||||
"id": cal.id,
|
"id": cal.id,
|
||||||
@@ -91,7 +93,9 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
|||||||
"color": color_override or cal.color,
|
"color": color_override or cal.color,
|
||||||
"enabled": cal.enabled,
|
"enabled": cal.enabled,
|
||||||
"sidebar_hidden": bool(cal.sidebar_hidden),
|
"sidebar_hidden": bool(cal.sidebar_hidden),
|
||||||
"reminders_enabled": bool(cal.reminders_enabled),
|
# A recipient's own choice wins over the owner's flag.
|
||||||
|
"reminders_enabled": bool(cal.reminders_enabled)
|
||||||
|
if reminders_override is None else reminders_override,
|
||||||
"caldav_published": bool(cal.caldav_published),
|
"caldav_published": bool(cal.caldav_published),
|
||||||
"is_birthday": bool(cal.is_birthday),
|
"is_birthday": bool(cal.is_birthday),
|
||||||
"birthday_notify_days_before": cal.birthday_notify_days_before,
|
"birthday_notify_days_before": cal.birthday_notify_days_before,
|
||||||
@@ -141,6 +145,7 @@ def list_calendars(
|
|||||||
|
|
||||||
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
|
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
|
||||||
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
||||||
|
reminder_prefs = permissions.reminder_prefs_for(db, current_user.id)
|
||||||
|
|
||||||
# Own calendars
|
# Own calendars
|
||||||
own = (
|
own = (
|
||||||
@@ -175,6 +180,7 @@ def list_calendars(
|
|||||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||||
permission=share.permission,
|
permission=share.permission,
|
||||||
color_override=color_prefs.get(cal.id),
|
color_override=color_prefs.get(cal.id),
|
||||||
|
reminders_override=reminder_prefs.get(cal.id, True),
|
||||||
)
|
)
|
||||||
if cal.id in group_cal_map:
|
if cal.id in group_cal_map:
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
@@ -190,7 +196,8 @@ def list_calendars(
|
|||||||
continue
|
continue
|
||||||
seen_ids.add(cal_id)
|
seen_ids.add(cal_id)
|
||||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
|
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
|
||||||
color_override=color_prefs.get(cal.id))
|
color_override=color_prefs.get(cal.id),
|
||||||
|
reminders_override=reminder_prefs.get(cal.id, True))
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
result.append(d)
|
result.append(d)
|
||||||
|
|
||||||
@@ -207,6 +214,7 @@ def list_calendars(
|
|||||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||||
permission="read",
|
permission="read",
|
||||||
color_override=color_prefs.get(cal.id),
|
color_override=color_prefs.get(cal.id),
|
||||||
|
reminders_override=reminder_prefs.get(cal.id, True),
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
d["group_shared"] = True
|
d["group_shared"] = True
|
||||||
@@ -353,6 +361,48 @@ def set_calendar_color(
|
|||||||
return {"ok": True, "color": data.color}
|
return {"ok": True, "color": data.color}
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarRemindersUpdate(BaseModel):
|
||||||
|
reminders_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/calendars/{calendar_id}/reminders")
|
||||||
|
def set_calendar_reminders(
|
||||||
|
calendar_id: int,
|
||||||
|
data: CalendarRemindersUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mute or unmute a calendar's reminders. The owner flips the calendar's own
|
||||||
|
flag; anyone else who can see it (direct share, group calendar, or a
|
||||||
|
co-member's group-visible calendar) sets only their OWN override and never
|
||||||
|
touches the owner's. The existing PUT /calendars/{id} stays owner-only,
|
||||||
|
which is what keeps a recipient from reaching the owner's flag at all."""
|
||||||
|
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
|
||||||
|
if cal is None:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
if cal.user_id == current_user.id:
|
||||||
|
cal.reminders_enabled = data.reminders_enabled
|
||||||
|
else:
|
||||||
|
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
|
||||||
|
raise HTTPException(403, "You cannot access this calendar")
|
||||||
|
pref = (
|
||||||
|
db.query(models.CalendarReminderPref)
|
||||||
|
.filter(
|
||||||
|
models.CalendarReminderPref.calendar_id == calendar_id,
|
||||||
|
models.CalendarReminderPref.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if pref:
|
||||||
|
pref.enabled = data.reminders_enabled
|
||||||
|
else:
|
||||||
|
db.add(models.CalendarReminderPref(
|
||||||
|
calendar_id=calendar_id, user_id=current_user.id,
|
||||||
|
enabled=data.reminders_enabled))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "reminders_enabled": data.reminders_enabled}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/calendars/{calendar_id}/dav-token/rotate")
|
@router.post("/calendars/{calendar_id}/dav-token/rotate")
|
||||||
def rotate_dav_token(
|
def rotate_dav_token(
|
||||||
calendar_id: int,
|
calendar_id: int,
|
||||||
@@ -790,9 +840,20 @@ def export_calendar(
|
|||||||
atts = attachments_store.by_event(db, [e.id for e in events])
|
atts = attachments_store.by_event(db, [e.id for e in events])
|
||||||
ics = ical_io.build_ics(cal, events, name_cache=name_cache,
|
ics = ical_io.build_ics(cal, events, name_cache=name_cache,
|
||||||
attachments_by_event=atts, base_url=base_url)
|
attachments_by_event=atts, base_url=base_url)
|
||||||
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
|
# RFC 6266, the same way attachments_router already does it: str.isalnum()
|
||||||
|
# is true for "ö", so without an explicit ASCII test the "safe" name carries
|
||||||
|
# the very bytes it exists to avoid — a raw umlaut in an HTTP header, which
|
||||||
|
# a reverse proxy rejects outright. That is what made exporting a calendar
|
||||||
|
# called "Persönlich" fail with no usable error.
|
||||||
|
ascii_name = "".join(
|
||||||
|
c for c in cal.name if c.isascii() and (c.isalnum() or c in " -_")
|
||||||
|
).strip() or "calendar"
|
||||||
|
disposition = (
|
||||||
|
f'attachment; filename="{ascii_name}.ics"; '
|
||||||
|
f"filename*=UTF-8''{quote(cal.name + '.ics')}"
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
content=ics,
|
content=ics,
|
||||||
media_type="text/calendar",
|
media_type="text/calendar",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
|
headers={"Content-Disposition": disposition},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,8 +108,10 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"line_contrast": s.line_contrast or 3,
|
"line_contrast": s.line_contrast or 3,
|
||||||
"hour_height": s.hour_height or 60,
|
"hour_height": s.hour_height or 60,
|
||||||
"language": s.language or "de",
|
"language": s.language or "de",
|
||||||
"month_divider_color": s.month_divider_color or "#7090c0",
|
# NULL stays NULL: the client resolves it against the admin instance
|
||||||
"month_label_color": s.month_label_color or "#7090c0",
|
# default. Substituting a colour here would hide the inheritance.
|
||||||
|
"month_divider_color": s.month_divider_color,
|
||||||
|
"month_label_color": s.month_label_color,
|
||||||
"text_color": s.text_color,
|
"text_color": s.text_color,
|
||||||
"line_color": s.line_color,
|
"line_color": s.line_color,
|
||||||
"bg_color": s.bg_color,
|
"bg_color": s.bg_color,
|
||||||
@@ -186,7 +188,7 @@ def update_settings(
|
|||||||
# For these three override colours, an explicit null is meaningful
|
# For these three override colours, an explicit null is meaningful
|
||||||
# ("reset to default") and must be persisted as NULL. All other fields
|
# ("reset to default") and must be persisted as NULL. All other fields
|
||||||
# keep the previous behaviour where a null/missing value is ignored.
|
# keep the previous behaviour where a null/missing value is ignored.
|
||||||
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
|
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon", "primary_color", "accent_color", "today_color", "month_divider_color", "month_label_color"}
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
||||||
|
|||||||
@@ -133,11 +133,23 @@ def refresh_ical_subscriptions(db: Session) -> None:
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_mail_invites(db: Session) -> None:
|
||||||
|
"""Read the watched mailbox for new invitations."""
|
||||||
|
import mail_invites
|
||||||
|
|
||||||
|
mail_invites.fetch_invites(db)
|
||||||
|
|
||||||
|
|
||||||
def install_default_jobs() -> None:
|
def install_default_jobs() -> None:
|
||||||
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
|
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
|
||||||
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
|
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
|
||||||
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
|
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
|
||||||
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
|
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
|
||||||
|
# Always registered: the mailbox is configured in the UI, so whether there
|
||||||
|
# is one to read can change without a restart. fetch_invites returns at once
|
||||||
|
# when none is set up.
|
||||||
|
mail_minutes = float(os.environ.get("MAIL_POLL_MINUTES", "5"))
|
||||||
|
register("mail.invites", mail_minutes * 60, fetch_mail_invites)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""RRULE serialisation.
|
||||||
|
|
||||||
|
An RRULE whose values stayed strings serialised fine until ``to_ical()``, where
|
||||||
|
icalendar rejects a str for UNTIL. One "repeats until <date>" event therefore
|
||||||
|
returned 500 for every REPORT, GET and export of its calendar while PROPFIND
|
||||||
|
kept succeeding — which is how a Home Assistant calendar ends up discovered
|
||||||
|
under the right name and permanently unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ical_io import _rrule_str_to_vrecur
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule", [
|
||||||
|
"FREQ=WEEKLY;UNTIL=20260501T235959Z",
|
||||||
|
"FREQ=DAILY;UNTIL=20260501T235959Z;INTERVAL=2",
|
||||||
|
"FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20260501T235959Z",
|
||||||
|
])
|
||||||
|
def test_until_survives_serialisation(rule):
|
||||||
|
"""The regression: these used to raise inside to_ical()."""
|
||||||
|
out = _rrule_str_to_vrecur(rule).to_ical().decode()
|
||||||
|
assert "UNTIL=20260501T235959Z" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule", [
|
||||||
|
"FREQ=WEEKLY;BYDAY=MO",
|
||||||
|
"FREQ=DAILY;COUNT=5",
|
||||||
|
"FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2",
|
||||||
|
"FREQ=MONTHLY;BYMONTHDAY=15",
|
||||||
|
])
|
||||||
|
def test_rules_without_until_still_round_trip(rule):
|
||||||
|
"""These always worked; pin them so the fix did not narrow what is accepted."""
|
||||||
|
out = _rrule_str_to_vrecur(rule).to_ical().decode()
|
||||||
|
for part in rule.split(";"):
|
||||||
|
key, val = part.split("=", 1)
|
||||||
|
assert key in out
|
||||||
|
for v in val.split(","):
|
||||||
|
assert v in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_and_interval_become_integers():
|
||||||
|
"""icalendar types these; the hand-rolled parser left them as strings."""
|
||||||
|
rec = _rrule_str_to_vrecur("FREQ=DAILY;COUNT=5;INTERVAL=2")
|
||||||
|
assert rec["COUNT"] == [5]
|
||||||
|
assert rec["INTERVAL"] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_rule_raises_rather_than_corrupting():
|
||||||
|
"""build_ics catches this and drops the recurrence; it must not pass silently."""
|
||||||
|
with pytest.raises((ValueError, TypeError)):
|
||||||
|
_rrule_str_to_vrecur("FREQ=WEEKLY;UNTIL=not-a-date").to_ical()
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Invitations arriving by mail.
|
||||||
|
|
||||||
|
The mailbox is never touched here — ingest_ics is fed directly, because what
|
||||||
|
matters is what an iCalendar payload from a stranger is allowed to do, not
|
||||||
|
whether imaplib works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mail_invites
|
||||||
|
import models
|
||||||
|
from conftest import auth, register_admin
|
||||||
|
from database import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def _ics(uid="meet-1", sequence=0, method="REQUEST", attendee="admin@example.com",
|
||||||
|
summary="Projektbesprechung", start="20260601T090000Z", end="20260601T100000Z"):
|
||||||
|
return (
|
||||||
|
"BEGIN:VCALENDAR\r\n"
|
||||||
|
"VERSION:2.0\r\n"
|
||||||
|
"PRODID:-//Test//EN\r\n"
|
||||||
|
f"METHOD:{method}\r\n"
|
||||||
|
"BEGIN:VEVENT\r\n"
|
||||||
|
f"UID:{uid}\r\n"
|
||||||
|
f"SEQUENCE:{sequence}\r\n"
|
||||||
|
f"SUMMARY:{summary}\r\n"
|
||||||
|
f"DTSTART:{start}\r\n"
|
||||||
|
f"DTEND:{end}\r\n"
|
||||||
|
"ORGANIZER;CN=Chefin:mailto:chefin@example.org\r\n"
|
||||||
|
f"ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{attendee}\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"END:VCALENDAR\r\n"
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_with_email(client, address="admin@example.com"):
|
||||||
|
token = register_admin(client)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(models.User).filter(models.User.username == "admin").first()
|
||||||
|
user.email = address
|
||||||
|
db.commit()
|
||||||
|
return token, user.id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest(payload):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return mail_invites.ingest_ics(db, payload)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_exposes_the_itip_fields():
|
||||||
|
import ical_io
|
||||||
|
|
||||||
|
parsed = ical_io.parse_ics(_ics(sequence=3))
|
||||||
|
assert parsed["method"] == "REQUEST"
|
||||||
|
item = parsed["events"][0]
|
||||||
|
assert item["sequence"] == 3
|
||||||
|
assert item["organizer_email"] == "chefin@example.org"
|
||||||
|
assert item["attendees"][0]["email"] == "admin@example.com"
|
||||||
|
assert item["attendees"][0]["partstat"] == "NEEDS-ACTION"
|
||||||
|
assert item["attendees"][0]["rsvp"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_invitation_lands_as_pending(client):
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
assert _ingest(_ics()) is True
|
||||||
|
|
||||||
|
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["status"] == "pending"
|
||||||
|
assert rows[0]["title"] == "Projektbesprechung"
|
||||||
|
assert rows[0]["organizer_email"] == "chefin@example.org"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mail_alone_creates_no_event(client):
|
||||||
|
"""The whole security model in one assertion."""
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
_ingest(_ics())
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert db.query(models.LocalEvent).count() == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_attendee_is_ignored(client):
|
||||||
|
register_admin(client) # no email set, so nothing matches
|
||||||
|
assert _ingest(_ics(attendee="someone-else@example.net")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_header_cannot_route(client):
|
||||||
|
"""Routing is by ATTENDEE only; a sender can claim anything."""
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
assert _ingest(_ics(attendee="stranger@example.net")) is False
|
||||||
|
assert client.get("/api/invites", headers=auth(token)).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_sequence_is_ignored(client):
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
_ingest(_ics(summary="Erste Fassung"))
|
||||||
|
_ingest(_ics(summary="Zweite Fassung")) # forwarded twice
|
||||||
|
|
||||||
|
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["title"] == "Erste Fassung"
|
||||||
|
|
||||||
|
|
||||||
|
def test_higher_sequence_reopens_an_answered_invitation(client):
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
_ingest(_ics())
|
||||||
|
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||||
|
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||||
|
headers=auth(token))
|
||||||
|
|
||||||
|
_ingest(_ics(sequence=1, summary="Verschoben"))
|
||||||
|
|
||||||
|
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["status"] == "pending"
|
||||||
|
assert rows[0]["title"] == "Verschoben"
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepting_creates_the_event(client):
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
_ingest(_ics())
|
||||||
|
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||||
|
|
||||||
|
res = client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||||
|
headers=auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["status"] == "accepted"
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ev = db.query(models.LocalEvent).one()
|
||||||
|
assert ev.title == "Projektbesprechung"
|
||||||
|
assert ev.external_uid == "invite:meet-1"
|
||||||
|
assert ev.uid != "meet-1" # never the organiser's UID
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
assert client.get("/api/invites", headers=auth(token)).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_invitation_cannot_overwrite_an_existing_event(client):
|
||||||
|
"""The test that matters: a colliding UID must not touch what is there."""
|
||||||
|
token, user_id = _admin_with_email(client)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
cal = models.LocalCalendar(user_id=user_id, name="Privat", color="#4285f4")
|
||||||
|
db.add(cal)
|
||||||
|
db.flush()
|
||||||
|
db.add(models.LocalEvent(calendar_id=cal.id, uid="meet-1", title="Mein Termin",
|
||||||
|
start="2026-06-01T09:00:00+00:00",
|
||||||
|
end="2026-06-01T10:00:00+00:00"))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
_ingest(_ics(uid="meet-1", summary="Fremder Termin"))
|
||||||
|
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||||
|
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||||
|
headers=auth(token))
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
mine = db.query(models.LocalEvent).filter(models.LocalEvent.uid == "meet-1").one()
|
||||||
|
assert mine.title == "Mein Termin" # untouched
|
||||||
|
assert db.query(models.LocalEvent).count() == 2
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_declining_creates_nothing(client):
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
_ingest(_ics())
|
||||||
|
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||||
|
|
||||||
|
client.post(f"/api/invites/{invite_id}/respond", json={"action": "decline"},
|
||||||
|
headers=auth(token))
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert db.query(models.LocalEvent).count() == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_removes_an_accepted_event(client):
|
||||||
|
token, _ = _admin_with_email(client)
|
||||||
|
_ingest(_ics())
|
||||||
|
invite_id = client.get("/api/invites", headers=auth(token)).json()[0]["id"]
|
||||||
|
client.post(f"/api/invites/{invite_id}/respond", json={"action": "accept"},
|
||||||
|
headers=auth(token))
|
||||||
|
|
||||||
|
_ingest(_ics(method="CANCEL", sequence=1))
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert db.query(models.LocalEvent).count() == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
rows = client.get("/api/invites", headers=auth(token)).json()
|
||||||
|
assert rows[0]["status"] == "cancelled" # stays visible, not silently gone
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_and_publish_are_not_invitations(client):
|
||||||
|
_admin_with_email(client)
|
||||||
|
assert _ingest(_ics(method="REPLY")) is False
|
||||||
|
assert _ingest(_ics(method="PUBLISH")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_payload_is_refused(client):
|
||||||
|
_admin_with_email(client)
|
||||||
|
padded = _ics(summary="X" * (mail_invites.MAX_ICS_BYTES + 10))
|
||||||
|
assert _ingest(padded) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mailbox configuration ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _mailbox_payload(**over):
|
||||||
|
body = {"imap_host": "imap.example.com", "imap_user": "einladungen@example.com",
|
||||||
|
"imap_password": "geheim", "imap_port": 993, "poll_minutes": 5}
|
||||||
|
body.update(over)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_mailbox_password_is_never_returned(client):
|
||||||
|
token = register_admin(client)
|
||||||
|
client.put("/api/invites/mailbox", json=_mailbox_payload(), headers=auth(token))
|
||||||
|
|
||||||
|
body = client.get("/api/invites/mailbox", headers=auth(token)).json()
|
||||||
|
assert body["imap_host"] == "imap.example.com"
|
||||||
|
assert body["has_imap_password"] is True
|
||||||
|
assert "imap_password" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_without_a_password_keeps_the_stored_one(client):
|
||||||
|
token = register_admin(client)
|
||||||
|
client.put("/api/invites/mailbox", json=_mailbox_payload(), headers=auth(token))
|
||||||
|
client.put("/api/invites/mailbox",
|
||||||
|
json=_mailbox_payload(imap_password=None, imap_port=143),
|
||||||
|
headers=auth(token))
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
acc = db.query(models.MailboxAccount).one()
|
||||||
|
assert acc.imap_port == 143
|
||||||
|
assert acc.imap_password == "geheim"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_save_requires_a_password(client):
|
||||||
|
token = register_admin(client)
|
||||||
|
res = client.put("/api/invites/mailbox", json=_mailbox_payload(imap_password=None),
|
||||||
|
headers=auth(token))
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_admins_touch_the_mailbox(client):
|
||||||
|
from conftest import create_user
|
||||||
|
|
||||||
|
admin = register_admin(client)
|
||||||
|
user = create_user(client, admin, "lisa")
|
||||||
|
assert client.get("/api/invites/mailbox", headers=auth(user)).status_code == 403
|
||||||
|
assert client.put("/api/invites/mailbox", json=_mailbox_payload(),
|
||||||
|
headers=auth(user)).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_mailbox_counts_as_unconfigured(client):
|
||||||
|
token = register_admin(client)
|
||||||
|
client.put("/api/invites/mailbox", json=_mailbox_payload(enabled=False),
|
||||||
|
headers=auth(token))
|
||||||
|
|
||||||
|
assert client.get("/api/invites/status", headers=auth(token)).json()["imap_configured"] is False
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert mail_invites.get_account(db) is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -19,9 +19,10 @@ def _run(fn):
|
|||||||
def test_default_jobs_are_registered_with_sane_intervals():
|
def test_default_jobs_are_registered_with_sane_intervals():
|
||||||
scheduler.install_default_jobs()
|
scheduler.install_default_jobs()
|
||||||
names = {j.name: j for j in scheduler._jobs}
|
names = {j.name: j for j in scheduler._jobs}
|
||||||
assert set(names) == {"attachments.sweep", "ical.refresh"}
|
assert set(names) == {"attachments.sweep", "ical.refresh", "mail.invites"}
|
||||||
assert names["attachments.sweep"].interval == 24 * 3600
|
assert names["attachments.sweep"].interval == 24 * 3600
|
||||||
assert names["ical.refresh"].interval == 15 * 60
|
assert names["ical.refresh"].interval == 15 * 60
|
||||||
|
assert names["mail.invites"].interval == 5 * 60
|
||||||
# Nothing runs immediately: a restart must not be slowed by maintenance.
|
# Nothing runs immediately: a restart must not be slowed by maintenance.
|
||||||
assert all(j.next_run > time.monotonic() for j in scheduler._jobs)
|
assert all(j.next_run > time.monotonic() for j in scheduler._jobs)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Theme colours: a user without an own colour must inherit the admin default.
|
||||||
|
|
||||||
|
These five colours used to carry a hardcoded column default, which meant every
|
||||||
|
user row always held a concrete value — so the admin's instance default could
|
||||||
|
never apply to them, however it was configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import models
|
||||||
|
from conftest import auth, create_user, register_admin
|
||||||
|
from database import SessionLocal
|
||||||
|
|
||||||
|
INHERITABLE = ["primary_color", "accent_color", "today_color",
|
||||||
|
"month_divider_color", "month_label_color"]
|
||||||
|
|
||||||
|
|
||||||
|
def db_session():
|
||||||
|
return SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_user_has_no_own_colours(client):
|
||||||
|
"""A fresh account inherits — nothing is baked into its row."""
|
||||||
|
admin_token = register_admin(client, "admin", "pw")
|
||||||
|
uid, token = create_user(client, admin_token, "hugo")
|
||||||
|
|
||||||
|
with db_session() as db:
|
||||||
|
s = db.query(models.UserSettings).filter(
|
||||||
|
models.UserSettings.user_id == uid).one()
|
||||||
|
for col in INHERITABLE:
|
||||||
|
assert getattr(s, col) is None, f"{col} should inherit, got {getattr(s, col)!r}"
|
||||||
|
|
||||||
|
body = client.get("/api/settings/", headers=auth(token)).json()
|
||||||
|
for col in INHERITABLE:
|
||||||
|
assert body[col] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_admin_also_inherits(client):
|
||||||
|
"""The first-run admin is created on a different code path."""
|
||||||
|
token = register_admin(client, "admin", "pw")
|
||||||
|
body = client.get("/api/settings/", headers=auth(token)).json()
|
||||||
|
for col in INHERITABLE:
|
||||||
|
assert body[col] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_a_colour_is_stored(client):
|
||||||
|
admin_token = register_admin(client, "admin", "pw")
|
||||||
|
_, token = create_user(client, admin_token, "hugo")
|
||||||
|
|
||||||
|
r = client.put("/api/settings/", headers=auth(token),
|
||||||
|
json={"primary_color": "#58B900"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.get("/api/settings/", headers=auth(token)).json()["primary_color"] == "#58B900"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_clears_back_to_inheriting(client):
|
||||||
|
"""The reset button sends null, and null has to persist — otherwise
|
||||||
|
"reset" would just freeze today's admin default into the user's row."""
|
||||||
|
admin_token = register_admin(client, "admin", "pw")
|
||||||
|
_, token = create_user(client, admin_token, "hugo")
|
||||||
|
|
||||||
|
client.put("/api/settings/", headers=auth(token), json={"primary_color": "#58B900"})
|
||||||
|
r = client.put("/api/settings/", headers=auth(token), json={"primary_color": None})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.get("/api/settings/", headers=auth(token)).json()["primary_color"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_save_does_not_invent_a_colour(client):
|
||||||
|
"""Saving some other setting must not write a colour into the row."""
|
||||||
|
admin_token = register_admin(client, "admin", "pw")
|
||||||
|
_, token = create_user(client, admin_token, "hugo")
|
||||||
|
|
||||||
|
client.put("/api/settings/", headers=auth(token), json={"week_start_day": "sunday"})
|
||||||
|
body = client.get("/api/settings/", headers=auth(token)).json()
|
||||||
|
assert body["week_start_day"] == "sunday"
|
||||||
|
for col in INHERITABLE:
|
||||||
|
assert body[col] is None
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
# Design contract (Web / iOS / Android)
|
||||||
|
|
||||||
|
> For the user-configurable **colours**, see [../THEME.md](../THEME.md). Colours come from
|
||||||
|
> the server and are already consistent across clients — this document covers everything
|
||||||
|
> below the colour level, which is not.
|
||||||
|
|
||||||
|
The three clients should read as one product. That means the same structure and the same
|
||||||
|
measurements, **not** the same pixels: each app keeps its platform's navigation, system font
|
||||||
|
and controls. A Material button on iOS would feel wrong, and a swipe-back gesture on Android
|
||||||
|
would feel broken.
|
||||||
|
|
||||||
|
This document is the reference the clients are checked against. When a value here and a value
|
||||||
|
in the code disagree, the code is wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Nothing below the colour level was ever decided once. Counted across the clients today:
|
||||||
|
|
||||||
|
| | Web | Android | iOS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Distinct spacing values | 8 | **40** | not audited |
|
||||||
|
| Distinct font sizes | 8 | **9** | system styles |
|
||||||
|
| Distinct corner radii | 8 | **9** | mixed |
|
||||||
|
| Screen gutters | — | **4** (12/16/20/28) | — |
|
||||||
|
|
||||||
|
The result is that the left text edge visibly jumps between screens, the same event pill has
|
||||||
|
four different radii, and nine list rows were each built separately with five different
|
||||||
|
paddings. That is what makes an app look hand-made.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
A four-step ladder. Every margin, padding and gap is one of these — no other value is allowed
|
||||||
|
without a comment saying why.
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `xs` | 4 | Inside a control; gap between an icon and its label |
|
||||||
|
| `sm` | 8 | Between related items in a list; vertical padding of a compact row |
|
||||||
|
| `md` | 12 | Vertical padding of a standard row |
|
||||||
|
| `lg` | **16** | **Screen gutter**, section spacing, dialog padding |
|
||||||
|
| `xl` | 24 | Between major sections |
|
||||||
|
| `xxl` | 32 | Above a primary action, around empty states |
|
||||||
|
|
||||||
|
**One screen gutter: `lg` (16).** Every screen, every sheet, every dialog. This is the single
|
||||||
|
change with the most visible effect — it is what stops the text edge from jumping.
|
||||||
|
|
||||||
|
| Platform | How |
|
||||||
|
|---|---|
|
||||||
|
| Web | `--space-xs … --space-xxl` in `:root`; existing `6px`/`10px` paddings collapse to `xs`/`sm` |
|
||||||
|
| Android | `ui/theme/Spacing.kt`, reached as `MaterialTheme.spacing.lg` |
|
||||||
|
| iOS | `enum Spacing { static let lg: CGFloat = 16 }` in the shared design file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Corner radii
|
||||||
|
|
||||||
|
Defined by role, not by number, so the same object cannot drift apart between views.
|
||||||
|
|
||||||
|
| Token | Value | Applies to |
|
||||||
|
|---|---|---|
|
||||||
|
| `event` | 4 | Event pills in month, week, day, agenda, all-day strip — **all of them** |
|
||||||
|
| `field` | 8 | Text fields, list rows, small cards, popovers |
|
||||||
|
| `sheet` | 20 | Modals, bottom sheets, day preview |
|
||||||
|
| `full` | pill | Buttons, chips, FAB |
|
||||||
|
|
||||||
|
Today the event pill is 3 in month view, 4 in week view, 3 in the all-day strip and 10 in the
|
||||||
|
agenda on Android; the web uses 3. All become `event`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type scale
|
||||||
|
|
||||||
|
Six steps. **Nothing below 11.**
|
||||||
|
|
||||||
|
| Token | Size | Use | Material | iOS |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `caption` | 11 | Timestamps, metadata, hints | `labelSmall` | `.caption2` |
|
||||||
|
| `label` | 12 | Field labels, chips, tab labels | `labelMedium` | `.caption` |
|
||||||
|
| `body` | 13 | Default reading size | `bodySmall` | `.footnote` |
|
||||||
|
| `bodyLarge` | 14 | Primary list text, input text | `bodyMedium` | `.subheadline` |
|
||||||
|
| `title` | 18 | Screen and section titles | `titleMedium` | `.headline` |
|
||||||
|
| `headline` | 22 | Page headline, month name | `headlineSmall` | `.title2` |
|
||||||
|
|
||||||
|
**Android must configure `Typography()`** — today it is the raw Material default
|
||||||
|
(`ui/theme/Theme.kt:68`), which is why every text size in the app is an ad-hoc `fontSize = N.sp`
|
||||||
|
override at the call site.
|
||||||
|
|
||||||
|
**The one documented exception:** the month grid's overflow counter (`+N`) and week-number
|
||||||
|
label may use **10**. They sit in a dense grid where 11 does not fit at the current row height.
|
||||||
|
The 8 and 9 values used today (five places) are removed outright. If the month grid is ever
|
||||||
|
made height-adaptive, this exception goes away too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hit targets
|
||||||
|
|
||||||
|
Minimum **48 × 48** for anything tappable, on every platform. A smaller icon is fine; the
|
||||||
|
touch area around it is not.
|
||||||
|
|
||||||
|
The top app bar is **64** tall with 48 targets. Android currently uses 50 with 40 targets,
|
||||||
|
which is the first thing a user sees and reads as cramped.
|
||||||
|
|
||||||
|
Every actionable icon has an accessibility label. Decorative icons — and only those — get
|
||||||
|
none. Android has 30 `contentDescription = null` today, several of them on buttons
|
||||||
|
(month back/forward, calendar visibility toggle, remove-reminder, password reveal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component inventory
|
||||||
|
|
||||||
|
Every client implements these, named the same way, with the same anatomy. If a screen needs
|
||||||
|
something that is not on this list, it goes on the list first.
|
||||||
|
|
||||||
|
### `ListRow`
|
||||||
|
`[leading icon or colour dot] [title, one line, ellipsised] [optional subtitle] [trailing control]`
|
||||||
|
Vertical padding `md`, horizontal `lg`, full-width tap area, `field` radius when it sits on a
|
||||||
|
surface. Android should build this on `ListItem`; today the app has **zero** `Card` and **zero**
|
||||||
|
`ListItem` — nine separate hand-rolled variants instead.
|
||||||
|
|
||||||
|
### `SettingsRow`
|
||||||
|
A `ListRow` whose trailing control is a switch, a dropdown or a colour swatch. One
|
||||||
|
implementation, not four.
|
||||||
|
|
||||||
|
### `EmptyState`
|
||||||
|
`[title] [one sentence of body] [optional action]`, centred, `xxl` above. Every list has one.
|
||||||
|
Today only the agenda does it properly (`AgendaView.kt:49-62`) — copy that.
|
||||||
|
|
||||||
|
### `LoadingState`
|
||||||
|
One spinner, centred, same size everywhere. Never a different shape per screen. The keys
|
||||||
|
`settings.loading`, `profile.loading` and `accounts.loading` already exist in the translation
|
||||||
|
tables and are never rendered.
|
||||||
|
|
||||||
|
### `Feedback`
|
||||||
|
**Success and failure appear in one place per platform** — a snackbar on Android, a toast on
|
||||||
|
web, a transient banner on iOS. Never as loose text in the page flow, and never as the last
|
||||||
|
item of a scrolling list, where the user who caused the error cannot see it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
Every list and every screen defines all four. A missing state is a bug, not an omission.
|
||||||
|
|
||||||
|
| State | Rule |
|
||||||
|
|---|---|
|
||||||
|
| **Loading** | Shown from the first frame, never a blank screen |
|
||||||
|
| **Empty** | Title plus one sentence; an action when there is a sensible one |
|
||||||
|
| **Error** | In the feedback slot above, with what failed and what to do |
|
||||||
|
| **Partial** | Stale data stays visible. Never replace loaded content with an empty list because one source failed — see `CalendarViewModel.kt:377-416` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event rendering
|
||||||
|
|
||||||
|
The one object all three clients draw, and the one most likely to drift.
|
||||||
|
|
||||||
|
| | Rule |
|
||||||
|
|---|---|
|
||||||
|
| Radius | `event` (4) everywhere |
|
||||||
|
| All-day | Own strip above the time grid, packed into lanes |
|
||||||
|
| Timed, overlapping | **Side by side, never stacked.** Android's week and day views currently draw every event at full column width, so one of two 10:00 meetings is completely invisible (`TimeGridView.kt:156-169`) |
|
||||||
|
| Overflow | `+N` when the lane count is exceeded, with a way to see the rest |
|
||||||
|
| Colour | Per-event colour, else calendar colour; the group view's server-resolved colour wins |
|
||||||
|
| Title | The server-decorated title (birthday age, group prefix) wins over the raw one |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
Home-screen widgets are **always native**: iOS requires WidgetKit with SwiftUI views, Android
|
||||||
|
requires Glance or RemoteViews. No cross-platform framework changes this. What is shared is
|
||||||
|
the **selection and the content**, defined here once.
|
||||||
|
|
||||||
|
| Widget | Shows | iOS | Android |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `today` | Today's events | small | 2×2 |
|
||||||
|
| `upNext` | The next few events | medium | 4×2 |
|
||||||
|
| `month` | Month grid with event dots | medium | 4×4 |
|
||||||
|
|
||||||
|
iOS ships 13 widgets today; these three are the ones Android mirrors. The rest stay
|
||||||
|
iOS-only — that is fine, as long as the three above look and behave the same.
|
||||||
|
|
||||||
|
**Data path, both platforms:** the app writes a snapshot into shared storage; the widget reads
|
||||||
|
it and performs **no networking of its own**. The snapshot format is defined by `CalendarrCore`
|
||||||
|
on iOS and is the reference for Android's equivalent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wording
|
||||||
|
|
||||||
|
The translation keys in `android/…/ui/L10n.kt` and `ios/…/Models/Localization.swift` are
|
||||||
|
deliberately kept identical — Android even converts `%@` to `%s` so the same table works. That
|
||||||
|
is a contract: **a key means the same thing on every platform**, and a new key is added to all
|
||||||
|
of them.
|
||||||
|
|
||||||
|
Nothing user-visible is hard-coded. Current violations, all of which show a German string to
|
||||||
|
an English user:
|
||||||
|
|
||||||
|
- `ColorPickerDialog.kt` — `"Abbrechen"`, `"OK"`, `"Hex"`
|
||||||
|
- `NotificationScheduler.kt` / `.swift` — every notification body
|
||||||
|
- The nine watch complication names on iOS
|
||||||
|
- Date patterns built without a `Locale`, and `is24HourView = true` forced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliberately not unified
|
||||||
|
|
||||||
|
| | Why |
|
||||||
|
|---|---|
|
||||||
|
| Navigation | Bottom sheets and a drawer on Android, navigation stack and sheets on iOS |
|
||||||
|
| System font | Roboto / SF — using one font on both would look wrong on one of them |
|
||||||
|
| Controls | Switches, pickers, date entry stay platform-native |
|
||||||
|
| Gestures | Swipe-back on iOS, system back on Android |
|
||||||
|
| Widget families | Each platform has its own sizes; only the three above must correspond |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to check
|
||||||
|
|
||||||
|
There is no automated check — both apps have **zero tests**. So it is done by eye, and these
|
||||||
|
are the questions that catch real drift:
|
||||||
|
|
||||||
|
1. Open every screen in turn. Does the **left text edge** stay put?
|
||||||
|
2. Put the two apps side by side on the same screen. Same order, same words, same rows?
|
||||||
|
3. System font size to maximum. Does anything clip? Do icons grow with their text?
|
||||||
|
4. Device language to English. Any German left? Trigger a reminder and read it.
|
||||||
|
5. Two events at the same time, week view. Are **both** visible?
|
||||||
|
6. Turn the server off and save something. Does the message appear in the feedback slot?
|
||||||
|
7. Every list: force it empty. Is there an empty state, or just nothing?
|
||||||
+20
-1
@@ -399,7 +399,26 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
|
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
|
||||||
|
|
||||||
/* ── Attachments ─────────────────────────────────────── */
|
/* ── Attachments ─────────────────────────────────────── */
|
||||||
.ev-attach-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
.ev-attach-list:not(:empty) { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
||||||
|
/* Looks like the text inputs above it rather than a button: same ground,
|
||||||
|
border, radius and padding, just left-aligned and in the muted text colour.
|
||||||
|
NOT .form-hint for the caption below — a later rule turns that class into a
|
||||||
|
bordered callout box, which is right for a warning and far too loud here. */
|
||||||
|
.ev-attach-add {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: var(--bg-app);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px; font-family: inherit;
|
||||||
|
cursor: pointer; outline: none;
|
||||||
|
transition: border-color var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.ev-attach-add:hover:not(:disabled) { border-color: var(--text-3); color: var(--text-1); }
|
||||||
|
.ev-attach-add:focus-visible { border-color: var(--primary); }
|
||||||
|
.ev-attach-add:disabled { opacity: .5; cursor: default; }
|
||||||
|
.ev-attach-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; }
|
||||||
.ev-attach-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
.ev-attach-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
||||||
border: 1px solid var(--border); border-radius: 8px; }
|
border: 1px solid var(--border); border-radius: 8px; }
|
||||||
.ev-attach-row:hover { background: var(--hover-highlight); }
|
.ev-attach-row:hover { background: var(--hover-highlight); }
|
||||||
|
|||||||
+50
-2
@@ -235,6 +235,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="group-list-items"></div>
|
<div id="group-list-items"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Invitations awaiting a decision -->
|
||||||
|
<div class="cal-list hidden" id="invite-list">
|
||||||
|
<div class="cal-list-header">
|
||||||
|
<span data-i18n="invites_title">Anfragen</span>
|
||||||
|
</div>
|
||||||
|
<div id="invite-list-items"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
<button class="sidebar-copyright" id="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -400,8 +408,8 @@
|
|||||||
<div class="form-group" id="ev-attachments-group" style="display:none">
|
<div class="form-group" id="ev-attachments-group" style="display:none">
|
||||||
<label data-i18n="attachments">Anhänge</label>
|
<label data-i18n="attachments">Anhänge</label>
|
||||||
<div id="ev-attachments-list" class="ev-attach-list"></div>
|
<div id="ev-attachments-list" class="ev-attach-list"></div>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="ev-attachment-add" data-i18n="attachment_add">Datei hinzufügen</button>
|
<button type="button" class="ev-attach-add" id="ev-attachment-add" data-i18n="attachment_add">Datei hinzufügen</button>
|
||||||
<div class="form-hint" data-i18n="attachment_hint">Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.</div>
|
<div class="ev-attach-hint" data-i18n="attachment_hint">Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -904,6 +912,46 @@
|
|||||||
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Postfach für Einladungen -->
|
||||||
|
<h4 class="panel-title" style="margin-top:28px"><span data-i18n="invites_mailbox">Einladungs-Postfach</span></h4>
|
||||||
|
<p class="panel-desc" data-i18n="invites_mailbox_desc">Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.</p>
|
||||||
|
<div id="mailbox-status" class="panel-desc"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="invites_imap_host">IMAP-Server</label>
|
||||||
|
<input type="text" id="mb-imap-host" spellcheck="false" autocapitalize="none" placeholder="imap.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="invites_imap_user">Benutzername</label>
|
||||||
|
<input type="text" id="mb-imap-user" spellcheck="false" autocapitalize="none" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="invites_imap_password">Passwort</label>
|
||||||
|
<input type="password" id="mb-imap-password" autocomplete="new-password" data-i18n-placeholder="invites_password_keep" placeholder="Unverändert lassen" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="invites_imap_port">Port</label>
|
||||||
|
<input type="number" id="mb-imap-port" value="993" min="1" max="65535" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="invites_imap_folder">Ordner</label>
|
||||||
|
<input type="text" id="mb-imap-folder" value="INBOX" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="invites_poll_minutes">Abrufintervall (Minuten)</label>
|
||||||
|
<input type="number" id="mb-poll-minutes" value="5" min="1" max="1440" />
|
||||||
|
</div>
|
||||||
|
<label class="toggle-label" style="margin-bottom:8px">
|
||||||
|
<input type="checkbox" id="mb-imap-ssl" checked /> <span data-i18n="invites_imap_ssl">SSL/TLS</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-label" style="margin-bottom:8px">
|
||||||
|
<input type="checkbox" id="mb-enabled" checked /> <span data-i18n="invites_enabled">Aktiv</span>
|
||||||
|
</label>
|
||||||
|
<div class="admin-theme-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" id="mb-save" data-i18n="save">Speichern</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="mb-poll" data-i18n="invites_poll_now">Jetzt abrufen</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="mb-delete" data-i18n="invites_mailbox_remove">Postfach entfernen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Instanz-Standardtheme -->
|
<!-- Instanz-Standardtheme -->
|
||||||
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
|
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
|
||||||
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
|
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
|
||||||
|
|||||||
+7
-2
@@ -86,8 +86,13 @@ async function downloadRequest(path, fallbackName) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
// A non-JSON body means the failure happened outside the app — a reverse
|
||||||
const e = new Error(err.detail || `HTTP ${res.status}`);
|
// proxy rejecting the response, say. Showing "unknown error" there hides
|
||||||
|
// the one clue there is, so fall back to the body text and the status.
|
||||||
|
const raw = await res.text().catch(() => '');
|
||||||
|
let detail = '';
|
||||||
|
try { detail = JSON.parse(raw).detail || ''; } catch { detail = raw.trim().slice(0, 200); }
|
||||||
|
const e = new Error(detail || t('unknown_error') + ' (HTTP ' + res.status + ')');
|
||||||
// Keep the status so callers can map it to a translated message instead of
|
// Keep the status so callers can map it to a translated message instead of
|
||||||
// showing the backend's German detail to someone running the app in Finnish.
|
// showing the backend's German detail to someone running the app in Finnish.
|
||||||
e.status = res.status;
|
e.status = res.status;
|
||||||
|
|||||||
+190
-25
@@ -153,9 +153,11 @@ export async function initCalendar() {
|
|||||||
// initial fetchAndRender) so a reload reopens settings instead of stripping it.
|
// initial fetchAndRender) so a reload reopens settings instead of stripping it.
|
||||||
uiSettingsOpen = urlState.settings === true;
|
uiSettingsOpen = urlState.settings === true;
|
||||||
|
|
||||||
setLang(settings.language || 'de');
|
// state.settings, not the raw server response: for any key whose sync flag
|
||||||
applyTheme(settings);
|
// is off the effective value is this browser's local one, and using the raw
|
||||||
applyFavicon(settings.primary_color);
|
// value here silently ignored it (and clobbered the setLang above).
|
||||||
|
applyTheme(state.settings);
|
||||||
|
applyFavicon(state.settings.primary_color);
|
||||||
updateViewButtons();
|
updateViewButtons();
|
||||||
renderCalendarList();
|
renderCalendarList();
|
||||||
renderMiniCal();
|
renderMiniCal();
|
||||||
@@ -177,6 +179,7 @@ export async function initCalendar() {
|
|||||||
bindSwipeNavigation();
|
bindSwipeNavigation();
|
||||||
handleHAOAuthReturn();
|
handleHAOAuthReturn();
|
||||||
loadGroups();
|
loadGroups();
|
||||||
|
loadInvites();
|
||||||
try {
|
try {
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -828,16 +831,18 @@ function renderCalendarList() {
|
|||||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||||
name: cal.shared_by || cal.name, color: cal.color, enabled: !hiddenLocal.has(cal.id), readOnly,
|
name: cal.shared_by || cal.name, color: cal.color, enabled: !hiddenLocal.has(cal.id), readOnly,
|
||||||
sourceLabel: `${t('shared_with_me')} · ${cal.name}${readOnly ? ' · ' + t('perm_read') : ''}`,
|
sourceLabel: `${t('shared_with_me')} · ${cal.name}${readOnly ? ' · ' + t('perm_read') : ''}`,
|
||||||
|
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||||
remove: { icon: EYE_OFF, title: t('hide_cal') } });
|
remove: { icon: EYE_OFF, title: t('hide_cal') } });
|
||||||
});
|
});
|
||||||
// Group calendars (owned by the creator or reached via membership) — shown so
|
// Group calendars (owned by the creator or reached via membership) — shown so
|
||||||
// they can be toggled/recoloured. Owned group calendars can also be muted
|
// they can be toggled, recoloured and muted. Muting is per user: the creator
|
||||||
// (reminders), which the server then syncs; member-reached ones can't (no PUT).
|
// owns the calendar, and silencing it for themselves must not silence the
|
||||||
|
// whole group.
|
||||||
state.localCalendars.filter(c => c.group).forEach(cal => {
|
state.localCalendars.filter(c => c.group).forEach(cal => {
|
||||||
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||||
name: cal.name, color: cal.color,
|
name: cal.name, color: cal.color,
|
||||||
enabled: cal.owned !== false ? cal.enabled : !hiddenLocal.has(cal.id),
|
enabled: cal.owned !== false ? cal.enabled : !hiddenLocal.has(cal.id),
|
||||||
reminders: cal.owned !== false, remindersEnabled: cal.reminders_enabled !== false,
|
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||||
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
|
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
|
||||||
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
|
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
|
||||||
});
|
});
|
||||||
@@ -982,7 +987,11 @@ function renderCalendarList() {
|
|||||||
}
|
}
|
||||||
if (!cal) return;
|
if (!cal) return;
|
||||||
const newVal = cal.reminders_enabled === false; // toggle
|
const newVal = cal.reminders_enabled === false; // toggle
|
||||||
const path = source === 'ical'
|
// local has a dedicated endpoint so share recipients can mute for
|
||||||
|
// themselves; the others still take the calendar-wide PUT.
|
||||||
|
const path = source === 'local'
|
||||||
|
? `/local/calendars/${id}/reminders`
|
||||||
|
: source === 'ical'
|
||||||
? `/ical/subscriptions/${id}`
|
? `/ical/subscriptions/${id}`
|
||||||
: `/${source}/calendars/${id}`;
|
: `/${source}/calendars/${id}`;
|
||||||
try {
|
try {
|
||||||
@@ -2355,12 +2364,8 @@ function renderAttachmentRows() {
|
|||||||
const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
|
const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
|
||||||
const pending = state.pendingAttachments || [];
|
const pending = state.pendingAttachments || [];
|
||||||
|
|
||||||
if (!existing.length && !pending.length) {
|
// Nothing when empty — the reminders list does the same, and the caption
|
||||||
const empty = document.createElement('div');
|
// under the button already says what can go here.
|
||||||
empty.className = 'form-hint';
|
|
||||||
empty.textContent = t('attachment_none');
|
|
||||||
list.appendChild(empty);
|
|
||||||
}
|
|
||||||
existing.forEach(a => list.appendChild(attachmentRow({
|
existing.forEach(a => list.appendChild(attachmentRow({
|
||||||
name: a.filename, size: a.size_bytes, contentType: a.content_type,
|
name: a.filename, size: a.size_bytes, contentType: a.content_type,
|
||||||
thumbId: a.has_thumb ? a.id : null,
|
thumbId: a.has_thumb ? a.id : null,
|
||||||
@@ -3713,7 +3718,7 @@ function populateSettings() {
|
|||||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
const usersNavBtn = document.getElementById('settings-nav-users');
|
const usersNavBtn = document.getElementById('settings-nav-users');
|
||||||
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
||||||
if (user.is_admin) { loadUsers(); loadAdminPanel(); }
|
if (user.is_admin) { loadUsers(); loadAdminPanel(); loadMailbox(); }
|
||||||
|
|
||||||
// Activate panel from URL or fall back to first visible
|
// Activate panel from URL or fall back to first visible
|
||||||
const urlTab = readUrlState().stab;
|
const urlTab = readUrlState().stab;
|
||||||
@@ -3763,8 +3768,13 @@ function settingRowHtml(def) {
|
|||||||
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
valueHtml = `<button type="button" class="sync-toggle ${val ? 'on' : ''}" data-vtoggle="${def.key}" role="switch" aria-checked="${!!val}"></button>`;
|
||||||
} else if (def.type === 'color') {
|
} else if (def.type === 'color') {
|
||||||
const hex = normalizeHex(val, baseColor(def.key));
|
const hex = normalizeHex(val, baseColor(def.key));
|
||||||
|
// No own value means the row inherits the admin instance default. The
|
||||||
|
// field still shows that colour, but saving must send null — writing the
|
||||||
|
// hex back would freeze today's admin default into the user's row and
|
||||||
|
// later admin changes would stop reaching them.
|
||||||
|
const inherits = val == null ? ' data-inherit="1"' : '';
|
||||||
valueHtml = `<div class="settings-color-ctl">
|
valueHtml = `<div class="settings-color-ctl">
|
||||||
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}" />
|
<input type="text" class="ev-color-hex" data-shex="${def.key}" maxlength="7" spellcheck="false" value="${hex}"${inherits} />
|
||||||
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
|
<div class="ev-color-preview" data-sprev="${def.key}" style="background:${hex}" title="${escHtml(t('color_pick'))}"></div>
|
||||||
<button type="button" class="icon-btn ev-color-reset" data-sreset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
|
<button type="button" class="icon-btn ev-color-reset" data-sreset="${def.key}" title="${escHtml(t('reset'))}">${RESET_ICON_SVG}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -3819,20 +3829,23 @@ function wireSettingRow(def) {
|
|||||||
state.settings[def.key] = color;
|
state.settings[def.key] = color;
|
||||||
applyTheme(state.settings);
|
applyTheme(state.settings);
|
||||||
};
|
};
|
||||||
|
const own = () => { delete hex.dataset.inherit; };
|
||||||
prev.addEventListener('click', async () => {
|
prev.addEventListener('click', async () => {
|
||||||
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
|
const picked = await openColorPicker(prev, hex.value || baseColor(def.key));
|
||||||
if (picked) { hex.value = picked.toUpperCase(); apply(picked); }
|
if (picked) { hex.value = picked.toUpperCase(); own(); apply(picked); }
|
||||||
});
|
});
|
||||||
hex.addEventListener('input', () => {
|
hex.addEventListener('input', () => {
|
||||||
const norm = normalizeHex(hex.value, null);
|
const norm = normalizeHex(hex.value, null);
|
||||||
if (norm) apply(norm);
|
if (norm) { own(); apply(norm); }
|
||||||
});
|
});
|
||||||
hex.addEventListener('change', () => {
|
hex.addEventListener('change', () => {
|
||||||
const norm = normalizeHex(hex.value, baseColor(def.key));
|
const norm = normalizeHex(hex.value, baseColor(def.key));
|
||||||
hex.value = norm; apply(norm);
|
hex.value = norm; own(); apply(norm);
|
||||||
});
|
});
|
||||||
reset.addEventListener('click', () => {
|
reset.addEventListener('click', () => {
|
||||||
|
// Back to inheriting, not "copy the current admin default in".
|
||||||
const d = baseColor(def.key);
|
const d = baseColor(def.key);
|
||||||
|
hex.dataset.inherit = '1';
|
||||||
hex.value = d; apply(d);
|
hex.value = d; apply(d);
|
||||||
});
|
});
|
||||||
} else if (def.type === 'icon') {
|
} else if (def.type === 'icon') {
|
||||||
@@ -3862,7 +3875,9 @@ function readAppearanceTable() {
|
|||||||
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
out[def.key] = el ? el.classList.contains('on') : !!state.settings[def.key];
|
||||||
} else if (def.type === 'color') {
|
} else if (def.type === 'color') {
|
||||||
const el = document.querySelector(`[data-shex="${def.key}"]`);
|
const el = document.querySelector(`[data-shex="${def.key}"]`);
|
||||||
out[def.key] = normalizeHex(el && el.value, baseColor(def.key));
|
out[def.key] = (el && el.dataset.inherit === '1')
|
||||||
|
? null // inherit the admin default
|
||||||
|
: normalizeHex(el && el.value, baseColor(def.key));
|
||||||
} else if (def.type === 'icon') {
|
} else if (def.type === 'icon') {
|
||||||
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
const on = document.querySelector(`[data-sicon="${def.key}"] .group-icon-opt.on`);
|
||||||
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
out[def.key] = on ? on.dataset.shareIcon : (state.settings[def.key] || 'share');
|
||||||
@@ -4369,7 +4384,7 @@ function initResizableCalTable(table) {
|
|||||||
// Lock to px + fixed layout so dragging one column doesn't reflow the others.
|
// Lock to px + fixed layout so dragging one column doesn't reflow the others.
|
||||||
// Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen
|
// Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen
|
||||||
// / Export-Import / Sync / trash); user drags override and persist.
|
// / Export-Import / Sync / trash); user drags override and persist.
|
||||||
const DEFAULTS = [200, 90, 80, 150, 240, 90, 44];
|
const DEFAULTS = [200, 90, 80, 150, 300, 90, 44];
|
||||||
ths.forEach((th, i) => {
|
ths.forEach((th, i) => {
|
||||||
const w = (Array.isArray(saved) && saved[i]) ? saved[i]
|
const w = (Array.isArray(saved) && saved[i]) ? saved[i]
|
||||||
: (DEFAULTS[i] || th.offsetWidth || 100);
|
: (DEFAULTS[i] || th.offsetWidth || 100);
|
||||||
@@ -4424,7 +4439,7 @@ function renderCalendarTable() {
|
|||||||
const hid = (src, id, isVisible) =>
|
const hid = (src, id, isVisible) =>
|
||||||
`<button class="icon-btn mini-btn ct-eye" data-ct-hid="${src}" data-ct-id="${id}" data-ct-visible="${isVisible ? '1' : '0'}" title="${isVisible ? 'Ausblenden' : 'Anzeigen'}">${isVisible ? EYE_ON : EYE_OFF}</button>`;
|
`<button class="icon-btn mini-btn ct-eye" data-ct-hid="${src}" data-ct-id="${id}" data-ct-visible="${isVisible ? '1' : '0'}" title="${isVisible ? 'Ausblenden' : 'Anzeigen'}">${isVisible ? EYE_ON : EYE_OFF}</button>`;
|
||||||
const rem = (src, id, on) =>
|
const rem = (src, id, on) =>
|
||||||
`<button class="icon-btn mini-btn ct-bell" data-ct-rem="${src}" data-ct-id="${id}" data-ct-on="${on ? '1' : '0'}" title="${on ? 'Benachrichtigungen aus' : 'Benachrichtigungen an'}">${on ? BELL_ON : BELL_OFF}</button>`;
|
`<button class="icon-btn mini-btn ct-bell" data-ct-rem="${src}" data-ct-id="${id}" data-ct-on="${on ? '1' : '0'}" title="${on ? t('calendar_reminders_on') : t('calendar_reminders_off')}">${on ? BELL_ON : BELL_OFF}</button>`;
|
||||||
const dot = (color, fb) =>
|
const dot = (color, fb) =>
|
||||||
`<span class="ct-dot" style="background:${color || fb}"></span>`;
|
`<span class="ct-dot" style="background:${color || fb}"></span>`;
|
||||||
|
|
||||||
@@ -4444,7 +4459,7 @@ function renderCalendarTable() {
|
|||||||
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
||||||
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
||||||
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
|
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
|
||||||
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>
|
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}" title="${pub ? t('caldav_unpublish') : t('caldav_publish')}">CalDAV</button>
|
||||||
</td>
|
</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
|
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
|
||||||
@@ -4557,7 +4572,8 @@ function renderCalendarTable() {
|
|||||||
rows += `<tr>
|
rows += `<tr>
|
||||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}</td>
|
<td>${dot(cal.color, '#34a853')}${escHtml(cal.shared_by || cal.name)}</td>
|
||||||
<td class="ct-src">${escHtml(cal.name)} <span class="cal-badge">${readOnly ? t('perm_read') : t('perm_read_write')}</span></td>
|
<td class="ct-src">${escHtml(cal.name)} <span class="cal-badge">${readOnly ? t('perm_read') : t('perm_read_write')}</span></td>
|
||||||
<td>${hid('shared', cal.id, !sharedHidden.has(cal.id))}</td><td>—</td><td>—</td><td>—</td><td>—</td>
|
<td>${hid('shared', cal.id, !sharedHidden.has(cal.id))}</td>
|
||||||
|
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td><td>—</td><td>—</td><td>—</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
rowCount++;
|
rowCount++;
|
||||||
}
|
}
|
||||||
@@ -4569,7 +4585,7 @@ function renderCalendarTable() {
|
|||||||
|
|
||||||
container.innerHTML = `<table class="cal-manage-table">
|
container.innerHTML = `<table class="cal-manage-table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Name</th><th>Herkunft</th><th>Sichtbar</th><th>Benachrichtigungen</th><th>Export / Import</th><th>Sync</th><th></th>
|
<th>Name</th><th>Herkunft</th><th>Sichtbar</th><th>${t('calendar_reminders_col')}</th><th>Export / Import</th><th>Sync</th><th></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
@@ -4614,7 +4630,9 @@ function renderCalendarTable() {
|
|||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const src = btn.dataset.ctRem, id = parseInt(btn.dataset.ctId);
|
const src = btn.dataset.ctRem, id = parseInt(btn.dataset.ctId);
|
||||||
const enabled = btn.dataset.ctOn !== '1'; // currently on → turn off, currently off → turn on
|
const enabled = btn.dataset.ctOn !== '1'; // currently on → turn off, currently off → turn on
|
||||||
const path = src === 'ical' ? `/ical/subscriptions/${id}` : `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`;
|
const path = src === 'local' ? `/local/calendars/${id}/reminders`
|
||||||
|
: src === 'ical' ? `/ical/subscriptions/${id}`
|
||||||
|
: `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`;
|
||||||
try {
|
try {
|
||||||
await api.put(path, { reminders_enabled: enabled });
|
await api.put(path, { reminders_enabled: enabled });
|
||||||
if (src === 'local') { const c = state.localCalendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; }
|
if (src === 'local') { const c = state.localCalendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; }
|
||||||
@@ -4705,6 +4723,9 @@ function renderCalendarTable() {
|
|||||||
const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal });
|
const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal });
|
||||||
cal.caldav_published = updated.caldav_published;
|
cal.caldav_published = updated.caldav_published;
|
||||||
cal.caldav_url = updated.caldav_url;
|
cal.caldav_url = updated.caldav_url;
|
||||||
|
// The response carries both URLs; dropping this one left the
|
||||||
|
// "with login" field blank until the next full page load.
|
||||||
|
cal.caldav_login_url = updated.caldav_login_url;
|
||||||
renderCalendarTable();
|
renderCalendarTable();
|
||||||
} catch (e) { showToast(e.message, true); }
|
} catch (e) { showToast(e.message, true); }
|
||||||
});
|
});
|
||||||
@@ -4724,6 +4745,7 @@ function renderCalendarTable() {
|
|||||||
try {
|
try {
|
||||||
const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`);
|
const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`);
|
||||||
cal.caldav_url = updated.caldav_url;
|
cal.caldav_url = updated.caldav_url;
|
||||||
|
cal.caldav_login_url = updated.caldav_login_url;
|
||||||
renderCalendarTable();
|
renderCalendarTable();
|
||||||
showToast(t('caldav_token_rotated'));
|
showToast(t('caldav_token_rotated'));
|
||||||
} catch (e) { showToast(e.message, true); }
|
} catch (e) { showToast(e.message, true); }
|
||||||
@@ -4751,6 +4773,148 @@ function renderCalendarTable() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Einladungs-Postfach (nur Admin) ──────────────────────
|
||||||
|
|
||||||
|
function mailboxFields() {
|
||||||
|
return {
|
||||||
|
enabled: document.getElementById('mb-enabled').checked,
|
||||||
|
imap_host: document.getElementById('mb-imap-host').value.trim(),
|
||||||
|
imap_user: document.getElementById('mb-imap-user').value.trim(),
|
||||||
|
imap_password: document.getElementById('mb-imap-password').value || null,
|
||||||
|
imap_port: parseInt(document.getElementById('mb-imap-port').value) || 993,
|
||||||
|
imap_ssl: document.getElementById('mb-imap-ssl').checked,
|
||||||
|
imap_folder: document.getElementById('mb-imap-folder').value.trim() || 'INBOX',
|
||||||
|
poll_minutes: parseInt(document.getElementById('mb-poll-minutes').value) || 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMailboxStatus(cfg) {
|
||||||
|
const el = document.getElementById('mailbox-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (!cfg) { el.textContent = t('invites_mailbox_none'); return; }
|
||||||
|
// A mailbox that stopped working has to say so, or invitations simply stop
|
||||||
|
// arriving and nothing explains why.
|
||||||
|
if (cfg.last_error) {
|
||||||
|
el.textContent = `${t('invites_last_error')}: ${cfg.last_error}`;
|
||||||
|
el.style.color = 'var(--danger, #ea4335)';
|
||||||
|
} else {
|
||||||
|
el.textContent = cfg.last_run
|
||||||
|
? `${t('invites_last_run')}: ${new Date(cfg.last_run).toLocaleString()}`
|
||||||
|
: t('invites_never_run');
|
||||||
|
el.style.color = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMailbox() {
|
||||||
|
const host = document.getElementById('mb-imap-host');
|
||||||
|
if (!host) return;
|
||||||
|
let cfg = null;
|
||||||
|
try { cfg = await api.get('/invites/mailbox'); } catch { return; }
|
||||||
|
renderMailboxStatus(cfg);
|
||||||
|
if (!cfg) return;
|
||||||
|
host.value = cfg.imap_host || '';
|
||||||
|
document.getElementById('mb-imap-user').value = cfg.imap_user || '';
|
||||||
|
document.getElementById('mb-imap-port').value = cfg.imap_port || 993;
|
||||||
|
document.getElementById('mb-imap-folder').value = cfg.imap_folder || 'INBOX';
|
||||||
|
document.getElementById('mb-poll-minutes').value = cfg.poll_minutes || 5;
|
||||||
|
document.getElementById('mb-imap-ssl').checked = cfg.imap_ssl !== false;
|
||||||
|
document.getElementById('mb-enabled').checked = cfg.enabled !== false;
|
||||||
|
// Left blank on purpose: an empty field means "keep what is stored".
|
||||||
|
document.getElementById('mb-imap-password').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireMailbox() {
|
||||||
|
const save = document.getElementById('mb-save');
|
||||||
|
if (!save) return;
|
||||||
|
save.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await api.put('/invites/mailbox', mailboxFields());
|
||||||
|
document.getElementById('mb-imap-password').value = '';
|
||||||
|
renderMailboxStatus(cfg);
|
||||||
|
showToast(t('settings_saved'));
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
document.getElementById('mb-poll').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.post('/invites/poll', {});
|
||||||
|
showToast(r.last_error ? r.last_error : t('invites_polled'), !!r.last_error);
|
||||||
|
await loadMailbox();
|
||||||
|
await loadInvites();
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
document.getElementById('mb-delete').addEventListener('click', async () => {
|
||||||
|
if (!await confirmModal(t('invites_mailbox_remove_confirm'), { danger: true })) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/invites/mailbox');
|
||||||
|
document.querySelectorAll('#settings-panel-users input[id^="mb-"]').forEach(el => {
|
||||||
|
if (el.type === 'checkbox') el.checked = true; else el.value = '';
|
||||||
|
});
|
||||||
|
renderMailboxStatus(null);
|
||||||
|
showToast(t('settings_saved'));
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anfragen ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadInvites() {
|
||||||
|
const box = document.getElementById('invite-list');
|
||||||
|
if (!box) return;
|
||||||
|
let rows = [];
|
||||||
|
try { rows = await api.get('/invites'); } catch { rows = []; }
|
||||||
|
box.classList.toggle('hidden', rows.length === 0);
|
||||||
|
const items = document.getElementById('invite-list-items');
|
||||||
|
items.innerHTML = '';
|
||||||
|
for (const inv of rows) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'cal-item';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'cal-item-name';
|
||||||
|
// textContent throughout: this text came from a stranger's mail.
|
||||||
|
name.textContent = inv.title;
|
||||||
|
name.title = `${fmtInviteWhen(inv)}${inv.organizer_name ? ' · ' + inv.organizer_name : ''}`;
|
||||||
|
row.appendChild(name);
|
||||||
|
if (inv.status === 'cancelled') {
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.className = 'cal-badge';
|
||||||
|
tag.textContent = t('invite_cancelled');
|
||||||
|
row.appendChild(tag);
|
||||||
|
row.appendChild(inviteBtn(inv, 'dismiss', '✕'));
|
||||||
|
} else {
|
||||||
|
row.appendChild(inviteBtn(inv, 'accept', '✓'));
|
||||||
|
row.appendChild(inviteBtn(inv, 'decline', '✕'));
|
||||||
|
}
|
||||||
|
items.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtInviteWhen(inv) {
|
||||||
|
try {
|
||||||
|
const d = new Date(inv.start);
|
||||||
|
return inv.all_day ? d.toLocaleDateString() : d.toLocaleString();
|
||||||
|
} catch { return inv.start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteBtn(inv, action, label) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'icon-btn mini-btn';
|
||||||
|
b.textContent = label;
|
||||||
|
b.title = t(`invite_${action}`);
|
||||||
|
b.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
if (action === 'dismiss') {
|
||||||
|
await api.delete(`/invites/${inv.id}`);
|
||||||
|
} else {
|
||||||
|
await api.post(`/invites/${inv.id}/respond`, { action });
|
||||||
|
showToast(t(action === 'accept' ? 'invite_accepted' : 'invite_declined'));
|
||||||
|
}
|
||||||
|
await loadInvites();
|
||||||
|
if (action === 'accept') fetchAndRender(true);
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const users = await api.get('/users/');
|
const users = await api.get('/users/');
|
||||||
@@ -4971,6 +5135,7 @@ function bindSettingsModal() {
|
|||||||
|
|
||||||
// Admin panel (default-theme editor + branding) — buttons bound once.
|
// Admin panel (default-theme editor + branding) — buttons bound once.
|
||||||
bindAdminPanel();
|
bindAdminPanel();
|
||||||
|
wireMailbox();
|
||||||
|
|
||||||
// Theme export / import (client-side .theme files).
|
// Theme export / import (client-side .theme files).
|
||||||
const themeExportBtn = document.getElementById('cfg-theme-export');
|
const themeExportBtn = document.getElementById('cfg-theme-export');
|
||||||
|
|||||||
+270
-2
@@ -1,5 +1,31 @@
|
|||||||
const translations = {
|
const translations = {
|
||||||
de: {
|
de: {
|
||||||
|
invites_title: 'Anfragen',
|
||||||
|
invite_accept: 'Annehmen',
|
||||||
|
invite_decline: 'Ablehnen',
|
||||||
|
invite_dismiss: 'Entfernen',
|
||||||
|
invite_cancelled: 'Zurückgezogen',
|
||||||
|
invite_accepted: 'Einladung angenommen',
|
||||||
|
invite_declined: 'Einladung abgelehnt',
|
||||||
|
invites_mailbox: 'Einladungs-Postfach',
|
||||||
|
invites_mailbox_desc: 'Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.',
|
||||||
|
invites_mailbox_none: 'Kein Postfach konfiguriert',
|
||||||
|
invites_mailbox_remove: 'Postfach entfernen',
|
||||||
|
invites_mailbox_remove_confirm: 'Postfach wirklich entfernen? Bereits empfangene Anfragen bleiben erhalten.',
|
||||||
|
invites_imap_host: 'IMAP-Server',
|
||||||
|
invites_imap_user: 'Benutzername',
|
||||||
|
invites_imap_password: 'Passwort',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Ordner',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Aktiv',
|
||||||
|
invites_poll_minutes: 'Abrufintervall (Minuten)',
|
||||||
|
invites_poll_now: 'Jetzt abrufen',
|
||||||
|
invites_polled: 'Postfach abgerufen',
|
||||||
|
invites_password_keep: 'Unverändert lassen',
|
||||||
|
invites_last_run: 'Zuletzt abgerufen',
|
||||||
|
invites_last_error: 'Letzter Fehler',
|
||||||
|
invites_never_run: 'Noch nicht abgerufen',
|
||||||
// Common
|
// Common
|
||||||
save: 'Speichern', cancel: 'Abbrechen', close: 'Schliessen',
|
save: 'Speichern', cancel: 'Abbrechen', close: 'Schliessen',
|
||||||
create: 'Erstellen', delete: 'Löschen', edit: 'Bearbeiten',
|
create: 'Erstellen', delete: 'Löschen', edit: 'Bearbeiten',
|
||||||
@@ -176,7 +202,6 @@ const translations = {
|
|||||||
attachments: 'Anhänge',
|
attachments: 'Anhänge',
|
||||||
attachment_add: 'Datei hinzufügen',
|
attachment_add: 'Datei hinzufügen',
|
||||||
attachment_hint: 'Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.',
|
attachment_hint: 'Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.',
|
||||||
attachment_none: 'Keine Anhänge',
|
|
||||||
attachment_uploading: 'Wird hochgeladen …',
|
attachment_uploading: 'Wird hochgeladen …',
|
||||||
attachment_deleted: 'Anhang gelöscht',
|
attachment_deleted: 'Anhang gelöscht',
|
||||||
attachment_delete_confirm: "Anhang „{name}“ löschen?",
|
attachment_delete_confirm: "Anhang „{name}“ löschen?",
|
||||||
@@ -203,6 +228,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
||||||
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
||||||
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
||||||
|
calendar_reminders_col: 'Benachrichtigungen',
|
||||||
copy: 'Kopieren',
|
copy: 'Kopieren',
|
||||||
caldav_publish: 'CalDAV veröffentlichen',
|
caldav_publish: 'CalDAV veröffentlichen',
|
||||||
caldav_unpublish: 'CalDAV deaktivieren',
|
caldav_unpublish: 'CalDAV deaktivieren',
|
||||||
@@ -433,6 +459,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ch: {
|
ch: {
|
||||||
|
invites_title: 'Aafrage',
|
||||||
|
invite_accept: 'Aanäh',
|
||||||
|
invite_decline: 'Ablehne',
|
||||||
|
invite_dismiss: 'Entfärne',
|
||||||
|
invite_cancelled: 'Zruggzoge',
|
||||||
|
invite_accepted: 'Iiladig aagnoh',
|
||||||
|
invite_declined: 'Iiladig abglehnt',
|
||||||
|
invites_mailbox: 'Iiladigs-Poschtfach',
|
||||||
|
invites_mailbox_desc: 'Es Poschtfach, wo Termin-Iiladige akömed. Sie erschiined als Aafrag und wärded erscht zu Termin, wänn du sie aanimmsch. Zuegordnet wird über d Teilnehmer-Adräss vo dr Iiladig — trag d Adräss, unter dere du iiglade wirsch, i dim Profil ii.',
|
||||||
|
invites_mailbox_none: 'Kes Poschtfach konfiguriert',
|
||||||
|
invites_mailbox_remove: 'Poschtfach entfärne',
|
||||||
|
invites_mailbox_remove_confirm: 'Poschtfach würkli entfärne? Scho empfangeni Aafrage blibed erhalte.',
|
||||||
|
invites_imap_host: 'IMAP-Server',
|
||||||
|
invites_imap_user: 'Benutzername',
|
||||||
|
invites_imap_password: 'Passwort',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Ordner',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Aktiv',
|
||||||
|
invites_poll_minutes: 'Abruefintervall (Minute)',
|
||||||
|
invites_poll_now: 'Jetz abruefe',
|
||||||
|
invites_polled: 'Poschtfach abgruefe',
|
||||||
|
invites_password_keep: 'Unverändert la',
|
||||||
|
invites_last_run: 'Zletscht abgruefe',
|
||||||
|
invites_last_error: 'Letschte Fähler',
|
||||||
|
invites_never_run: 'No nid abgruefe',
|
||||||
// Common
|
// Common
|
||||||
save: 'Speicherä', cancel: 'Abbräche', close: 'Schlüsse',
|
save: 'Speicherä', cancel: 'Abbräche', close: 'Schlüsse',
|
||||||
create: 'Erstellä', delete: 'Löschä', edit: 'Bearbeitä',
|
create: 'Erstellä', delete: 'Löschä', edit: 'Bearbeitä',
|
||||||
@@ -597,6 +649,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Für dä Kalender sind Benachrichtigunge deaktiviert – Erinnerige werded nöd usgfüert.',
|
reminders_calendar_off: 'Für dä Kalender sind Benachrichtigunge deaktiviert – Erinnerige werded nöd usgfüert.',
|
||||||
calendar_reminders_on: 'Benachrichtigunge aktiviert',
|
calendar_reminders_on: 'Benachrichtigunge aktiviert',
|
||||||
calendar_reminders_off: 'Benachrichtigunge deaktiviert',
|
calendar_reminders_off: 'Benachrichtigunge deaktiviert',
|
||||||
|
calendar_reminders_col: 'Benachrichtigunge',
|
||||||
copy: 'Kopierä',
|
copy: 'Kopierä',
|
||||||
caldav_publish: 'CalDAV veröffentlichä',
|
caldav_publish: 'CalDAV veröffentlichä',
|
||||||
caldav_unpublish: 'CalDAV deaktivierä',
|
caldav_unpublish: 'CalDAV deaktivierä',
|
||||||
@@ -827,6 +880,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
en: {
|
en: {
|
||||||
|
invites_title: 'Requests',
|
||||||
|
invite_accept: 'Accept',
|
||||||
|
invite_decline: 'Decline',
|
||||||
|
invite_dismiss: 'Dismiss',
|
||||||
|
invite_cancelled: 'Withdrawn',
|
||||||
|
invite_accepted: 'Invitation accepted',
|
||||||
|
invite_declined: 'Invitation declined',
|
||||||
|
invites_mailbox: 'Invitation mailbox',
|
||||||
|
invites_mailbox_desc: 'A mailbox that invitations arrive in. They show up as requests and only become events once you accept them. An invitation is matched by its attendee address — put the address people invite you under in your profile.',
|
||||||
|
invites_mailbox_none: 'No mailbox configured',
|
||||||
|
invites_mailbox_remove: 'Remove mailbox',
|
||||||
|
invites_mailbox_remove_confirm: 'Remove the mailbox? Requests already received are kept.',
|
||||||
|
invites_imap_host: 'IMAP server',
|
||||||
|
invites_imap_user: 'Username',
|
||||||
|
invites_imap_password: 'Password',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Folder',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Enabled',
|
||||||
|
invites_poll_minutes: 'Check every (minutes)',
|
||||||
|
invites_poll_now: 'Check now',
|
||||||
|
invites_polled: 'Mailbox checked',
|
||||||
|
invites_password_keep: 'Leave unchanged',
|
||||||
|
invites_last_run: 'Last checked',
|
||||||
|
invites_last_error: 'Last error',
|
||||||
|
invites_never_run: 'Not checked yet',
|
||||||
// Common
|
// Common
|
||||||
save: 'Save', cancel: 'Cancel', close: 'Close',
|
save: 'Save', cancel: 'Cancel', close: 'Close',
|
||||||
create: 'Create', delete: 'Delete', edit: 'Edit',
|
create: 'Create', delete: 'Delete', edit: 'Edit',
|
||||||
@@ -1003,7 +1082,6 @@ const translations = {
|
|||||||
attachments: 'Attachments',
|
attachments: 'Attachments',
|
||||||
attachment_add: 'Add file',
|
attachment_add: 'Add file',
|
||||||
attachment_hint: 'Max. 10 MB per file, 10 files. PDF, images, text.',
|
attachment_hint: 'Max. 10 MB per file, 10 files. PDF, images, text.',
|
||||||
attachment_none: 'No attachments',
|
|
||||||
attachment_uploading: 'Uploading …',
|
attachment_uploading: 'Uploading …',
|
||||||
attachment_deleted: 'Attachment deleted',
|
attachment_deleted: 'Attachment deleted',
|
||||||
attachment_delete_confirm: "Delete attachment “{name}”?",
|
attachment_delete_confirm: "Delete attachment “{name}”?",
|
||||||
@@ -1030,6 +1108,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
||||||
calendar_reminders_on: 'Reminders enabled',
|
calendar_reminders_on: 'Reminders enabled',
|
||||||
calendar_reminders_off: 'Reminders disabled',
|
calendar_reminders_off: 'Reminders disabled',
|
||||||
|
calendar_reminders_col: 'Notifications',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
caldav_publish: 'Publish via CalDAV',
|
caldav_publish: 'Publish via CalDAV',
|
||||||
caldav_unpublish: 'Unpublish CalDAV',
|
caldav_unpublish: 'Unpublish CalDAV',
|
||||||
@@ -1260,6 +1339,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fr: {
|
fr: {
|
||||||
|
invites_title: 'Demandes',
|
||||||
|
invite_accept: 'Accepter',
|
||||||
|
invite_decline: 'Refuser',
|
||||||
|
invite_dismiss: 'Retirer',
|
||||||
|
invite_cancelled: 'Annulée',
|
||||||
|
invite_accepted: 'Invitation acceptée',
|
||||||
|
invite_declined: 'Invitation refusée',
|
||||||
|
invites_mailbox: 'Boîte aux lettres des invitations',
|
||||||
|
invites_mailbox_desc: "Une boîte dans laquelle arrivent les invitations. Elles apparaissent comme demandes et ne deviennent des événements qu'une fois acceptées. L'attribution se fait par l'adresse du participant — indiquez dans votre profil l'adresse sous laquelle on vous invite.",
|
||||||
|
invites_mailbox_none: 'Aucune boîte configurée',
|
||||||
|
invites_mailbox_remove: 'Supprimer la boîte',
|
||||||
|
invites_mailbox_remove_confirm: 'Supprimer la boîte ? Les demandes déjà reçues sont conservées.',
|
||||||
|
invites_imap_host: 'Serveur IMAP',
|
||||||
|
invites_imap_user: "Nom d'utilisateur",
|
||||||
|
invites_imap_password: 'Mot de passe',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Dossier',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Activé',
|
||||||
|
invites_poll_minutes: 'Intervalle (minutes)',
|
||||||
|
invites_poll_now: 'Relever maintenant',
|
||||||
|
invites_polled: 'Boîte relevée',
|
||||||
|
invites_password_keep: 'Laisser inchangé',
|
||||||
|
invites_last_run: 'Dernier relevé',
|
||||||
|
invites_last_error: 'Dernière erreur',
|
||||||
|
invites_never_run: 'Jamais relevée',
|
||||||
// Common
|
// Common
|
||||||
save: 'Enregistrer', cancel: 'Annuler', close: 'Fermer',
|
save: 'Enregistrer', cancel: 'Annuler', close: 'Fermer',
|
||||||
create: 'Créer', delete: 'Supprimer', edit: 'Modifier',
|
create: 'Créer', delete: 'Supprimer', edit: 'Modifier',
|
||||||
@@ -1424,6 +1529,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Les rappels sont désactivés pour cet agenda – ils ne se déclencheront pas.',
|
reminders_calendar_off: 'Les rappels sont désactivés pour cet agenda – ils ne se déclencheront pas.',
|
||||||
calendar_reminders_on: 'Rappels activés',
|
calendar_reminders_on: 'Rappels activés',
|
||||||
calendar_reminders_off: 'Rappels désactivés',
|
calendar_reminders_off: 'Rappels désactivés',
|
||||||
|
calendar_reminders_col: 'Notifications',
|
||||||
copy: 'Copier',
|
copy: 'Copier',
|
||||||
caldav_publish: 'Publier via CalDAV',
|
caldav_publish: 'Publier via CalDAV',
|
||||||
caldav_unpublish: 'Dépublier CalDAV',
|
caldav_unpublish: 'Dépublier CalDAV',
|
||||||
@@ -1654,6 +1760,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
it: {
|
it: {
|
||||||
|
invites_title: 'Richieste',
|
||||||
|
invite_accept: 'Accetta',
|
||||||
|
invite_decline: 'Rifiuta',
|
||||||
|
invite_dismiss: 'Rimuovi',
|
||||||
|
invite_cancelled: 'Ritirata',
|
||||||
|
invite_accepted: 'Invito accettato',
|
||||||
|
invite_declined: 'Invito rifiutato',
|
||||||
|
invites_mailbox: 'Casella degli inviti',
|
||||||
|
invites_mailbox_desc: "Una casella in cui arrivano gli inviti. Compaiono come richieste e diventano eventi solo quando li accetti. L'abbinamento avviene tramite l'indirizzo del partecipante — inserisci nel profilo l'indirizzo con cui vieni invitato.",
|
||||||
|
invites_mailbox_none: 'Nessuna casella configurata',
|
||||||
|
invites_mailbox_remove: 'Rimuovi casella',
|
||||||
|
invites_mailbox_remove_confirm: 'Rimuovere la casella? Le richieste già ricevute restano.',
|
||||||
|
invites_imap_host: 'Server IMAP',
|
||||||
|
invites_imap_user: 'Nome utente',
|
||||||
|
invites_imap_password: 'Password',
|
||||||
|
invites_imap_port: 'Porta',
|
||||||
|
invites_imap_folder: 'Cartella',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Attiva',
|
||||||
|
invites_poll_minutes: 'Intervallo (minuti)',
|
||||||
|
invites_poll_now: 'Controlla ora',
|
||||||
|
invites_polled: 'Casella controllata',
|
||||||
|
invites_password_keep: 'Lascia invariato',
|
||||||
|
invites_last_run: 'Ultimo controllo',
|
||||||
|
invites_last_error: 'Ultimo errore',
|
||||||
|
invites_never_run: 'Mai controllata',
|
||||||
// Common
|
// Common
|
||||||
save: 'Salva', cancel: 'Annulla', close: 'Chiudi',
|
save: 'Salva', cancel: 'Annulla', close: 'Chiudi',
|
||||||
create: 'Crea', delete: 'Elimina', edit: 'Modifica',
|
create: 'Crea', delete: 'Elimina', edit: 'Modifica',
|
||||||
@@ -1818,6 +1950,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'I promemoria sono disattivati per questo calendario – non verranno attivati.',
|
reminders_calendar_off: 'I promemoria sono disattivati per questo calendario – non verranno attivati.',
|
||||||
calendar_reminders_on: 'Promemoria attivati',
|
calendar_reminders_on: 'Promemoria attivati',
|
||||||
calendar_reminders_off: 'Promemoria disattivati',
|
calendar_reminders_off: 'Promemoria disattivati',
|
||||||
|
calendar_reminders_col: 'Notifiche',
|
||||||
copy: 'Copia',
|
copy: 'Copia',
|
||||||
caldav_publish: 'Pubblica via CalDAV',
|
caldav_publish: 'Pubblica via CalDAV',
|
||||||
caldav_unpublish: 'Annulla pubblicazione CalDAV',
|
caldav_unpublish: 'Annulla pubblicazione CalDAV',
|
||||||
@@ -2048,6 +2181,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
nl: {
|
nl: {
|
||||||
|
invites_title: 'Verzoeken',
|
||||||
|
invite_accept: 'Accepteren',
|
||||||
|
invite_decline: 'Weigeren',
|
||||||
|
invite_dismiss: 'Verwijderen',
|
||||||
|
invite_cancelled: 'Ingetrokken',
|
||||||
|
invite_accepted: 'Uitnodiging geaccepteerd',
|
||||||
|
invite_declined: 'Uitnodiging geweigerd',
|
||||||
|
invites_mailbox: 'Uitnodigingen-postbus',
|
||||||
|
invites_mailbox_desc: 'Een postbus waarin uitnodigingen binnenkomen. Ze verschijnen als verzoek en worden pas afspraken zodra je ze accepteert. Toewijzing gebeurt via het deelnemersadres — zet het adres waarop je wordt uitgenodigd in je profiel.',
|
||||||
|
invites_mailbox_none: 'Geen postbus ingesteld',
|
||||||
|
invites_mailbox_remove: 'Postbus verwijderen',
|
||||||
|
invites_mailbox_remove_confirm: 'Postbus verwijderen? Reeds ontvangen verzoeken blijven staan.',
|
||||||
|
invites_imap_host: 'IMAP-server',
|
||||||
|
invites_imap_user: 'Gebruikersnaam',
|
||||||
|
invites_imap_password: 'Wachtwoord',
|
||||||
|
invites_imap_port: 'Poort',
|
||||||
|
invites_imap_folder: 'Map',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Actief',
|
||||||
|
invites_poll_minutes: 'Interval (minuten)',
|
||||||
|
invites_poll_now: 'Nu ophalen',
|
||||||
|
invites_polled: 'Postbus opgehaald',
|
||||||
|
invites_password_keep: 'Ongewijzigd laten',
|
||||||
|
invites_last_run: 'Laatst opgehaald',
|
||||||
|
invites_last_error: 'Laatste fout',
|
||||||
|
invites_never_run: 'Nog niet opgehaald',
|
||||||
// Common
|
// Common
|
||||||
save: 'Opslaan', cancel: 'Annuleren', close: 'Sluiten',
|
save: 'Opslaan', cancel: 'Annuleren', close: 'Sluiten',
|
||||||
create: 'Aanmaken', delete: 'Verwijderen', edit: 'Bewerken',
|
create: 'Aanmaken', delete: 'Verwijderen', edit: 'Bewerken',
|
||||||
@@ -2212,6 +2371,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Herinneringen zijn uitgeschakeld voor deze agenda – ze worden niet geactiveerd.',
|
reminders_calendar_off: 'Herinneringen zijn uitgeschakeld voor deze agenda – ze worden niet geactiveerd.',
|
||||||
calendar_reminders_on: 'Herinneringen ingeschakeld',
|
calendar_reminders_on: 'Herinneringen ingeschakeld',
|
||||||
calendar_reminders_off: 'Herinneringen uitgeschakeld',
|
calendar_reminders_off: 'Herinneringen uitgeschakeld',
|
||||||
|
calendar_reminders_col: 'Meldingen',
|
||||||
copy: 'Kopiëren',
|
copy: 'Kopiëren',
|
||||||
caldav_publish: 'Publiceren via CalDAV',
|
caldav_publish: 'Publiceren via CalDAV',
|
||||||
caldav_unpublish: 'CalDAV-publicatie stoppen',
|
caldav_unpublish: 'CalDAV-publicatie stoppen',
|
||||||
@@ -2442,6 +2602,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
da: {
|
da: {
|
||||||
|
invites_title: 'Anmodninger',
|
||||||
|
invite_accept: 'Accepter',
|
||||||
|
invite_decline: 'Afvis',
|
||||||
|
invite_dismiss: 'Fjern',
|
||||||
|
invite_cancelled: 'Trukket tilbage',
|
||||||
|
invite_accepted: 'Invitation accepteret',
|
||||||
|
invite_declined: 'Invitation afvist',
|
||||||
|
invites_mailbox: 'Postkasse til invitationer',
|
||||||
|
invites_mailbox_desc: 'En postkasse hvor invitationer ankommer. De vises som anmodninger og bliver først til begivenheder, når du accepterer dem. Tilknytning sker via deltageradressen — angiv i din profil den adresse, du bliver inviteret på.',
|
||||||
|
invites_mailbox_none: 'Ingen postkasse konfigureret',
|
||||||
|
invites_mailbox_remove: 'Fjern postkasse',
|
||||||
|
invites_mailbox_remove_confirm: 'Fjern postkassen? Allerede modtagne anmodninger bevares.',
|
||||||
|
invites_imap_host: 'IMAP-server',
|
||||||
|
invites_imap_user: 'Brugernavn',
|
||||||
|
invites_imap_password: 'Adgangskode',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Mappe',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Aktiv',
|
||||||
|
invites_poll_minutes: 'Interval (minutter)',
|
||||||
|
invites_poll_now: 'Hent nu',
|
||||||
|
invites_polled: 'Postkasse hentet',
|
||||||
|
invites_password_keep: 'Lad være uændret',
|
||||||
|
invites_last_run: 'Sidst hentet',
|
||||||
|
invites_last_error: 'Seneste fejl',
|
||||||
|
invites_never_run: 'Endnu ikke hentet',
|
||||||
// Common
|
// Common
|
||||||
save: 'Gem', cancel: 'Annullér', close: 'Luk',
|
save: 'Gem', cancel: 'Annullér', close: 'Luk',
|
||||||
create: 'Opret', delete: 'Slet', edit: 'Redigér',
|
create: 'Opret', delete: 'Slet', edit: 'Redigér',
|
||||||
@@ -2606,6 +2792,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Påmindelser er slået fra for denne kalender – de bliver ikke udløst.',
|
reminders_calendar_off: 'Påmindelser er slået fra for denne kalender – de bliver ikke udløst.',
|
||||||
calendar_reminders_on: 'Påmindelser slået til',
|
calendar_reminders_on: 'Påmindelser slået til',
|
||||||
calendar_reminders_off: 'Påmindelser slået fra',
|
calendar_reminders_off: 'Påmindelser slået fra',
|
||||||
|
calendar_reminders_col: 'Notifikationer',
|
||||||
copy: 'Kopiér',
|
copy: 'Kopiér',
|
||||||
caldav_publish: 'Udgiv via CalDAV',
|
caldav_publish: 'Udgiv via CalDAV',
|
||||||
caldav_unpublish: 'Stop CalDAV-udgivelse',
|
caldav_unpublish: 'Stop CalDAV-udgivelse',
|
||||||
@@ -2836,6 +3023,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
no: {
|
no: {
|
||||||
|
invites_title: 'Forespørsler',
|
||||||
|
invite_accept: 'Godta',
|
||||||
|
invite_decline: 'Avslå',
|
||||||
|
invite_dismiss: 'Fjern',
|
||||||
|
invite_cancelled: 'Trukket tilbake',
|
||||||
|
invite_accepted: 'Invitasjon godtatt',
|
||||||
|
invite_declined: 'Invitasjon avslått',
|
||||||
|
invites_mailbox: 'Postkasse for invitasjoner',
|
||||||
|
invites_mailbox_desc: 'En postkasse der invitasjoner kommer inn. De vises som forespørsler og blir først avtaler når du godtar dem. Tilknytning skjer via deltakeradressen — legg inn adressen du blir invitert på i profilen din.',
|
||||||
|
invites_mailbox_none: 'Ingen postkasse satt opp',
|
||||||
|
invites_mailbox_remove: 'Fjern postkasse',
|
||||||
|
invites_mailbox_remove_confirm: 'Fjerne postkassen? Allerede mottatte forespørsler beholdes.',
|
||||||
|
invites_imap_host: 'IMAP-server',
|
||||||
|
invites_imap_user: 'Brukernavn',
|
||||||
|
invites_imap_password: 'Passord',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Mappe',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Aktiv',
|
||||||
|
invites_poll_minutes: 'Intervall (minutter)',
|
||||||
|
invites_poll_now: 'Hent nå',
|
||||||
|
invites_polled: 'Postkasse hentet',
|
||||||
|
invites_password_keep: 'La stå uendret',
|
||||||
|
invites_last_run: 'Sist hentet',
|
||||||
|
invites_last_error: 'Siste feil',
|
||||||
|
invites_never_run: 'Ikke hentet ennå',
|
||||||
// Common
|
// Common
|
||||||
save: 'Lagre', cancel: 'Avbryt', close: 'Lukk',
|
save: 'Lagre', cancel: 'Avbryt', close: 'Lukk',
|
||||||
create: 'Opprett', delete: 'Slett', edit: 'Rediger',
|
create: 'Opprett', delete: 'Slett', edit: 'Rediger',
|
||||||
@@ -3000,6 +3213,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Påminnelser er slått av for denne kalenderen – de blir ikke utløst.',
|
reminders_calendar_off: 'Påminnelser er slått av for denne kalenderen – de blir ikke utløst.',
|
||||||
calendar_reminders_on: 'Påminnelser slått på',
|
calendar_reminders_on: 'Påminnelser slått på',
|
||||||
calendar_reminders_off: 'Påminnelser slått av',
|
calendar_reminders_off: 'Påminnelser slått av',
|
||||||
|
calendar_reminders_col: 'Varsler',
|
||||||
copy: 'Kopier',
|
copy: 'Kopier',
|
||||||
caldav_publish: 'Publiser via CalDAV',
|
caldav_publish: 'Publiser via CalDAV',
|
||||||
caldav_unpublish: 'Stopp CalDAV-publisering',
|
caldav_unpublish: 'Stopp CalDAV-publisering',
|
||||||
@@ -3230,6 +3444,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
sv: {
|
sv: {
|
||||||
|
invites_title: 'Förfrågningar',
|
||||||
|
invite_accept: 'Acceptera',
|
||||||
|
invite_decline: 'Avböj',
|
||||||
|
invite_dismiss: 'Ta bort',
|
||||||
|
invite_cancelled: 'Återkallad',
|
||||||
|
invite_accepted: 'Inbjudan accepterad',
|
||||||
|
invite_declined: 'Inbjudan avböjd',
|
||||||
|
invites_mailbox: 'Postlåda för inbjudningar',
|
||||||
|
invites_mailbox_desc: 'En postlåda dit inbjudningar kommer. De visas som förfrågningar och blir händelser först när du accepterar dem. Kopplingen sker via deltagaradressen — ange i din profil den adress du blir inbjuden på.',
|
||||||
|
invites_mailbox_none: 'Ingen postlåda konfigurerad',
|
||||||
|
invites_mailbox_remove: 'Ta bort postlåda',
|
||||||
|
invites_mailbox_remove_confirm: 'Ta bort postlådan? Redan mottagna förfrågningar behålls.',
|
||||||
|
invites_imap_host: 'IMAP-server',
|
||||||
|
invites_imap_user: 'Användarnamn',
|
||||||
|
invites_imap_password: 'Lösenord',
|
||||||
|
invites_imap_port: 'Port',
|
||||||
|
invites_imap_folder: 'Mapp',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Aktiv',
|
||||||
|
invites_poll_minutes: 'Intervall (minuter)',
|
||||||
|
invites_poll_now: 'Hämta nu',
|
||||||
|
invites_polled: 'Postlådan hämtad',
|
||||||
|
invites_password_keep: 'Lämna oförändrat',
|
||||||
|
invites_last_run: 'Senast hämtad',
|
||||||
|
invites_last_error: 'Senaste fel',
|
||||||
|
invites_never_run: 'Inte hämtad ännu',
|
||||||
// Common
|
// Common
|
||||||
save: 'Spara', cancel: 'Avbryt', close: 'Stäng',
|
save: 'Spara', cancel: 'Avbryt', close: 'Stäng',
|
||||||
create: 'Skapa', delete: 'Radera', edit: 'Redigera',
|
create: 'Skapa', delete: 'Radera', edit: 'Redigera',
|
||||||
@@ -3394,6 +3634,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Påminnelser är avstängda för den här kalendern – de kommer inte att utlösas.',
|
reminders_calendar_off: 'Påminnelser är avstängda för den här kalendern – de kommer inte att utlösas.',
|
||||||
calendar_reminders_on: 'Påminnelser aktiverade',
|
calendar_reminders_on: 'Påminnelser aktiverade',
|
||||||
calendar_reminders_off: 'Påminnelser avstängda',
|
calendar_reminders_off: 'Påminnelser avstängda',
|
||||||
|
calendar_reminders_col: 'Aviseringar',
|
||||||
copy: 'Kopiera',
|
copy: 'Kopiera',
|
||||||
caldav_publish: 'Publicera via CalDAV',
|
caldav_publish: 'Publicera via CalDAV',
|
||||||
caldav_unpublish: 'Sluta publicera via CalDAV',
|
caldav_unpublish: 'Sluta publicera via CalDAV',
|
||||||
@@ -3624,6 +3865,32 @@ const translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fi: {
|
fi: {
|
||||||
|
invites_title: 'Pyynnöt',
|
||||||
|
invite_accept: 'Hyväksy',
|
||||||
|
invite_decline: 'Hylkää',
|
||||||
|
invite_dismiss: 'Poista',
|
||||||
|
invite_cancelled: 'Peruttu',
|
||||||
|
invite_accepted: 'Kutsu hyväksytty',
|
||||||
|
invite_declined: 'Kutsu hylätty',
|
||||||
|
invites_mailbox: 'Kutsujen postilaatikko',
|
||||||
|
invites_mailbox_desc: 'Postilaatikko, johon kutsut saapuvat. Ne näkyvät pyyntöinä ja muuttuvat tapahtumiksi vasta kun hyväksyt ne. Kohdistus tehdään osallistujan osoitteen perusteella — lisää profiiliisi osoite, jolla sinut kutsutaan.',
|
||||||
|
invites_mailbox_none: 'Postilaatikkoa ei määritetty',
|
||||||
|
invites_mailbox_remove: 'Poista postilaatikko',
|
||||||
|
invites_mailbox_remove_confirm: 'Poistetaanko postilaatikko? Jo vastaanotetut pyynnöt säilyvät.',
|
||||||
|
invites_imap_host: 'IMAP-palvelin',
|
||||||
|
invites_imap_user: 'Käyttäjätunnus',
|
||||||
|
invites_imap_password: 'Salasana',
|
||||||
|
invites_imap_port: 'Portti',
|
||||||
|
invites_imap_folder: 'Kansio',
|
||||||
|
invites_imap_ssl: 'SSL/TLS',
|
||||||
|
invites_enabled: 'Käytössä',
|
||||||
|
invites_poll_minutes: 'Hakuväli (minuuttia)',
|
||||||
|
invites_poll_now: 'Hae nyt',
|
||||||
|
invites_polled: 'Postilaatikko haettu',
|
||||||
|
invites_password_keep: 'Jätä ennalleen',
|
||||||
|
invites_last_run: 'Viimeksi haettu',
|
||||||
|
invites_last_error: 'Viimeisin virhe',
|
||||||
|
invites_never_run: 'Ei vielä haettu',
|
||||||
// Common
|
// Common
|
||||||
save: 'Tallenna', cancel: 'Peruuta', close: 'Sulje',
|
save: 'Tallenna', cancel: 'Peruuta', close: 'Sulje',
|
||||||
create: 'Luo', delete: 'Poista', edit: 'Muokkaa',
|
create: 'Luo', delete: 'Poista', edit: 'Muokkaa',
|
||||||
@@ -3788,6 +4055,7 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Muistutukset on poistettu käytöstä tässä kalenterissa – niitä ei laukaista.',
|
reminders_calendar_off: 'Muistutukset on poistettu käytöstä tässä kalenterissa – niitä ei laukaista.',
|
||||||
calendar_reminders_on: 'Muistutukset käytössä',
|
calendar_reminders_on: 'Muistutukset käytössä',
|
||||||
calendar_reminders_off: 'Muistutukset pois käytöstä',
|
calendar_reminders_off: 'Muistutukset pois käytöstä',
|
||||||
|
calendar_reminders_col: 'Ilmoitukset',
|
||||||
copy: 'Kopioi',
|
copy: 'Kopioi',
|
||||||
caldav_publish: 'Julkaise CalDAV:n kautta',
|
caldav_publish: 'Julkaise CalDAV:n kautta',
|
||||||
caldav_unpublish: 'Lopeta CalDAV-julkaisu',
|
caldav_unpublish: 'Lopeta CalDAV-julkaisu',
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v95';
|
export const APP_VERSION = 'v97';
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
// the entry HTML / version files). New releases take effect on the next
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
// reload, no manual SW unregister required.
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v41';
|
const CACHE_VERSION = 'calendarr-v43';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|||||||
@@ -1084,6 +1084,7 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Calendarr zeigt die Kalender dieses iPhones (z. B. iCloud und Outlook) an und legt dort Termine an.";
|
||||||
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -1136,6 +1137,7 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Calendarr zeigt die Kalender dieses iPhones (z. B. iCloud und Outlook) an und legt dort Termine an.";
|
||||||
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
|||||||
BIN
Binary file not shown.
+3
-3
@@ -17,12 +17,12 @@
|
|||||||
<key>CalendarrWatch.xcscheme_^#shared#^_</key>
|
<key>CalendarrWatch.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CalendarrWatchComplications.xcscheme_^#shared#^_</key>
|
<key>CalendarrWatchComplications.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>3</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CalendarrWatchExtension.xcscheme_^#shared#^_</key>
|
<key>CalendarrWatchExtension.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>4</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ struct CalDAVCalendar: Codable, Identifiable {
|
|||||||
case sidebarHidden = "sidebar_hidden"
|
case sidebarHidden = "sidebar_hidden"
|
||||||
case remindersEnabled = "reminders_enabled"
|
case remindersEnabled = "reminders_enabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||||
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||||
|
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||||
|
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LocalCalendar: Codable, Identifiable {
|
struct LocalCalendar: Codable, Identifiable {
|
||||||
@@ -161,12 +171,27 @@ struct ICalSubscription: Codable, Identifiable {
|
|||||||
var refreshMinutes: Int
|
var refreshMinutes: Int
|
||||||
var lastFetched: String?
|
var lastFetched: String?
|
||||||
var remindersEnabled: Bool?
|
var remindersEnabled: Bool?
|
||||||
|
var sidebarHidden: Bool = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, url, color, enabled
|
case id, name, url, color, enabled
|
||||||
case refreshMinutes = "refresh_minutes"
|
case refreshMinutes = "refresh_minutes"
|
||||||
case lastFetched = "last_fetched"
|
case lastFetched = "last_fetched"
|
||||||
case remindersEnabled = "reminders_enabled"
|
case remindersEnabled = "reminders_enabled"
|
||||||
|
case sidebarHidden = "sidebar_hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#4285f4"
|
||||||
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||||
|
refreshMinutes = try c.decodeIfPresent(Int.self, forKey: .refreshMinutes) ?? 60
|
||||||
|
lastFetched = try c.decodeIfPresent(String.self, forKey: .lastFetched)
|
||||||
|
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled)
|
||||||
|
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +214,16 @@ struct GoogleCalendar: Codable, Identifiable {
|
|||||||
case sidebarHidden = "sidebar_hidden"
|
case sidebarHidden = "sidebar_hidden"
|
||||||
case remindersEnabled = "reminders_enabled"
|
case remindersEnabled = "reminders_enabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||||
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||||
|
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||||
|
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HomeAssistantAccount: Codable, Identifiable {
|
struct HomeAssistantAccount: Codable, Identifiable {
|
||||||
@@ -202,6 +237,15 @@ struct HomeAssistantAccount: Codable, Identifiable {
|
|||||||
case id, name, url, calendars
|
case id, name, url, calendars
|
||||||
case authMethod = "auth_method"
|
case authMethod = "auth_method"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Home Assistant"
|
||||||
|
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||||
|
authMethod = try c.decodeIfPresent(String.self, forKey: .authMethod) ?? "token"
|
||||||
|
calendars = try c.decodeIfPresent([HACalendar].self, forKey: .calendars)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HACalendar: Codable, Identifiable {
|
struct HACalendar: Codable, Identifiable {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CalendarrCore
|
import CalendarrCore
|
||||||
|
// loadWritableCalendars reads EKCalendar.title/.calendarIdentifier directly.
|
||||||
|
import EventKit
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
/// Posted whenever the persistent "banished calendars" set is mutated from
|
/// Posted whenever the persistent "banished calendars" set is mutated from
|
||||||
@@ -44,6 +46,12 @@ struct WritableCalendar: Identifiable {
|
|||||||
let color: String
|
let color: String
|
||||||
let source: String
|
let source: String
|
||||||
let numericId: Int
|
let numericId: Int
|
||||||
|
/// EventKit calendar identifier; set only for `source == "device"`, whose
|
||||||
|
/// ids are strings rather than server row ids.
|
||||||
|
var deviceId: String? = nil
|
||||||
|
|
||||||
|
/// The id as it appears in a calendar key, whichever kind it is.
|
||||||
|
var calendarIdString: String { deviceId ?? String(numericId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -68,6 +76,10 @@ class CalendarStore {
|
|||||||
var syncErrors: [SyncError] = []
|
var syncErrors: [SyncError] = []
|
||||||
var weekStartsOnMonday = true
|
var weekStartsOnMonday = true
|
||||||
var writableCalendars: [WritableCalendar] = []
|
var writableCalendars: [WritableCalendar] = []
|
||||||
|
/// Why a source contributed nothing, keyed by source name. Empty when every
|
||||||
|
/// source answered. Surfaced in the event editor, because an empty picker
|
||||||
|
/// with no explanation is indistinguishable from "you own no calendars".
|
||||||
|
var writableCalendarErrors: [String: String] = [:]
|
||||||
// When set, the calendar shows the group's combined overlay instead of the
|
// When set, the calendar shows the group's combined overlay instead of the
|
||||||
// user's own events. nil = personal view.
|
// user's own events. nil = personal view.
|
||||||
var activeGroup: CalGroup? = nil
|
var activeGroup: CalGroup? = nil
|
||||||
@@ -283,7 +295,8 @@ class CalendarStore {
|
|||||||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||||
async let g = (try? await api.getGoogleAccounts()) ?? []
|
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||||
let (caldav, google, ha) = await (c, g, h)
|
async let i = (try? await api.getICalSubscriptions()) ?? []
|
||||||
|
let (caldav, google, ha, ical) = await (c, g, h, i)
|
||||||
|
|
||||||
var b = banishedCalendarKeys
|
var b = banishedCalendarKeys
|
||||||
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||||
@@ -293,6 +306,7 @@ class CalendarStore {
|
|||||||
for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||||
for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||||
for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||||
|
for sub in ical { applyServerHidden("ical", sub.id, sub.sidebarHidden) }
|
||||||
|
|
||||||
guard b != banishedCalendarKeys else { return false }
|
guard b != banishedCalendarKeys else { return false }
|
||||||
banishedCalendarKeys = b
|
banishedCalendarKeys = b
|
||||||
@@ -346,7 +360,7 @@ class CalendarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
|
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
|
||||||
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
|
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant", "ical"]
|
||||||
|
|
||||||
var userCalendar: Calendar {
|
var userCalendar: Calendar {
|
||||||
var cal = Calendar.current
|
var cal = Calendar.current
|
||||||
@@ -414,8 +428,13 @@ class CalendarStore {
|
|||||||
/// Load events for a specific range. Skips the network if already cached,
|
/// Load events for a specific range. Skips the network if already cached,
|
||||||
/// unless `force` is set (used after create/edit to pull fresh server data
|
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||||
/// for the visible range, bypassing the cache).
|
/// for the visible range, bypassing the cache).
|
||||||
|
/// Device-calendar generation the cache was filled at (see DeviceCalendarService).
|
||||||
|
private var deviceGeneration = DeviceCalendarService.generation
|
||||||
|
|
||||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
||||||
if !force, isCached(start: start, end: end) {
|
let deviceChanged = deviceGeneration != DeviceCalendarService.generation
|
||||||
|
if deviceChanged { deviceGeneration = DeviceCalendarService.generation }
|
||||||
|
if !force, !deviceChanged, isCached(start: start, end: end) {
|
||||||
refreshFromCache(start: start, end: end)
|
refreshFromCache(start: start, end: end)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -447,7 +466,11 @@ class CalendarStore {
|
|||||||
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
||||||
return (combined.map { decorateGroupEvent($0) }, [])
|
return (combined.map { decorateGroupEvent($0) }, [])
|
||||||
}
|
}
|
||||||
return try await api.fetchEvents(start: start, end: end)
|
let (server, errors) = try await api.fetchEvents(start: start, end: end)
|
||||||
|
// Device calendars live on the phone, so they are merged in here rather
|
||||||
|
// than coming from the server. They are deliberately absent from the
|
||||||
|
// group overlay, which is a server-side combined view.
|
||||||
|
return (server + DeviceCalendarService.shared.events(start: start, end: end), errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix a combined-view event with its owner (others) or 👥 + creator
|
/// Prefix a combined-view event with its owner (others) or 👥 + creator
|
||||||
@@ -508,6 +531,14 @@ class CalendarStore {
|
|||||||
allCachedEvents = []
|
allCachedEvents = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force the next fetch to reach the server, without emptying what is on
|
||||||
|
/// screen. `invalidateCache()` drops the events too, which makes the whole
|
||||||
|
/// calendar blink empty and refill — far too much for one new event.
|
||||||
|
func invalidateRange() {
|
||||||
|
cachedStart = nil
|
||||||
|
cachedEnd = nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Calendars / sources that reported a sync error on the last fetch. Events
|
/// Calendars / sources that reported a sync error on the last fetch. Events
|
||||||
/// matching either must NOT be evicted from the cache on a partial sync —
|
/// matching either must NOT be evicted from the cache on a partial sync —
|
||||||
/// stale data beats a completely empty calendar.
|
/// stale data beats a completely empty calendar.
|
||||||
@@ -634,29 +665,62 @@ class CalendarStore {
|
|||||||
// MARK: – Writable calendars
|
// MARK: – Writable calendars
|
||||||
|
|
||||||
func loadWritableCalendars(api: CalendarrAPI) async {
|
func loadWritableCalendars(api: CalendarrAPI) async {
|
||||||
async let localCals = (try? await api.getLocalCalendars()) ?? []
|
// Each source is fetched independently and reports its own failure.
|
||||||
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? []
|
// `try?` used to discard all four, so a source that errored looked
|
||||||
async let googleCals = (try? await api.getGoogleCalendars()) ?? []
|
// exactly like a source with nothing in it — which is how a missing
|
||||||
async let haCals = (try? await api.getHACalendars()) ?? []
|
// Home Assistant calendar can go unexplained for a long time.
|
||||||
|
var errors: [String: String] = [:]
|
||||||
|
|
||||||
|
async let localResult = Self.attempt("local") { try await api.getLocalCalendars() }
|
||||||
|
async let caldavResult = Self.attempt("caldav") { try await api.getCalDAVAccounts() }
|
||||||
|
async let googleResult = Self.attempt("google") { try await api.getGoogleCalendars() }
|
||||||
|
async let haResult = Self.attempt("homeassistant"){ try await api.getHACalendars() }
|
||||||
|
|
||||||
|
let (localCals, localErr) = await localResult
|
||||||
|
let (caldavAccs, caldavErr) = await caldavResult
|
||||||
|
let (googleCals, googleErr) = await googleResult
|
||||||
|
let (haCals, haErr) = await haResult
|
||||||
|
for (source, message) in [("local", localErr), ("caldav", caldavErr),
|
||||||
|
("google", googleErr), ("homeassistant", haErr)] {
|
||||||
|
if let message { errors[source] = message }
|
||||||
|
}
|
||||||
|
|
||||||
var result: [WritableCalendar] = []
|
var result: [WritableCalendar] = []
|
||||||
// Skip read-only shared calendars — offering them in the event editor
|
// Skip read-only shared calendars — offering them in the event editor
|
||||||
// only leads to a 403 on save. Own + read_write (incl. group) stay.
|
// only leads to a 403 on save. Own + read_write (incl. group) stay.
|
||||||
for cal in await localCals where cal.owned || cal.permission == "read_write" {
|
for cal in localCals where cal.owned || cal.permission == "read_write" {
|
||||||
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
|
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
|
||||||
}
|
}
|
||||||
for acc in await caldavAccs where acc.enabled {
|
for acc in caldavAccs where acc.enabled {
|
||||||
for cal in acc.calendars ?? [] where cal.enabled {
|
for cal in acc.calendars ?? [] where cal.enabled {
|
||||||
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
|
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (email, id, name, color) in await googleCals {
|
for (email, id, name, color) in googleCals {
|
||||||
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id))
|
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id))
|
||||||
}
|
}
|
||||||
for (accName, id, name, color) in await haCals {
|
for (accName, id, name, color) in haCals {
|
||||||
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
|
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
|
||||||
}
|
}
|
||||||
|
for cal in DeviceCalendarService.shared.calendars() where cal.allowsContentModifications {
|
||||||
|
result.append(WritableCalendar(
|
||||||
|
id: "device-\(cal.calendarIdentifier)",
|
||||||
|
name: cal.title,
|
||||||
|
color: "#8E8E93",
|
||||||
|
source: DeviceCalendarService.source,
|
||||||
|
numericId: 0,
|
||||||
|
deviceId: cal.calendarIdentifier))
|
||||||
|
}
|
||||||
writableCalendars = result
|
writableCalendars = result
|
||||||
|
writableCalendarErrors = errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one source fetch, returning its result or the reason it produced
|
||||||
|
/// nothing. Never throws: one broken source must not empty the picker.
|
||||||
|
private static func attempt<T>(_ source: String,
|
||||||
|
_ work: () async throws -> [T]) async -> ([T], String?) {
|
||||||
|
do { return (try await work(), nil) }
|
||||||
|
catch { return ([], error.localizedDescription) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Query helpers
|
// MARK: – Query helpers
|
||||||
|
|||||||
@@ -297,6 +297,10 @@ private let strings: [String: [String: String]] = [
|
|||||||
"login.totp_placeholder": "6-stelliger Code",
|
"login.totp_placeholder": "6-stelliger Code",
|
||||||
"login.remember": "Angemeldet bleiben",
|
"login.remember": "Angemeldet bleiben",
|
||||||
"login.signin": "Anmelden",
|
"login.signin": "Anmelden",
|
||||||
|
"device.calendars.header": "Gerätekalender",
|
||||||
|
"device.calendars.enable": "Kalender des iPhones anzeigen",
|
||||||
|
"device.calendars.desc": "Zeigt iCloud-, Outlook- und andere Kalender, die auf diesem iPhone eingerichtet sind. Sie bleiben auf dem Gerät und werden nicht an den Server übertragen — im Web und auf anderen Geräten erscheinen sie deshalb nicht.",
|
||||||
|
"device.calendars.denied": "Kein Zugriff auf die Kalender. Erlaube ihn in den Systemeinstellungen unter Datenschutz → Kalender.",
|
||||||
"login.choose_server": "Anderen Server wählen",
|
"login.choose_server": "Anderen Server wählen",
|
||||||
// Single sign-on (OpenID Connect)
|
// Single sign-on (OpenID Connect)
|
||||||
"sso.or": "oder",
|
"sso.or": "oder",
|
||||||
@@ -694,6 +698,10 @@ private let strings: [String: [String: String]] = [
|
|||||||
"login.totp_placeholder": "6-digit code",
|
"login.totp_placeholder": "6-digit code",
|
||||||
"login.remember": "Stay signed in",
|
"login.remember": "Stay signed in",
|
||||||
"login.signin": "Sign in",
|
"login.signin": "Sign in",
|
||||||
|
"device.calendars.header": "Device calendars",
|
||||||
|
"device.calendars.enable": "Show this iPhone's calendars",
|
||||||
|
"device.calendars.desc": "Shows iCloud, Outlook and other calendars set up on this iPhone. They stay on the device and are never sent to the server, so they do not appear on the web or on your other devices.",
|
||||||
|
"device.calendars.denied": "No calendar access. Grant it in System Settings under Privacy → Calendars.",
|
||||||
"login.choose_server": "Choose another server",
|
"login.choose_server": "Choose another server",
|
||||||
// Single sign-on (OpenID Connect)
|
// Single sign-on (OpenID Connect)
|
||||||
"sso.or": "or",
|
"sso.or": "or",
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ class CalendarrAPI {
|
|||||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||||
|
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
|
||||||
default: return
|
default: return
|
||||||
}
|
}
|
||||||
_ = try await request(path, method: "PUT",
|
_ = try await request(path, method: "PUT",
|
||||||
@@ -531,7 +532,7 @@ class CalendarrAPI {
|
|||||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||||
case "local": path = "/api/local/calendars/\(calendarId)"
|
case "local": path = "/api/local/calendars/\(calendarId)/reminders"
|
||||||
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
|
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
|
||||||
default: return
|
default: return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// The calendars iOS itself manages — iCloud, Outlook/Exchange, Google — read
|
||||||
|
/// and written through EventKit.
|
||||||
|
///
|
||||||
|
/// These never reach the Calendarr server: they live on the device, so they
|
||||||
|
/// show up in this app only. That is the point. Publishing an Outlook calendar
|
||||||
|
/// is often blocked by company policy, and the phone already has the account.
|
||||||
|
///
|
||||||
|
/// Off until the user turns it on in Accounts, so nobody is asked for calendar
|
||||||
|
/// access they did not ask for.
|
||||||
|
@MainActor
|
||||||
|
final class DeviceCalendarService {
|
||||||
|
static let shared = DeviceCalendarService()
|
||||||
|
|
||||||
|
/// `CalEvent.source` for device events; also the prefix of their calendar key.
|
||||||
|
static let source = "device"
|
||||||
|
static let enabledKey = "deviceCalendarsEnabled"
|
||||||
|
|
||||||
|
private let store = EKEventStore()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Access
|
||||||
|
|
||||||
|
static var isEnabled: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: enabledKey) }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasAccess: Bool {
|
||||||
|
EKEventStore.authorizationStatus(for: .event) == .fullAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once the user granted access. Write access needs full access —
|
||||||
|
/// `.writeOnly` would let us add events but never show them.
|
||||||
|
@discardableResult
|
||||||
|
func requestAccess() async -> Bool {
|
||||||
|
if hasAccess { return true }
|
||||||
|
let granted = (try? await store.requestFullAccessToEvents()) ?? false
|
||||||
|
if granted { Self.generation += 1 }
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both switches on: the user enabled the feature *and* granted access.
|
||||||
|
var isActive: Bool { Self.isEnabled && hasAccess }
|
||||||
|
|
||||||
|
/// Bumped when access is granted. The event cache was filled before the
|
||||||
|
/// device calendars existed, so the store treats a changed generation as a
|
||||||
|
/// cache miss — otherwise the new calendars would only turn up after a
|
||||||
|
/// restart. Cheaper than threading the store through the accounts screen.
|
||||||
|
private(set) static var generation = 0
|
||||||
|
|
||||||
|
// MARK: - Reading
|
||||||
|
|
||||||
|
/// Every calendar the device knows, writable ones included.
|
||||||
|
func calendars() -> [EKCalendar] {
|
||||||
|
guard isActive else { return [] }
|
||||||
|
return store.calendars(for: .event)
|
||||||
|
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
func calendar(id: String) -> EKCalendar? {
|
||||||
|
guard isActive else { return nil }
|
||||||
|
return store.calendar(withIdentifier: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device events in the range, mapped onto the app's own event type so the
|
||||||
|
/// existing views, filters and ordering treat them like any other source.
|
||||||
|
func events(start: Date, end: Date) -> [CalEvent] {
|
||||||
|
guard isActive else { return [] }
|
||||||
|
let cals = store.calendars(for: .event)
|
||||||
|
guard !cals.isEmpty else { return [] }
|
||||||
|
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: cals)
|
||||||
|
return store.events(matching: predicate).compactMap(Self.map)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func map(_ ek: EKEvent) -> CalEvent? {
|
||||||
|
// A recurring event's occurrences share one eventIdentifier, so append
|
||||||
|
// the start date to keep SwiftUI's per-event identity unique.
|
||||||
|
guard let ident = ek.eventIdentifier,
|
||||||
|
let start = ek.startDate, let end = ek.endDate else { return nil }
|
||||||
|
let cal = ek.calendar
|
||||||
|
return CalEvent(
|
||||||
|
id: "\(ident)|\(Int(start.timeIntervalSince1970))",
|
||||||
|
url: "",
|
||||||
|
title: ek.title ?? "",
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
isAllDay: ek.isAllDay,
|
||||||
|
location: ek.location ?? "",
|
||||||
|
notes: ek.notes ?? "",
|
||||||
|
color: hex(cal?.cgColor),
|
||||||
|
calendarId: cal?.calendarIdentifier ?? "",
|
||||||
|
calendarName: cal?.title ?? "",
|
||||||
|
calendarColor: hex(cal?.cgColor) ?? "#8E8E93",
|
||||||
|
source: source,
|
||||||
|
readOnly: !(cal?.allowsContentModifications ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The EKEvent behind one of our composite ids (see `map`).
|
||||||
|
private func ekEvent(for calEventId: String) -> EKEvent? {
|
||||||
|
let ident = calEventId.split(separator: "|").first.map(String.init) ?? calEventId
|
||||||
|
return store.event(withIdentifier: ident)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writing
|
||||||
|
|
||||||
|
func create(calendarId: String, title: String, start: Date, end: Date,
|
||||||
|
isAllDay: Bool, location: String, notes: String) throws {
|
||||||
|
guard let cal = store.calendar(withIdentifier: calendarId) else {
|
||||||
|
throw DeviceCalendarError.calendarGone
|
||||||
|
}
|
||||||
|
let ev = EKEvent(eventStore: store)
|
||||||
|
ev.calendar = cal
|
||||||
|
apply(ev, title: title, start: start, end: end,
|
||||||
|
isAllDay: isAllDay, location: location, notes: notes)
|
||||||
|
try store.save(ev, span: .thisEvent, commit: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(eventId: String, title: String, start: Date, end: Date,
|
||||||
|
isAllDay: Bool, location: String, notes: String) throws {
|
||||||
|
guard let ev = ekEvent(for: eventId) else { throw DeviceCalendarError.eventGone }
|
||||||
|
apply(ev, title: title, start: start, end: end,
|
||||||
|
isAllDay: isAllDay, location: location, notes: notes)
|
||||||
|
// .thisEvent on a recurring series edits only the occurrence the user
|
||||||
|
// opened, which is what tapping a single day implies.
|
||||||
|
try store.save(ev, span: .thisEvent, commit: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(eventId: String) throws {
|
||||||
|
guard let ev = ekEvent(for: eventId) else { throw DeviceCalendarError.eventGone }
|
||||||
|
try store.remove(ev, span: .thisEvent, commit: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ ev: EKEvent, title: String, start: Date, end: Date,
|
||||||
|
isAllDay: Bool, location: String, notes: String) {
|
||||||
|
ev.title = title
|
||||||
|
ev.startDate = start
|
||||||
|
ev.endDate = end
|
||||||
|
ev.isAllDay = isAllDay
|
||||||
|
ev.location = location.isEmpty ? nil : location
|
||||||
|
ev.notes = notes.isEmpty ? nil : notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static func hex(_ color: CGColor?) -> String? {
|
||||||
|
guard let color, let c = UIColor(cgColor: color).cgColor.components, c.count >= 3 else { return nil }
|
||||||
|
return String(format: "#%02X%02X%02X",
|
||||||
|
Int((c[0] * 255).rounded()),
|
||||||
|
Int((c[1] * 255).rounded()),
|
||||||
|
Int((c[2] * 255).rounded()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceCalendarError: LocalizedError {
|
||||||
|
case calendarGone
|
||||||
|
case eventGone
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .calendarGone: return "Dieser Gerätekalender existiert nicht mehr."
|
||||||
|
case .eventGone: return "Dieser Termin existiert auf dem Gerät nicht mehr."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,13 @@ enum NotificationScheduler {
|
|||||||
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||||
if disabledCalendarKeys.contains(key) { continue }
|
if disabledCalendarKeys.contains(key) { continue }
|
||||||
}
|
}
|
||||||
|
// Device calendars are already served by the system Calendar
|
||||||
|
// app, so applying our default here notifies twice for one
|
||||||
|
// event. They stay silent unless the event carries its own
|
||||||
|
// reminder; the per-calendar toggle can still switch them on.
|
||||||
|
let allowsDefault = defaultMin >= 0 && ev.source != "device"
|
||||||
let offsets = ev.reminders.isEmpty
|
let offsets = ev.reminders.isEmpty
|
||||||
? (defaultMin >= 0 ? [defaultMin] : [])
|
? (allowsDefault ? [defaultMin] : [])
|
||||||
: ev.reminders
|
: ev.reminders
|
||||||
for m in offsets {
|
for m in offsets {
|
||||||
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
|
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import EventKit
|
||||||
|
|
||||||
struct AccountsView: View {
|
struct AccountsView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -30,6 +31,8 @@ struct AccountsView: View {
|
|||||||
@State private var birthdayNotify = -1
|
@State private var birthdayNotify = -1
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@AppStorage(DeviceCalendarService.enabledKey) private var deviceCalendarsEnabled = false
|
||||||
|
@State private var deviceCalendarAccessGranted = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -42,6 +45,7 @@ struct AccountsView: View {
|
|||||||
caldavSection
|
caldavSection
|
||||||
localSection
|
localSection
|
||||||
birthdayContactsSection
|
birthdayContactsSection
|
||||||
|
deviceCalendarSection
|
||||||
icalSection
|
icalSection
|
||||||
googleSection
|
googleSection
|
||||||
haSection
|
haSection
|
||||||
@@ -235,6 +239,30 @@ struct AccountsView: View {
|
|||||||
localCalendars.first { $0.isBirthday && $0.owned }
|
localCalendars.first { $0.isBirthday && $0.owned }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calendars the phone already has (iCloud, Outlook/Exchange, Google).
|
||||||
|
/// They stay on the device — the server never sees them — so this is the
|
||||||
|
/// way in when publishing an Outlook calendar is blocked by company policy.
|
||||||
|
@ViewBuilder var deviceCalendarSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle(L10n.t("device.calendars.enable", appLang), isOn: $deviceCalendarsEnabled)
|
||||||
|
if deviceCalendarsEnabled && !deviceCalendarAccessGranted {
|
||||||
|
Text(L10n.t("device.calendars.denied", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("device.calendars.header", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("device.calendars.desc", appLang))
|
||||||
|
}
|
||||||
|
.onChange(of: deviceCalendarsEnabled) { _, on in
|
||||||
|
guard on else { return }
|
||||||
|
// Granting access bumps the service generation, which makes the
|
||||||
|
// calendar reload instead of serving its pre-device cache.
|
||||||
|
Task { deviceCalendarAccessGranted = await DeviceCalendarService.shared.requestAccess() }
|
||||||
|
}
|
||||||
|
.task { deviceCalendarAccessGranted = DeviceCalendarService.shared.hasAccess }
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder var birthdayContactsSection: some View {
|
@ViewBuilder var birthdayContactsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
if let cal = birthdayCalendar {
|
if let cal = birthdayCalendar {
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ struct CalendarHostView: View {
|
|||||||
showNewBirthday: $showNewBirthday,
|
showNewBirthday: $showNewBirthday,
|
||||||
api: api,
|
api: api,
|
||||||
reload: { await onNavigate() },
|
reload: { await onNavigate() },
|
||||||
reloadForce: { await reloadVisible(force: true) })
|
reloadForce: { await reloadAfterSave() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Loading logic
|
// MARK: – Loading logic
|
||||||
@@ -525,11 +525,15 @@ struct CalendarHostView: View {
|
|||||||
await store.loadEvents(api: api, start: s, end: e)
|
await store.loadEvents(api: api, start: s, end: e)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
|
/// After creating or editing: refetch the visible range from the server,
|
||||||
/// created/edited event shows up immediately (the server is authoritative).
|
/// keeping the current events on screen while it happens. Clearing only the
|
||||||
private func reloadVisible(force: Bool) async {
|
/// range — not the events — is what separates this from the menu's manual
|
||||||
|
/// sync, which is allowed to blank the calendar because the user asked for
|
||||||
|
/// a full refresh.
|
||||||
|
private func reloadAfterSave() async {
|
||||||
|
store.invalidateRange()
|
||||||
let (s, e) = store.rangeForCurrentView()
|
let (s, e) = store.rangeForCurrentView()
|
||||||
await store.loadEvents(api: api, start: s, end: e, force: force)
|
await store.loadEvents(api: api, start: s, end: e, force: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import EventKit
|
||||||
|
|
||||||
/// `.sheet(item:)` needs Identifiable and URL is not. A retroactive conformance
|
/// `.sheet(item:)` needs Identifiable and URL is not. A retroactive conformance
|
||||||
/// on a stdlib type would be visible module-wide, so wrap it instead.
|
/// on a stdlib type would be visible module-wide, so wrap it instead.
|
||||||
@@ -264,6 +265,8 @@ struct EventDetailSheet: View {
|
|||||||
// calendarId looks like "homeassistant-42" → numeric DB id 42
|
// calendarId looks like "homeassistant-42" → numeric DB id 42
|
||||||
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
|
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
|
||||||
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
|
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
|
||||||
|
case DeviceCalendarService.source:
|
||||||
|
try DeviceCalendarService.shared.delete(eventId: event.id)
|
||||||
default:
|
default:
|
||||||
let calId = Int(event.calendarId)
|
let calId = Int(event.calendarId)
|
||||||
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import EventKit
|
||||||
|
|
||||||
struct EventEditorSheet: View {
|
struct EventEditorSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -35,6 +36,18 @@ struct EventEditorSheet: View {
|
|||||||
private var isEditing: Bool { editingEvent != nil }
|
private var isEditing: Bool { editingEvent != nil }
|
||||||
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
||||||
|
|
||||||
|
/// Sources that failed to answer, named instead of silently leaving their
|
||||||
|
/// calendars out of the picker.
|
||||||
|
@ViewBuilder
|
||||||
|
private var sourceErrorRows: some View {
|
||||||
|
let failures = store.writableCalendarErrors.sorted { $0.key < $1.key }
|
||||||
|
ForEach(failures, id: \.key) { failure in
|
||||||
|
Label("\(failure.key): \(failure.value)", systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var selectedCal: WritableCalendar? {
|
private var selectedCal: WritableCalendar? {
|
||||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
store.writableCalendars.first { $0.id == selectedCalendarId }
|
||||||
}
|
}
|
||||||
@@ -44,7 +57,7 @@ struct EventEditorSheet: View {
|
|||||||
/// controls and explain that they won't fire.
|
/// controls and explain that they won't fire.
|
||||||
private var remindersDisabled: Bool {
|
private var remindersDisabled: Bool {
|
||||||
guard let cal = selectedCal else { return false }
|
guard let cal = selectedCal else { return false }
|
||||||
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
|
let key = CalendarStore.calendarKey(source: cal.source, calendarId: cal.calendarIdString)
|
||||||
return store.reminderDisabledKeys.contains(key)
|
return store.reminderDisabledKeys.contains(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +93,9 @@ struct EventEditorSheet: View {
|
|||||||
Text(L10n.t("event.no_writable", appLang))
|
Text(L10n.t("event.no_writable", appLang))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
} else {
|
}
|
||||||
|
sourceErrorRows
|
||||||
|
if !store.writableCalendars.isEmpty {
|
||||||
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
|
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
|
||||||
ForEach(store.writableCalendars) { cal in
|
ForEach(store.writableCalendars) { cal in
|
||||||
HStack {
|
HStack {
|
||||||
@@ -122,7 +137,7 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
.disabled(remindersDisabled)
|
.disabled(remindersDisabled)
|
||||||
|
|
||||||
Section(L10n.t("event.attachments", appLang)) {
|
Section {
|
||||||
ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in
|
ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: att.symbolName).foregroundStyle(.secondary)
|
Image(systemName: att.symbolName).foregroundStyle(.secondary)
|
||||||
@@ -156,6 +171,8 @@ struct EventEditorSheet: View {
|
|||||||
Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip")
|
Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip")
|
||||||
}
|
}
|
||||||
.disabled(attachmentSlotsUsed >= 10)
|
.disabled(attachmentSlotsUsed >= 10)
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("event.attachments", appLang))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(L10n.t("event.attachment_hint", appLang))
|
Text(L10n.t("event.attachment_hint", appLang))
|
||||||
}
|
}
|
||||||
@@ -277,6 +294,8 @@ struct EventEditorSheet: View {
|
|||||||
if ev.source == "homeassistant" {
|
if ev.source == "homeassistant" {
|
||||||
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
selectedCalendarId = "ha-\(num)"
|
selectedCalendarId = "ha-\(num)"
|
||||||
|
} else if ev.source == DeviceCalendarService.source {
|
||||||
|
selectedCalendarId = "device-\(ev.calendarId)"
|
||||||
} else {
|
} else {
|
||||||
selectedCalendarId = ev.calendarId
|
selectedCalendarId = ev.calendarId
|
||||||
}
|
}
|
||||||
@@ -331,6 +350,10 @@ struct EventEditorSheet: View {
|
|||||||
try await api.createHAEvent(calendarId: haCalId, title: title,
|
try await api.createHAEvent(calendarId: haCalId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
location: location, description: notes)
|
location: location, description: notes)
|
||||||
|
case DeviceCalendarService.source:
|
||||||
|
try DeviceCalendarService.shared.update(
|
||||||
|
eventId: ev.id, title: title, start: start, end: end,
|
||||||
|
isAllDay: isAllDay, location: location, notes: notes)
|
||||||
default: // caldav
|
default: // caldav
|
||||||
let calId = Int(ev.calendarId)
|
let calId = Int(ev.calendarId)
|
||||||
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
||||||
@@ -354,6 +377,10 @@ struct EventEditorSheet: View {
|
|||||||
try await api.createHAEvent(calendarId: cal.numericId, title: title,
|
try await api.createHAEvent(calendarId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
location: location, description: notes)
|
location: location, description: notes)
|
||||||
|
case DeviceCalendarService.source:
|
||||||
|
try DeviceCalendarService.shared.create(
|
||||||
|
calendarId: cal.calendarIdString, title: title, start: start, end: end,
|
||||||
|
isAllDay: isAllDay, location: location, notes: notes)
|
||||||
default: // caldav
|
default: // caldav
|
||||||
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
|
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import EventKit
|
||||||
|
|
||||||
/// A single calendar row in the flat, reorderable list.
|
/// A single calendar row in the flat, reorderable list.
|
||||||
private struct CalRow: Identifiable {
|
private struct CalRow: Identifiable {
|
||||||
@@ -87,39 +88,44 @@ struct CalendarFilterContent: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func row(_ cal: CalRow) -> some View {
|
private func row(_ cal: CalRow) -> some View {
|
||||||
let isVisible = !hidden.contains(cal.id)
|
let isVisible = !hidden.contains(cal.id)
|
||||||
Button {
|
let muted = reminderDisabled.contains(cal.id)
|
||||||
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
|
HStack(spacing: 8) {
|
||||||
store.setCalendarHidden(cal.id, hidden: isVisible)
|
Button {
|
||||||
} label: {
|
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
|
||||||
HStack(spacing: 12) {
|
store.setCalendarHidden(cal.id, hidden: isVisible)
|
||||||
Circle()
|
} label: {
|
||||||
.fill(Color(hex: cal.colorHex))
|
HStack(spacing: 12) {
|
||||||
.frame(width: 14, height: 14)
|
Circle()
|
||||||
.opacity(isVisible ? 1.0 : 0.35)
|
.fill(Color(hex: cal.colorHex))
|
||||||
Text(cal.name)
|
.frame(width: 14, height: 14)
|
||||||
.foregroundStyle(isVisible ? .primary : .secondary)
|
.opacity(isVisible ? 1.0 : 0.35)
|
||||||
.strikethrough(!isVisible, color: .secondary)
|
Text(cal.name)
|
||||||
if cal.readOnly {
|
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||||
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
|
.strikethrough(!isVisible, color: .secondary)
|
||||||
|
if cal.readOnly {
|
||||||
|
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||||
|
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
.contentShape(Rectangle())
|
||||||
if reminderDisabled.contains(cal.id) {
|
|
||||||
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Image(systemName: isVisible ? "eye" : "eye.slash")
|
|
||||||
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.buttonStyle(.plain)
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
// A visible control, the way Android already does it. The mute used
|
||||||
.contextMenu {
|
// to live only behind a long press, with no hint it existed until
|
||||||
|
// after it had been switched on.
|
||||||
Button {
|
Button {
|
||||||
toggleReminders(forKey: cal.id)
|
toggleReminders(forKey: cal.id)
|
||||||
} label: {
|
} label: {
|
||||||
let disabled = reminderDisabled.contains(cal.id)
|
Image(systemName: muted ? "bell.slash" : "bell")
|
||||||
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
|
.foregroundStyle(muted ? .secondary : Color.accentColor)
|
||||||
systemImage: disabled ? "bell" : "bell.slash")
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(L10n.t(muted ? "filter.reminders_on" : "filter.reminders_off", appLang))
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
hidden.remove(cal.id)
|
hidden.remove(cal.id)
|
||||||
banished.insert(cal.id)
|
banished.insert(cal.id)
|
||||||
@@ -213,6 +219,7 @@ struct CalendarFilterContent: View {
|
|||||||
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||||
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||||
|
for sub in icalSubs { applyServerHidden("ical", sub.id, sub.sidebarHidden) }
|
||||||
store.setBanishedCalendars(b)
|
store.setBanishedCalendars(b)
|
||||||
banished = b
|
banished = b
|
||||||
|
|
||||||
@@ -225,8 +232,16 @@ struct CalendarFilterContent: View {
|
|||||||
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
|
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
|
||||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
|
||||||
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
|
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
|
||||||
store.setReminderDisabledKeys(rd)
|
// Only server-backed sources are reconciled here. Replacing the whole
|
||||||
reminderDisabled = rd
|
// set would drop every device-calendar mute — and the mute of any
|
||||||
|
// calendar the server happened not to return on this call.
|
||||||
|
let serverSources: Set<String> = ["local", "caldav", "ical", "google", "homeassistant"]
|
||||||
|
let keptLocally = store.reminderDisabledKeys.filter { key in
|
||||||
|
!serverSources.contains(key.split(separator: ":").first.map(String.init) ?? "")
|
||||||
|
}
|
||||||
|
let merged = rd.union(keptLocally)
|
||||||
|
store.setReminderDisabledKeys(merged)
|
||||||
|
reminderDisabled = merged
|
||||||
|
|
||||||
// Build the flat row list (banished excluded), then sort by stored order.
|
// Build the flat row list (banished excluded), then sort by stored order.
|
||||||
var combined: [CalRow] = []
|
var combined: [CalRow] = []
|
||||||
@@ -243,6 +258,14 @@ struct CalendarFilterContent: View {
|
|||||||
for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) }
|
for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) }
|
||||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } }
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } }
|
||||||
for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } }
|
for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } }
|
||||||
|
// Device calendars key on an EventKit identifier, not a server row id.
|
||||||
|
for cal in DeviceCalendarService.shared.calendars() {
|
||||||
|
let key = CalendarStore.calendarKey(source: DeviceCalendarService.source,
|
||||||
|
calendarId: cal.calendarIdentifier)
|
||||||
|
if b.contains(key) { continue }
|
||||||
|
combined.append(CalRow(id: key, name: cal.title, colorHex: "#8E8E93",
|
||||||
|
readOnly: !cal.allowsContentModifications))
|
||||||
|
}
|
||||||
|
|
||||||
allKeys = Set(combined.map(\.id))
|
allKeys = Set(combined.map(\.id))
|
||||||
let orderedKeys = store.ordered(combined.map(\.id))
|
let orderedKeys = store.ordered(combined.map(\.id))
|
||||||
|
|||||||
Reference in New Issue
Block a user