Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a78d09399 | |||
| 60dab1e9df | |||
| 191381ece4 | |||
| fabdfb121a | |||
| 4454adca59 | |||
| 9ae417cb03 | |||
| 367aa4c67b | |||
| cac3768885 | |||
| b953908f58 | |||
| 0ffb1c771e | |||
| 8858a08a32 | |||
| faa36d0e5e | |||
| 25766959f1 | |||
| 5f949121bf |
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import HealthKit
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HealthBridgeApp: App {
|
||||||
|
@StateObject private var appState = AppState()
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerBackgroundTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(healthKitManager)
|
||||||
|
.environmentObject(syncCoordinator)
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
await requestHealthKitAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerBackgroundTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: "com.healthbridge.sync",
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let bgTask = task as? BGAppRefreshTask else { return }
|
||||||
|
handleBackgroundSync(task: bgTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBackgroundSync(task: BGAppRefreshTask) {
|
||||||
|
scheduleNextBackgroundSync()
|
||||||
|
|
||||||
|
let syncTask = Task {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
syncTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleNextBackgroundSync() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync")
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule background sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestHealthKitAuthorization() async {
|
||||||
|
do {
|
||||||
|
try await healthKitManager.requestAuthorization()
|
||||||
|
} catch {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App State
|
||||||
|
@MainActor
|
||||||
|
class AppState: ObservableObject {
|
||||||
|
@Published var selectedTab: Tab = .dashboard
|
||||||
|
@Published var showingConflictDetail: Conflict?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
case dashboard
|
||||||
|
case conflicts
|
||||||
|
case rules
|
||||||
|
case sources
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Conflict
|
||||||
|
struct Conflict: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var status: ConflictStatus
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var appliedStrategy: MergeStrategy?
|
||||||
|
let detectedAt: Date
|
||||||
|
var resolvedAt: Date?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
status: ConflictStatus = .pending,
|
||||||
|
resolution: ConflictResolution? = nil,
|
||||||
|
appliedStrategy: MergeStrategy? = nil,
|
||||||
|
detectedAt: Date = Date(),
|
||||||
|
resolvedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.status = status
|
||||||
|
self.resolution = resolution
|
||||||
|
self.appliedStrategy = appliedStrategy
|
||||||
|
self.detectedAt = detectedAt
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
return (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentageDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
guard let min = values.min(), min > 0 else { return 0 }
|
||||||
|
guard let max = values.max() else { return 0 }
|
||||||
|
return ((max - min) / min) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var severity: ConflictSeverity {
|
||||||
|
let pctDiff = percentageDifference
|
||||||
|
if pctDiff < 5 { return .minor }
|
||||||
|
if pctDiff < 20 { return .moderate }
|
||||||
|
if pctDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
var highestValueReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var lowestValueReading: SourceReading? {
|
||||||
|
readings.min(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var primarySourceReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Status
|
||||||
|
enum ConflictStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case resolved = "resolved"
|
||||||
|
case manualReview = "manual_review"
|
||||||
|
case ignored = "ignored"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "Offen"
|
||||||
|
case .resolved: return "Gelöst"
|
||||||
|
case .manualReview: return "Manuelle Prüfung"
|
||||||
|
case .ignored: return "Ignoriert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "clock.fill"
|
||||||
|
case .resolved: return "checkmark.circle.fill"
|
||||||
|
case .manualReview: return "hand.raised.fill"
|
||||||
|
case .ignored: return "eye.slash.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Severity
|
||||||
|
enum ConflictSeverity: String, Codable {
|
||||||
|
case minor = "minor"
|
||||||
|
case moderate = "moderate"
|
||||||
|
case significant = "significant"
|
||||||
|
case major = "major"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "Gering"
|
||||||
|
case .moderate: return "Moderat"
|
||||||
|
case .significant: return "Erheblich"
|
||||||
|
case .major: return "Gross"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "green"
|
||||||
|
case .moderate: return "yellow"
|
||||||
|
case .significant: return "orange"
|
||||||
|
case .major: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Resolution
|
||||||
|
struct ConflictResolution: Codable {
|
||||||
|
let resolvedValue: Double
|
||||||
|
let secondaryResolvedValue: Double? // For blood pressure
|
||||||
|
let winningSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let isManual: Bool
|
||||||
|
let resolvedAt: Date
|
||||||
|
let notes: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
resolvedValue: Double,
|
||||||
|
secondaryResolvedValue: Double? = nil,
|
||||||
|
winningSourceId: String,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
isManual: Bool = false,
|
||||||
|
resolvedAt: Date = Date(),
|
||||||
|
notes: String? = nil
|
||||||
|
) {
|
||||||
|
self.resolvedValue = resolvedValue
|
||||||
|
self.secondaryResolvedValue = secondaryResolvedValue
|
||||||
|
self.winningSourceId = winningSourceId
|
||||||
|
self.strategy = strategy
|
||||||
|
self.isManual = isManual
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Strategy
|
||||||
|
enum MergeStrategy: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case exclusive = "exclusive"
|
||||||
|
case priority = "priority"
|
||||||
|
case higherWins = "higher_wins"
|
||||||
|
case lowerWins = "lower_wins"
|
||||||
|
case average = "average"
|
||||||
|
case coverage = "coverage"
|
||||||
|
case coverageThenHigher = "coverage_then_higher"
|
||||||
|
case manual = "manual"
|
||||||
|
case mostRecent = "most_recent"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "Exklusiv"
|
||||||
|
case .priority: return "Priorität"
|
||||||
|
case .higherWins: return "Höherer Wert"
|
||||||
|
case .lowerWins: return "Niedrigerer Wert"
|
||||||
|
case .average: return "Durchschnitt"
|
||||||
|
case .coverage: return "Abdeckung"
|
||||||
|
case .coverageThenHigher: return "Abdeckung + Höher"
|
||||||
|
case .manual: return "Manuell"
|
||||||
|
case .mostRecent: return "Neuester"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive:
|
||||||
|
return "Nur eine Quelle kann diesen Datentyp liefern"
|
||||||
|
case .priority:
|
||||||
|
return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen"
|
||||||
|
case .higherWins:
|
||||||
|
return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)"
|
||||||
|
case .lowerWins:
|
||||||
|
return "Der kleinere Wert wird verwendet"
|
||||||
|
case .average:
|
||||||
|
return "Durchschnitt aller Quellen"
|
||||||
|
case .coverage:
|
||||||
|
return "Quelle mit Daten für dieses Zeitfenster"
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt"
|
||||||
|
case .manual:
|
||||||
|
return "Benutzer entscheidet bei jedem Konflikt"
|
||||||
|
case .mostRecent:
|
||||||
|
return "Zuletzt erfasster Wert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "1.circle.fill"
|
||||||
|
case .priority: return "list.number"
|
||||||
|
case .higherWins: return "arrow.up.circle.fill"
|
||||||
|
case .lowerWins: return "arrow.down.circle.fill"
|
||||||
|
case .average: return "divide.circle.fill"
|
||||||
|
case .coverage: return "square.fill.on.square.fill"
|
||||||
|
case .coverageThenHigher: return "square.stack.3d.up.fill"
|
||||||
|
case .manual: return "hand.raised.fill"
|
||||||
|
case .mostRecent: return "clock.arrow.circlepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Rule
|
||||||
|
struct MergeRule: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var primarySourceId: String?
|
||||||
|
var fallbackSourceId: String?
|
||||||
|
var sourcePriorities: [String: Int]
|
||||||
|
var autoApply: Bool
|
||||||
|
var thresholdForManualReview: Double? // Percentage difference threshold
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
primarySourceId: String? = nil,
|
||||||
|
fallbackSourceId: String? = nil,
|
||||||
|
sourcePriorities: [String: Int] = [:],
|
||||||
|
autoApply: Bool = true,
|
||||||
|
thresholdForManualReview: Double? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.strategy = strategy
|
||||||
|
self.primarySourceId = primarySourceId
|
||||||
|
self.fallbackSourceId = fallbackSourceId
|
||||||
|
self.sourcePriorities = sourcePriorities
|
||||||
|
self.autoApply = autoApply
|
||||||
|
self.thresholdForManualReview = thresholdForManualReview
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
switch dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .steps, .distance, .activeEnergy:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .coverageThenHigher)
|
||||||
|
case .sleep:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Record
|
||||||
|
struct SyncRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var mergedValue: Double?
|
||||||
|
var secondaryMergedValue: Double? // For blood pressure
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var status: SyncStatus
|
||||||
|
var hasConflict: Bool
|
||||||
|
var conflictId: UUID?
|
||||||
|
let createdAt: Date
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case processing = "processing"
|
||||||
|
case completed = "completed"
|
||||||
|
case failed = "failed"
|
||||||
|
case requiresManualReview = "requires_manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
mergedValue: Double? = nil,
|
||||||
|
secondaryMergedValue: Double? = nil,
|
||||||
|
strategy: MergeStrategy = .priority,
|
||||||
|
status: SyncStatus = .pending,
|
||||||
|
hasConflict: Bool = false,
|
||||||
|
conflictId: UUID? = nil,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
processedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.mergedValue = mergedValue
|
||||||
|
self.secondaryMergedValue = secondaryMergedValue
|
||||||
|
self.strategy = strategy
|
||||||
|
self.status = status
|
||||||
|
self.hasConflict = hasConflict
|
||||||
|
self.conflictId = conflictId
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.processedAt = processedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Data Type
|
||||||
|
enum HealthDataType: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case steps = "steps"
|
||||||
|
case heartRate = "heart_rate"
|
||||||
|
case bloodPressureSystolic = "blood_pressure_systolic"
|
||||||
|
case bloodPressureDiastolic = "blood_pressure_diastolic"
|
||||||
|
case bloodOxygen = "blood_oxygen"
|
||||||
|
case sleep = "sleep"
|
||||||
|
case distance = "distance"
|
||||||
|
case floorsClimbed = "floors_climbed"
|
||||||
|
case activeEnergy = "active_energy"
|
||||||
|
case restingHeartRate = "resting_heart_rate"
|
||||||
|
case heartRateVariability = "hrv"
|
||||||
|
case respiratoryRate = "respiratory_rate"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate: return "Herzfrequenz"
|
||||||
|
case .bloodPressureSystolic: return "Blutdruck (Systolisch)"
|
||||||
|
case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)"
|
||||||
|
case .bloodOxygen: return "Blutsauerstoff (SpO2)"
|
||||||
|
case .sleep: return "Schlaf"
|
||||||
|
case .distance: return "Distanz"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "Aktive Energie"
|
||||||
|
case .restingHeartRate: return "Ruhepuls"
|
||||||
|
case .heartRateVariability: return "HRV"
|
||||||
|
case .respiratoryRate: return "Atemfrequenz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "figure.walk"
|
||||||
|
case .heartRate, .restingHeartRate: return "heart.fill"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill"
|
||||||
|
case .bloodOxygen: return "lungs.fill"
|
||||||
|
case .sleep: return "bed.double.fill"
|
||||||
|
case .distance: return "map.fill"
|
||||||
|
case .floorsClimbed: return "stairs"
|
||||||
|
case .activeEnergy: return "flame.fill"
|
||||||
|
case .heartRateVariability: return "waveform.path.ecg"
|
||||||
|
case .respiratoryRate: return "wind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unit: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate, .restingHeartRate: return "bpm"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
|
||||||
|
case .bloodOxygen: return "%"
|
||||||
|
case .sleep: return "h"
|
||||||
|
case .distance: return "km"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "kcal"
|
||||||
|
case .heartRateVariability: return "ms"
|
||||||
|
case .respiratoryRate: return "/min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkQuantityType: HKQuantityType? {
|
||||||
|
switch self {
|
||||||
|
case .steps:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||||
|
case .heartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRate)
|
||||||
|
case .bloodPressureSystolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)
|
||||||
|
case .bloodPressureDiastolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)
|
||||||
|
case .distance:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .flightsClimbed)
|
||||||
|
case .activeEnergy:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
|
||||||
|
case .restingHeartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .restingHeartRate)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .respiratoryRate)
|
||||||
|
case .sleep:
|
||||||
|
return nil // Sleep uses category type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkCategoryType: HKCategoryType? {
|
||||||
|
switch self {
|
||||||
|
case .sleep:
|
||||||
|
return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkUnit: HKUnit {
|
||||||
|
switch self {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return .count()
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return HKUnit.count().unitDivided(by: .minute())
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return .millimeterOfMercury()
|
||||||
|
case .bloodOxygen:
|
||||||
|
return .percent()
|
||||||
|
case .sleep:
|
||||||
|
return .hour()
|
||||||
|
case .distance:
|
||||||
|
return .meterUnit(with: .kilo)
|
||||||
|
case .activeEnergy:
|
||||||
|
return .kilocalorie()
|
||||||
|
case .heartRateVariability:
|
||||||
|
return .secondUnit(with: .milli)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default primary source for this data type
|
||||||
|
var defaultPrimarySource: SourceCategory {
|
||||||
|
switch self {
|
||||||
|
case .floorsClimbed:
|
||||||
|
return .iPhone
|
||||||
|
case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic,
|
||||||
|
.bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate,
|
||||||
|
.heartRateVariability, .respiratoryRate:
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this data type typically has only one source
|
||||||
|
var isExclusive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Category
|
||||||
|
enum SourceCategory: String, Codable, CaseIterable {
|
||||||
|
case iPhone = "iphone"
|
||||||
|
case watch = "watch"
|
||||||
|
case thirdPartyWatch = "third_party_watch"
|
||||||
|
case thirdPartyApp = "third_party_app"
|
||||||
|
case healthBridge = "health_bridge"
|
||||||
|
case unknown = "unknown"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iPhone"
|
||||||
|
case .watch: return "Apple Watch"
|
||||||
|
case .thirdPartyWatch: return "Drittanbieter-Watch"
|
||||||
|
case .thirdPartyApp: return "Drittanbieter-App"
|
||||||
|
case .healthBridge: return "HealthBridge"
|
||||||
|
case .unknown: return "Unbekannt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iphone"
|
||||||
|
case .watch: return "applewatch"
|
||||||
|
case .thirdPartyWatch: return "applewatch.side.right"
|
||||||
|
case .thirdPartyApp: return "app.badge"
|
||||||
|
case .healthBridge: return "arrow.triangle.2.circlepath"
|
||||||
|
case .unknown: return "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var priority: Int {
|
||||||
|
switch self {
|
||||||
|
case .healthBridge: return 100
|
||||||
|
case .watch: return 80
|
||||||
|
case .thirdPartyWatch: return 70
|
||||||
|
case .iPhone: return 50
|
||||||
|
case .thirdPartyApp: return 30
|
||||||
|
case .unknown: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Quality
|
||||||
|
enum DataQuality: String, Codable {
|
||||||
|
case complete = "complete"
|
||||||
|
case partial = "partial"
|
||||||
|
case missing = "missing"
|
||||||
|
case invalid = "invalid"
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "checkmark.circle.fill"
|
||||||
|
case .partial: return "circle.lefthalf.filled"
|
||||||
|
case .missing: return "circle.dashed"
|
||||||
|
case .invalid: return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "green"
|
||||||
|
case .partial: return "yellow"
|
||||||
|
case .missing: return "gray"
|
||||||
|
case .invalid: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Window
|
||||||
|
struct TimeWindow: Codable, Hashable, Identifiable {
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
|
||||||
|
var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
|
||||||
|
|
||||||
|
var interval: DateInterval {
|
||||||
|
DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedRange: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = startOfDay
|
||||||
|
|
||||||
|
while current < endOfDay {
|
||||||
|
let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)!
|
||||||
|
windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay)))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hourlyWindows(for date: Date) -> [TimeWindow] {
|
||||||
|
windows(for: date, intervalMinutes: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Source
|
||||||
|
struct HealthSource: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let bundleIdentifier: String
|
||||||
|
let name: String
|
||||||
|
let category: SourceCategory
|
||||||
|
var supportedDataTypes: Set<HealthDataType>
|
||||||
|
var lastActivityDate: Date?
|
||||||
|
var userPriorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
bundleIdentifier: String,
|
||||||
|
name: String,
|
||||||
|
category: SourceCategory,
|
||||||
|
supportedDataTypes: Set<HealthDataType> = [],
|
||||||
|
lastActivityDate: Date? = nil,
|
||||||
|
userPriorities: [HealthDataType: Int] = [:],
|
||||||
|
isEnabled: Bool = true
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.name = name
|
||||||
|
self.category = category
|
||||||
|
self.supportedDataTypes = supportedDataTypes
|
||||||
|
self.lastActivityDate = lastActivityDate
|
||||||
|
self.userPriorities = userPriorities
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
if name.isEmpty {
|
||||||
|
return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
var isHealthBridge: Bool {
|
||||||
|
bundleIdentifier == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(for dataType: HealthDataType) -> Int {
|
||||||
|
userPriorities[dataType] ?? category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(hkSource: HKSource) -> HealthSource {
|
||||||
|
let category = classifySource(bundleId: hkSource.bundleIdentifier)
|
||||||
|
return HealthSource(
|
||||||
|
id: hkSource.bundleIdentifier,
|
||||||
|
bundleIdentifier: hkSource.bundleIdentifier,
|
||||||
|
name: hkSource.name,
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classifySource(bundleId: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleId.lowercased()
|
||||||
|
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if lowercased.contains("apple.health") {
|
||||||
|
return .iPhone
|
||||||
|
} else if lowercased.contains("watch") || lowercased.contains("applewatch") {
|
||||||
|
return .watch
|
||||||
|
} else if lowercased.contains("huawei") || lowercased.contains("samsung") ||
|
||||||
|
lowercased.contains("fitbit") || lowercased.contains("garmin") ||
|
||||||
|
lowercased.contains("polar") || lowercased.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Reading
|
||||||
|
struct SourceReading: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let sourceId: String
|
||||||
|
let sourceName: String
|
||||||
|
let sourceCategory: SourceCategory
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double? // For blood pressure (diastolic)
|
||||||
|
let timestamp: Date
|
||||||
|
let originalRecordId: String?
|
||||||
|
let quality: DataQuality
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
sourceId: String,
|
||||||
|
sourceName: String,
|
||||||
|
sourceCategory: SourceCategory,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
timestamp: Date,
|
||||||
|
originalRecordId: String? = nil,
|
||||||
|
quality: DataQuality = .complete
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.sourceId = sourceId
|
||||||
|
self.sourceName = sourceName
|
||||||
|
self.sourceCategory = sourceCategory
|
||||||
|
self.value = value
|
||||||
|
self.secondaryValue = secondaryValue
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.originalRecordId = originalRecordId
|
||||||
|
self.quality = quality
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Status
|
||||||
|
struct SourceHealthStatus: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let source: HealthSource
|
||||||
|
let lastSync: Date?
|
||||||
|
let recordCount: Int
|
||||||
|
let dataGaps: [TimeWindow]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
guard let lastSync = lastSync else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .recentlySynced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .syncedToday
|
||||||
|
} else if hoursSinceSync < 72 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case recentlySynced
|
||||||
|
case syncedToday
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "checkmark.circle.fill"
|
||||||
|
case .syncedToday: return "checkmark.circle"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "Kürzlich synchronisiert"
|
||||||
|
case .syncedToday: return "Heute synchronisiert"
|
||||||
|
case .stale: return "Sync überfällig"
|
||||||
|
case .veryStale: return "Lange nicht synchronisiert"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
enum HealthBridgeConstants {
|
||||||
|
static let bundleIdentifier = "com.healthbridge.merged"
|
||||||
|
static let displayName = "HealthBridge"
|
||||||
|
static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes
|
||||||
|
static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# HealthBridge
|
||||||
|
|
||||||
|
Intelligente Health-Daten-Synchronisation für iOS – Eine "Single Source of Truth" für Gesundheitsdaten.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Source Discovery
|
||||||
|
- Automatische Erkennung aller verbundenen Datenquellen
|
||||||
|
- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps)
|
||||||
|
- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle
|
||||||
|
|
||||||
|
### Konflikt-Erkennung
|
||||||
|
- Automatische Erkennung von Datenkonflikten zwischen Quellen
|
||||||
|
- Zeitfenster-basierte Analyse (15-Minuten-Intervalle)
|
||||||
|
- Schweregrad-Klassifizierung (minor, moderate, significant, major)
|
||||||
|
|
||||||
|
### Merge-Strategien
|
||||||
|
- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2)
|
||||||
|
- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen
|
||||||
|
- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte)
|
||||||
|
- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt
|
||||||
|
- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert
|
||||||
|
- **Average**: Durchschnitt aller Quellen
|
||||||
|
- **Manual**: Benutzer entscheidet bei jedem Konflikt
|
||||||
|
|
||||||
|
### UI-Komponenten
|
||||||
|
- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status
|
||||||
|
- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung
|
||||||
|
- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp
|
||||||
|
- **Quellen**: Übersicht aller erkannten Datenquellen
|
||||||
|
|
||||||
|
### Background Sync
|
||||||
|
- Automatische Synchronisierung im Hintergrund
|
||||||
|
- Konfigurierbares Intervall (15 Min bis 2 Stunden)
|
||||||
|
- Push-Benachrichtigungen bei neuen Konflikten
|
||||||
|
|
||||||
|
## Unterstützte Datentypen
|
||||||
|
|
||||||
|
| Datentyp | Primärquelle | Strategie |
|
||||||
|
|----------|--------------|-----------|
|
||||||
|
| Schritte | Watch | Coverage + Higher |
|
||||||
|
| Herzfrequenz | Watch | Exclusive |
|
||||||
|
| Blutdruck | Watch D2 | Exclusive |
|
||||||
|
| SpO2 | Watch | Exclusive |
|
||||||
|
| Schlaf | Watch | Priority |
|
||||||
|
| Distanz | Watch/iPhone | Coverage + Higher |
|
||||||
|
| Stockwerke | iPhone | Exclusive |
|
||||||
|
| Aktive Energie | Watch | Coverage + Higher |
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Apple Health │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HealthBridge App │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │SourceManager│ │ RuleEngine │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SyncCoordinator │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
HealthBridge/
|
||||||
|
├── App/
|
||||||
|
│ └── HealthBridgeApp.swift # App-Entry, Background Tasks
|
||||||
|
├── Models/
|
||||||
|
│ ├── HealthDataTypes.swift # Datentypen, TimeWindow
|
||||||
|
│ ├── Source.swift # HealthSource, SourceReading
|
||||||
|
│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule
|
||||||
|
├── Services/
|
||||||
|
│ ├── HealthKitManager.swift # HealthKit-Integration
|
||||||
|
│ ├── SourceManager.swift # Source Discovery & Management
|
||||||
|
│ ├── DataReader.swift # Daten lesen, Konflikte erkennen
|
||||||
|
│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden
|
||||||
|
│ ├── MergeEngine.swift # Konflikte analysieren & lösen
|
||||||
|
│ ├── DataWriter.swift # Daten zurückschreiben
|
||||||
|
│ └── SyncCoordinator.swift # Orchestrierung aller Services
|
||||||
|
├── Views/
|
||||||
|
│ ├── ContentView.swift # Tab-Navigation
|
||||||
|
│ ├── DashboardView.swift # Hauptübersicht
|
||||||
|
│ ├── ConflictsView.swift # Konflikt-Liste & Detail
|
||||||
|
│ ├── RulesView.swift # Regelwerk-Editor
|
||||||
|
│ ├── SourcesView.swift # Quellen-Übersicht
|
||||||
|
│ ├── SettingsView.swift # Einstellungen
|
||||||
|
│ └── Components/
|
||||||
|
│ └── HealthChart.swift # Diagramm-Komponenten
|
||||||
|
├── ViewModels/
|
||||||
|
│ └── DashboardViewModel.swift # Dashboard-Logik
|
||||||
|
├── Utils/
|
||||||
|
│ ├── Extensions.swift # Swift-Erweiterungen
|
||||||
|
│ └── NotificationManager.swift # Push-Benachrichtigungen
|
||||||
|
└── Resources/
|
||||||
|
├── Info.plist # App-Konfiguration
|
||||||
|
└── HealthBridge.entitlements # HealthKit-Berechtigungen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- iOS 16.0+
|
||||||
|
- Xcode 15.0+
|
||||||
|
- Apple Developer Account (für HealthKit-Entitlements)
|
||||||
|
- Physisches Gerät (HealthKit nicht im Simulator verfügbar)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Projekt in Xcode öffnen
|
||||||
|
2. Team für Code Signing auswählen
|
||||||
|
3. HealthKit-Capability aktivieren
|
||||||
|
4. Auf physischem Gerät ausführen
|
||||||
|
|
||||||
|
## Berechtigungen
|
||||||
|
|
||||||
|
Die App benötigt folgende Berechtigungen:
|
||||||
|
- **HealthKit Read**: Lesen aller Gesundheitsdaten
|
||||||
|
- **HealthKit Write**: Schreiben gemergter Daten
|
||||||
|
- **Background App Refresh**: Für automatische Synchronisierung
|
||||||
|
- **Notifications**: Für Konflikt-Benachrichtigungen
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array>
|
||||||
|
<string>health-records</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
|
<true/>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>HealthBridge</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchScreenBackground</string>
|
||||||
|
<key>UIImageName</key>
|
||||||
|
<string>LaunchIcon</string>
|
||||||
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
<string>healthkit</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- HealthKit -->
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
|
||||||
|
|
||||||
|
<!-- Background Modes -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.healthbridge.sync</string>
|
||||||
|
<string>com.healthbridge.cleanup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Reader
|
||||||
|
@MainActor
|
||||||
|
class DataReader: ObservableObject {
|
||||||
|
static let shared = DataReader()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var isReading = false
|
||||||
|
@Published var lastReadDate: Date?
|
||||||
|
@Published var detectedConflicts: [Conflict] = []
|
||||||
|
@Published var readingProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Fetch Data by Type and Date Range
|
||||||
|
|
||||||
|
func fetchData(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
groupByWindow intervalMinutes: Int = 15
|
||||||
|
) async throws -> [TimeWindowData] {
|
||||||
|
isReading = true
|
||||||
|
defer { isReading = false }
|
||||||
|
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group samples by source
|
||||||
|
let groupedBySource = groupBySource(samples: samples, dataType: dataType)
|
||||||
|
|
||||||
|
// Create time windows
|
||||||
|
let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes)
|
||||||
|
|
||||||
|
// Assign samples to windows
|
||||||
|
var windowDataList: [TimeWindowData] = []
|
||||||
|
|
||||||
|
for window in windows {
|
||||||
|
let windowData = createWindowData(
|
||||||
|
window: window,
|
||||||
|
dataType: dataType,
|
||||||
|
groupedBySource: groupedBySource
|
||||||
|
)
|
||||||
|
windowDataList.append(windowData)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReadDate = Date()
|
||||||
|
return windowDataList
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] {
|
||||||
|
var grouped: [String: [HKSample]] = [:]
|
||||||
|
|
||||||
|
for sample in samples {
|
||||||
|
let sourceId = sample.sourceRevision.source.bundleIdentifier
|
||||||
|
if grouped[sourceId] == nil {
|
||||||
|
grouped[sourceId] = []
|
||||||
|
}
|
||||||
|
grouped[sourceId]?.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] {
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = start
|
||||||
|
|
||||||
|
while current < end {
|
||||||
|
let windowEnd = min(
|
||||||
|
Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!,
|
||||||
|
end
|
||||||
|
)
|
||||||
|
windows.append(TimeWindow(start: current, end: windowEnd))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindowData(
|
||||||
|
window: TimeWindow,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
groupedBySource: [String: [HKSample]]
|
||||||
|
) -> TimeWindowData {
|
||||||
|
var readings: [SourceReading] = []
|
||||||
|
|
||||||
|
for (sourceId, samples) in groupedBySource {
|
||||||
|
let windowSamples = samples.filter { sample in
|
||||||
|
sample.startDate < window.end && sample.endDate > window.start
|
||||||
|
}
|
||||||
|
|
||||||
|
if !windowSamples.isEmpty {
|
||||||
|
let reading = createReading(
|
||||||
|
from: windowSamples,
|
||||||
|
sourceId: sourceId,
|
||||||
|
dataType: dataType,
|
||||||
|
window: window
|
||||||
|
)
|
||||||
|
readings.append(reading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasConflict = detectConflict(in: readings, dataType: dataType)
|
||||||
|
|
||||||
|
return TimeWindowData(
|
||||||
|
timeWindow: window,
|
||||||
|
dataType: dataType,
|
||||||
|
readings: readings,
|
||||||
|
hasConflict: hasConflict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createReading(
|
||||||
|
from samples: [HKSample],
|
||||||
|
sourceId: String,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
window: TimeWindow
|
||||||
|
) -> SourceReading {
|
||||||
|
let value: Double
|
||||||
|
var secondaryValue: Double? = nil
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
// Sum up values for cumulative types
|
||||||
|
value = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen:
|
||||||
|
// Average for rate-based types
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// For blood pressure, we need to handle correlations
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
// Sum up sleep duration
|
||||||
|
value = samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId }
|
||||||
|
let category = source?.category ?? sourceManager.classifySource(sourceId)
|
||||||
|
|
||||||
|
return SourceReading(
|
||||||
|
sourceId: sourceId,
|
||||||
|
sourceName: source?.name ?? sourceId,
|
||||||
|
sourceCategory: category,
|
||||||
|
value: value,
|
||||||
|
secondaryValue: secondaryValue,
|
||||||
|
timestamp: window.start,
|
||||||
|
originalRecordId: samples.first?.uuid.uuidString,
|
||||||
|
quality: samples.isEmpty ? .missing : .complete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detection
|
||||||
|
|
||||||
|
private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool {
|
||||||
|
// No conflict if less than 2 readings
|
||||||
|
guard readings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Filter out zero values (device wasn't tracking)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
guard nonZeroReadings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Check if values differ significantly
|
||||||
|
let values = nonZeroReadings.map { $0.value }
|
||||||
|
guard let minVal = values.min(), let maxVal = values.max() else { return false }
|
||||||
|
|
||||||
|
// Threshold varies by data type
|
||||||
|
let threshold = conflictThreshold(for: dataType)
|
||||||
|
|
||||||
|
if minVal == 0 {
|
||||||
|
return maxVal > threshold.absoluteThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (maxVal - minVal) / minVal * 100
|
||||||
|
return percentDiff > threshold.percentageThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold {
|
||||||
|
switch dataType {
|
||||||
|
case .steps:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100)
|
||||||
|
case .distance:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m
|
||||||
|
case .heartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2)
|
||||||
|
case .activeEnergy:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50)
|
||||||
|
case .sleep:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min
|
||||||
|
case .restingHeartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detect All Conflicts
|
||||||
|
|
||||||
|
func detectConflicts(
|
||||||
|
for date: Date,
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws -> [Conflict] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var allConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
for (index, dataType) in dataTypes.enumerated() {
|
||||||
|
readingProgress = Double(index) / Double(dataTypes.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflicts = windowData
|
||||||
|
.filter { $0.hasConflict }
|
||||||
|
.map { data in
|
||||||
|
Conflict(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: data.timeWindow,
|
||||||
|
readings: data.readings,
|
||||||
|
status: .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
allConflicts.append(contentsOf: conflicts)
|
||||||
|
} catch {
|
||||||
|
print("Failed to detect conflicts for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgress = 1.0
|
||||||
|
detectedConflicts = allConflicts
|
||||||
|
return allConflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Gaps Detection
|
||||||
|
|
||||||
|
func detectGaps(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
expectedIntervalMinutes: Int = 15
|
||||||
|
) async throws -> [DataGap] {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: endDate),
|
||||||
|
expectedRecordCount: 0,
|
||||||
|
actualRecordCount: 0
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
var gaps: [DataGap] = []
|
||||||
|
let expectedInterval = TimeInterval(expectedIntervalMinutes * 60)
|
||||||
|
|
||||||
|
// Check gap at start
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: firstSample.startDate),
|
||||||
|
expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let current = sortedSamples[i]
|
||||||
|
let next = sortedSamples[i + 1]
|
||||||
|
let gap = next.startDate.timeIntervalSince(current.endDate)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: current.endDate, end: next.startDate),
|
||||||
|
expectedRecordCount: Int(gap / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: lastSample.endDate, end: endDate),
|
||||||
|
expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aggregated Data
|
||||||
|
|
||||||
|
func fetchDailySummary(for date: Date) async throws -> DailySummary {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var summary = DailySummary(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = aggregateValue(samples: samples, dataType: dataType)
|
||||||
|
summary.values[dataType] = value
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
let conflictCount = windowData.filter { $0.hasConflict }.count
|
||||||
|
summary.conflictCounts[dataType] = conflictCount
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch \(dataType) for summary: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double {
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
return samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability,
|
||||||
|
.bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
return samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct TimeWindowData: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let readings: [SourceReading]
|
||||||
|
let hasConflict: Bool
|
||||||
|
|
||||||
|
var primaryReading: SourceReading? {
|
||||||
|
readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictSeverity: ConflictSeverity? {
|
||||||
|
guard hasConflict, readings.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard let min = values.min(), let max = values.max(), min > 0 else { return nil }
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
|
||||||
|
if percentDiff < 5 { return .minor }
|
||||||
|
if percentDiff < 20 { return .moderate }
|
||||||
|
if percentDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConflictThreshold {
|
||||||
|
let percentageThreshold: Double
|
||||||
|
let absoluteThreshold: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataGap: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let expectedRecordCount: Int
|
||||||
|
let actualRecordCount: Int
|
||||||
|
|
||||||
|
var severity: GapSeverity {
|
||||||
|
let duration = timeWindow.duration
|
||||||
|
if duration < 3600 { return .minor } // < 1 hour
|
||||||
|
if duration < 4 * 3600 { return .moderate } // < 4 hours
|
||||||
|
if duration < 12 * 3600 { return .significant } // < 12 hours
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GapSeverity {
|
||||||
|
case minor, moderate, significant, major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailySummary {
|
||||||
|
let date: Date
|
||||||
|
var values: [HealthDataType: Double] = [:]
|
||||||
|
var conflictCounts: [HealthDataType: Int] = [:]
|
||||||
|
var lastUpdated = Date()
|
||||||
|
|
||||||
|
var totalConflicts: Int {
|
||||||
|
conflictCounts.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedValue(for dataType: HealthDataType) -> String {
|
||||||
|
guard let value = values[dataType] else { return "–" }
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
case .distance:
|
||||||
|
return String(format: "%.2f km", value)
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return String(format: "%.0f %@", value, dataType.unit)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return String(format: "%.0f mmHg", value)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return String(format: "%.0f%%", value * 100)
|
||||||
|
case .activeEnergy:
|
||||||
|
return String(format: "%.0f kcal", value)
|
||||||
|
case .sleep:
|
||||||
|
let hours = Int(value)
|
||||||
|
let minutes = Int((value - Double(hours)) * 60)
|
||||||
|
return "\(hours)h \(minutes)min"
|
||||||
|
case .heartRateVariability:
|
||||||
|
return String(format: "%.0f ms", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Writer
|
||||||
|
@MainActor
|
||||||
|
class DataWriter: ObservableObject {
|
||||||
|
static let shared = DataWriter()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isWriting = false
|
||||||
|
@Published var writeProgress: Double = 0
|
||||||
|
@Published var lastWriteDate: Date?
|
||||||
|
@Published var writtenRecords: [WrittenRecord] = []
|
||||||
|
@Published var failedWrites: [FailedWrite] = []
|
||||||
|
|
||||||
|
private let processedRecordsKey = "healthbridge.processed.records"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadProcessedRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Single Record
|
||||||
|
|
||||||
|
func writeRecord(_ mergedRecord: MergedRecord) async throws -> WrittenRecord {
|
||||||
|
isWriting = true
|
||||||
|
defer { isWriting = false }
|
||||||
|
|
||||||
|
// Check if already written
|
||||||
|
if isAlreadyWritten(mergedRecord) {
|
||||||
|
throw DataWriterError.duplicateRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = createMetadata(from: mergedRecord)
|
||||||
|
|
||||||
|
switch mergedRecord.dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// Blood pressure needs special handling
|
||||||
|
guard let diastolic = mergedRecord.secondaryValue else {
|
||||||
|
throw DataWriterError.missingSecondaryValue
|
||||||
|
}
|
||||||
|
try await writeBloodPressure(
|
||||||
|
systolic: mergedRecord.value,
|
||||||
|
diastolic: diastolic,
|
||||||
|
date: mergedRecord.timeWindow.start,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
try await writeSample(
|
||||||
|
dataType: mergedRecord.dataType,
|
||||||
|
value: mergedRecord.value,
|
||||||
|
date: mergedRecord.timeWindow.start,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let writtenRecord = WrittenRecord(
|
||||||
|
id: UUID(),
|
||||||
|
mergedRecordId: mergedRecord.id,
|
||||||
|
dataType: mergedRecord.dataType,
|
||||||
|
value: mergedRecord.value,
|
||||||
|
secondaryValue: mergedRecord.secondaryValue,
|
||||||
|
writtenAt: Date(),
|
||||||
|
timeWindow: mergedRecord.timeWindow
|
||||||
|
)
|
||||||
|
|
||||||
|
writtenRecords.append(writtenRecord)
|
||||||
|
markAsProcessed(mergedRecord)
|
||||||
|
lastWriteDate = Date()
|
||||||
|
|
||||||
|
return writtenRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Batch
|
||||||
|
|
||||||
|
func writeBatch(_ mergedRecords: [MergedRecord]) async -> BatchWriteResult {
|
||||||
|
isWriting = true
|
||||||
|
defer { isWriting = false }
|
||||||
|
|
||||||
|
var successful: [WrittenRecord] = []
|
||||||
|
var failed: [FailedWrite] = []
|
||||||
|
|
||||||
|
for (index, record) in mergedRecords.enumerated() {
|
||||||
|
writeProgress = Double(index) / Double(mergedRecords.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let writtenRecord = try await writeRecord(record)
|
||||||
|
successful.append(writtenRecord)
|
||||||
|
} catch {
|
||||||
|
let failedWrite = FailedWrite(
|
||||||
|
mergedRecord: record,
|
||||||
|
error: error,
|
||||||
|
attemptedAt: Date()
|
||||||
|
)
|
||||||
|
failed.append(failedWrite)
|
||||||
|
failedWrites.append(failedWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeProgress = 1.0
|
||||||
|
|
||||||
|
return BatchWriteResult(
|
||||||
|
successful: successful,
|
||||||
|
failed: failed,
|
||||||
|
completedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Write Methods
|
||||||
|
|
||||||
|
private func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
|
||||||
|
let sample = HKQuantitySample(
|
||||||
|
type: quantityType,
|
||||||
|
quantity: quantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]
|
||||||
|
) async throws {
|
||||||
|
// Validate blood pressure values
|
||||||
|
let validation = BloodPressureHandler.shared.validate(
|
||||||
|
systolic: systolic,
|
||||||
|
diastolic: diastolic
|
||||||
|
)
|
||||||
|
|
||||||
|
if !validation.isValid {
|
||||||
|
throw DataWriterError.invalidValue(validation.issues.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
|
||||||
|
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
|
||||||
|
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
|
||||||
|
|
||||||
|
let systolicSample = HKQuantitySample(
|
||||||
|
type: systolicType,
|
||||||
|
quantity: systolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let diastolicSample = HKQuantitySample(
|
||||||
|
type: diastolicType,
|
||||||
|
quantity: diastolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata
|
||||||
|
|
||||||
|
private func createMetadata(from record: MergedRecord) -> [String: Any] {
|
||||||
|
var metadata: [String: Any] = [
|
||||||
|
HKMetadataKeyWasUserEntered: false,
|
||||||
|
"HealthBridgeSource": HealthBridgeConstants.bundleIdentifier,
|
||||||
|
"OriginalSourceId": record.originalSourceId,
|
||||||
|
"MergeStrategy": record.strategy.rawValue,
|
||||||
|
"MergedRecordId": record.id.uuidString,
|
||||||
|
"MergedAt": ISO8601DateFormatter().string(from: record.createdAt)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (key, value) in record.metadata {
|
||||||
|
metadata["HB_\(key)"] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Duplicate Prevention
|
||||||
|
|
||||||
|
private var processedRecordIds: Set<String> = []
|
||||||
|
|
||||||
|
private func loadProcessedRecords() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: processedRecordsKey),
|
||||||
|
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||||
|
processedRecordIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProcessedRecords() {
|
||||||
|
if let data = try? JSONEncoder().encode(processedRecordIds) {
|
||||||
|
UserDefaults.standard.set(data, forKey: processedRecordsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAlreadyWritten(_ record: MergedRecord) -> Bool {
|
||||||
|
let identifier = createRecordIdentifier(record)
|
||||||
|
return processedRecordIds.contains(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markAsProcessed(_ record: MergedRecord) {
|
||||||
|
let identifier = createRecordIdentifier(record)
|
||||||
|
processedRecordIds.insert(identifier)
|
||||||
|
saveProcessedRecords()
|
||||||
|
|
||||||
|
// Cleanup old records (keep last 7 days)
|
||||||
|
cleanupOldRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRecordIdentifier(_ record: MergedRecord) -> String {
|
||||||
|
let components = [
|
||||||
|
record.dataType.rawValue,
|
||||||
|
String(record.timeWindow.start.timeIntervalSince1970),
|
||||||
|
String(record.value)
|
||||||
|
]
|
||||||
|
return components.joined(separator: "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupOldRecords() {
|
||||||
|
// Keep only identifiers that contain recent timestamps
|
||||||
|
let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
||||||
|
let cutoffTimestamp = sevenDaysAgo.timeIntervalSince1970
|
||||||
|
|
||||||
|
processedRecordIds = processedRecordIds.filter { identifier in
|
||||||
|
guard let parts = identifier.split(separator: "-").dropFirst().first,
|
||||||
|
let timestamp = Double(parts) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return timestamp > cutoffTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProcessedRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Records
|
||||||
|
|
||||||
|
func deleteHealthBridgeRecords(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> Int {
|
||||||
|
guard let sampleType = dataType.hkQuantityType else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(
|
||||||
|
withStart: startDate,
|
||||||
|
end: endDate,
|
||||||
|
options: .strictStartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { [weak self] _, samplesOrNil, errorOrNil in
|
||||||
|
guard let self = self else {
|
||||||
|
continuation.resume(throwing: DataWriterError.unknownError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let samples = samplesOrNil else {
|
||||||
|
continuation.resume(returning: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only HealthBridge records
|
||||||
|
let healthBridgeSamples = samples.filter { sample in
|
||||||
|
if let metadata = sample.metadata,
|
||||||
|
let source = metadata["HealthBridgeSource"] as? String {
|
||||||
|
return source == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !healthBridgeSamples.isEmpty else {
|
||||||
|
continuation.resume(returning: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await self.healthStore.delete(healthBridgeSamples)
|
||||||
|
continuation.resume(returning: healthBridgeSamples.count)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WrittenRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let mergedRecordId: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let writtenAt: Date
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailedWrite: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let mergedRecord: MergedRecord
|
||||||
|
let error: Error
|
||||||
|
let attemptedAt: Date
|
||||||
|
|
||||||
|
var errorMessage: String {
|
||||||
|
error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BatchWriteResult {
|
||||||
|
let successful: [WrittenRecord]
|
||||||
|
let failed: [FailedWrite]
|
||||||
|
let completedAt: Date
|
||||||
|
|
||||||
|
var successCount: Int { successful.count }
|
||||||
|
var failureCount: Int { failed.count }
|
||||||
|
var totalCount: Int { successCount + failureCount }
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalCount > 0 else { return 1.0 }
|
||||||
|
return Double(successCount) / Double(totalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum DataWriterError: LocalizedError {
|
||||||
|
case unsupportedDataType
|
||||||
|
case duplicateRecord
|
||||||
|
case missingSecondaryValue
|
||||||
|
case invalidValue(String)
|
||||||
|
case writeFailed(String)
|
||||||
|
case unknownError
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .duplicateRecord:
|
||||||
|
return "Dieser Datensatz wurde bereits geschrieben"
|
||||||
|
case .missingSecondaryValue:
|
||||||
|
return "Fehlender sekundärer Wert (z.B. diastolischer Blutdruck)"
|
||||||
|
case .invalidValue(let message):
|
||||||
|
return "Ungültiger Wert: \(message)"
|
||||||
|
case .writeFailed(let message):
|
||||||
|
return "Schreiben fehlgeschlagen: \(message)"
|
||||||
|
case .unknownError:
|
||||||
|
return "Unbekannter Fehler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - HealthKit Manager
|
||||||
|
@MainActor
|
||||||
|
class HealthKitManager: ObservableObject {
|
||||||
|
static let shared = HealthKitManager()
|
||||||
|
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:]
|
||||||
|
@Published var discoveredSources: [HealthSource] = []
|
||||||
|
@Published var sourceHealthStatus: [String: SourceHealthStatus] = [:]
|
||||||
|
@Published var lastError: Error?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var allQuantityTypes: Set<HKQuantityType> {
|
||||||
|
var types = Set<HKQuantityType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
types.insert(quantityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCategoryTypes: Set<HKCategoryType> {
|
||||||
|
var types = Set<HKCategoryType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let categoryType = dataType.hkCategoryType {
|
||||||
|
types.insert(categoryType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allSampleTypes: Set<HKSampleType> {
|
||||||
|
var types = Set<HKSampleType>()
|
||||||
|
allQuantityTypes.forEach { types.insert($0) }
|
||||||
|
allCategoryTypes.forEach { types.insert($0) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() async throws {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
throw HealthKitError.healthDataNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let typesToRead = allSampleTypes
|
||||||
|
let typesToWrite = allQuantityTypes
|
||||||
|
|
||||||
|
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
|
||||||
|
isAuthorized = true
|
||||||
|
|
||||||
|
await updateAuthorizationStatus()
|
||||||
|
await discoverSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAuthorizationStatus() async {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let status = healthStore.authorizationStatus(for: quantityType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
} else if let categoryType = dataType.hkCategoryType {
|
||||||
|
let status = healthStore.authorizationStatus(for: categoryType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
var allSources: [String: HealthSource] = [:]
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let sources = try await fetchSources(for: sampleType)
|
||||||
|
for source in sources {
|
||||||
|
if var existingSource = allSources[source.bundleIdentifier] {
|
||||||
|
existingSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = existingSource
|
||||||
|
} else {
|
||||||
|
var newSource = source
|
||||||
|
newSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = newSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch sources for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority }
|
||||||
|
|
||||||
|
// Update source health status
|
||||||
|
for source in discoveredSources {
|
||||||
|
await updateSourceHealth(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] {
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
// Handled via continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sources = sourcesOrNil else {
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthSources = sources.map { HealthSource.from(hkSource: $0) }
|
||||||
|
continuation.resume(returning: healthSources)
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSourceHealth(_ source: HealthSource) async {
|
||||||
|
var recordCount = 0
|
||||||
|
var lastActivity: Date?
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) )
|
||||||
|
// Simplified: just get count
|
||||||
|
if let count = try? await fetchRecordCount(for: quantityType, source: source) {
|
||||||
|
recordCount += count
|
||||||
|
}
|
||||||
|
if let date = try? await fetchLastActivityDate(for: quantityType, source: source) {
|
||||||
|
if lastActivity == nil || date > lastActivity! {
|
||||||
|
lastActivity = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = SourceHealthStatus(
|
||||||
|
id: source.id,
|
||||||
|
source: source,
|
||||||
|
lastSync: lastActivity,
|
||||||
|
recordCount: recordCount,
|
||||||
|
dataGaps: [], // TODO: Implement gap detection
|
||||||
|
overallQuality: recordCount > 0 ? .complete : .missing
|
||||||
|
)
|
||||||
|
|
||||||
|
sourceHealthStatus[source.id] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let startOfDay = calendar.startOfDay(for: now)
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples = samplesOrNil ?? []
|
||||||
|
let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSamples.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? {
|
||||||
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: nil,
|
||||||
|
limit: 1,
|
||||||
|
sortDescriptors: [sortDescriptor]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSample?.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ source: HKSource) -> SourceCategory {
|
||||||
|
let bundleId = source.bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
if bundleId.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if bundleId.contains("apple.health") && !bundleId.contains("watch") {
|
||||||
|
return .iPhone
|
||||||
|
} else if bundleId.contains("apple") && bundleId.contains("watch") {
|
||||||
|
return .watch
|
||||||
|
} else if bundleId.contains("huawei") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("samsung") || bundleId.contains("galaxy") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("fitbit") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("garmin") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("polar") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> Set<HealthDataType> {
|
||||||
|
return source.supportedDataTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Fetching (Basic)
|
||||||
|
|
||||||
|
func fetchSamples(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> [HKSample] {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(
|
||||||
|
withStart: startDate,
|
||||||
|
end: endDate,
|
||||||
|
options: .strictStartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: samplesOrNil ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Writing
|
||||||
|
|
||||||
|
func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
|
||||||
|
let sample = HKQuantitySample(
|
||||||
|
type: quantityType,
|
||||||
|
quantity: quantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
|
||||||
|
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
|
||||||
|
|
||||||
|
let systolicSample = HKQuantitySample(
|
||||||
|
type: systolicType,
|
||||||
|
quantity: systolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let diastolicSample = HKQuantitySample(
|
||||||
|
type: diastolicType,
|
||||||
|
quantity: diastolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create correlation for blood pressure
|
||||||
|
guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HealthKit Errors
|
||||||
|
enum HealthKitError: LocalizedError {
|
||||||
|
case healthDataNotAvailable
|
||||||
|
case authorizationDenied
|
||||||
|
case unsupportedDataType
|
||||||
|
case noDataFound
|
||||||
|
case writeFailed(Error)
|
||||||
|
case queryFailed(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .healthDataNotAvailable:
|
||||||
|
return "Health-Daten sind auf diesem Gerät nicht verfügbar"
|
||||||
|
case .authorizationDenied:
|
||||||
|
return "Zugriff auf Health-Daten wurde verweigert"
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .noDataFound:
|
||||||
|
return "Keine Daten gefunden"
|
||||||
|
case .writeFailed(let error):
|
||||||
|
return "Schreiben fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
case .queryFailed(let error):
|
||||||
|
return "Abfrage fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HKSource Extension
|
||||||
|
extension HKSource {
|
||||||
|
convenience init(bundleIdentifier: String, name: String) {
|
||||||
|
// Note: This is a workaround since HKSource doesn't have a public initializer
|
||||||
|
// In production, sources come from HealthKit queries
|
||||||
|
fatalError("HKSource cannot be initialized directly - use source from HKSample")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Merge Engine
|
||||||
|
@MainActor
|
||||||
|
class MergeEngine: ObservableObject {
|
||||||
|
static let shared = MergeEngine()
|
||||||
|
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@Published var pendingMerges: [MergeOperation] = []
|
||||||
|
@Published var completedMerges: [MergeOperation] = []
|
||||||
|
@Published var isMerging = false
|
||||||
|
@Published var mergeProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Analyze Window
|
||||||
|
|
||||||
|
func analyze(windowData: TimeWindowData) -> WindowAnalysis {
|
||||||
|
let readings = windowData.readings
|
||||||
|
let dataType = windowData.dataType
|
||||||
|
|
||||||
|
// No analysis needed for single reading
|
||||||
|
if readings.count <= 1 {
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: false,
|
||||||
|
conflictSeverity: nil,
|
||||||
|
recommendedReading: readings.first,
|
||||||
|
alternativeReadings: [],
|
||||||
|
confidence: .high,
|
||||||
|
analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rule to get recommendation
|
||||||
|
let result = ruleEngine.applyRule(to: readings, dataType: dataType)
|
||||||
|
|
||||||
|
// Calculate conflict severity
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
var severity: ConflictSeverity? = nil
|
||||||
|
|
||||||
|
if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 {
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
if percentDiff >= 5 {
|
||||||
|
if percentDiff < 10 { severity = .minor }
|
||||||
|
else if percentDiff < 25 { severity = .moderate }
|
||||||
|
else if percentDiff < 50 { severity = .significant }
|
||||||
|
else { severity = .major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id }
|
||||||
|
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: windowData.hasConflict,
|
||||||
|
conflictSeverity: severity,
|
||||||
|
recommendedReading: result.selectedReading,
|
||||||
|
alternativeReadings: alternativeReadings,
|
||||||
|
confidence: result.confidence,
|
||||||
|
analysisNotes: result.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Resolve Conflict
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = result.selectedReading else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: result.strategy,
|
||||||
|
isManual: result.strategy == .manual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflictManually(
|
||||||
|
_ conflict: Conflict,
|
||||||
|
selectedReadingId: UUID
|
||||||
|
) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: .manual,
|
||||||
|
isManual: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create Merged Record
|
||||||
|
|
||||||
|
func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord {
|
||||||
|
return MergedRecord(
|
||||||
|
id: UUID(),
|
||||||
|
dataType: conflict.dataType,
|
||||||
|
timeWindow: conflict.timeWindow,
|
||||||
|
value: resolution.resolvedValue,
|
||||||
|
secondaryValue: resolution.secondaryResolvedValue,
|
||||||
|
originalSourceId: resolution.winningSourceId,
|
||||||
|
strategy: resolution.strategy,
|
||||||
|
createdAt: Date(),
|
||||||
|
metadata: [
|
||||||
|
"conflictId": conflict.id.uuidString,
|
||||||
|
"originalSourceCount": String(conflict.readings.count),
|
||||||
|
"isManualResolution": String(resolution.isManual)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch Processing
|
||||||
|
|
||||||
|
func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] {
|
||||||
|
isMerging = true
|
||||||
|
defer { isMerging = false }
|
||||||
|
|
||||||
|
var operations: [MergeOperation] = []
|
||||||
|
|
||||||
|
for (index, conflict) in conflicts.enumerated() {
|
||||||
|
mergeProgress = Double(index) / Double(conflicts.count)
|
||||||
|
|
||||||
|
let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType)
|
||||||
|
|
||||||
|
if result.confidence == .requiresManual ||
|
||||||
|
ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) {
|
||||||
|
// Add to pending for manual review
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .pendingManualReview,
|
||||||
|
result: result
|
||||||
|
)
|
||||||
|
pendingMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
} else if let resolution = resolveConflict(conflict, using: result) {
|
||||||
|
// Auto-resolve
|
||||||
|
let mergedRecord = createMergedRecord(from: conflict, resolution: resolution)
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .resolved,
|
||||||
|
result: result,
|
||||||
|
resolution: resolution,
|
||||||
|
mergedRecord: mergedRecord
|
||||||
|
)
|
||||||
|
completedMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeProgress = 1.0
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Merge
|
||||||
|
|
||||||
|
func performDailyMerge(for date: Date) async throws -> DailyMergeReport {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var report = DailyMergeReport(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let windowData = try await dataReader.fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictWindows = windowData.filter { $0.hasConflict }
|
||||||
|
report.totalConflicts += conflictWindows.count
|
||||||
|
|
||||||
|
for window in conflictWindows {
|
||||||
|
let analysis = analyze(windowData: window)
|
||||||
|
|
||||||
|
if analysis.confidence == .requiresManual {
|
||||||
|
report.pendingManualReview += 1
|
||||||
|
} else {
|
||||||
|
report.autoResolved += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
report.analysesByType[dataType, default: []].append(analysis)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WindowAnalysis {
|
||||||
|
let windowData: TimeWindowData
|
||||||
|
let hasConflict: Bool
|
||||||
|
let conflictSeverity: ConflictSeverity?
|
||||||
|
let recommendedReading: SourceReading?
|
||||||
|
let alternativeReadings: [SourceReading]
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let analysisNotes: String
|
||||||
|
|
||||||
|
var recommendedValue: Double? {
|
||||||
|
recommendedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double? {
|
||||||
|
guard let recommended = recommendedReading?.value,
|
||||||
|
let alternative = alternativeReadings.first?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return abs(recommended - alternative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergeOperation: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let conflict: Conflict
|
||||||
|
var status: MergeStatus
|
||||||
|
let result: RuleApplicationResult
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var mergedRecord: MergedRecord?
|
||||||
|
let createdAt = Date()
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum MergeStatus {
|
||||||
|
case pending
|
||||||
|
case pendingManualReview
|
||||||
|
case resolved
|
||||||
|
case written
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergedRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let originalSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let createdAt: Date
|
||||||
|
var writtenAt: Date?
|
||||||
|
var healthKitRecordId: String?
|
||||||
|
var metadata: [String: String]
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailyMergeReport {
|
||||||
|
let date: Date
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var analysesByType: [HealthDataType: [WindowAnalysis]] = [:]
|
||||||
|
var errors: [String] = []
|
||||||
|
let generatedAt = Date()
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
if totalConflicts == 0 {
|
||||||
|
return "Keine Konflikte gefunden"
|
||||||
|
}
|
||||||
|
return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Rule Engine
|
||||||
|
@MainActor
|
||||||
|
class RuleEngine: ObservableObject {
|
||||||
|
static let shared = RuleEngine()
|
||||||
|
|
||||||
|
@Published var rules: [HealthDataType: MergeRule] = [:]
|
||||||
|
@Published var isLoaded = false
|
||||||
|
|
||||||
|
private let storage = RuleStorageManager()
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Loading
|
||||||
|
|
||||||
|
func loadRules() {
|
||||||
|
let savedRules = storage.loadRules()
|
||||||
|
|
||||||
|
if savedRules.isEmpty {
|
||||||
|
// Initialize with defaults
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
} else {
|
||||||
|
rules = savedRules
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRules() {
|
||||||
|
storage.saveRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Access
|
||||||
|
|
||||||
|
func getRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
return rules[dataType] ?? MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRule(_ rule: MergeRule, for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = rule
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefault(for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAllToDefaults() {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application
|
||||||
|
|
||||||
|
func applyRule(
|
||||||
|
to readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
// Filter out empty/zero readings for most strategies
|
||||||
|
let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete }
|
||||||
|
|
||||||
|
guard !validReadings.isEmpty else {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine gültigen Werte vorhanden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one valid reading, no conflict
|
||||||
|
if validReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: validReadings[0],
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Nur eine Quelle verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply strategy
|
||||||
|
switch rule.strategy {
|
||||||
|
case .exclusive:
|
||||||
|
return applyExclusiveStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .priority:
|
||||||
|
return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType)
|
||||||
|
|
||||||
|
case .higherWins:
|
||||||
|
return applyHigherWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .lowerWins:
|
||||||
|
return applyLowerWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .average:
|
||||||
|
return applyAverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverage:
|
||||||
|
return applyCoverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .mostRecent:
|
||||||
|
return applyMostRecentStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .manual:
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .manual,
|
||||||
|
confidence: .requiresManual,
|
||||||
|
reason: "Manuelle Entscheidung erforderlich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Strategy Implementations
|
||||||
|
|
||||||
|
private func applyExclusiveStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// If primary source is specified, use it
|
||||||
|
if let primaryId = rule.primarySourceId,
|
||||||
|
let reading = readings.first(where: { $0.sourceId == primaryId }) {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: reading,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Exklusive Quelle: \(reading.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use highest priority source
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine geeignete Quelle gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPriorityStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule,
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Sort by user-defined priority, then by category priority
|
||||||
|
let sorted = readings.sorted { r1, r2 in
|
||||||
|
let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority
|
||||||
|
let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority
|
||||||
|
return p1 > p2
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Priorität gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHigherWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value > $1.value }
|
||||||
|
|
||||||
|
if let highest = sorted.first {
|
||||||
|
// Check if there's a significant difference
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let spread = (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
let avgValue = values.reduce(0, +) / Double(values.count)
|
||||||
|
let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0
|
||||||
|
|
||||||
|
let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: confidence,
|
||||||
|
reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLowerWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value < $1.value }
|
||||||
|
|
||||||
|
if let lowest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: lowest,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let average = values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
// Create a synthetic reading for the average
|
||||||
|
let syntheticReading = SourceReading(
|
||||||
|
sourceId: HealthBridgeConstants.bundleIdentifier,
|
||||||
|
sourceName: "Durchschnitt",
|
||||||
|
sourceCategory: .healthBridge,
|
||||||
|
value: average,
|
||||||
|
timestamp: readings.first?.timestamp ?? Date(),
|
||||||
|
quality: .complete
|
||||||
|
)
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: syntheticReading,
|
||||||
|
strategy: .average,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Durchschnitt aus \(readings.count) Quellen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Prefer readings with complete quality
|
||||||
|
let completeReadings = readings.filter { $0.quality == .complete }
|
||||||
|
|
||||||
|
if completeReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: completeReadings[0],
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple complete readings, fall back to priority
|
||||||
|
if !completeReadings.isEmpty {
|
||||||
|
let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No complete readings, use any reading with highest priority
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Daten gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageThenHigherStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// First check if one source has data and others don't (coverage)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
let zeroReadings = readings.filter { $0.value == 0 }
|
||||||
|
|
||||||
|
// If only one source has data, it wins on coverage
|
||||||
|
if nonZeroReadings.count == 1 && !zeroReadings.isEmpty {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nonZeroReadings[0],
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple sources have data, use higher wins
|
||||||
|
if nonZeroReadings.count > 1 {
|
||||||
|
let sorted = nonZeroReadings.sorted { $0.value > $1.value }
|
||||||
|
if let highest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
if let first = readings.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Fallback auf erste Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Daten verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMostRecentStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
|
if let mostRecent = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: mostRecent,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Neuester Wert von \(mostRecent.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Zeitstempel verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Threshold Check
|
||||||
|
|
||||||
|
func shouldRequestManualReview(
|
||||||
|
readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> Bool {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
guard let threshold = rule.thresholdForManualReview else {
|
||||||
|
return rule.strategy == .manual
|
||||||
|
}
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard values.count >= 2,
|
||||||
|
let min = values.min(),
|
||||||
|
let max = values.max(),
|
||||||
|
min > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
return percentDiff > threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application Result
|
||||||
|
struct RuleApplicationResult {
|
||||||
|
let selectedReading: SourceReading?
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
var resolvedValue: Double? {
|
||||||
|
selectedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var winningSourceId: String? {
|
||||||
|
selectedReading?.sourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuleConfidence: String, Codable {
|
||||||
|
case high = "high"
|
||||||
|
case medium = "medium"
|
||||||
|
case low = "low"
|
||||||
|
case requiresManual = "requires_manual"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "Hohe Sicherheit"
|
||||||
|
case .medium: return "Mittlere Sicherheit"
|
||||||
|
case .low: return "Geringe Sicherheit"
|
||||||
|
case .requiresManual: return "Manuelle Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "checkmark.seal.fill"
|
||||||
|
case .medium: return "checkmark.seal"
|
||||||
|
case .low: return "questionmark.circle"
|
||||||
|
case .requiresManual: return "hand.raised.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Storage Manager
|
||||||
|
class RuleStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let rulesKey = "healthbridge.merge.rules"
|
||||||
|
|
||||||
|
func saveRules(_ rules: [HealthDataType: MergeRule]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(rules)
|
||||||
|
userDefaults.set(data, forKey: rulesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save rules: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRules() -> [HealthDataType: MergeRule] {
|
||||||
|
guard let data = userDefaults.data(forKey: rulesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load rules: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Handler
|
||||||
|
class BloodPressureHandler {
|
||||||
|
static let shared = BloodPressureHandler()
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let issues: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(systolic: Double, diastolic: Double) -> ValidationResult {
|
||||||
|
var issues: [String] = []
|
||||||
|
|
||||||
|
// Range validation
|
||||||
|
if systolic < 70 || systolic > 200 {
|
||||||
|
issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if diastolic < 40 || diastolic > 130 {
|
||||||
|
issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plausibility check
|
||||||
|
if diastolic >= systolic {
|
||||||
|
issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic < 20 {
|
||||||
|
issues.append("Pulsdruck zu gering (< 20 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic > 100 {
|
||||||
|
issues.append("Pulsdruck zu hoch (> 100 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult(isValid: issues.isEmpty, issues: issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification {
|
||||||
|
if systolic < 120 && diastolic < 80 {
|
||||||
|
return .normal
|
||||||
|
} else if systolic < 130 && diastolic < 80 {
|
||||||
|
return .elevated
|
||||||
|
} else if systolic < 140 || diastolic < 90 {
|
||||||
|
return .hypertensionStage1
|
||||||
|
} else if systolic < 180 || diastolic < 120 {
|
||||||
|
return .hypertensionStage2
|
||||||
|
} else {
|
||||||
|
return .hypertensiveCrisis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BloodPressureClassification: String {
|
||||||
|
case normal = "Normal"
|
||||||
|
case elevated = "Erhöht"
|
||||||
|
case hypertensionStage1 = "Bluthochdruck Stufe 1"
|
||||||
|
case hypertensionStage2 = "Bluthochdruck Stufe 2"
|
||||||
|
case hypertensiveCrisis = "Hypertensive Krise"
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "green"
|
||||||
|
case .elevated: return "yellow"
|
||||||
|
case .hypertensionStage1: return "orange"
|
||||||
|
case .hypertensionStage2: return "red"
|
||||||
|
case .hypertensiveCrisis: return "purple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recommendation: String {
|
||||||
|
switch self {
|
||||||
|
case .normal:
|
||||||
|
return "Weiter so! Regelmässige Kontrolle empfohlen."
|
||||||
|
case .elevated:
|
||||||
|
return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz."
|
||||||
|
case .hypertensionStage1:
|
||||||
|
return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich."
|
||||||
|
case .hypertensionStage2:
|
||||||
|
return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig."
|
||||||
|
case .hypertensiveCrisis:
|
||||||
|
return "SOFORT medizinische Hilfe aufsuchen!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Source Manager
|
||||||
|
@MainActor
|
||||||
|
class SourceManager: ObservableObject {
|
||||||
|
static let shared = SourceManager()
|
||||||
|
|
||||||
|
@Published var sources: [HealthSource] = []
|
||||||
|
@Published var sourceProfiles: [String: SourceProfile] = [:]
|
||||||
|
@Published var isDiscovering = false
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let storage = SourceStorageManager()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
isDiscovering = true
|
||||||
|
defer { isDiscovering = false }
|
||||||
|
|
||||||
|
await healthKitManager.discoverSources()
|
||||||
|
sources = healthKitManager.discoveredSources
|
||||||
|
|
||||||
|
// Load saved source profiles and merge with discovered sources
|
||||||
|
let savedProfiles = storage.loadSourceProfiles()
|
||||||
|
for source in sources {
|
||||||
|
if let savedProfile = savedProfiles[source.bundleIdentifier] {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = savedProfile
|
||||||
|
} else {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ bundleIdentifier: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
// HealthBridge
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Devices
|
||||||
|
if lowercased.contains("com.apple") {
|
||||||
|
if lowercased.contains("watch") || lowercased.contains("nano") {
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
if lowercased.contains("health") {
|
||||||
|
return .iPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Watch Brands
|
||||||
|
let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar",
|
||||||
|
"withings", "amazfit", "xiaomi", "honor", "oppo", "suunto",
|
||||||
|
"coros", "whoop", "oura"]
|
||||||
|
for brand in watchBrands {
|
||||||
|
if lowercased.contains(brand) {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Health Apps
|
||||||
|
let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper",
|
||||||
|
"mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"]
|
||||||
|
for app in healthApps {
|
||||||
|
if lowercased.contains(app) {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities {
|
||||||
|
let category = source.category
|
||||||
|
|
||||||
|
switch category {
|
||||||
|
case .iPhone:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: true,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: true,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .watch:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyWatch:
|
||||||
|
// Check for specific features based on name
|
||||||
|
let name = source.name.lowercased()
|
||||||
|
let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d"))
|
||||||
|
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyApp:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
case .healthBridge, .unknown:
|
||||||
|
return SourceCapabilities.empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health
|
||||||
|
|
||||||
|
func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport {
|
||||||
|
var dataGaps: [HealthDataType: [TimeWindow]] = [:]
|
||||||
|
var lastActivityByType: [HealthDataType: Date] = [:]
|
||||||
|
var recordCountByType: [HealthDataType: Int] = [:]
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: yesterday,
|
||||||
|
to: now
|
||||||
|
)
|
||||||
|
|
||||||
|
let matchingSamples = samples.filter {
|
||||||
|
$0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
recordCountByType[dataType] = matchingSamples.count
|
||||||
|
|
||||||
|
if let lastSample = matchingSamples.last {
|
||||||
|
lastActivityByType[dataType] = lastSample.endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect gaps
|
||||||
|
let gaps = detectDataGaps(
|
||||||
|
in: matchingSamples,
|
||||||
|
from: yesterday,
|
||||||
|
to: now,
|
||||||
|
expectedInterval: 15 * 60 // 15 minutes
|
||||||
|
)
|
||||||
|
if !gaps.isEmpty {
|
||||||
|
dataGaps[dataType] = gaps
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error checking health for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
if recordCountByType.values.reduce(0, +) == 0 {
|
||||||
|
overallQuality = .missing
|
||||||
|
} else if dataGaps.isEmpty {
|
||||||
|
overallQuality = .complete
|
||||||
|
} else {
|
||||||
|
overallQuality = .partial
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceHealthReport(
|
||||||
|
source: source,
|
||||||
|
lastActivityByType: lastActivityByType,
|
||||||
|
recordCountByType: recordCountByType,
|
||||||
|
dataGaps: dataGaps,
|
||||||
|
overallQuality: overallQuality,
|
||||||
|
checkedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectDataGaps(
|
||||||
|
in samples: [HKSample],
|
||||||
|
from start: Date,
|
||||||
|
to end: Date,
|
||||||
|
expectedInterval: TimeInterval
|
||||||
|
) -> [TimeWindow] {
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [TimeWindow(start: start, end: end)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var gaps: [TimeWindow] = []
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
|
||||||
|
// Check gap at beginning
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: start, end: firstSample.startDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let currentEnd = sortedSamples[i].endDate
|
||||||
|
let nextStart = sortedSamples[i + 1].startDate
|
||||||
|
let gap = nextStart.timeIntervalSince(currentEnd)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: currentEnd, end: nextStart))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: lastSample.endDate, end: end))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Management
|
||||||
|
|
||||||
|
func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.priorities[dataType] = priority
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int {
|
||||||
|
if let profile = sourceProfiles[source.bundleIdentifier],
|
||||||
|
let priority = profile.priorities[dataType] {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
return source.category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] {
|
||||||
|
return sources
|
||||||
|
.filter { $0.supportedDataTypes.contains(dataType) }
|
||||||
|
.sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Enable/Disable
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool, for source: HealthSource) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.isEnabled = enabled
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEnabled(_ source: HealthSource) -> Bool {
|
||||||
|
return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Profile
|
||||||
|
struct SourceProfile: Codable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
var priorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
var customName: String?
|
||||||
|
var notes: String?
|
||||||
|
let addedAt: Date
|
||||||
|
|
||||||
|
init(source: HealthSource) {
|
||||||
|
self.bundleIdentifier = source.bundleIdentifier
|
||||||
|
self.priorities = [:]
|
||||||
|
self.isEnabled = true
|
||||||
|
self.customName = nil
|
||||||
|
self.notes = nil
|
||||||
|
self.addedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
struct SourceCapabilities {
|
||||||
|
let canMeasureSteps: Bool
|
||||||
|
let canMeasureDistance: Bool
|
||||||
|
let canMeasureFloors: Bool
|
||||||
|
let canMeasureHeartRate: Bool
|
||||||
|
let canMeasureBloodPressure: Bool
|
||||||
|
let canMeasureBloodOxygen: Bool
|
||||||
|
let canMeasureSleep: Bool
|
||||||
|
let canMeasureWorkouts: Bool
|
||||||
|
let hasGPS: Bool
|
||||||
|
let hasBarometer: Bool
|
||||||
|
let hasAccelerometer: Bool
|
||||||
|
|
||||||
|
static let empty = SourceCapabilities(
|
||||||
|
canMeasureSteps: false,
|
||||||
|
canMeasureDistance: false,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: false,
|
||||||
|
hasGPS: false,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedDataTypes: Set<HealthDataType> {
|
||||||
|
var types = Set<HealthDataType>()
|
||||||
|
if canMeasureSteps { types.insert(.steps) }
|
||||||
|
if canMeasureDistance { types.insert(.distance) }
|
||||||
|
if canMeasureFloors { types.insert(.floorsClimbed) }
|
||||||
|
if canMeasureHeartRate {
|
||||||
|
types.insert(.heartRate)
|
||||||
|
types.insert(.restingHeartRate)
|
||||||
|
}
|
||||||
|
if canMeasureBloodPressure {
|
||||||
|
types.insert(.bloodPressureSystolic)
|
||||||
|
types.insert(.bloodPressureDiastolic)
|
||||||
|
}
|
||||||
|
if canMeasureBloodOxygen { types.insert(.bloodOxygen) }
|
||||||
|
if canMeasureSleep { types.insert(.sleep) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Report
|
||||||
|
struct SourceHealthReport {
|
||||||
|
let source: HealthSource
|
||||||
|
let lastActivityByType: [HealthDataType: Date]
|
||||||
|
let recordCountByType: [HealthDataType: Int]
|
||||||
|
let dataGaps: [HealthDataType: [TimeWindow]]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
let checkedAt: Date
|
||||||
|
|
||||||
|
var lastOverallActivity: Date? {
|
||||||
|
lastActivityByType.values.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRecordCount: Int {
|
||||||
|
recordCountByType.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSignificantGaps: Bool {
|
||||||
|
dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Storage Manager
|
||||||
|
class SourceStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let profilesKey = "healthbridge.source.profiles"
|
||||||
|
|
||||||
|
func saveSourceProfiles(_ profiles: [String: SourceProfile]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(profiles)
|
||||||
|
userDefaults.set(data, forKey: profilesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save source profiles: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSourceProfiles() -> [String: SourceProfile] {
|
||||||
|
guard let data = userDefaults.data(forKey: profilesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: SourceProfile].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load source profiles: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Sync Coordinator
|
||||||
|
@MainActor
|
||||||
|
class SyncCoordinator: ObservableObject {
|
||||||
|
static let shared = SyncCoordinator()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let mergeEngine = MergeEngine.shared
|
||||||
|
private let dataWriter = DataWriter.shared
|
||||||
|
|
||||||
|
@Published var isSyncing = false
|
||||||
|
@Published var syncProgress: Double = 0
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var lastSyncResult: SyncResult?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
@Published var syncHistory: [SyncResult] = []
|
||||||
|
|
||||||
|
private let syncHistoryKey = "healthbridge.sync.history"
|
||||||
|
private let maxHistoryItems = 50
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main Sync
|
||||||
|
|
||||||
|
func performSync(
|
||||||
|
for date: Date = Date(),
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
syncProgress = 0
|
||||||
|
defer { isSyncing = false }
|
||||||
|
|
||||||
|
let startTime = Date()
|
||||||
|
var result = SyncResult(startedAt: startTime)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Step 1: Refresh sources (10%)
|
||||||
|
syncProgress = 0.05
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
syncProgress = 0.1
|
||||||
|
|
||||||
|
// Step 2: Detect conflicts (40%)
|
||||||
|
let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes)
|
||||||
|
result.totalConflicts = conflicts.count
|
||||||
|
syncProgress = 0.4
|
||||||
|
|
||||||
|
// Step 3: Process conflicts with merge engine (70%)
|
||||||
|
let operations = await mergeEngine.processConflicts(conflicts)
|
||||||
|
syncProgress = 0.7
|
||||||
|
|
||||||
|
let autoResolved = operations.filter { $0.status == .resolved }
|
||||||
|
let pendingManual = operations.filter { $0.status == .pendingManualReview }
|
||||||
|
|
||||||
|
result.autoResolved = autoResolved.count
|
||||||
|
result.pendingManualReview = pendingManual.count
|
||||||
|
|
||||||
|
// Update pending conflicts
|
||||||
|
pendingConflicts = pendingManual.map { $0.conflict }
|
||||||
|
|
||||||
|
// Step 4: Write resolved records (90%)
|
||||||
|
let recordsToWrite = autoResolved.compactMap { $0.mergedRecord }
|
||||||
|
if !recordsToWrite.isEmpty {
|
||||||
|
let writeResult = await dataWriter.writeBatch(recordsToWrite)
|
||||||
|
result.writtenRecords = writeResult.successCount
|
||||||
|
result.writeErrors = writeResult.failureCount
|
||||||
|
}
|
||||||
|
syncProgress = 0.9
|
||||||
|
|
||||||
|
// Step 5: Finalize (100%)
|
||||||
|
result.completedAt = Date()
|
||||||
|
result.status = .success
|
||||||
|
syncProgress = 1.0
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
result.status = .failed
|
||||||
|
result.error = error.localizedDescription
|
||||||
|
result.completedAt = Date()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncDate = Date()
|
||||||
|
lastSyncResult = result
|
||||||
|
addToHistory(result)
|
||||||
|
|
||||||
|
// Send notification if there are pending conflicts
|
||||||
|
if result.pendingManualReview > 0 {
|
||||||
|
await sendConflictNotification(count: result.pendingManualReview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Sync
|
||||||
|
|
||||||
|
func performQuickSync() async throws {
|
||||||
|
try await performSync(
|
||||||
|
for: Date(),
|
||||||
|
dataTypes: [.steps, .heartRate, .activeEnergy]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Specific Data Type
|
||||||
|
|
||||||
|
func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws {
|
||||||
|
try await performSync(for: date, dataTypes: [dataType])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual Conflict Resolution
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws {
|
||||||
|
guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else {
|
||||||
|
throw SyncError.resolutionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedConflict = conflict
|
||||||
|
resolvedConflict.status = .resolved
|
||||||
|
resolvedConflict.resolution = resolution
|
||||||
|
resolvedConflict.resolvedAt = Date()
|
||||||
|
|
||||||
|
let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution)
|
||||||
|
|
||||||
|
// Write the record
|
||||||
|
_ = try await dataWriter.writeRecord(mergedRecord)
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ignoreConflict(_ conflict: Conflict) {
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync History
|
||||||
|
|
||||||
|
private func loadSyncHistory() {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: syncHistoryKey),
|
||||||
|
let history = try? JSONDecoder().decode([SyncResult].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncHistory = history
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addToHistory(_ result: SyncResult) {
|
||||||
|
syncHistory.insert(result, at: 0)
|
||||||
|
if syncHistory.count > maxHistoryItems {
|
||||||
|
syncHistory = Array(syncHistory.prefix(maxHistoryItems))
|
||||||
|
}
|
||||||
|
saveSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSyncHistory() {
|
||||||
|
guard let data = try? JSONEncoder().encode(syncHistory) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
syncHistory.removeAll()
|
||||||
|
UserDefaults.standard.removeObject(forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
private func sendConflictNotification(count: Int) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard settings.authorizationStatus == .authorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
content.body = count == 1
|
||||||
|
? "1 Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
: "\(count) Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflicts",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationPermission() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics
|
||||||
|
|
||||||
|
var todayStats: TodayStats {
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
let todaySyncs = syncHistory.filter {
|
||||||
|
Calendar.current.isDate($0.startedAt, inSameDayAs: today)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TodayStats(
|
||||||
|
syncCount: todaySyncs.count,
|
||||||
|
totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts },
|
||||||
|
autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved },
|
||||||
|
pendingManual: pendingConflicts.count,
|
||||||
|
lastSync: lastSyncDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct SyncResult: Identifiable, Codable {
|
||||||
|
let id = UUID()
|
||||||
|
let startedAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
var status: SyncStatus = .inProgress
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var writtenRecords = 0
|
||||||
|
var writeErrors = 0
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case success = "success"
|
||||||
|
case partialSuccess = "partial_success"
|
||||||
|
case failed = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval? {
|
||||||
|
guard let completed = completedAt else { return nil }
|
||||||
|
return completed.timeIntervalSince(startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String {
|
||||||
|
guard let duration = duration else { return "–" }
|
||||||
|
if duration < 1 {
|
||||||
|
return "< 1s"
|
||||||
|
}
|
||||||
|
return String(format: "%.1fs", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TodayStats {
|
||||||
|
let syncCount: Int
|
||||||
|
let totalConflicts: Int
|
||||||
|
let autoResolved: Int
|
||||||
|
let pendingManual: Int
|
||||||
|
let lastSync: Date?
|
||||||
|
|
||||||
|
var resolutionRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedLastSync: String {
|
||||||
|
guard let date = lastSync else { return "Nie" }
|
||||||
|
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncError: LocalizedError {
|
||||||
|
case notAuthorized
|
||||||
|
case syncInProgress
|
||||||
|
case resolutionFailed
|
||||||
|
case writeFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthorized:
|
||||||
|
return "Keine Berechtigung für HealthKit"
|
||||||
|
case .syncInProgress:
|
||||||
|
return "Synchronisierung läuft bereits"
|
||||||
|
case .resolutionFailed:
|
||||||
|
return "Konfliktauflösung fehlgeschlagen"
|
||||||
|
case .writeFailed:
|
||||||
|
return "Schreiben der Daten fehlgeschlagen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Date Extensions
|
||||||
|
extension Date {
|
||||||
|
var startOfDay: Date {
|
||||||
|
Calendar.current.startOfDay(for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfDay: Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool {
|
||||||
|
Calendar.current.isDateInToday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isYesterday: Bool {
|
||||||
|
Calendar.current.isDateInYesterday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatted(style: DateFormatter.Style) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = style
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedRelative() -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: self, relativeTo: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(days: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: days, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(hours: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .hour, value: hours, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(minutes: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Double Extensions
|
||||||
|
extension Double {
|
||||||
|
func formatted(decimals: Int = 1) -> String {
|
||||||
|
String(format: "%.\(decimals)f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsInteger: String {
|
||||||
|
String(format: "%.0f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsPercentage: String {
|
||||||
|
String(format: "%.1f%%", self * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array Extensions
|
||||||
|
extension Array {
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Extensions
|
||||||
|
extension Color {
|
||||||
|
static let healthBridgePrimary = Color.blue
|
||||||
|
static let healthBridgeSecondary = Color.cyan
|
||||||
|
static let healthBridgeAccent = Color.orange
|
||||||
|
|
||||||
|
static func forSeverity(_ severity: ConflictSeverity) -> Color {
|
||||||
|
switch severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func forQuality(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Extensions
|
||||||
|
extension View {
|
||||||
|
func cardStyle() -> some View {
|
||||||
|
self
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionHeader(_ title: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binding Extensions
|
||||||
|
extension Binding {
|
||||||
|
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
|
||||||
|
Binding(
|
||||||
|
get: { self.wrappedValue },
|
||||||
|
set: { newValue in
|
||||||
|
self.wrappedValue = newValue
|
||||||
|
handler(newValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Optional Extensions
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
var orEmpty: String {
|
||||||
|
self ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNilOrEmpty: Bool {
|
||||||
|
self?.isEmpty ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection Extensions
|
||||||
|
extension Collection {
|
||||||
|
var isNotEmpty: Bool {
|
||||||
|
!isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class NotificationManager: ObservableObject {
|
||||||
|
static let shared = NotificationManager()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var pendingNotifications: [String] = []
|
||||||
|
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
Task {
|
||||||
|
await checkAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
isAuthorized = granted
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("Notification authorization failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorization() async {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
isAuthorized = settings.authorizationStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Notifications
|
||||||
|
|
||||||
|
func sendConflictNotification(count: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
} else {
|
||||||
|
content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
content.categoryIdentifier = "CONFLICT"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Notifications
|
||||||
|
|
||||||
|
func sendSyncCompleteNotification(
|
||||||
|
conflictsResolved: Int,
|
||||||
|
pendingConflicts: Int
|
||||||
|
) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Sync abgeschlossen"
|
||||||
|
|
||||||
|
if pendingConflicts > 0 {
|
||||||
|
content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen"
|
||||||
|
} else {
|
||||||
|
content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = "SYNC_COMPLETE"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scheduled Notifications
|
||||||
|
|
||||||
|
func scheduleReminder(at hour: Int, minute: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge Erinnerung"
|
||||||
|
content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var dateComponents = DateComponents()
|
||||||
|
dateComponents.hour = hour
|
||||||
|
dateComponents.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.reminder.daily",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule reminder: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelReminder() {
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Badge Management
|
||||||
|
|
||||||
|
func clearBadge() async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(0)
|
||||||
|
} catch {
|
||||||
|
print("Failed to clear badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBadge(count: Int) async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(count)
|
||||||
|
} catch {
|
||||||
|
print("Failed to update badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Categories
|
||||||
|
|
||||||
|
func registerCategories() {
|
||||||
|
// Conflict category with actions
|
||||||
|
let resolveAction = UNNotificationAction(
|
||||||
|
identifier: "RESOLVE_AUTO",
|
||||||
|
title: "Automatisch lösen",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let viewAction = UNNotificationAction(
|
||||||
|
identifier: "VIEW_CONFLICTS",
|
||||||
|
title: "Anzeigen",
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictCategory = UNNotificationCategory(
|
||||||
|
identifier: "CONFLICT",
|
||||||
|
actions: [resolveAction, viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync complete category
|
||||||
|
let syncCategory = UNNotificationCategory(
|
||||||
|
identifier: "SYNC_COMPLETE",
|
||||||
|
actions: [viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
center.setNotificationCategories([conflictCategory, syncCategory])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DashboardViewModel: ObservableObject {
|
||||||
|
private let syncCoordinator = SyncCoordinator.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var dailySummary: DailySummary?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var selectedDate = Date()
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
setupBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
$selectedDate
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] date in
|
||||||
|
Task {
|
||||||
|
await self?.loadData(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData(for date: Date = Date()) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: date)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
return .syncing
|
||||||
|
} else if let lastSync = syncCoordinator.lastSyncDate {
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .synced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case syncing
|
||||||
|
case synced
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "Synchronisiere..."
|
||||||
|
case .synced: return "Synchronisiert"
|
||||||
|
case .stale: return "Sync empfohlen"
|
||||||
|
case .veryStale: return "Sync überfällig"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "arrow.triangle.2.circlepath"
|
||||||
|
case .synced: return "checkmark.circle.fill"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct HealthChart: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let data: [ChartDataPoint]
|
||||||
|
let showConflicts: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let latest = data.last {
|
||||||
|
Text(latest.formattedValue)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Chart(data) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Wert", point.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
PointMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Wert", point.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue)
|
||||||
|
.symbolSize(point.hasConflict && showConflicts ? 100 : 50)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .stride(by: .hour, count: 4)) { value in
|
||||||
|
AxisValueLabel(format: .dateTime.hour())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for iOS 15
|
||||||
|
simpleChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var simpleChart: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let maxValue = data.map { $0.value }.max() ?? 1
|
||||||
|
let minValue = data.map { $0.value }.min() ?? 0
|
||||||
|
let range = maxValue - minValue
|
||||||
|
|
||||||
|
Path { path in
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
|
||||||
|
let xStep = geometry.size.width / CGFloat(max(1, data.count - 1))
|
||||||
|
|
||||||
|
for (index, point) in data.enumerated() {
|
||||||
|
let x = CGFloat(index) * xStep
|
||||||
|
let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5
|
||||||
|
let y = geometry.size.height * (1 - normalizedY)
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.blue, lineWidth: 2)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartDataPoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let value: Double
|
||||||
|
let hasConflict: Bool
|
||||||
|
let sourceId: String?
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Chart
|
||||||
|
struct BloodPressureChart: View {
|
||||||
|
let data: [BloodPressurePoint]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "drop.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Blutdruck")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let latest = data.last {
|
||||||
|
Text("\(Int(latest.systolic))/\(Int(latest.diastolic))")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text("mmHg")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Chart(data) { point in
|
||||||
|
// Systolic
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Systolisch", point.systolic)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
// Diastolic
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Diastolisch", point.diastolic)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
.chartYScale(domain: 40...180)
|
||||||
|
.chartLegend(position: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BloodPressurePoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let systolic: Double
|
||||||
|
let diastolic: Double
|
||||||
|
let classification: BloodPressureHandler.BloodPressureClassification
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary Ring
|
||||||
|
struct SummaryRing: View {
|
||||||
|
let progress: Double
|
||||||
|
let color: Color
|
||||||
|
let icon: String
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.2), lineWidth: 8)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: min(progress, 1.0))
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(value)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
HealthChart(
|
||||||
|
dataType: .steps,
|
||||||
|
data: (0..<24).map { hour in
|
||||||
|
ChartDataPoint(
|
||||||
|
date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!,
|
||||||
|
value: Double.random(in: 0...1000),
|
||||||
|
hasConflict: hour % 5 == 0,
|
||||||
|
sourceId: nil
|
||||||
|
)
|
||||||
|
},
|
||||||
|
showConflicts: true
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
SummaryRing(
|
||||||
|
progress: 0.75,
|
||||||
|
color: .blue,
|
||||||
|
icon: "figure.walk",
|
||||||
|
value: "7.5k",
|
||||||
|
label: "Schritte"
|
||||||
|
)
|
||||||
|
|
||||||
|
SummaryRing(
|
||||||
|
progress: 0.5,
|
||||||
|
color: .red,
|
||||||
|
icon: "heart.fill",
|
||||||
|
value: "72",
|
||||||
|
label: "Puls"
|
||||||
|
)
|
||||||
|
|
||||||
|
SummaryRing(
|
||||||
|
progress: 1.0,
|
||||||
|
color: .green,
|
||||||
|
icon: "checkmark.circle",
|
||||||
|
value: "100%",
|
||||||
|
label: "Synced"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConflictsView: View {
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
@State private var selectedConflict: Conflict?
|
||||||
|
@State private var showingDetail = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if syncCoordinator.pendingConflicts.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
conflictsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Konflikte")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("Alle automatisch lösen") {
|
||||||
|
Task { await resolveAllAuto() }
|
||||||
|
}
|
||||||
|
Button("Alle ignorieren", role: .destructive) {
|
||||||
|
ignoreAll()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.disabled(syncCoordinator.pendingConflicts.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedConflict) { conflict in
|
||||||
|
ConflictDetailView(conflict: conflict) { selectedReadingId in
|
||||||
|
Task {
|
||||||
|
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
|
||||||
|
Text("Keine Konflikte")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflicts List
|
||||||
|
|
||||||
|
private var conflictsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
Section(dataType.displayName) {
|
||||||
|
ForEach(groupedConflicts[dataType] ?? []) { conflict in
|
||||||
|
ConflictRow(conflict: conflict)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedConflict = conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedConflicts: [HealthDataType: [Conflict]] {
|
||||||
|
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
|
||||||
|
selectedConflict = nil
|
||||||
|
} catch {
|
||||||
|
print("Failed to resolve conflict: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAllAuto() async {
|
||||||
|
for conflict in syncCoordinator.pendingConflicts {
|
||||||
|
if let primaryReading = conflict.primarySourceReading {
|
||||||
|
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ignoreAll() {
|
||||||
|
for conflict in syncCoordinator.pendingConflicts {
|
||||||
|
syncCoordinator.ignoreConflict(conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Row
|
||||||
|
|
||||||
|
struct ConflictRow: View {
|
||||||
|
let conflict: Conflict
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: conflict.dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedRange)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
severityBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(conflict.readings.prefix(3)) { reading in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reading.sourceName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(reading.formattedValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflict.readings.count > 3 {
|
||||||
|
Text("+\(conflict.readings.count - 3) weitere")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityBadge: some View {
|
||||||
|
Text(conflict.severity.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(severityColor.opacity(0.2))
|
||||||
|
.foregroundStyle(severityColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch conflict.severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detail View
|
||||||
|
|
||||||
|
struct ConflictDetailView: View {
|
||||||
|
let conflict: Conflict
|
||||||
|
let onResolve: (UUID) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selectedReadingId: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Readings
|
||||||
|
readingsSection
|
||||||
|
|
||||||
|
// Difference Info
|
||||||
|
differenceSection
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Konflikt lösen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Auswählen") {
|
||||||
|
if let id = selectedReadingId {
|
||||||
|
onResolve(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(selectedReadingId == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: conflict.dataType.icon)
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(conflict.dataType.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedRange)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Readings
|
||||||
|
|
||||||
|
private var readingsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Quellen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(conflict.readings) { reading in
|
||||||
|
ReadingCard(
|
||||||
|
reading: reading,
|
||||||
|
isSelected: selectedReadingId == reading.id,
|
||||||
|
dataType: conflict.dataType
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedReadingId = reading.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Difference
|
||||||
|
|
||||||
|
private var differenceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Analyse")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Differenz")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Prozentual")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.1f%%", conflict.percentageDifference))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading Card
|
||||||
|
|
||||||
|
struct ReadingCard: View {
|
||||||
|
let reading: SourceReading
|
||||||
|
let isSelected: Bool
|
||||||
|
let dataType: HealthDataType
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: reading.sourceCategory.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(reading.sourceName)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(reading.sourceCategory.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(reading.formattedValue)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ConflictsView()
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $appState.selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Dashboard", systemImage: "chart.bar.fill")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.dashboard)
|
||||||
|
|
||||||
|
ConflictsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.conflicts)
|
||||||
|
.badge(syncCoordinator.pendingConflicts.count)
|
||||||
|
|
||||||
|
RulesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Regeln", systemImage: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.rules)
|
||||||
|
|
||||||
|
SourcesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.sources)
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
@StateObject private var dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@State private var dailySummary: DailySummary?
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var selectedDate = Date()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Sync Status Card
|
||||||
|
syncStatusCard
|
||||||
|
|
||||||
|
// Date Picker
|
||||||
|
datePicker
|
||||||
|
|
||||||
|
// Health Metrics
|
||||||
|
if let summary = dailySummary {
|
||||||
|
healthMetricsGrid(summary: summary)
|
||||||
|
} else if isLoading {
|
||||||
|
loadingView
|
||||||
|
} else {
|
||||||
|
emptyStateView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending Conflicts Alert
|
||||||
|
if !syncCoordinator.pendingConflicts.isEmpty {
|
||||||
|
pendingConflictsCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Sync History
|
||||||
|
recentSyncHistory
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("HealthBridge")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task { await performSync() }
|
||||||
|
} label: {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(syncCoordinator.isSyncing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedDate) {
|
||||||
|
Task { await loadData() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Card
|
||||||
|
|
||||||
|
private var syncStatusCard: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Heute")
|
||||||
|
.font(.headline)
|
||||||
|
Text(syncCoordinator.todayStats.formattedLastSync)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
ProgressView(value: syncCoordinator.syncProgress)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("Synchronisiere...")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
StatItem(
|
||||||
|
value: "\(syncCoordinator.todayStats.syncCount)",
|
||||||
|
label: "Syncs",
|
||||||
|
icon: "arrow.triangle.2.circlepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatItem(
|
||||||
|
value: "\(syncCoordinator.todayStats.totalConflicts)",
|
||||||
|
label: "Konflikte",
|
||||||
|
icon: "exclamationmark.triangle"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatItem(
|
||||||
|
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
|
||||||
|
label: "Gelöst",
|
||||||
|
icon: "checkmark.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Picker
|
||||||
|
|
||||||
|
private var datePicker: some View {
|
||||||
|
DatePicker(
|
||||||
|
"Datum",
|
||||||
|
selection: $selectedDate,
|
||||||
|
in: ...Date(),
|
||||||
|
displayedComponents: .date
|
||||||
|
)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health Metrics Grid
|
||||||
|
|
||||||
|
private func healthMetricsGrid(summary: DailySummary) -> some View {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 16) {
|
||||||
|
ForEach(HealthDataType.allCases) { dataType in
|
||||||
|
if let value = summary.values[dataType], value > 0 {
|
||||||
|
HealthMetricCard(
|
||||||
|
dataType: dataType,
|
||||||
|
value: summary.formattedValue(for: dataType),
|
||||||
|
conflictCount: summary.conflictCounts[dataType] ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Conflicts Card
|
||||||
|
|
||||||
|
private var pendingConflictsCard: some View {
|
||||||
|
Button {
|
||||||
|
appState.selectedTab = .conflicts
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Tippen zum Anzeigen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Sync History
|
||||||
|
|
||||||
|
private var recentSyncHistory: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Letzte Synchronisierungen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
|
||||||
|
SyncHistoryRow(result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncCoordinator.syncHistory.isEmpty {
|
||||||
|
Text("Keine Synchronisierungen")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading & Empty States
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Lade Daten...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Keine Daten verfügbar")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Synchronisieren Sie, um Daten zu laden")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
print("Sync failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
struct StatItem: View {
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HealthMetricCard: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let value: String
|
||||||
|
let conflictCount: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Spacer()
|
||||||
|
if conflictCount > 0 {
|
||||||
|
Text("\(conflictCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncHistoryRow: View {
|
||||||
|
let result: SyncResult
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(result.formattedDuration)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch result.status {
|
||||||
|
case .success: return "checkmark.circle.fill"
|
||||||
|
case .partialSuccess: return "exclamationmark.circle.fill"
|
||||||
|
case .failed: return "xmark.circle.fill"
|
||||||
|
case .inProgress: return "arrow.clockwise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch result.status {
|
||||||
|
case .success: return .green
|
||||||
|
case .partialSuccess: return .orange
|
||||||
|
case .failed: return .red
|
||||||
|
case .inProgress: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: result.startedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DashboardView()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RulesView: View {
|
||||||
|
@StateObject private var ruleEngine = RuleEngine.shared
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var selectedDataType: HealthDataType?
|
||||||
|
@State private var showingResetConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(HealthDataType.allCases) { dataType in
|
||||||
|
RuleRow(
|
||||||
|
dataType: dataType,
|
||||||
|
rule: ruleEngine.getRule(for: dataType)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedDataType = dataType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle("Regeln")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("Alle zurücksetzen", role: .destructive) {
|
||||||
|
showingResetConfirmation = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedDataType) { dataType in
|
||||||
|
RuleEditorView(dataType: dataType)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Alle Regeln zurücksetzen?",
|
||||||
|
isPresented: $showingResetConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Zurücksetzen", role: .destructive) {
|
||||||
|
ruleEngine.resetAllToDefaults()
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Row
|
||||||
|
|
||||||
|
struct RuleRow: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let rule: MergeRule
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: rule.strategy.icon)
|
||||||
|
.font(.caption)
|
||||||
|
Text(rule.strategy.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !rule.autoApply {
|
||||||
|
Image(systemName: "hand.raised.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Editor View
|
||||||
|
|
||||||
|
struct RuleEditorView: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var ruleEngine = RuleEngine.shared
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@State private var selectedStrategy: MergeStrategy
|
||||||
|
@State private var autoApply: Bool
|
||||||
|
@State private var primarySourceId: String?
|
||||||
|
@State private var thresholdForManualReview: Double?
|
||||||
|
@State private var useThreshold: Bool
|
||||||
|
|
||||||
|
init(dataType: HealthDataType) {
|
||||||
|
self.dataType = dataType
|
||||||
|
let rule = RuleEngine.shared.getRule(for: dataType)
|
||||||
|
_selectedStrategy = State(initialValue: rule.strategy)
|
||||||
|
_autoApply = State(initialValue: rule.autoApply)
|
||||||
|
_primarySourceId = State(initialValue: rule.primarySourceId)
|
||||||
|
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
|
||||||
|
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Data Type Info
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy Selection
|
||||||
|
Section("Strategie") {
|
||||||
|
Picker("Merge-Strategie", selection: $selectedStrategy) {
|
||||||
|
ForEach(MergeStrategy.allCases) { strategy in
|
||||||
|
Label(strategy.displayName, systemImage: strategy.icon)
|
||||||
|
.tag(strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
|
||||||
|
Text(selectedStrategy.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary Source (for exclusive/priority)
|
||||||
|
if selectedStrategy == .exclusive || selectedStrategy == .priority {
|
||||||
|
Section("Primäre Quelle") {
|
||||||
|
let sources = sourceManager.sources.filter {
|
||||||
|
$0.supportedDataTypes.contains(dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sources.isEmpty {
|
||||||
|
Text("Keine Quellen für diesen Datentyp")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Picker("Quelle", selection: $primarySourceId) {
|
||||||
|
Text("Automatisch").tag(nil as String?)
|
||||||
|
ForEach(sources) { source in
|
||||||
|
Text(source.displayName).tag(source.bundleIdentifier as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto Apply
|
||||||
|
Section("Automatisierung") {
|
||||||
|
Toggle("Automatisch anwenden", isOn: $autoApply)
|
||||||
|
|
||||||
|
if autoApply {
|
||||||
|
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
|
||||||
|
|
||||||
|
if useThreshold {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { thresholdForManualReview ?? 20 },
|
||||||
|
set: { thresholdForManualReview = $0 }
|
||||||
|
),
|
||||||
|
in: 5...50,
|
||||||
|
step: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
Section {
|
||||||
|
Button("Auf Standard zurücksetzen", role: .destructive) {
|
||||||
|
let defaultRule = MergeRule.defaultRule(for: dataType)
|
||||||
|
selectedStrategy = defaultRule.strategy
|
||||||
|
autoApply = defaultRule.autoApply
|
||||||
|
primarySourceId = defaultRule.primarySourceId
|
||||||
|
thresholdForManualReview = defaultRule.thresholdForManualReview
|
||||||
|
useThreshold = defaultRule.thresholdForManualReview != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Regel bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Speichern") {
|
||||||
|
saveRule()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveRule() {
|
||||||
|
let rule = MergeRule(
|
||||||
|
dataType: dataType,
|
||||||
|
strategy: selectedStrategy,
|
||||||
|
primarySourceId: primarySourceId,
|
||||||
|
autoApply: autoApply,
|
||||||
|
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
|
||||||
|
)
|
||||||
|
ruleEngine.setRule(rule, for: dataType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RulesView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
|
||||||
|
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
|
||||||
|
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
|
||||||
|
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
|
||||||
|
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
|
||||||
|
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
|
||||||
|
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
|
||||||
|
@State private var showingClearDataConfirmation = false
|
||||||
|
@State private var showingExportSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Sync Settings
|
||||||
|
Section("Synchronisierung") {
|
||||||
|
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
|
||||||
|
|
||||||
|
if backgroundSyncEnabled {
|
||||||
|
Picker("Intervall", selection: $syncIntervalMinutes) {
|
||||||
|
Text("15 Minuten").tag(15)
|
||||||
|
Text("30 Minuten").tag(30)
|
||||||
|
Text("1 Stunde").tag(60)
|
||||||
|
Text("2 Stunden").tag(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification Settings
|
||||||
|
Section("Benachrichtigungen") {
|
||||||
|
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
|
||||||
|
|
||||||
|
if notificationsEnabled {
|
||||||
|
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
|
||||||
|
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health Status
|
||||||
|
Section("HealthKit Status") {
|
||||||
|
HStack {
|
||||||
|
Text("Autorisierung")
|
||||||
|
Spacer()
|
||||||
|
if healthKitManager.isAuthorized {
|
||||||
|
Label("Erteilt", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Ausstehend", systemImage: "exclamationmark.circle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !healthKitManager.isAuthorized {
|
||||||
|
Button("HealthKit-Zugriff anfordern") {
|
||||||
|
Task {
|
||||||
|
try? await healthKitManager.requestAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
Section("Statistiken") {
|
||||||
|
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
|
||||||
|
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
|
||||||
|
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
|
||||||
|
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Management
|
||||||
|
Section("Daten") {
|
||||||
|
Button("Sync-Verlauf exportieren") {
|
||||||
|
showingExportSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Sync-Verlauf löschen", role: .destructive) {
|
||||||
|
showingClearDataConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// About
|
||||||
|
Section("Info") {
|
||||||
|
HStack {
|
||||||
|
Text("Version")
|
||||||
|
Spacer()
|
||||||
|
Text("1.0.0")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: "https://apple.com/health")!) {
|
||||||
|
HStack {
|
||||||
|
Text("Apple Health")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einstellungen")
|
||||||
|
.confirmationDialog(
|
||||||
|
"Sync-Verlauf löschen?",
|
||||||
|
isPresented: $showingClearDataConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
syncCoordinator.clearHistory()
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingExportSheet) {
|
||||||
|
ExportView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stat Row
|
||||||
|
|
||||||
|
struct StatRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export View
|
||||||
|
|
||||||
|
struct ExportView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
@State private var exportFormat: ExportFormat = .json
|
||||||
|
@State private var isExporting = false
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable {
|
||||||
|
case json = "JSON"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text("Daten exportieren")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Picker("Format", selection: $exportFormat) {
|
||||||
|
ForEach(ExportFormat.allCases, id: \.self) { format in
|
||||||
|
Text(format.rawValue).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportData()
|
||||||
|
} label: {
|
||||||
|
Label("Exportieren", systemImage: "square.and.arrow.up")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding()
|
||||||
|
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Export")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportData() {
|
||||||
|
isExporting = true
|
||||||
|
|
||||||
|
// In a real app, this would create and share a file
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
isExporting = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourcesView: View {
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var selectedSource: HealthSource?
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
sourcesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Quellen")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task { await refreshSources() }
|
||||||
|
} label: {
|
||||||
|
if isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRefreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedSource) { source in
|
||||||
|
SourceDetailView(source: source)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if sourceManager.sources.isEmpty {
|
||||||
|
await refreshSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Keine Quellen gefunden")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Verbinden Sie Geräte mit Apple Health")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await refreshSources() }
|
||||||
|
} label: {
|
||||||
|
Label("Aktualisieren", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sources List
|
||||||
|
|
||||||
|
private var sourcesList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
|
||||||
|
Section(category.displayName) {
|
||||||
|
ForEach(groupedSources[category] ?? []) { source in
|
||||||
|
SourceRow(source: source)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable {
|
||||||
|
await refreshSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedSources: [SourceCategory: [HealthSource]] {
|
||||||
|
Dictionary(grouping: sourceManager.sources, by: { $0.category })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSources() async {
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Row
|
||||||
|
|
||||||
|
struct SourceRow: View {
|
||||||
|
let source: HealthSource
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: source.category.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("\(source.supportedDataTypes.count) Datentypen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let status = sourceManager.sourceHealthStatus[source.id] {
|
||||||
|
Image(systemName: status.syncStatus.icon)
|
||||||
|
.foregroundStyle(statusColor(for: status.syncStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .recentlySynced: return .green
|
||||||
|
case .syncedToday: return .blue
|
||||||
|
case .stale: return .orange
|
||||||
|
case .veryStale, .neverSynced: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Detail View
|
||||||
|
|
||||||
|
struct SourceDetailView: View {
|
||||||
|
let source: HealthSource
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var healthReport: SourceHealthReport?
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
capabilitiesSection
|
||||||
|
|
||||||
|
// Data Types
|
||||||
|
dataTypesSection
|
||||||
|
|
||||||
|
// Health Report
|
||||||
|
if let report = healthReport {
|
||||||
|
healthReportSection(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority Settings
|
||||||
|
prioritySection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(source.displayName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Fertig") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadHealthReport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: source.category.icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(source.category.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(source.bundleIdentifier)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capabilities
|
||||||
|
|
||||||
|
private var capabilitiesSection: some View {
|
||||||
|
let capabilities = sourceManager.getSourceCapabilities(source)
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Fähigkeiten")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
|
||||||
|
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
|
||||||
|
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
|
||||||
|
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
|
||||||
|
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
|
||||||
|
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
|
||||||
|
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Types
|
||||||
|
|
||||||
|
private var dataTypesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Unterstützte Datentypen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(dataType.displayName)
|
||||||
|
Spacer()
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health Report
|
||||||
|
|
||||||
|
private func healthReportSection(_ report: SourceHealthReport) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Zustand")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Datensätze (24h)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(report.totalRecordCount)")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Qualität")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Image(systemName: report.overallQuality.icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(qualityColor(report.overallQuality))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastActivity = report.lastOverallActivity {
|
||||||
|
HStack {
|
||||||
|
Text("Letzte Aktivität")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(formattedDate(lastActivity))
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.hasSignificantGaps {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Datenlücken erkannt")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Section
|
||||||
|
|
||||||
|
private var prioritySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Priorität")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
HStack {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Stepper(
|
||||||
|
"\(sourceManager.getPriority(for: source, dataType: dataType))",
|
||||||
|
value: Binding(
|
||||||
|
get: { sourceManager.getPriority(for: source, dataType: dataType) },
|
||||||
|
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
|
||||||
|
),
|
||||||
|
in: 0...100,
|
||||||
|
step: 10
|
||||||
|
)
|
||||||
|
.frame(width: 150)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func loadHealthReport() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
healthReport = await sourceManager.getSourceHealth(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func qualityColor(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedDate(_ date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capability Badge
|
||||||
|
|
||||||
|
struct CapabilityBadge: View {
|
||||||
|
let name: String
|
||||||
|
let available: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
|
||||||
|
.foregroundStyle(available ? .green : .secondary)
|
||||||
|
Text(name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(available ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SourcesView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Integration Guide für Aurora Livecam Erweiterungen
|
||||||
|
|
||||||
|
## Übersicht der neuen Dateien
|
||||||
|
|
||||||
|
```
|
||||||
|
aurora-livecam/
|
||||||
|
├── SettingsManager.php # Admin-Einstellungen Klasse
|
||||||
|
├── settings.json # Einstellungen Datei
|
||||||
|
├── js/
|
||||||
|
│ ├── timelapse-controls.js # Timelapse mit Slider
|
||||||
|
│ ├── video-player.js # Tagesvideos im Player
|
||||||
|
│ └── admin-settings.js # Admin AJAX
|
||||||
|
├── css/
|
||||||
|
│ └── player-controls.css # Styles für Controls
|
||||||
|
└── INTEGRATION.md # Diese Anleitung
|
||||||
|
```
|
||||||
|
|
||||||
|
## Änderungen in index.php
|
||||||
|
|
||||||
|
### 1. Am Anfang der Datei (nach den requires)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// ... bestehende requires ...
|
||||||
|
|
||||||
|
// NEU: Settings Manager einbinden
|
||||||
|
require_once 'SettingsManager.php';
|
||||||
|
$settingsManager = new SettingsManager();
|
||||||
|
|
||||||
|
// AJAX-Handler für Settings (VOR session_start!)
|
||||||
|
$settingsManager->handleAjax();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Im HEAD-Bereich (CSS einbinden)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="css/player-controls.css">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vor </body> (JavaScript einbinden)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="js/timelapse-controls.js"></script>
|
||||||
|
<script src="js/video-player.js"></script>
|
||||||
|
<?php if ($adminManager->isAdmin()): ?>
|
||||||
|
<script src="js/admin-settings.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Video-Container anpassen
|
||||||
|
|
||||||
|
Ersetze den bestehenden video-container:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="video-container">
|
||||||
|
<?php echo $webcamManager->displayWebcam(); ?>
|
||||||
|
|
||||||
|
<!-- Timelapse Overlay -->
|
||||||
|
<div id="timelapse-viewer" style="display: none;">
|
||||||
|
<img id="timelapse-image" src="" alt="Timelapse">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEU: Daily Video Player (wird dynamisch befüllt) -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEU: Timelapse Controls (außerhalb des Containers) -->
|
||||||
|
<div id="timelapse-controls"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Zuschauer-Anzeige konditionell machen
|
||||||
|
|
||||||
|
Ersetze die Viewer-Stat Anzeige:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$viewerCount = $viewerCounter->getInitialCount();
|
||||||
|
$showViewers = $settingsManager->shouldShowViewers($viewerCount);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($showViewers): ?>
|
||||||
|
<div class="info-badge viewer-stat">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
<strong id="viewer-count-display"><?php echo $viewerCount; ?></strong>
|
||||||
|
<span>Zuschauer</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Kalender Links anpassen
|
||||||
|
|
||||||
|
In der `VisualCalendarManager::displayVisualCalendar()` Methode:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Für Tagesvideos
|
||||||
|
$playInPlayer = $settingsManager->shouldPlayInPlayer();
|
||||||
|
$allowDownload = $settingsManager->shouldAllowDownload();
|
||||||
|
|
||||||
|
if ($playInPlayer) {
|
||||||
|
// Im Player abspielen
|
||||||
|
$output .= '<a href="#" onclick="DailyVideoPlayer.playVideo(\'' . $video['path'] . '\', ' . ($allowDownload ? 'true' : 'false') . '); return false;" class="play-link">';
|
||||||
|
$output .= '▶️ Abspielen';
|
||||||
|
$output .= '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allowDownload) {
|
||||||
|
// Download Link
|
||||||
|
$output .= '<a href="?download_specific_video=..." class="download-link">⬇️ Download</a>';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Admin-Panel erweitern
|
||||||
|
|
||||||
|
Füge im Admin-Bereich hinzu:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php if ($adminManager->isAdmin()): ?>
|
||||||
|
<section id="admin" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Admin-Bereich</h2>
|
||||||
|
|
||||||
|
<!-- NEU: Settings Panel -->
|
||||||
|
<div id="admin-settings-panel">
|
||||||
|
<h3>⚙️ Anzeige-Einstellungen</h3>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<h4>👥 Zuschauer-Anzeige</h4>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Zuschauer-Anzahl anzeigen</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-viewer-enabled"
|
||||||
|
<?php echo $settingsManager->get('viewer_display.enabled') ? 'checked' : ''; ?>>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Mindestanzahl für Anzeige</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<input type="number" id="setting-min-viewers" class="number-input"
|
||||||
|
min="1" max="100"
|
||||||
|
value="<?php echo $settingsManager->get('viewer_display.min_viewers'); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<h4>🎬 Video-Modus</h4>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Videos im Player abspielen</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-play-in-player"
|
||||||
|
<?php echo $settingsManager->get('video_mode.play_in_player') ? 'checked' : ''; ?>>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Download erlauben</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-allow-download"
|
||||||
|
<?php echo $settingsManager->get('video_mode.allow_download') ? 'checked' : ''; ?>>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bestehender Admin-Content -->
|
||||||
|
<?php echo $adminManager->displayAdminContent(); ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Timelapse Button Event anpassen
|
||||||
|
|
||||||
|
Im bestehenden JavaScript:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
timelapseButton.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (timelapseViewer.style.display === 'none') {
|
||||||
|
// NEU: TimelapseController verwenden
|
||||||
|
TimelapseController.init(imageFiles);
|
||||||
|
TimelapseController.show();
|
||||||
|
timelapseButton.textContent = 'Zurück zur Live-Webcam';
|
||||||
|
} else {
|
||||||
|
TimelapseController.backToLive();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Viewer Heartbeat anpassen
|
||||||
|
|
||||||
|
Im JavaScript für den Viewer-Counter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function updateViewerCount() {
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({action: 'viewer_heartbeat'})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const display = document.getElementById('viewer-count-display');
|
||||||
|
const container = document.querySelector('.viewer-stat');
|
||||||
|
|
||||||
|
if (data.count && display) {
|
||||||
|
display.textContent = data.count;
|
||||||
|
|
||||||
|
// Mindestanzahl prüfen (aus Settings)
|
||||||
|
const minViewers = window.minViewersToShow || 1;
|
||||||
|
if (container) {
|
||||||
|
container.style.display = data.count >= minViewers ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fertig!
|
||||||
|
|
||||||
|
Nach diesen Änderungen hast du:
|
||||||
|
- ✅ Timelapse mit Slider und 1x/10x/100x Geschwindigkeit
|
||||||
|
- ✅ Rückwärts-Spulen im Timelapse
|
||||||
|
- ✅ Tagesvideos im Player abspielen statt nur Download
|
||||||
|
- ✅ "Zurück zu Live" Button
|
||||||
|
- ✅ Admin-Einstellungen für Zuschauer-Anzeige
|
||||||
|
- ✅ Mindestanzahl für Zuschauer-Anzeige
|
||||||
|
- ✅ Video-Modus wählbar (Player/Download)
|
||||||
|
- ✅ Alles ohne Seiten-Reload
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SettingsManager - Verwaltet Admin-Einstellungen
|
||||||
|
* Speichert in settings.json, lädt ohne Reload
|
||||||
|
*/
|
||||||
|
class SettingsManager {
|
||||||
|
private $settingsFile;
|
||||||
|
private $settings = [];
|
||||||
|
|
||||||
|
public function __construct($file = null) {
|
||||||
|
$this->settingsFile = $file ?: (__DIR__ . '/settings.json');
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load() {
|
||||||
|
if (file_exists($this->settingsFile)) {
|
||||||
|
$content = file_get_contents($this->settingsFile);
|
||||||
|
$this->settings = json_decode($content, true) ?? $this->getDefaults();
|
||||||
|
} else {
|
||||||
|
$this->settings = $this->getDefaults();
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDefaults() {
|
||||||
|
return [
|
||||||
|
'viewer_display' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'min_viewers' => 1
|
||||||
|
],
|
||||||
|
'video_mode' => [
|
||||||
|
'play_in_player' => true,
|
||||||
|
'allow_download' => true
|
||||||
|
],
|
||||||
|
'timelapse' => [
|
||||||
|
'default_speed' => 1,
|
||||||
|
'available_speeds' => [1, 10, 100]
|
||||||
|
],
|
||||||
|
'last_updated' => null,
|
||||||
|
'updated_by' => null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get($key = null) {
|
||||||
|
if ($key === null) return $this->settings;
|
||||||
|
$keys = explode('.', $key);
|
||||||
|
$value = $this->settings;
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
if (!isset($value[$k])) return null;
|
||||||
|
$value = $value[$k];
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set($key, $value) {
|
||||||
|
$keys = explode('.', $key);
|
||||||
|
$ref = &$this->settings;
|
||||||
|
foreach ($keys as $i => $k) {
|
||||||
|
if ($i === count($keys) - 1) {
|
||||||
|
$ref[$k] = $value;
|
||||||
|
} else {
|
||||||
|
if (!isset($ref[$k])) $ref[$k] = [];
|
||||||
|
$ref = &$ref[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->settings['last_updated'] = date('Y-m-d H:i:s');
|
||||||
|
return $this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function save() {
|
||||||
|
$payload = json_encode($this->settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($payload === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_put_contents($this->settingsFile, $payload, LOCK_EX) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für AJAX-Anfragen
|
||||||
|
public function handleAjax() {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
|
if (!isset($_POST['settings_action'])) return;
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
switch ($_POST['settings_action']) {
|
||||||
|
case 'get':
|
||||||
|
echo json_encode(['success' => true, 'settings' => $this->settings]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'update':
|
||||||
|
$key = $_POST['key'] ?? null;
|
||||||
|
$value = $_POST['value'] ?? null;
|
||||||
|
|
||||||
|
// Boolean-Werte konvertieren
|
||||||
|
if ($value === 'true') $value = true;
|
||||||
|
if ($value === 'false') $value = false;
|
||||||
|
if (is_numeric($value)) $value = intval($value);
|
||||||
|
|
||||||
|
if ($key && $this->set($key, $value)) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Einstellung gespeichert']);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Fehler beim Speichern. Bitte Dateirechte prüfen.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewer-Anzeige prüfen
|
||||||
|
public function shouldShowViewers($currentCount) {
|
||||||
|
if (!$this->get('viewer_display.enabled')) return false;
|
||||||
|
return $currentCount >= $this->get('viewer_display.min_viewers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video-Modus prüfen
|
||||||
|
public function shouldPlayInPlayer() {
|
||||||
|
return $this->get('video_mode.play_in_player') === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldAllowDownload() {
|
||||||
|
return $this->get('video_mode.allow_download') === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/* ========== TIMELAPSE CONTROLS ========== */
|
||||||
|
#timelapse-controls {
|
||||||
|
display: none;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelapse-control-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 50px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn.active {
|
||||||
|
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-slider-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-time-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-speed-btn {
|
||||||
|
width: auto !important;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
border-radius: 22px !important;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-back-btn {
|
||||||
|
width: auto !important;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
border-radius: 22px !important;
|
||||||
|
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%) !important;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DAILY VIDEO PLAYER ========== */
|
||||||
|
#daily-video-player {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
#daily-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== ADMIN SETTINGS PANEL ========== */
|
||||||
|
#admin-settings-panel {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#admin-settings-panel h3 {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group h4 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 50px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number Input */
|
||||||
|
.number-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== MOBILE RESPONSIVE ========== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.timelapse-control-bar {
|
||||||
|
padding: 10px 15px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-slider-container {
|
||||||
|
width: 100%;
|
||||||
|
order: 10;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-time-display {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Admin Settings Manager - AJAX ohne Reload
|
||||||
|
*/
|
||||||
|
const AdminSettings = {
|
||||||
|
settings: {},
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this.loadSettings();
|
||||||
|
this.setupEventListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSettings: function() {
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'settings_action=get'
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Settings load error:', err));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSetting: function(key, value) {
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `settings_action=update&key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('✓ Einstellung gespeichert', 'success');
|
||||||
|
// Sofort UI aktualisieren
|
||||||
|
this.applySettingImmediately(key, value);
|
||||||
|
} else {
|
||||||
|
this.showNotification('✗ Fehler beim Speichern', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Settings update error:', err);
|
||||||
|
this.showNotification('✗ Netzwerkfehler', 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
applySettingImmediately: function(key, value) {
|
||||||
|
// Sofortige Anwendung ohne Reload
|
||||||
|
switch(key) {
|
||||||
|
case 'viewer_display.enabled':
|
||||||
|
const viewerEl = document.querySelector('.viewer-stat');
|
||||||
|
if (viewerEl) {
|
||||||
|
viewerEl.style.display = value === true || value === 'true' ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'viewer_display.min_viewers':
|
||||||
|
// Wird beim nächsten Heartbeat angewendet
|
||||||
|
window.minViewersToShow = parseInt(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUI: function() {
|
||||||
|
// Checkbox für Zuschauer-Anzeige
|
||||||
|
const viewerEnabled = document.getElementById('setting-viewer-enabled');
|
||||||
|
if (viewerEnabled) {
|
||||||
|
viewerEnabled.checked = this.settings.viewer_display?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestanzahl
|
||||||
|
const minViewers = document.getElementById('setting-min-viewers');
|
||||||
|
if (minViewers) {
|
||||||
|
minViewers.value = this.settings.viewer_display?.min_viewers ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video-Modus
|
||||||
|
const playInPlayer = document.getElementById('setting-play-in-player');
|
||||||
|
if (playInPlayer) {
|
||||||
|
playInPlayer.checked = this.settings.video_mode?.play_in_player ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowDownload = document.getElementById('setting-allow-download');
|
||||||
|
if (allowDownload) {
|
||||||
|
allowDownload.checked = this.settings.video_mode?.allow_download ?? true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setupEventListeners: function() {
|
||||||
|
// Zuschauer-Anzeige Toggle
|
||||||
|
document.getElementById('setting-viewer-enabled')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('viewer_display.enabled', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mindestanzahl Zuschauer
|
||||||
|
document.getElementById('setting-min-viewers')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('viewer_display.min_viewers', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Video im Player abspielen
|
||||||
|
document.getElementById('setting-play-in-player')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('video_mode.play_in_player', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download erlauben
|
||||||
|
document.getElementById('setting-allow-download')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('video_mode.allow_download', e.target.checked);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showNotification: function(message, type) {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `admin-notification ${type}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${type === 'success' ? '#4CAF50' : '#f44336'};
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
setTimeout(() => notification.remove(), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialisierung nur im Admin-Bereich
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (document.getElementById('admin-settings-panel')) {
|
||||||
|
AdminSettings.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Timelapse Controller mit Slider, Geschwindigkeit und Rückwärts
|
||||||
|
*/
|
||||||
|
const TimelapseController = {
|
||||||
|
imageFiles: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
isReverse: false,
|
||||||
|
speed: 1,
|
||||||
|
availableSpeeds: [1, 10, 100],
|
||||||
|
intervalId: null,
|
||||||
|
baseInterval: 200, // ms bei 1x
|
||||||
|
|
||||||
|
init: function(imageFilesArray) {
|
||||||
|
this.imageFiles = imageFilesArray;
|
||||||
|
this.setupControls();
|
||||||
|
this.updateSlider();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupControls: function() {
|
||||||
|
const container = document.getElementById('timelapse-controls');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="timelapse-control-bar">
|
||||||
|
<button id="tl-play-pause" class="tl-btn" title="Play/Pause">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
<button id="tl-reverse" class="tl-btn" title="Rückwärts">
|
||||||
|
<i class="fas fa-backward"></i>
|
||||||
|
</button>
|
||||||
|
<div class="tl-slider-container">
|
||||||
|
<input type="range" id="tl-slider" min="0" max="100" value="0">
|
||||||
|
<span id="tl-time-display">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="tl-speed-container">
|
||||||
|
<button id="tl-speed" class="tl-btn tl-speed-btn">1x</button>
|
||||||
|
</div>
|
||||||
|
<button id="tl-back-live" class="tl-btn tl-back-btn" title="Zurück zu Live">
|
||||||
|
<i class="fas fa-video"></i> Live
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
document.getElementById('tl-play-pause').onclick = () => this.togglePlay();
|
||||||
|
document.getElementById('tl-reverse').onclick = () => this.toggleReverse();
|
||||||
|
document.getElementById('tl-speed').onclick = () => this.cycleSpeed();
|
||||||
|
document.getElementById('tl-back-live').onclick = () => this.backToLive();
|
||||||
|
|
||||||
|
const slider = document.getElementById('tl-slider');
|
||||||
|
slider.max = this.imageFiles.length - 1;
|
||||||
|
slider.oninput = (e) => this.seekTo(parseInt(e.target.value));
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePlay: function() {
|
||||||
|
this.isPlaying = !this.isPlaying;
|
||||||
|
const btn = document.getElementById('tl-play-pause');
|
||||||
|
btn.innerHTML = this.isPlaying ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.startPlayback();
|
||||||
|
} else {
|
||||||
|
this.stopPlayback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleReverse: function() {
|
||||||
|
this.isReverse = !this.isReverse;
|
||||||
|
const btn = document.getElementById('tl-reverse');
|
||||||
|
btn.classList.toggle('active', this.isReverse);
|
||||||
|
btn.innerHTML = this.isReverse ?
|
||||||
|
'<i class="fas fa-forward"></i>' :
|
||||||
|
'<i class="fas fa-backward"></i>';
|
||||||
|
},
|
||||||
|
|
||||||
|
cycleSpeed: function() {
|
||||||
|
const idx = this.availableSpeeds.indexOf(this.speed);
|
||||||
|
this.speed = this.availableSpeeds[(idx + 1) % this.availableSpeeds.length];
|
||||||
|
document.getElementById('tl-speed').textContent = this.speed + 'x';
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.stopPlayback();
|
||||||
|
this.startPlayback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPlayback: function() {
|
||||||
|
const interval = this.baseInterval / this.speed;
|
||||||
|
this.intervalId = setInterval(() => this.nextFrame(), interval);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPlayback: function() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
nextFrame: function() {
|
||||||
|
if (this.isReverse) {
|
||||||
|
this.currentIndex--;
|
||||||
|
if (this.currentIndex < 0) this.currentIndex = this.imageFiles.length - 1;
|
||||||
|
} else {
|
||||||
|
this.currentIndex++;
|
||||||
|
if (this.currentIndex >= this.imageFiles.length) this.currentIndex = 0;
|
||||||
|
}
|
||||||
|
this.showFrame(this.currentIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
seekTo: function(index) {
|
||||||
|
this.currentIndex = index;
|
||||||
|
this.showFrame(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
showFrame: function(index) {
|
||||||
|
const img = document.getElementById('timelapse-image');
|
||||||
|
if (img && this.imageFiles[index]) {
|
||||||
|
img.src = this.imageFiles[index];
|
||||||
|
}
|
||||||
|
this.updateSlider();
|
||||||
|
this.updateTimeDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSlider: function() {
|
||||||
|
const slider = document.getElementById('tl-slider');
|
||||||
|
if (slider) slider.value = this.currentIndex;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimeDisplay: function() {
|
||||||
|
const display = document.getElementById('tl-time-display');
|
||||||
|
if (!display || !this.imageFiles[this.currentIndex]) return;
|
||||||
|
|
||||||
|
const filename = this.imageFiles[this.currentIndex];
|
||||||
|
const match = filename.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const [_, y, m, d, h, min, s] = match;
|
||||||
|
display.textContent = `${d}.${m}.${y} ${h}:${min}:${s}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
backToLive: function() {
|
||||||
|
this.stopPlayback();
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
// Live-Video wieder anzeigen
|
||||||
|
document.getElementById('timelapse-viewer').style.display = 'none';
|
||||||
|
document.getElementById('webcam-player').style.display = 'block';
|
||||||
|
document.getElementById('timelapse-button').textContent = 'Wochenzeitraffer';
|
||||||
|
|
||||||
|
// Controls verstecken
|
||||||
|
const controls = document.getElementById('timelapse-controls');
|
||||||
|
if (controls) controls.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
show: function() {
|
||||||
|
document.getElementById('timelapse-viewer').style.display = 'block';
|
||||||
|
document.getElementById('webcam-player').style.display = 'none';
|
||||||
|
document.getElementById('daily-video-player').style.display = 'none';
|
||||||
|
|
||||||
|
const controls = document.getElementById('timelapse-controls');
|
||||||
|
if (controls) controls.style.display = 'block';
|
||||||
|
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.showFrame(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Daily Video Player - Spielt Tagesvideos im Hauptfenster ab
|
||||||
|
*/
|
||||||
|
const DailyVideoPlayer = {
|
||||||
|
currentVideo: null,
|
||||||
|
videoElement: null,
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this.createPlayerElement();
|
||||||
|
this.setupEventListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlayerElement: function() {
|
||||||
|
// Player-Container erstellen falls nicht vorhanden
|
||||||
|
if (document.getElementById('daily-video-player')) return;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'daily-video-player';
|
||||||
|
container.style.display = 'none';
|
||||||
|
container.innerHTML = `
|
||||||
|
<video id="daily-video" controls playsinline>
|
||||||
|
<source src="" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
<div class="video-player-controls">
|
||||||
|
<button id="dvp-back-live" class="tl-btn tl-back-btn">
|
||||||
|
<i class="fas fa-video"></i> Zurück zu Live
|
||||||
|
</button>
|
||||||
|
<a id="dvp-download" class="button" style="display:none;">
|
||||||
|
<i class="fas fa-download"></i> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Nach dem Webcam-Player einfügen
|
||||||
|
const videoContainer = document.querySelector('.video-container');
|
||||||
|
if (videoContainer) {
|
||||||
|
videoContainer.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.videoElement = document.getElementById('daily-video');
|
||||||
|
},
|
||||||
|
|
||||||
|
setupEventListeners: function() {
|
||||||
|
document.getElementById('dvp-back-live')?.addEventListener('click', () => this.backToLive());
|
||||||
|
|
||||||
|
// Video-Ende Event
|
||||||
|
this.videoElement?.addEventListener('ended', () => {
|
||||||
|
// Optional: Automatisch zurück zu Live
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
playVideo: function(videoPath, allowDownload = true) {
|
||||||
|
this.currentVideo = videoPath;
|
||||||
|
|
||||||
|
// Andere Player verstecken
|
||||||
|
document.getElementById('webcam-player').style.display = 'none';
|
||||||
|
document.getElementById('timelapse-viewer').style.display = 'none';
|
||||||
|
document.getElementById('timelapse-controls')?.style.display = 'none';
|
||||||
|
|
||||||
|
// Diesen Player anzeigen
|
||||||
|
const player = document.getElementById('daily-video-player');
|
||||||
|
player.style.display = 'block';
|
||||||
|
|
||||||
|
// Video laden
|
||||||
|
this.videoElement.src = videoPath;
|
||||||
|
this.videoElement.load();
|
||||||
|
this.videoElement.play();
|
||||||
|
|
||||||
|
// Download-Button
|
||||||
|
const downloadBtn = document.getElementById('dvp-download');
|
||||||
|
if (allowDownload && downloadBtn) {
|
||||||
|
downloadBtn.style.display = 'inline-block';
|
||||||
|
downloadBtn.href = videoPath;
|
||||||
|
downloadBtn.download = videoPath.split('/').pop();
|
||||||
|
} else if (downloadBtn) {
|
||||||
|
downloadBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
backToLive: function() {
|
||||||
|
// Video stoppen
|
||||||
|
if (this.videoElement) {
|
||||||
|
this.videoElement.pause();
|
||||||
|
this.videoElement.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player verstecken
|
||||||
|
document.getElementById('daily-video-player').style.display = 'none';
|
||||||
|
|
||||||
|
// Live-Stream anzeigen
|
||||||
|
document.getElementById('webcam-player').style.display = 'block';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wird vom Kalender aufgerufen
|
||||||
|
handleCalendarClick: function(videoPath, playInPlayer, allowDownload) {
|
||||||
|
if (playInPlayer) {
|
||||||
|
this.playVideo(videoPath, allowDownload);
|
||||||
|
} else {
|
||||||
|
// Nur Download
|
||||||
|
window.location.href = videoPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialisierung
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
DailyVideoPlayer.init();
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"viewer_display": {
|
||||||
|
"enabled": true,
|
||||||
|
"min_viewers": 1
|
||||||
|
},
|
||||||
|
"video_mode": {
|
||||||
|
"play_in_player": true,
|
||||||
|
"allow_download": true
|
||||||
|
},
|
||||||
|
"timelapse": {
|
||||||
|
"default_speed": 1,
|
||||||
|
"available_speeds": [1, 10, 100]
|
||||||
|
},
|
||||||
|
"last_updated": null,
|
||||||
|
"updated_by": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.dctp_backups/
|
||||||
|
.dctp_settings.json
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# DCTP - Delta Code Transfer Protocol
|
||||||
|
|
||||||
|
Du generierst Code im DCTP-Format fuer effiziente Uebertragung.
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
1. **Zeilennummern am Ende jeder Zeile** im passenden Kommentar-Format
|
||||||
|
2. **Immer mit ###FILE: beginnen** bei jedem Codeblock
|
||||||
|
3. **Bei Korrekturen NUR die geaenderten Zeilen senden**, nie den ganzen File
|
||||||
|
|
||||||
|
## Zeilennummern-Format
|
||||||
|
|
||||||
|
- Python/Shell: `code #Z1`
|
||||||
|
- JavaScript/Java/C/C++: `code //Z1`
|
||||||
|
- HTML: `code <!--Z1-->`
|
||||||
|
- CSS: `code /*Z1*/`
|
||||||
|
- SQL: `code --Z1`
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Syntax | Beschreibung |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| `###FILE:` | `###FILE:pfad/datei.ext` | Datei angeben |
|
||||||
|
| `###NEW` | | Neue Datei, kompletter Inhalt folgt |
|
||||||
|
| `###DELETE:` | `###DELETE:Z5-Z12` | Zeilen 5-12 loeschen |
|
||||||
|
| `###INSERT_AFTER:` | `###INSERT_AFTER:Z5` | Nach Zeile 5 einfuegen |
|
||||||
|
| `###REPLACE:` | `###REPLACE:Z5-Z8` | Zeilen 5-8 ersetzen |
|
||||||
|
| `###END` | | Ende des Blocks |
|
||||||
|
| `###RENUMBER` | | Zeilennummern neu berechnen |
|
||||||
|
| `###CHECKSUM:` | `###CHECKSUM:a3f2b8c1` | Optional: Hash zur Validierung |
|
||||||
|
|
||||||
|
## Beispiel: Neue Datei
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Korrektur (REPLACE)
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z4-Z5
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
"""Multipliziert zwei Zahlen.""" #Z5
|
||||||
|
return a * b #Z6
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Zeilen einfuegen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Zeilen loeschen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Mehrere Dateien
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/models/user.py
|
||||||
|
###NEW
|
||||||
|
class User: #Z1
|
||||||
|
def __init__(self, name: str): #Z2
|
||||||
|
self.name = name #Z3
|
||||||
|
###END
|
||||||
|
|
||||||
|
###FILE:src/models/order.py
|
||||||
|
###NEW
|
||||||
|
from .user import User #Z1
|
||||||
|
#Z2
|
||||||
|
class Order: #Z3
|
||||||
|
def __init__(self, user: User): #Z4
|
||||||
|
self.user = user #Z5
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtig
|
||||||
|
|
||||||
|
- Bei Korrekturen: NUR Delta senden, nie kompletten File
|
||||||
|
- Nach INSERT/DELETE/REPLACE immer ###RENUMBER
|
||||||
|
- Leerzeilen auch nummerieren
|
||||||
|
- Zeilennummern werden beim Schreiben automatisch entfernt
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# DCTP - Delta Code Transfer Protocol
|
||||||
|
|
||||||
|
Ein Tool um KI-generierten Code effizient in lokale Dateien zu uebertragen. Statt bei jeder Korrektur den kompletten Code neu zu senden, werden nur Aenderungen (Deltas) uebertragen.
|
||||||
|
|
||||||
|
## Das Problem
|
||||||
|
|
||||||
|
Claude generiert 500 Zeilen Code. Eine kleine Korrektur = nochmal 500 Zeilen. Verschwendung.
|
||||||
|
|
||||||
|
## Die Loesung
|
||||||
|
|
||||||
|
Zeilennummerierter Code + Steueranweisungen fuer gezielte Aenderungen.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requirements installieren
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# GUI starten
|
||||||
|
python dctp_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### 1. Projektverzeichnis waehlen
|
||||||
|
|
||||||
|
Klicke auf "Waehlen" und waehle dein Projektverzeichnis.
|
||||||
|
|
||||||
|
### 2. KI-Output einfuegen
|
||||||
|
|
||||||
|
Kopiere den DCTP-formatierten Output aus deinem Claude-Chat in das Input-Feld.
|
||||||
|
|
||||||
|
### 3. Analysieren
|
||||||
|
|
||||||
|
Klicke "Analysieren" um eine Vorschau der Operationen zu sehen.
|
||||||
|
|
||||||
|
### 4. Ausfuehren
|
||||||
|
|
||||||
|
Klicke "Ausfuehren" um die Aenderungen auf deine Dateien anzuwenden.
|
||||||
|
|
||||||
|
## DCTP-Format
|
||||||
|
|
||||||
|
### Neue Datei erstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen ersetzen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z1-Z2
|
||||||
|
def add(a: int, b: int) -> int: #Z1
|
||||||
|
"""Addiert zwei Zahlen.""" #Z2
|
||||||
|
return a + b #Z3
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen einfuegen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen loeschen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zeilennummern-Format
|
||||||
|
|
||||||
|
Die Zeilennummern werden automatisch entsprechend der Programmiersprache formatiert:
|
||||||
|
|
||||||
|
| Sprache | Format | Beispiel |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Python | `#Z1` | `code #Z1` |
|
||||||
|
| JavaScript | `//Z1` | `code //Z1` |
|
||||||
|
| HTML | `<!--Z1-->` | `code <!--Z1-->` |
|
||||||
|
| CSS | `/*Z1*/` | `code /*Z1*/` |
|
||||||
|
| SQL | `--Z1` | `code --Z1` |
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `###FILE:pfad` | Zieldatei angeben |
|
||||||
|
| `###NEW` | Neue Datei erstellen |
|
||||||
|
| `###DELETE:Z5-Z12` | Zeilen loeschen |
|
||||||
|
| `###INSERT_AFTER:Z5` | Nach Zeile einfuegen |
|
||||||
|
| `###REPLACE:Z5-Z8` | Zeilen ersetzen |
|
||||||
|
| `###END` | Block beenden |
|
||||||
|
| `###RENUMBER` | Zeilennummern aktualisieren |
|
||||||
|
| `###CHECKSUM:hash` | Datei-Hash validieren |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Vorschau**: Zeigt was passieren wird, bevor es ausgefuehrt wird
|
||||||
|
- **Diff-Ansicht**: Zeigt Aenderungen farbig markiert (alt vs neu)
|
||||||
|
- **Undo**: Stellt den letzten Zustand wieder her
|
||||||
|
- **Backup**: Automatische Backups vor jeder Aenderung
|
||||||
|
- **Multi-File**: Mehrere Dateien in einem Durchgang bearbeiten
|
||||||
|
- **Checksum**: Optionale Validierung gegen externe Aenderungen
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
- **Projektpfad**: Standard-Projektverzeichnis
|
||||||
|
- **Backup-Verzeichnis**: Wo Backups gespeichert werden
|
||||||
|
- **Auto-Renumber**: Zeilennummern automatisch aktualisieren
|
||||||
|
- **Checksum-Validierung**: Externe Aenderungen erkennen
|
||||||
|
- **Theme**: Hell oder dunkel
|
||||||
|
|
||||||
|
## Claude-Integration
|
||||||
|
|
||||||
|
Kopiere den Inhalt von `CLAUDE.md` in deine Claude-Chats (als Custom Instructions oder am Anfang des Gespraechs), damit Claude im DCTP-Format antwortet.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
dctp/
|
||||||
|
├── dctp_gui.py # Hauptfenster (CustomTkinter)
|
||||||
|
├── dctp_parser.py # Core-Logik: parse Steueranweisungen
|
||||||
|
├── dctp_executor.py # Fuehrt Operationen aus
|
||||||
|
├── dctp_backup.py # Undo/Backup-Verwaltung
|
||||||
|
├── dctp_diff.py # Diff-Berechnung fuer Vorschau
|
||||||
|
├── requirements.txt # Dependencies
|
||||||
|
├── CLAUDE.md # Anweisung fuer KI
|
||||||
|
└── README.md # Diese Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
DCTP Backup Manager - Handles backup and undo functionality.
|
||||||
|
|
||||||
|
Creates timestamped backups before operations and supports
|
||||||
|
restoring files to their previous state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileBackup:
|
||||||
|
"""Represents a single file backup."""
|
||||||
|
original: str
|
||||||
|
backup: str
|
||||||
|
existed: bool # Whether the file existed before (for new file handling)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupSession:
|
||||||
|
"""Represents a backup session (one execution run)."""
|
||||||
|
timestamp: str
|
||||||
|
files: list[FileBackup]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupInfo:
|
||||||
|
"""Info about a backup for display purposes."""
|
||||||
|
timestamp: str
|
||||||
|
file_count: int
|
||||||
|
files: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManager:
|
||||||
|
"""Manages file backups for undo functionality."""
|
||||||
|
|
||||||
|
BACKUP_DIR_NAME = ".dctp_backups"
|
||||||
|
MANIFEST_FILE = "manifest.json"
|
||||||
|
MAX_SESSIONS = 50 # Keep last 50 sessions
|
||||||
|
|
||||||
|
def __init__(self, project_path: str):
|
||||||
|
"""
|
||||||
|
Initialize backup manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Base project directory
|
||||||
|
"""
|
||||||
|
self.project_path = Path(project_path)
|
||||||
|
self.backup_dir = self.project_path / self.BACKUP_DIR_NAME
|
||||||
|
self.manifest_path = self.backup_dir / self.MANIFEST_FILE
|
||||||
|
self._current_session: Optional[BackupSession] = None
|
||||||
|
self._ensure_backup_dir()
|
||||||
|
|
||||||
|
def _ensure_backup_dir(self) -> None:
|
||||||
|
"""Create backup directory if it doesn't exist."""
|
||||||
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create .gitignore in backup dir
|
||||||
|
gitignore_path = self.backup_dir / ".gitignore"
|
||||||
|
if not gitignore_path.exists():
|
||||||
|
gitignore_path.write_text("*\n")
|
||||||
|
|
||||||
|
def _load_manifest(self) -> dict:
|
||||||
|
"""Load the manifest file."""
|
||||||
|
if self.manifest_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(self.manifest_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {"sessions": []}
|
||||||
|
return {"sessions": []}
|
||||||
|
|
||||||
|
def _save_manifest(self, manifest: dict) -> None:
|
||||||
|
"""Save the manifest file."""
|
||||||
|
self.manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
def start_session(self) -> None:
|
||||||
|
"""Start a new backup session."""
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
self._current_session = BackupSession(timestamp=timestamp, files=[])
|
||||||
|
|
||||||
|
def backup(self, file_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Create a backup of a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file (relative to project or absolute)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Backup filename if successful, None if file doesn't exist
|
||||||
|
"""
|
||||||
|
if self._current_session is None:
|
||||||
|
self.start_session()
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
if os.path.isabs(file_path):
|
||||||
|
full_path = Path(file_path)
|
||||||
|
rel_path = full_path.relative_to(self.project_path)
|
||||||
|
else:
|
||||||
|
rel_path = Path(file_path)
|
||||||
|
full_path = self.project_path / rel_path
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
existed = full_path.exists()
|
||||||
|
|
||||||
|
if existed:
|
||||||
|
# Generate backup filename
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||||
|
safe_name = str(rel_path).replace(os.sep, "_").replace("/", "_")
|
||||||
|
backup_name = f"{timestamp}_{safe_name}"
|
||||||
|
|
||||||
|
# Copy file to backup
|
||||||
|
backup_path = self.backup_dir / backup_name
|
||||||
|
shutil.copy2(full_path, backup_path)
|
||||||
|
else:
|
||||||
|
backup_name = ""
|
||||||
|
|
||||||
|
# Add to current session
|
||||||
|
self._current_session.files.append(FileBackup(
|
||||||
|
original=str(rel_path),
|
||||||
|
backup=backup_name,
|
||||||
|
existed=existed
|
||||||
|
))
|
||||||
|
|
||||||
|
return backup_name if existed else None
|
||||||
|
|
||||||
|
def end_session(self) -> None:
|
||||||
|
"""End the current backup session and save to manifest."""
|
||||||
|
if self._current_session is None or len(self._current_session.files) == 0:
|
||||||
|
self._current_session = None
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
# Convert to dict for JSON storage
|
||||||
|
session_dict = {
|
||||||
|
"timestamp": self._current_session.timestamp,
|
||||||
|
"files": [asdict(f) for f in self._current_session.files]
|
||||||
|
}
|
||||||
|
manifest["sessions"].append(session_dict)
|
||||||
|
|
||||||
|
# Limit number of sessions
|
||||||
|
if len(manifest["sessions"]) > self.MAX_SESSIONS:
|
||||||
|
# Remove old sessions and their backup files
|
||||||
|
old_sessions = manifest["sessions"][:-self.MAX_SESSIONS]
|
||||||
|
for session in old_sessions:
|
||||||
|
for file_info in session["files"]:
|
||||||
|
backup_file = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_file.exists():
|
||||||
|
backup_file.unlink()
|
||||||
|
manifest["sessions"] = manifest["sessions"][-self.MAX_SESSIONS:]
|
||||||
|
|
||||||
|
self._save_manifest(manifest)
|
||||||
|
self._current_session = None
|
||||||
|
|
||||||
|
def restore_last(self) -> tuple[bool, list[str]]:
|
||||||
|
"""
|
||||||
|
Restore files from the last backup session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, list of restored files)
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
if not manifest["sessions"]:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
# Get last session
|
||||||
|
last_session = manifest["sessions"].pop()
|
||||||
|
restored_files = []
|
||||||
|
|
||||||
|
for file_info in last_session["files"]:
|
||||||
|
original_path = self.project_path / file_info["original"]
|
||||||
|
|
||||||
|
if file_info["existed"]:
|
||||||
|
# Restore from backup
|
||||||
|
backup_path = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_path.exists():
|
||||||
|
# Ensure parent directory exists
|
||||||
|
original_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(backup_path, original_path)
|
||||||
|
backup_path.unlink() # Remove backup file
|
||||||
|
restored_files.append(file_info["original"])
|
||||||
|
else:
|
||||||
|
# File was newly created, delete it
|
||||||
|
if original_path.exists():
|
||||||
|
original_path.unlink()
|
||||||
|
restored_files.append(f"{file_info['original']} (deleted)")
|
||||||
|
|
||||||
|
self._save_manifest(manifest)
|
||||||
|
return True, restored_files
|
||||||
|
|
||||||
|
def list_backups(self) -> list[BackupInfo]:
|
||||||
|
"""
|
||||||
|
List all backup sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of BackupInfo objects, newest first
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
for session in reversed(manifest["sessions"]):
|
||||||
|
files = [f["original"] for f in session["files"]]
|
||||||
|
backups.append(BackupInfo(
|
||||||
|
timestamp=session["timestamp"],
|
||||||
|
file_count=len(files),
|
||||||
|
files=files
|
||||||
|
))
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def get_file_backup_path(self, file_path: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Get the backup path for a file from the most recent session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Original file path (relative to project)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to backup file if found, None otherwise
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
if not manifest["sessions"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Search from newest to oldest
|
||||||
|
for session in reversed(manifest["sessions"]):
|
||||||
|
for file_info in session["files"]:
|
||||||
|
if file_info["original"] == file_path and file_info["existed"]:
|
||||||
|
backup_path = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_path.exists():
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_all_backups(self) -> int:
|
||||||
|
"""
|
||||||
|
Clear all backups.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of backup files deleted
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
if self.backup_dir.exists():
|
||||||
|
for item in self.backup_dir.iterdir():
|
||||||
|
if item.name != ".gitignore":
|
||||||
|
if item.is_file():
|
||||||
|
item.unlink()
|
||||||
|
count += 1
|
||||||
|
elif item.is_dir():
|
||||||
|
shutil.rmtree(item)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Reset manifest
|
||||||
|
self._save_manifest({"sessions": []})
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the backup manager."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Create a temporary project directory
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create some test files
|
||||||
|
test_file = Path(tmpdir) / "test.py"
|
||||||
|
test_file.write_text("print('hello')\n")
|
||||||
|
|
||||||
|
# Initialize backup manager
|
||||||
|
manager = BackupManager(tmpdir)
|
||||||
|
|
||||||
|
# Start a session and backup the file
|
||||||
|
manager.start_session()
|
||||||
|
backup_name = manager.backup("test.py")
|
||||||
|
print(f"Created backup: {backup_name}")
|
||||||
|
|
||||||
|
# Modify the file
|
||||||
|
test_file.write_text("print('modified')\n")
|
||||||
|
print(f"File content after modification: {test_file.read_text()}")
|
||||||
|
|
||||||
|
# End session
|
||||||
|
manager.end_session()
|
||||||
|
|
||||||
|
# List backups
|
||||||
|
backups = manager.list_backups()
|
||||||
|
print(f"Backup sessions: {len(backups)}")
|
||||||
|
for b in backups:
|
||||||
|
print(f" {b.timestamp}: {b.file_count} files")
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
success, restored = manager.restore_last()
|
||||||
|
print(f"Restore successful: {success}")
|
||||||
|
print(f"Restored files: {restored}")
|
||||||
|
print(f"File content after restore: {test_file.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
DCTP Diff Generator - Generates diffs for preview display.
|
||||||
|
|
||||||
|
Compares old and new content and produces colored diff output
|
||||||
|
for the GUI preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DiffType(Enum):
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
ADDED = "added"
|
||||||
|
REMOVED = "removed"
|
||||||
|
CONTEXT = "context"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffLine:
|
||||||
|
"""Represents a single line in a diff."""
|
||||||
|
type: DiffType
|
||||||
|
line_number_old: Optional[int] # Line number in old file
|
||||||
|
line_number_new: Optional[int] # Line number in new file
|
||||||
|
content: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefix(self) -> str:
|
||||||
|
"""Get the diff prefix character."""
|
||||||
|
if self.type == DiffType.ADDED:
|
||||||
|
return "+"
|
||||||
|
elif self.type == DiffType.REMOVED:
|
||||||
|
return "-"
|
||||||
|
else:
|
||||||
|
return " "
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
old_num = str(self.line_number_old) if self.line_number_old else ""
|
||||||
|
new_num = str(self.line_number_new) if self.line_number_new else ""
|
||||||
|
return f"{old_num:>4} {new_num:>4} {self.prefix} {self.content}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffBlock:
|
||||||
|
"""A block of related diff lines."""
|
||||||
|
start_old: int
|
||||||
|
end_old: int
|
||||||
|
start_new: int
|
||||||
|
end_new: int
|
||||||
|
lines: list[DiffLine]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def header(self) -> str:
|
||||||
|
"""Generate a unified diff style header."""
|
||||||
|
return f"@@ -{self.start_old},{self.end_old - self.start_old + 1} +{self.start_new},{self.end_new - self.start_new + 1} @@"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileDiff:
|
||||||
|
"""Complete diff for a file."""
|
||||||
|
filename: str
|
||||||
|
old_content: list[str]
|
||||||
|
new_content: list[str]
|
||||||
|
blocks: list[DiffBlock]
|
||||||
|
lines: list[DiffLine]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_changes(self) -> bool:
|
||||||
|
return any(line.type in (DiffType.ADDED, DiffType.REMOVED) for line in self.lines)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additions(self) -> int:
|
||||||
|
return sum(1 for line in self.lines if line.type == DiffType.ADDED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deletions(self) -> int:
|
||||||
|
return sum(1 for line in self.lines if line.type == DiffType.REMOVED)
|
||||||
|
|
||||||
|
|
||||||
|
class DiffGenerator:
|
||||||
|
"""Generates diffs between old and new content."""
|
||||||
|
|
||||||
|
def __init__(self, context_lines: int = 3):
|
||||||
|
"""
|
||||||
|
Initialize diff generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context_lines: Number of context lines around changes
|
||||||
|
"""
|
||||||
|
self.context_lines = context_lines
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
filename: str = ""
|
||||||
|
) -> FileDiff:
|
||||||
|
"""
|
||||||
|
Generate a diff between old and new content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
filename: Optional filename for display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileDiff object with all diff information
|
||||||
|
"""
|
||||||
|
diff_lines: list[DiffLine] = []
|
||||||
|
|
||||||
|
# Use difflib to compute differences
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||||
|
|
||||||
|
old_line_num = 1
|
||||||
|
new_line_num = 1
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.UNCHANGED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'replace':
|
||||||
|
# Show removed lines first, then added
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.REMOVED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=None,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.ADDED,
|
||||||
|
line_number_old=None,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=new_lines[j1 + idx]
|
||||||
|
))
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'delete':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.REMOVED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=None,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'insert':
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.ADDED,
|
||||||
|
line_number_old=None,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=new_lines[j1 + idx]
|
||||||
|
))
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
# Generate blocks with context
|
||||||
|
blocks = self._generate_blocks(diff_lines)
|
||||||
|
|
||||||
|
return FileDiff(
|
||||||
|
filename=filename,
|
||||||
|
old_content=old_lines,
|
||||||
|
new_content=new_lines,
|
||||||
|
blocks=blocks,
|
||||||
|
lines=diff_lines
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_blocks(self, diff_lines: list[DiffLine]) -> list[DiffBlock]:
|
||||||
|
"""Generate diff blocks with context."""
|
||||||
|
if not diff_lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
blocks: list[DiffBlock] = []
|
||||||
|
current_block_lines: list[DiffLine] = []
|
||||||
|
in_change = False
|
||||||
|
unchanged_count = 0
|
||||||
|
|
||||||
|
for line in diff_lines:
|
||||||
|
is_change = line.type in (DiffType.ADDED, DiffType.REMOVED)
|
||||||
|
|
||||||
|
if is_change:
|
||||||
|
if not in_change:
|
||||||
|
# Starting a new change block, include context
|
||||||
|
in_change = True
|
||||||
|
unchanged_count = 0
|
||||||
|
current_block_lines.append(line)
|
||||||
|
|
||||||
|
else: # Unchanged line
|
||||||
|
if in_change:
|
||||||
|
unchanged_count += 1
|
||||||
|
if unchanged_count <= self.context_lines:
|
||||||
|
current_block_lines.append(line)
|
||||||
|
else:
|
||||||
|
# End current block and start fresh
|
||||||
|
if current_block_lines:
|
||||||
|
blocks.append(self._create_block(current_block_lines))
|
||||||
|
current_block_lines = []
|
||||||
|
in_change = False
|
||||||
|
unchanged_count = 0
|
||||||
|
else:
|
||||||
|
# Keep track of potential context lines
|
||||||
|
current_block_lines.append(line)
|
||||||
|
if len(current_block_lines) > self.context_lines:
|
||||||
|
current_block_lines.pop(0)
|
||||||
|
|
||||||
|
# Don't forget the last block
|
||||||
|
if current_block_lines and any(
|
||||||
|
l.type in (DiffType.ADDED, DiffType.REMOVED) for l in current_block_lines
|
||||||
|
):
|
||||||
|
blocks.append(self._create_block(current_block_lines))
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def _create_block(self, lines: list[DiffLine]) -> DiffBlock:
|
||||||
|
"""Create a DiffBlock from a list of lines."""
|
||||||
|
old_nums = [l.line_number_old for l in lines if l.line_number_old is not None]
|
||||||
|
new_nums = [l.line_number_new for l in lines if l.line_number_new is not None]
|
||||||
|
|
||||||
|
return DiffBlock(
|
||||||
|
start_old=min(old_nums) if old_nums else 0,
|
||||||
|
end_old=max(old_nums) if old_nums else 0,
|
||||||
|
start_new=min(new_nums) if new_nums else 0,
|
||||||
|
end_new=max(new_nums) if new_nums else 0,
|
||||||
|
lines=lines
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_unified_diff(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
old_filename: str = "a/file",
|
||||||
|
new_filename: str = "b/file"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unified diff string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
old_filename: Label for old file
|
||||||
|
new_filename: Label for new file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unified diff as string
|
||||||
|
"""
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
old_lines,
|
||||||
|
new_lines,
|
||||||
|
fromfile=old_filename,
|
||||||
|
tofile=new_filename,
|
||||||
|
lineterm=""
|
||||||
|
)
|
||||||
|
return "\n".join(diff)
|
||||||
|
|
||||||
|
def generate_side_by_side(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
width: int = 80
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
"""
|
||||||
|
Generate a side-by-side diff representation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
width: Width for each column
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (left_line, marker, right_line)
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
half_width = (width - 3) // 2
|
||||||
|
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
line = old_lines[i1 + idx][:half_width]
|
||||||
|
result.append((line, " ", line))
|
||||||
|
|
||||||
|
elif tag == 'replace':
|
||||||
|
max_len = max(i2 - i1, j2 - j1)
|
||||||
|
for idx in range(max_len):
|
||||||
|
old_line = old_lines[i1 + idx][:half_width] if idx < i2 - i1 else ""
|
||||||
|
new_line = new_lines[j1 + idx][:half_width] if idx < j2 - j1 else ""
|
||||||
|
result.append((old_line, "|", new_line))
|
||||||
|
|
||||||
|
elif tag == 'delete':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
old_line = old_lines[i1 + idx][:half_width]
|
||||||
|
result.append((old_line, "<", ""))
|
||||||
|
|
||||||
|
elif tag == 'insert':
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
new_line = new_lines[j1 + idx][:half_width]
|
||||||
|
result.append(("", ">", new_line))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_diff_for_display(diff: FileDiff, use_colors: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Format a FileDiff for terminal/GUI display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: The FileDiff to format
|
||||||
|
use_colors: Whether to use ANSI colors
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if diff.filename:
|
||||||
|
lines.append(f"--- {diff.filename}")
|
||||||
|
lines.append(f"+++ {diff.filename}")
|
||||||
|
|
||||||
|
for line in diff.lines:
|
||||||
|
if use_colors:
|
||||||
|
if line.type == DiffType.ADDED:
|
||||||
|
prefix = "\033[32m+" # Green
|
||||||
|
suffix = "\033[0m"
|
||||||
|
elif line.type == DiffType.REMOVED:
|
||||||
|
prefix = "\033[31m-" # Red
|
||||||
|
suffix = "\033[0m"
|
||||||
|
else:
|
||||||
|
prefix = " "
|
||||||
|
suffix = ""
|
||||||
|
else:
|
||||||
|
prefix = line.prefix
|
||||||
|
suffix = ""
|
||||||
|
|
||||||
|
lines.append(f"{prefix} {line.content}{suffix}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the diff generator."""
|
||||||
|
old_content = [
|
||||||
|
"def calculate_tax(amount):",
|
||||||
|
" rate = 0.19",
|
||||||
|
" if amount > 1000:",
|
||||||
|
" rate = 0.25",
|
||||||
|
" return amount * rate",
|
||||||
|
]
|
||||||
|
|
||||||
|
new_content = [
|
||||||
|
"def calculate_tax(amount):",
|
||||||
|
" rate = 0.19",
|
||||||
|
" if amount > 10000:",
|
||||||
|
" rate = 0.22",
|
||||||
|
" elif amount > 1000:",
|
||||||
|
" rate = 0.19",
|
||||||
|
" return amount * rate",
|
||||||
|
]
|
||||||
|
|
||||||
|
generator = DiffGenerator()
|
||||||
|
diff = generator.generate(old_content, new_content, "calculator.py")
|
||||||
|
|
||||||
|
print(f"File: {diff.filename}")
|
||||||
|
print(f"Additions: {diff.additions}, Deletions: {diff.deletions}")
|
||||||
|
print()
|
||||||
|
print("Diff output:")
|
||||||
|
print(format_diff_for_display(diff))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""
|
||||||
|
DCTP Executor - Executes DCTP operations on files.
|
||||||
|
|
||||||
|
Handles CREATE, DELETE, INSERT_AFTER, REPLACE, and RENUMBER operations
|
||||||
|
with backup support and checksum validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dctp_parser import DCTPParser, Operation, OperationType
|
||||||
|
from dctp_backup import BackupManager
|
||||||
|
from dctp_diff import DiffGenerator, FileDiff
|
||||||
|
|
||||||
|
|
||||||
|
class ResultStatus(Enum):
|
||||||
|
SUCCESS = "success"
|
||||||
|
WARNING = "warning"
|
||||||
|
ERROR = "error"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionResult:
|
||||||
|
"""Result of executing a single operation."""
|
||||||
|
status: ResultStatus
|
||||||
|
operation: Operation
|
||||||
|
message: str
|
||||||
|
diff: Optional[FileDiff] = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
status_symbols = {
|
||||||
|
ResultStatus.SUCCESS: "✅",
|
||||||
|
ResultStatus.WARNING: "⚠️",
|
||||||
|
ResultStatus.ERROR: "❌",
|
||||||
|
ResultStatus.SKIPPED: "⏭️",
|
||||||
|
}
|
||||||
|
return f"{status_symbols[self.status]} {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreviewResult:
|
||||||
|
"""Result of previewing operations before execution."""
|
||||||
|
operation: Operation
|
||||||
|
description: str
|
||||||
|
diff: Optional[FileDiff] = None
|
||||||
|
warnings: list[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.warnings is None:
|
||||||
|
self.warnings = []
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPExecutor:
|
||||||
|
"""Executes DCTP operations on files."""
|
||||||
|
|
||||||
|
# Line number patterns (same as parser)
|
||||||
|
LINE_NUMBER_PATTERNS = [
|
||||||
|
re.compile(r'\s*#Z(\d+)\s*$'),
|
||||||
|
re.compile(r'\s*//Z(\d+)\s*$'),
|
||||||
|
re.compile(r'\s*<!--Z(\d+)-->\s*$'),
|
||||||
|
re.compile(r'\s*/\*Z(\d+)\*/\s*$'),
|
||||||
|
re.compile(r'\s*--Z(\d+)\s*$'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_path: str,
|
||||||
|
backup_manager: Optional[BackupManager] = None,
|
||||||
|
auto_renumber: bool = True,
|
||||||
|
validate_checksums: bool = True
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the executor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Base project directory
|
||||||
|
backup_manager: Optional backup manager for undo support
|
||||||
|
auto_renumber: Automatically renumber after operations
|
||||||
|
validate_checksums: Validate checksums before operations
|
||||||
|
"""
|
||||||
|
self.project_path = Path(project_path)
|
||||||
|
self.backup_manager = backup_manager or BackupManager(project_path)
|
||||||
|
self.auto_renumber = auto_renumber
|
||||||
|
self.validate_checksums = validate_checksums
|
||||||
|
self.diff_generator = DiffGenerator()
|
||||||
|
self.parser = DCTPParser()
|
||||||
|
|
||||||
|
def preview(self, operations: list[Operation]) -> list[PreviewResult]:
|
||||||
|
"""
|
||||||
|
Preview operations without executing them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operations: List of operations to preview
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of preview results
|
||||||
|
"""
|
||||||
|
previews = []
|
||||||
|
|
||||||
|
for op in operations:
|
||||||
|
preview = self._preview_operation(op)
|
||||||
|
previews.append(preview)
|
||||||
|
|
||||||
|
return previews
|
||||||
|
|
||||||
|
def _preview_operation(self, op: Operation) -> PreviewResult:
|
||||||
|
"""Generate preview for a single operation."""
|
||||||
|
file_path = self.project_path / op.file
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if op.type == OperationType.NEW:
|
||||||
|
if file_path.exists():
|
||||||
|
warnings.append(f"File already exists and will be overwritten")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"CREATE {op.file} ({len(op.content)} lines)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.DELETE:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.end_line > len(old_lines):
|
||||||
|
warnings.append(f"Line range exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = min(op.end_line, len(old_lines))
|
||||||
|
del new_lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line}",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.INSERT_AFTER:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"INSERT_AFTER {op.file} Z{op.start_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.start_line > len(old_lines):
|
||||||
|
warnings.append(f"Line {op.start_line} exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
insert_idx = min(op.start_line, len(old_lines))
|
||||||
|
for i, line in enumerate(op.content):
|
||||||
|
new_lines.insert(insert_idx + i, line)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"INSERT_AFTER {op.file} Z{op.start_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.REPLACE:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.end_line > len(old_lines):
|
||||||
|
warnings.append(f"Line range exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = min(op.end_line, len(old_lines))
|
||||||
|
new_lines[start_idx:end_idx] = op.content
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.RENUMBER:
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"RENUMBER {op.file}",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"UNKNOWN {op.type}",
|
||||||
|
warnings=["Unknown operation type"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
operations: list[Operation],
|
||||||
|
skip_checksum_mismatch: bool = False
|
||||||
|
) -> list[ExecutionResult]:
|
||||||
|
"""
|
||||||
|
Execute a list of operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operations: List of operations to execute
|
||||||
|
skip_checksum_mismatch: Continue even if checksums don't match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of execution results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Start backup session
|
||||||
|
self.backup_manager.start_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for op in operations:
|
||||||
|
result = self._execute_operation(op, skip_checksum_mismatch)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Stop on error
|
||||||
|
if result.status == ResultStatus.ERROR:
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# End backup session
|
||||||
|
self.backup_manager.end_session()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _execute_operation(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
skip_checksum_mismatch: bool = False
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a single operation."""
|
||||||
|
file_path = self.project_path / op.file
|
||||||
|
|
||||||
|
try:
|
||||||
|
if op.type == OperationType.NEW:
|
||||||
|
return self._execute_new(op, file_path)
|
||||||
|
elif op.type == OperationType.DELETE:
|
||||||
|
return self._execute_delete(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.INSERT_AFTER:
|
||||||
|
return self._execute_insert_after(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.REPLACE:
|
||||||
|
return self._execute_replace(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.RENUMBER:
|
||||||
|
return self._execute_renumber(op, file_path)
|
||||||
|
else:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Unknown operation type: {op.type}"
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Permission denied: {file_path}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_new(self, op: Operation, file_path: Path) -> ExecutionResult:
|
||||||
|
"""Execute a NEW operation (create file)."""
|
||||||
|
# Backup if file exists
|
||||||
|
if file_path.exists():
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Create parent directories
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write content
|
||||||
|
content = "\n".join(op.content)
|
||||||
|
if op.content and not content.endswith("\n"):
|
||||||
|
content += "\n"
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"CREATE {op.file} ({len(op.content)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_delete(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a DELETE operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and delete lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.end_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line range Z{op.start_line}-Z{op.end_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = op.end_line
|
||||||
|
del lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line}",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_insert_after(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute an INSERT_AFTER operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and insert lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.start_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line Z{op.start_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_idx = op.start_line
|
||||||
|
for i, line in enumerate(op.content):
|
||||||
|
lines.insert(insert_idx + i, line)
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"INSERT_AFTER {op.file} Z{op.start_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_replace(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a REPLACE operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and replace lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.end_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line range Z{op.start_line}-Z{op.end_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = op.end_line
|
||||||
|
lines[start_idx:end_idx] = op.content
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_renumber(self, op: Operation, file_path: Path) -> ExecutionResult:
|
||||||
|
"""Execute a RENUMBER operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No backup needed for renumber (just updates line numbers)
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
renumbered_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(lines, 1):
|
||||||
|
# Remove existing line number
|
||||||
|
clean_line = self._remove_line_number(line)
|
||||||
|
# Add new line number
|
||||||
|
suffix = self.parser.get_line_number_suffix(op.file, i)
|
||||||
|
renumbered_lines.append(clean_line + suffix)
|
||||||
|
|
||||||
|
self._write_file_lines(file_path, renumbered_lines)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"RENUMBER {op.file} ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_file_lines(self, file_path: Path) -> list[str]:
|
||||||
|
"""Read file and return lines without trailing newlines."""
|
||||||
|
content = file_path.read_text()
|
||||||
|
lines = content.split('\n')
|
||||||
|
# Remove trailing empty line if file ends with newline
|
||||||
|
if lines and lines[-1] == '':
|
||||||
|
lines = lines[:-1]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _write_file_lines(self, file_path: Path, lines: list[str]) -> None:
|
||||||
|
"""Write lines to file with trailing newline."""
|
||||||
|
content = '\n'.join(lines)
|
||||||
|
if lines and not content.endswith('\n'):
|
||||||
|
content += '\n'
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
def _remove_line_number(self, line: str) -> str:
|
||||||
|
"""Remove line number marker from end of line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return line[:match.start()]
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _validate_checksum(self, file_path: Path, expected: str) -> bool:
|
||||||
|
"""Validate file checksum."""
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
actual = hashlib.md5(content).hexdigest()[:8]
|
||||||
|
return actual.lower() == expected.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_checksum(file_path: Path) -> str:
|
||||||
|
"""Calculate checksum for a file."""
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
return hashlib.md5(content).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the executor."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
test_input = """###FILE:calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
parser = DCTPParser()
|
||||||
|
result = parser.parse(test_input)
|
||||||
|
|
||||||
|
print("Parsed operations:")
|
||||||
|
for op in result.operations:
|
||||||
|
print(f" {op}")
|
||||||
|
|
||||||
|
executor = DCTPExecutor(tmpdir)
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
print("\nPreviews:")
|
||||||
|
previews = executor.preview(result.operations)
|
||||||
|
for preview in previews:
|
||||||
|
print(f" {preview.description}")
|
||||||
|
if preview.warnings:
|
||||||
|
for w in preview.warnings:
|
||||||
|
print(f" ⚠️ {w}")
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
print("\nExecution:")
|
||||||
|
exec_results = executor.execute(result.operations)
|
||||||
|
for r in exec_results:
|
||||||
|
print(f" {r}")
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
file_path = Path(tmpdir) / "calculator.py"
|
||||||
|
if file_path.exists():
|
||||||
|
print(f"\nFile content:\n{file_path.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
DCTP GUI - Delta Code Transfer Protocol graphical user interface.
|
||||||
|
|
||||||
|
A CustomTkinter-based GUI for managing AI-generated code transfers
|
||||||
|
using delta operations for efficient updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from dctp_parser import DCTPParser, ParseResult, Operation, OperationType
|
||||||
|
from dctp_executor import DCTPExecutor, ExecutionResult, PreviewResult, ResultStatus
|
||||||
|
from dctp_backup import BackupManager
|
||||||
|
from dctp_diff import DiffType
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(ctk.CTkToplevel):
|
||||||
|
"""Settings dialog window."""
|
||||||
|
|
||||||
|
def __init__(self, parent, settings: dict):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.title("Einstellungen")
|
||||||
|
self.geometry("500x400")
|
||||||
|
self.resizable(False, False)
|
||||||
|
|
||||||
|
self.settings = settings.copy()
|
||||||
|
self.result = None
|
||||||
|
|
||||||
|
# Make modal
|
||||||
|
self.transient(parent)
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Center on parent
|
||||||
|
self.update_idletasks()
|
||||||
|
x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2
|
||||||
|
y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2
|
||||||
|
self.geometry(f"+{x}+{y}")
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
# Main frame
|
||||||
|
main_frame = ctk.CTkFrame(self)
|
||||||
|
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||||
|
|
||||||
|
# Project path
|
||||||
|
ctk.CTkLabel(main_frame, text="Standard-Projektpfad:").pack(anchor="w", pady=(0, 5))
|
||||||
|
path_frame = ctk.CTkFrame(main_frame)
|
||||||
|
path_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
self.path_entry = ctk.CTkEntry(path_frame, width=350)
|
||||||
|
self.path_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.path_entry.insert(0, self.settings.get("project_path", ""))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
path_frame,
|
||||||
|
text="...",
|
||||||
|
width=40,
|
||||||
|
command=self._browse_path
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# Backup directory
|
||||||
|
ctk.CTkLabel(main_frame, text="Backup-Verzeichnis:").pack(anchor="w", pady=(0, 5))
|
||||||
|
backup_frame = ctk.CTkFrame(main_frame)
|
||||||
|
backup_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
self.backup_entry = ctk.CTkEntry(backup_frame, width=350)
|
||||||
|
self.backup_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.backup_entry.insert(0, self.settings.get("backup_dir", ".dctp_backups"))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
backup_frame,
|
||||||
|
text="...",
|
||||||
|
width=40,
|
||||||
|
command=self._browse_backup
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# Options
|
||||||
|
options_frame = ctk.CTkFrame(main_frame)
|
||||||
|
options_frame.pack(fill="x", pady=15)
|
||||||
|
|
||||||
|
self.auto_renumber_var = ctk.BooleanVar(
|
||||||
|
value=self.settings.get("auto_renumber", True)
|
||||||
|
)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
options_frame,
|
||||||
|
text="Auto-Renumber nach Operationen",
|
||||||
|
variable=self.auto_renumber_var
|
||||||
|
).pack(anchor="w", pady=5)
|
||||||
|
|
||||||
|
self.validate_checksum_var = ctk.BooleanVar(
|
||||||
|
value=self.settings.get("validate_checksum", True)
|
||||||
|
)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
options_frame,
|
||||||
|
text="Checksum-Validierung aktiviert",
|
||||||
|
variable=self.validate_checksum_var
|
||||||
|
).pack(anchor="w", pady=5)
|
||||||
|
|
||||||
|
# Theme
|
||||||
|
ctk.CTkLabel(main_frame, text="Theme:").pack(anchor="w", pady=(15, 5))
|
||||||
|
self.theme_var = ctk.StringVar(value=self.settings.get("theme", "dark"))
|
||||||
|
theme_frame = ctk.CTkFrame(main_frame)
|
||||||
|
theme_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
theme_frame,
|
||||||
|
text="Dunkel",
|
||||||
|
variable=self.theme_var,
|
||||||
|
value="dark"
|
||||||
|
).pack(side="left", padx=(0, 20))
|
||||||
|
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
theme_frame,
|
||||||
|
text="Hell",
|
||||||
|
variable=self.theme_var,
|
||||||
|
value="light"
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_frame = ctk.CTkFrame(main_frame)
|
||||||
|
button_frame.pack(fill="x", pady=(20, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Abbrechen",
|
||||||
|
command=self._cancel
|
||||||
|
).pack(side="right", padx=(10, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Speichern",
|
||||||
|
command=self._save
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
def _browse_path(self):
|
||||||
|
path = filedialog.askdirectory(
|
||||||
|
initialdir=self.path_entry.get() or os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.path_entry.delete(0, "end")
|
||||||
|
self.path_entry.insert(0, path)
|
||||||
|
|
||||||
|
def _browse_backup(self):
|
||||||
|
path = filedialog.askdirectory(
|
||||||
|
initialdir=os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.backup_entry.delete(0, "end")
|
||||||
|
self.backup_entry.insert(0, path)
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.result = {
|
||||||
|
"project_path": self.path_entry.get(),
|
||||||
|
"backup_dir": self.backup_entry.get(),
|
||||||
|
"auto_renumber": self.auto_renumber_var.get(),
|
||||||
|
"validate_checksum": self.validate_checksum_var.get(),
|
||||||
|
"theme": self.theme_var.get()
|
||||||
|
}
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _cancel(self):
|
||||||
|
self.result = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPApp(ctk.CTk):
|
||||||
|
"""Main DCTP application window."""
|
||||||
|
|
||||||
|
SETTINGS_FILE = ".dctp_settings.json"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.title("DCTP - Delta Code Transfer")
|
||||||
|
self.geometry("1200x900")
|
||||||
|
self.minsize(800, 600)
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
self.parser = DCTPParser()
|
||||||
|
self.executor: Optional[DCTPExecutor] = None
|
||||||
|
self.backup_manager: Optional[BackupManager] = None
|
||||||
|
self.current_operations: list[Operation] = []
|
||||||
|
self.current_previews: list[PreviewResult] = []
|
||||||
|
|
||||||
|
# Load settings
|
||||||
|
self.settings = self._load_settings()
|
||||||
|
ctk.set_appearance_mode(self.settings.get("theme", "dark"))
|
||||||
|
|
||||||
|
# Create UI
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Initialize project if path is set
|
||||||
|
if self.settings.get("project_path"):
|
||||||
|
self._init_project(self.settings["project_path"])
|
||||||
|
|
||||||
|
self._log("Bereit")
|
||||||
|
|
||||||
|
def _load_settings(self) -> dict:
|
||||||
|
"""Load settings from file."""
|
||||||
|
settings_path = Path.home() / self.SETTINGS_FILE
|
||||||
|
if settings_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(settings_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"project_path": "",
|
||||||
|
"backup_dir": ".dctp_backups",
|
||||||
|
"auto_renumber": True,
|
||||||
|
"validate_checksum": True,
|
||||||
|
"theme": "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _save_settings(self):
|
||||||
|
"""Save settings to file."""
|
||||||
|
settings_path = Path.home() / self.SETTINGS_FILE
|
||||||
|
settings_path.write_text(json.dumps(self.settings, indent=2))
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
"""Create all UI widgets."""
|
||||||
|
# Top bar - project selection
|
||||||
|
top_frame = ctk.CTkFrame(self)
|
||||||
|
top_frame.pack(fill="x", padx=10, pady=10)
|
||||||
|
|
||||||
|
ctk.CTkLabel(top_frame, text="Projekt:").pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.project_entry = ctk.CTkEntry(top_frame, width=400)
|
||||||
|
self.project_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.project_entry.insert(0, self.settings.get("project_path", ""))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
top_frame,
|
||||||
|
text="Waehlen",
|
||||||
|
width=100,
|
||||||
|
command=self._browse_project
|
||||||
|
).pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
top_frame,
|
||||||
|
text="Einstellungen",
|
||||||
|
width=100,
|
||||||
|
command=self._open_settings
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Main content area with paned layout
|
||||||
|
content_frame = ctk.CTkFrame(self)
|
||||||
|
content_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||||||
|
|
||||||
|
# Left side - Input and preview
|
||||||
|
left_frame = ctk.CTkFrame(content_frame)
|
||||||
|
left_frame.pack(side="left", fill="both", expand=True, padx=(0, 5))
|
||||||
|
|
||||||
|
# Input area
|
||||||
|
input_label_frame = ctk.CTkFrame(left_frame)
|
||||||
|
input_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
input_label_frame,
|
||||||
|
text="Input (KI-Output hier einfuegen)",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.input_text = ctk.CTkTextbox(
|
||||||
|
left_frame,
|
||||||
|
height=250,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=12)
|
||||||
|
)
|
||||||
|
self.input_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_frame = ctk.CTkFrame(left_frame)
|
||||||
|
button_frame.pack(fill="x", padx=5, pady=(0, 10))
|
||||||
|
|
||||||
|
self.analyze_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Analysieren",
|
||||||
|
command=self._analyze,
|
||||||
|
width=120
|
||||||
|
)
|
||||||
|
self.analyze_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.execute_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Ausfuehren",
|
||||||
|
command=self._execute,
|
||||||
|
width=120,
|
||||||
|
state="disabled"
|
||||||
|
)
|
||||||
|
self.execute_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.undo_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Undo",
|
||||||
|
command=self._undo,
|
||||||
|
width=80
|
||||||
|
)
|
||||||
|
self.undo_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Clear",
|
||||||
|
command=self._clear,
|
||||||
|
width=80
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Preview operations
|
||||||
|
preview_label_frame = ctk.CTkFrame(left_frame)
|
||||||
|
preview_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
preview_label_frame,
|
||||||
|
text="Vorschau Operationen",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.preview_text = ctk.CTkTextbox(
|
||||||
|
left_frame,
|
||||||
|
height=150,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.preview_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Right side - Diff and file tree
|
||||||
|
right_frame = ctk.CTkFrame(content_frame)
|
||||||
|
right_frame.pack(side="right", fill="both", expand=True, padx=(5, 0))
|
||||||
|
|
||||||
|
# Diff view
|
||||||
|
diff_label_frame = ctk.CTkFrame(right_frame)
|
||||||
|
diff_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
diff_label_frame,
|
||||||
|
text="Diff-Ansicht",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.diff_text = ctk.CTkTextbox(
|
||||||
|
right_frame,
|
||||||
|
height=300,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.diff_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# File tree
|
||||||
|
tree_label_frame = ctk.CTkFrame(right_frame)
|
||||||
|
tree_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
tree_label_frame,
|
||||||
|
text="Projektdateien",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
tree_label_frame,
|
||||||
|
text="Aktualisieren",
|
||||||
|
width=80,
|
||||||
|
command=self._refresh_file_tree
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
self.tree_text = ctk.CTkTextbox(
|
||||||
|
right_frame,
|
||||||
|
height=150,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.tree_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Bottom - Log
|
||||||
|
log_label_frame = ctk.CTkFrame(self)
|
||||||
|
log_label_frame.pack(fill="x", padx=10, pady=(0, 5))
|
||||||
|
ctk.CTkLabel(
|
||||||
|
log_label_frame,
|
||||||
|
text="Log",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.log_text = ctk.CTkTextbox(
|
||||||
|
self,
|
||||||
|
height=120,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=10)
|
||||||
|
)
|
||||||
|
self.log_text.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
|
self.log_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _log(self, message: str, level: str = "info"):
|
||||||
|
"""Add a message to the log."""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
prefix = ""
|
||||||
|
if level == "error":
|
||||||
|
prefix = "ERROR "
|
||||||
|
elif level == "warning":
|
||||||
|
prefix = "WARN "
|
||||||
|
|
||||||
|
self.log_text.configure(state="normal")
|
||||||
|
self.log_text.insert("end", f"{timestamp} {prefix}{message}\n")
|
||||||
|
self.log_text.see("end")
|
||||||
|
self.log_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _init_project(self, path: str):
|
||||||
|
"""Initialize project with given path."""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
self._log(f"Verzeichnis existiert nicht: {path}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.backup_manager = BackupManager(path)
|
||||||
|
self.executor = DCTPExecutor(
|
||||||
|
path,
|
||||||
|
self.backup_manager,
|
||||||
|
auto_renumber=self.settings.get("auto_renumber", True),
|
||||||
|
validate_checksums=self.settings.get("validate_checksum", True)
|
||||||
|
)
|
||||||
|
self.settings["project_path"] = path
|
||||||
|
self._save_settings()
|
||||||
|
self._log(f"Projekt geladen: {path}")
|
||||||
|
self._refresh_file_tree()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _browse_project(self):
|
||||||
|
"""Open directory browser for project selection."""
|
||||||
|
initial_dir = self.project_entry.get() or os.path.expanduser("~")
|
||||||
|
path = filedialog.askdirectory(initialdir=initial_dir)
|
||||||
|
if path:
|
||||||
|
self.project_entry.delete(0, "end")
|
||||||
|
self.project_entry.insert(0, path)
|
||||||
|
self._init_project(path)
|
||||||
|
|
||||||
|
def _open_settings(self):
|
||||||
|
"""Open settings dialog."""
|
||||||
|
dialog = SettingsDialog(self, self.settings)
|
||||||
|
self.wait_window(dialog)
|
||||||
|
|
||||||
|
if dialog.result:
|
||||||
|
old_theme = self.settings.get("theme")
|
||||||
|
self.settings.update(dialog.result)
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
# Apply theme change
|
||||||
|
if dialog.result.get("theme") != old_theme:
|
||||||
|
ctk.set_appearance_mode(dialog.result["theme"])
|
||||||
|
|
||||||
|
# Reinitialize project with new settings
|
||||||
|
if self.settings.get("project_path"):
|
||||||
|
self._init_project(self.settings["project_path"])
|
||||||
|
|
||||||
|
self._log("Einstellungen gespeichert")
|
||||||
|
|
||||||
|
def _analyze(self):
|
||||||
|
"""Analyze input and show preview."""
|
||||||
|
# Ensure project is initialized
|
||||||
|
project_path = self.project_entry.get()
|
||||||
|
if not project_path:
|
||||||
|
messagebox.showerror("Fehler", "Bitte waehle ein Projektverzeichnis")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.executor or self.settings.get("project_path") != project_path:
|
||||||
|
if not self._init_project(project_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get input
|
||||||
|
input_text = self.input_text.get("1.0", "end-1c")
|
||||||
|
if not input_text.strip():
|
||||||
|
self._log("Kein Input vorhanden", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse
|
||||||
|
self._log("Analysiere...")
|
||||||
|
result = self.parser.parse(input_text)
|
||||||
|
|
||||||
|
# Handle errors
|
||||||
|
if result.has_errors:
|
||||||
|
self._log(f"{len(result.errors)} Parse-Fehler gefunden", "error")
|
||||||
|
for error in result.errors:
|
||||||
|
self._log(f" Zeile {error.line_number}: {error.message}", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not result.operations:
|
||||||
|
self._log("Keine Operationen gefunden", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_operations = result.operations
|
||||||
|
self._log(f"{len(result.operations)} Operationen gefunden")
|
||||||
|
|
||||||
|
# Generate previews
|
||||||
|
self.current_previews = self.executor.preview(result.operations)
|
||||||
|
|
||||||
|
# Display previews
|
||||||
|
self._display_previews()
|
||||||
|
|
||||||
|
# Enable execute button
|
||||||
|
self.execute_btn.configure(state="normal")
|
||||||
|
|
||||||
|
def _display_previews(self):
|
||||||
|
"""Display operation previews."""
|
||||||
|
self.preview_text.configure(state="normal")
|
||||||
|
self.preview_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
self.diff_text.configure(state="normal")
|
||||||
|
self.diff_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
for preview in self.current_previews:
|
||||||
|
# Add to preview list
|
||||||
|
self.preview_text.insert("end", f"{preview.description}\n")
|
||||||
|
for warning in preview.warnings:
|
||||||
|
self.preview_text.insert("end", f" WARNING {warning}\n")
|
||||||
|
|
||||||
|
# Add diff if available
|
||||||
|
if preview.diff and preview.diff.has_changes:
|
||||||
|
self.diff_text.insert("end", f"--- {preview.diff.filename} ---\n")
|
||||||
|
for line in preview.diff.lines:
|
||||||
|
if line.type == DiffType.ADDED:
|
||||||
|
self.diff_text.insert("end", f"+ {line.content}\n")
|
||||||
|
elif line.type == DiffType.REMOVED:
|
||||||
|
self.diff_text.insert("end", f"- {line.content}\n")
|
||||||
|
elif line.type == DiffType.UNCHANGED:
|
||||||
|
self.diff_text.insert("end", f" {line.content}\n")
|
||||||
|
self.diff_text.insert("end", "\n")
|
||||||
|
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _execute(self):
|
||||||
|
"""Execute the analyzed operations."""
|
||||||
|
if not self.current_operations:
|
||||||
|
self._log("Keine Operationen zum Ausfuehren", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.executor:
|
||||||
|
self._log("Kein Projekt initialisiert", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm
|
||||||
|
count = len(self.current_operations)
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Bestaetigen",
|
||||||
|
f"{count} Operationen ausfuehren?"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log(f"Fuehre {count} Operationen aus...")
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
results = self.executor.execute(self.current_operations)
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
success_count = 0
|
||||||
|
for result in results:
|
||||||
|
if result.status == ResultStatus.SUCCESS:
|
||||||
|
self._log(f"OK {result.message}")
|
||||||
|
success_count += 1
|
||||||
|
elif result.status == ResultStatus.WARNING:
|
||||||
|
self._log(f"WARN {result.message}", "warning")
|
||||||
|
elif result.status == ResultStatus.ERROR:
|
||||||
|
self._log(f"ERROR {result.message}", "error")
|
||||||
|
|
||||||
|
self._log(f"Abgeschlossen: {success_count}/{count} erfolgreich")
|
||||||
|
|
||||||
|
# Clear current operations
|
||||||
|
self.current_operations = []
|
||||||
|
self.current_previews = []
|
||||||
|
self.execute_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
# Refresh file tree
|
||||||
|
self._refresh_file_tree()
|
||||||
|
|
||||||
|
def _undo(self):
|
||||||
|
"""Undo last operation."""
|
||||||
|
if not self.backup_manager:
|
||||||
|
self._log("Kein Projekt initialisiert", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
success, restored = self.backup_manager.restore_last()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self._log(f"Undo erfolgreich: {len(restored)} Dateien wiederhergestellt")
|
||||||
|
for f in restored:
|
||||||
|
self._log(f" -> {f}")
|
||||||
|
self._refresh_file_tree()
|
||||||
|
else:
|
||||||
|
self._log("Kein Backup zum Wiederherstellen", "warning")
|
||||||
|
|
||||||
|
def _clear(self):
|
||||||
|
"""Clear input and preview areas."""
|
||||||
|
self.input_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
self.preview_text.configure(state="normal")
|
||||||
|
self.preview_text.delete("1.0", "end")
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
|
||||||
|
self.diff_text.configure(state="normal")
|
||||||
|
self.diff_text.delete("1.0", "end")
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
self.current_operations = []
|
||||||
|
self.current_previews = []
|
||||||
|
self.execute_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
self._log("Eingabe geloescht")
|
||||||
|
|
||||||
|
def _refresh_file_tree(self):
|
||||||
|
"""Refresh the file tree display."""
|
||||||
|
self.tree_text.configure(state="normal")
|
||||||
|
self.tree_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
project_path = self.project_entry.get()
|
||||||
|
if not project_path or not os.path.isdir(project_path):
|
||||||
|
self.tree_text.insert("end", "(Kein Projekt geladen)")
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build simple tree
|
||||||
|
try:
|
||||||
|
self._add_tree_items(Path(project_path), 0)
|
||||||
|
except Exception as e:
|
||||||
|
self.tree_text.insert("end", f"Fehler: {e}")
|
||||||
|
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _add_tree_items(self, path: Path, level: int, max_items: int = 100):
|
||||||
|
"""Recursively add items to tree display."""
|
||||||
|
if level > 5: # Limit depth
|
||||||
|
return
|
||||||
|
|
||||||
|
indent = " " * level
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if count >= max_items:
|
||||||
|
self.tree_text.insert("end", f"{indent} ... (mehr Dateien)\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Skip hidden files and backup directory
|
||||||
|
if item.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.is_dir():
|
||||||
|
self.tree_text.insert("end", f"{indent}DIR {item.name}/\n")
|
||||||
|
self._add_tree_items(item, level + 1, max_items=20)
|
||||||
|
else:
|
||||||
|
self.tree_text.insert("end", f"{indent}FILE {item.name}\n")
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
self.tree_text.insert("end", f"{indent} (Zugriff verweigert)\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
app = DCTPApp()
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
DCTP Parser - Parses DCTP control commands and code blocks.
|
||||||
|
|
||||||
|
Handles line-numbered code with language-specific comment formats:
|
||||||
|
- Python/Shell: #Z1
|
||||||
|
- JavaScript/Java/C/C++: //Z1
|
||||||
|
- HTML: <!--Z1-->
|
||||||
|
- CSS: /*Z1*/
|
||||||
|
- SQL: --Z1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class OperationType(Enum):
|
||||||
|
NEW = "NEW"
|
||||||
|
DELETE = "DELETE"
|
||||||
|
INSERT_AFTER = "INSERT_AFTER"
|
||||||
|
REPLACE = "REPLACE"
|
||||||
|
RENUMBER = "RENUMBER"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operation:
|
||||||
|
"""Represents a single DCTP operation."""
|
||||||
|
type: OperationType
|
||||||
|
file: str
|
||||||
|
start_line: Optional[int] = None
|
||||||
|
end_line: Optional[int] = None
|
||||||
|
content: list[str] = field(default_factory=list)
|
||||||
|
checksum: Optional[str] = None
|
||||||
|
raw_content: list[str] = field(default_factory=list) # Content with line numbers
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.type == OperationType.NEW:
|
||||||
|
return f"CREATE {self.file} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.DELETE:
|
||||||
|
return f"DELETE {self.file} Z{self.start_line}-Z{self.end_line}"
|
||||||
|
elif self.type == OperationType.INSERT_AFTER:
|
||||||
|
return f"INSERT_AFTER {self.file} Z{self.start_line} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.REPLACE:
|
||||||
|
return f"REPLACE {self.file} Z{self.start_line}-Z{self.end_line} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.RENUMBER:
|
||||||
|
return f"RENUMBER {self.file}"
|
||||||
|
return f"{self.type.value} {self.file}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseError:
|
||||||
|
"""Represents a parsing error."""
|
||||||
|
line_number: int
|
||||||
|
line_content: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseResult:
|
||||||
|
"""Result of parsing DCTP input."""
|
||||||
|
operations: list[Operation]
|
||||||
|
errors: list[ParseError]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
return len(self.errors) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPParser:
|
||||||
|
"""Parser for DCTP (Delta Code Transfer Protocol) format."""
|
||||||
|
|
||||||
|
# Regex patterns for line number markers in different languages
|
||||||
|
LINE_NUMBER_PATTERNS = [
|
||||||
|
re.compile(r'\s*#Z(\d+)\s*$'), # Python, Shell
|
||||||
|
re.compile(r'\s*//Z(\d+)\s*$'), # JavaScript, Java, C, C++
|
||||||
|
re.compile(r'\s*<!--Z(\d+)-->\s*$'), # HTML
|
||||||
|
re.compile(r'\s*/\*Z(\d+)\*/\s*$'), # CSS
|
||||||
|
re.compile(r'\s*--Z(\d+)\s*$'), # SQL
|
||||||
|
]
|
||||||
|
|
||||||
|
# Control command patterns
|
||||||
|
FILE_PATTERN = re.compile(r'^###FILE:(.+)$')
|
||||||
|
NEW_PATTERN = re.compile(r'^###NEW\s*$')
|
||||||
|
DELETE_PATTERN = re.compile(r'^###DELETE:Z(\d+)(?:-Z(\d+))?\s*$')
|
||||||
|
INSERT_AFTER_PATTERN = re.compile(r'^###INSERT_AFTER:Z(\d+)\s*$')
|
||||||
|
REPLACE_PATTERN = re.compile(r'^###REPLACE:Z(\d+)(?:-Z(\d+))?\s*$')
|
||||||
|
END_PATTERN = re.compile(r'^###END\s*$')
|
||||||
|
RENUMBER_PATTERN = re.compile(r'^###RENUMBER\s*$')
|
||||||
|
CHECKSUM_PATTERN = re.compile(r'^###CHECKSUM:([a-fA-F0-9]+)\s*$')
|
||||||
|
|
||||||
|
def parse(self, text: str) -> ParseResult:
|
||||||
|
"""
|
||||||
|
Parse DCTP formatted text into a list of operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The DCTP formatted input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParseResult containing operations and any errors
|
||||||
|
"""
|
||||||
|
operations: list[Operation] = []
|
||||||
|
errors: list[ParseError] = []
|
||||||
|
|
||||||
|
current_file: Optional[str] = None
|
||||||
|
current_op: Optional[Operation] = None
|
||||||
|
buffer: list[str] = []
|
||||||
|
raw_buffer: list[str] = []
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
for line_num, line in enumerate(lines, 1):
|
||||||
|
# Skip empty lines outside of content blocks
|
||||||
|
if not line.strip() and current_op is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for FILE command
|
||||||
|
file_match = self.FILE_PATTERN.match(line)
|
||||||
|
if file_match:
|
||||||
|
current_file = file_match.group(1).strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for NEW command
|
||||||
|
if self.NEW_PATTERN.match(line):
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###NEW without ###FILE"))
|
||||||
|
continue
|
||||||
|
current_op = Operation(type=OperationType.NEW, file=current_file)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for DELETE command
|
||||||
|
delete_match = self.DELETE_PATTERN.match(line)
|
||||||
|
if delete_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###DELETE without ###FILE"))
|
||||||
|
continue
|
||||||
|
start = int(delete_match.group(1))
|
||||||
|
end = int(delete_match.group(2)) if delete_match.group(2) else start
|
||||||
|
operations.append(Operation(
|
||||||
|
type=OperationType.DELETE,
|
||||||
|
file=current_file,
|
||||||
|
start_line=start,
|
||||||
|
end_line=end
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for INSERT_AFTER command
|
||||||
|
insert_match = self.INSERT_AFTER_PATTERN.match(line)
|
||||||
|
if insert_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###INSERT_AFTER without ###FILE"))
|
||||||
|
continue
|
||||||
|
current_op = Operation(
|
||||||
|
type=OperationType.INSERT_AFTER,
|
||||||
|
file=current_file,
|
||||||
|
start_line=int(insert_match.group(1))
|
||||||
|
)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for REPLACE command
|
||||||
|
replace_match = self.REPLACE_PATTERN.match(line)
|
||||||
|
if replace_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###REPLACE without ###FILE"))
|
||||||
|
continue
|
||||||
|
start = int(replace_match.group(1))
|
||||||
|
end = int(replace_match.group(2)) if replace_match.group(2) else start
|
||||||
|
current_op = Operation(
|
||||||
|
type=OperationType.REPLACE,
|
||||||
|
file=current_file,
|
||||||
|
start_line=start,
|
||||||
|
end_line=end
|
||||||
|
)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for END command
|
||||||
|
if self.END_PATTERN.match(line):
|
||||||
|
if current_op:
|
||||||
|
current_op.content = buffer.copy()
|
||||||
|
current_op.raw_content = raw_buffer.copy()
|
||||||
|
operations.append(current_op)
|
||||||
|
current_op = None
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for RENUMBER command
|
||||||
|
if self.RENUMBER_PATTERN.match(line):
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###RENUMBER without ###FILE"))
|
||||||
|
continue
|
||||||
|
operations.append(Operation(type=OperationType.RENUMBER, file=current_file))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for CHECKSUM command
|
||||||
|
checksum_match = self.CHECKSUM_PATTERN.match(line)
|
||||||
|
if checksum_match:
|
||||||
|
if current_op:
|
||||||
|
current_op.checksum = checksum_match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regular code line - add to buffer if we're in an operation
|
||||||
|
if current_op is not None:
|
||||||
|
raw_buffer.append(line)
|
||||||
|
clean_line = self._remove_line_number(line)
|
||||||
|
buffer.append(clean_line)
|
||||||
|
|
||||||
|
# Handle unclosed operation
|
||||||
|
if current_op is not None:
|
||||||
|
errors.append(ParseError(
|
||||||
|
len(lines),
|
||||||
|
"",
|
||||||
|
f"Unclosed operation: {current_op.type.value} for {current_op.file}"
|
||||||
|
))
|
||||||
|
|
||||||
|
return ParseResult(operations=operations, errors=errors)
|
||||||
|
|
||||||
|
def _remove_line_number(self, line: str) -> str:
|
||||||
|
"""Remove line number marker from end of line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return line[:match.start()]
|
||||||
|
return line
|
||||||
|
|
||||||
|
def extract_line_number(self, line: str) -> Optional[int]:
|
||||||
|
"""Extract line number from a code line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_line_number_suffix(filename: str, line_num: int) -> str:
|
||||||
|
"""Get the appropriate line number suffix for a file type."""
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
|
if ext in ('py', 'sh', 'bash', 'zsh', 'yaml', 'yml', 'toml', 'ini', 'conf', 'rb', 'pl'):
|
||||||
|
return f" #Z{line_num}"
|
||||||
|
elif ext in ('js', 'ts', 'jsx', 'tsx', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'scala'):
|
||||||
|
return f" //Z{line_num}"
|
||||||
|
elif ext in ('html', 'htm', 'xml', 'svg'):
|
||||||
|
return f" <!--Z{line_num}-->"
|
||||||
|
elif ext in ('css', 'scss', 'sass', 'less'):
|
||||||
|
return f" /*Z{line_num}*/"
|
||||||
|
elif ext in ('sql',):
|
||||||
|
return f" --Z{line_num}"
|
||||||
|
else:
|
||||||
|
# Default to Python style
|
||||||
|
return f" #Z{line_num}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the parser with example input."""
|
||||||
|
test_input = """###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z4-Z5
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
\"\"\"Multipliziert zwei Zahlen.\"\"\" #Z5
|
||||||
|
return a * b #Z6
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = DCTPParser()
|
||||||
|
result = parser.parse(test_input)
|
||||||
|
|
||||||
|
print("Operations found:")
|
||||||
|
for op in result.operations:
|
||||||
|
print(f" {op}")
|
||||||
|
|
||||||
|
if result.has_errors:
|
||||||
|
print("\nErrors:")
|
||||||
|
for error in result.errors:
|
||||||
|
print(f" Line {error.line_number}: {error.message}")
|
||||||
|
print(f" {error.line_content}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
customtkinter>=5.2.0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# FamilyAlbums - Apache Configuration
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-XSS-Protection "1; mode=block"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Deny access to config file
|
||||||
|
<Files "config.php">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</Files>
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive On
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType image/gif "access plus 1 month"
|
||||||
|
ExpiresByType image/webp "access plus 1 month"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Default charset
|
||||||
|
AddDefaultCharset UTF-8
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# FamilyAlbums - Familien-Fotoalbum-Portal
|
||||||
|
|
||||||
|
Ein einfaches, PHP-basiertes Portal zur Verwaltung und Anzeige von Familien-Fotoalben mit Links zu Nextcloud.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Öffentliche Galerie-Ansicht mit Jahr/Monat-Filter
|
||||||
|
- Stichwortsuche über Titel, Tags und Beschreibung
|
||||||
|
- Kommentarfunktion für Familienmitglieder
|
||||||
|
- Admin-Interface zur Albumverwaltung
|
||||||
|
- Responsive Design (Tailwind CSS)
|
||||||
|
- Flat-File Datenbank (JSON) - kein MySQL erforderlich
|
||||||
|
- Spam-Schutz (Honeypot + Rate-Limiting)
|
||||||
|
- CSRF-Schutz für Admin-Aktionen
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Dateien kopieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf den Webserver kopieren
|
||||||
|
sudo cp -r familyalbums /var/www/
|
||||||
|
|
||||||
|
# Berechtigungen setzen
|
||||||
|
sudo chown -R www-data:www-data /var/www/familyalbums
|
||||||
|
sudo chmod -R 755 /var/www/familyalbums
|
||||||
|
sudo chmod 770 /var/www/familyalbums/data
|
||||||
|
sudo chmod 770 /var/www/familyalbums/thumbnails
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Admin-Passwort ändern
|
||||||
|
|
||||||
|
**WICHTIG:** Das Standard-Passwort muss vor dem produktiven Einsatz geändert werden!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Neuen Passwort-Hash generieren
|
||||||
|
php -r "echo password_hash('DeinSicheresPasswort', PASSWORD_DEFAULT);"
|
||||||
|
```
|
||||||
|
|
||||||
|
Den generierten Hash in `config.php` eintragen:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$DEIN_GENERIERTER_HASH_HIER');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apache Virtual Host (optional)
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName familyalbums.example.com
|
||||||
|
DocumentRoot /var/www/familyalbums
|
||||||
|
|
||||||
|
<Directory /var/www/familyalbums>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Öffentliche Galerie
|
||||||
|
|
||||||
|
- URL: `https://deine-domain.ch/`
|
||||||
|
- Filter nach Jahr und Monat
|
||||||
|
- Stichwortsuche
|
||||||
|
- Kommentare zu Alben hinterlassen
|
||||||
|
|
||||||
|
### Admin-Bereich
|
||||||
|
|
||||||
|
- URL: `https://deine-domain.ch/admin.php`
|
||||||
|
- Login mit dem konfigurierten Passwort
|
||||||
|
- Alben hinzufügen, bearbeiten, löschen
|
||||||
|
- Optional: Vorschaubilder hochladen
|
||||||
|
- Kommentare moderieren
|
||||||
|
|
||||||
|
## Datenstruktur
|
||||||
|
|
||||||
|
### albums.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"albums": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "Albumtitel",
|
||||||
|
"url": "https://nextcloud.../apps/photos/public/...",
|
||||||
|
"date": "2024-12-25",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"thumbnail": "thumbnails/bild.jpg",
|
||||||
|
"created_at": "2024-12-26T10:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### comments.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"album_id": "album-uuid",
|
||||||
|
"author": "Name",
|
||||||
|
"text": "Kommentar",
|
||||||
|
"created_at": "2024-12-27T14:30:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Admin-Passwort mit bcrypt gehasht
|
||||||
|
- CSRF-Token für alle Admin-Aktionen
|
||||||
|
- XSS-Schutz durch `htmlspecialchars()`
|
||||||
|
- Rate-Limiting für Kommentare (5/Minute pro IP)
|
||||||
|
- Honeypot-Feld gegen Spam-Bots
|
||||||
|
- `.htaccess` schützt config.php und data/
|
||||||
|
|
||||||
|
## Anforderungen
|
||||||
|
|
||||||
|
- PHP 8.0+
|
||||||
|
- Apache mit mod_rewrite (optional)
|
||||||
|
- Schreibrechte für data/ und thumbnails/
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privates Projekt für Familien-Nutzung.
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Admin Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$pageTitle = SITE_TITLE . ' - Administration';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.tag-input { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; }
|
||||||
|
.tag-item { background: #dbeafe; color: #1d4ed8; padding: 0.25rem 0.5rem; border-radius: 9999px; display: flex; align-items: center; gap: 0.25rem; }
|
||||||
|
.tag-item button { color: #1d4ed8; cursor: pointer; }
|
||||||
|
.tag-input input { flex: 1; min-width: 100px; border: none; outline: none; }
|
||||||
|
.suggestions { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #d1d5db; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 10; }
|
||||||
|
.suggestions div { padding: 0.5rem 1rem; cursor: pointer; }
|
||||||
|
.suggestions div:hover { background: #f3f4f6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Login-Bereich (wird per JS gesteuert) -->
|
||||||
|
<div id="login-section" class="hidden min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-center mb-6">
|
||||||
|
<i class="fas fa-lock mr-2 text-blue-600"></i>Admin Login
|
||||||
|
</h1>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 mb-2">Passwort</label>
|
||||||
|
<input type="password" id="login-password" required
|
||||||
|
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Admin-Passwort eingeben">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="hidden text-red-500 text-sm mb-4"></div>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
|
||||||
|
<i class="fas fa-sign-in-alt mr-2"></i>Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center">
|
||||||
|
<a href="index.php" class="text-blue-600 hover:underline">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i>Zurück zur Galerie
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin-Bereich -->
|
||||||
|
<div id="admin-section" class="hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gradient-to-r from-gray-800 to-gray-900 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold">
|
||||||
|
<i class="fas fa-cog mr-2"></i><?= e($pageTitle) ?>
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="index.php" class="text-white/80 hover:text-white">
|
||||||
|
<i class="fas fa-eye mr-1"></i>Galerie
|
||||||
|
</a>
|
||||||
|
<button onclick="logout()" class="text-white/80 hover:text-white">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="bg-white shadow">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="showTab('albums')" id="tab-albums"
|
||||||
|
class="tab-btn py-4 px-2 border-b-2 border-blue-600 text-blue-600 font-medium">
|
||||||
|
<i class="fas fa-images mr-1"></i>Alben
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('comments')" id="tab-comments"
|
||||||
|
class="tab-btn py-4 px-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-comments mr-1"></i>Kommentare
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<!-- Alben-Tab -->
|
||||||
|
<div id="content-albums">
|
||||||
|
<!-- Album hinzufügen -->
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-plus-circle mr-2 text-green-600"></i>
|
||||||
|
<span id="form-title">Neues Album hinzufügen</span>
|
||||||
|
</h2>
|
||||||
|
<form id="album-form" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<input type="hidden" id="album-id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 mb-1">Titel *</label>
|
||||||
|
<input type="text" id="album-title" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="z.B. Weihnachten bei Oma">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 mb-1">Datum *</label>
|
||||||
|
<input type="date" id="album-date" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Nextcloud-Link *</label>
|
||||||
|
<input type="url" id="album-url" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://nextcloud.example.com/apps/photos/public/...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea id="album-description" rows="2"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Kurze Beschreibung des Albums"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 relative">
|
||||||
|
<label class="block text-gray-700 mb-1">Tags</label>
|
||||||
|
<div class="tag-input" id="tags-container">
|
||||||
|
<input type="text" id="tag-input" placeholder="Tag eingeben und Enter drücken">
|
||||||
|
</div>
|
||||||
|
<div id="tag-suggestions" class="suggestions hidden"></div>
|
||||||
|
<input type="hidden" id="album-tags">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-gray-700 mb-1">Vorschaubild (optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="file" id="thumbnail-file" accept="image/*"
|
||||||
|
class="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<button type="button" onclick="uploadThumbnail()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="album-thumbnail">
|
||||||
|
<div id="thumbnail-preview" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 flex gap-2">
|
||||||
|
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition">
|
||||||
|
<i class="fas fa-save mr-2"></i><span id="submit-text">Speichern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="resetForm()" class="bg-gray-200 px-6 py-2 rounded-lg hover:bg-gray-300 transition">
|
||||||
|
<i class="fas fa-times mr-2"></i>Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album-Liste -->
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-list mr-2 text-blue-600"></i>Alle Alben
|
||||||
|
</h2>
|
||||||
|
<div id="albums-list" class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Titel</th>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Datum</th>
|
||||||
|
<th class="px-4 py-3 text-left text-gray-600">Tags</th>
|
||||||
|
<th class="px-4 py-3 text-right text-gray-600">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="albums-table-body">
|
||||||
|
<!-- Wird per JS befüllt -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kommentare-Tab -->
|
||||||
|
<div id="content-comments" class="hidden">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
<i class="fas fa-comments mr-2 text-blue-600"></i>Alle Kommentare
|
||||||
|
</h2>
|
||||||
|
<div id="comments-list" class="space-y-4">
|
||||||
|
<!-- Wird per JS befüllt -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bestätigungs-Modal -->
|
||||||
|
<div id="confirm-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4" id="confirm-title">Bestätigung</h3>
|
||||||
|
<p id="confirm-message" class="text-gray-600 mb-6"></p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="closeConfirm()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button id="confirm-btn" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// === State ===
|
||||||
|
let csrfToken = '';
|
||||||
|
let allTags = [];
|
||||||
|
let currentTags = [];
|
||||||
|
let editingAlbumId = null;
|
||||||
|
let confirmCallback = null;
|
||||||
|
|
||||||
|
// === Auth ===
|
||||||
|
async function checkAuth() {
|
||||||
|
const response = await fetch('api.php?action=check_auth');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
csrfToken = data.csrf;
|
||||||
|
document.getElementById('login-section').classList.add('hidden');
|
||||||
|
document.getElementById('admin-section').classList.remove('hidden');
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
document.getElementById('login-section').classList.remove('hidden');
|
||||||
|
document.getElementById('admin-section').classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
const errorDiv = document.getElementById('login-error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
csrfToken = data.csrf;
|
||||||
|
document.getElementById('login-section').classList.add('hidden');
|
||||||
|
document.getElementById('admin-section').classList.remove('hidden');
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = data.error || 'Login fehlgeschlagen';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = 'Verbindungsfehler';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('api.php?action=logout', { method: 'POST' });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tabs ===
|
||||||
|
function showTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('border-blue-600', 'text-blue-600');
|
||||||
|
btn.classList.add('border-transparent', 'text-gray-500');
|
||||||
|
});
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-600');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
|
||||||
|
document.getElementById('content-albums').classList.add('hidden');
|
||||||
|
document.getElementById('content-comments').classList.add('hidden');
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
if (tab === 'comments') {
|
||||||
|
loadAllComments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Albums ===
|
||||||
|
async function loadAlbums() {
|
||||||
|
const response = await fetch('api.php?action=albums');
|
||||||
|
const data = await response.json();
|
||||||
|
renderAlbumsTable(data.albums || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlbumsTable(albums) {
|
||||||
|
const tbody = document.getElementById('albums-table-body');
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-8 text-gray-500">Noch keine Alben vorhanden</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = albums.map(album => `
|
||||||
|
<tr class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-medium">${escapeHtml(album.title)}</div>
|
||||||
|
<div class="text-sm text-gray-500 truncate max-w-xs">${escapeHtml(album.url)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600">${album.date}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${album.tags.slice(0, 3).map(tag =>
|
||||||
|
`<span class="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">${escapeHtml(tag)}</span>`
|
||||||
|
).join('')}
|
||||||
|
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button onclick='editAlbum(${JSON.stringify(album).replace(/'/g, "'")})' class="text-blue-600 hover:text-blue-800 mr-2">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmDelete('album', '${album.id}', '${escapeHtml(album.title)}')" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTags() {
|
||||||
|
const response = await fetch('api.php?action=tags');
|
||||||
|
const data = await response.json();
|
||||||
|
allTags = data.tags || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Album Form ===
|
||||||
|
document.getElementById('album-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const album = {
|
||||||
|
csrf: csrfToken,
|
||||||
|
title: document.getElementById('album-title').value,
|
||||||
|
url: document.getElementById('album-url').value,
|
||||||
|
date: document.getElementById('album-date').value,
|
||||||
|
description: document.getElementById('album-description').value,
|
||||||
|
tags: currentTags,
|
||||||
|
thumbnail: document.getElementById('album-thumbnail').value
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = 'api.php?action=album';
|
||||||
|
let method = 'POST';
|
||||||
|
|
||||||
|
if (editingAlbumId) {
|
||||||
|
album.id = editingAlbumId;
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(album)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
resetForm();
|
||||||
|
loadAlbums();
|
||||||
|
loadAllTags();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function editAlbum(album) {
|
||||||
|
editingAlbumId = album.id;
|
||||||
|
document.getElementById('album-id').value = album.id;
|
||||||
|
document.getElementById('album-title').value = album.title;
|
||||||
|
document.getElementById('album-url').value = album.url;
|
||||||
|
document.getElementById('album-date').value = album.date;
|
||||||
|
document.getElementById('album-description').value = album.description || '';
|
||||||
|
document.getElementById('album-thumbnail').value = album.thumbnail || '';
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
currentTags = [...album.tags];
|
||||||
|
renderTags();
|
||||||
|
|
||||||
|
// Thumbnail preview
|
||||||
|
if (album.thumbnail) {
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML =
|
||||||
|
`<img src="${escapeHtml(album.thumbnail)}" class="h-20 rounded">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form-title').textContent = 'Album bearbeiten';
|
||||||
|
document.getElementById('submit-text').textContent = 'Aktualisieren';
|
||||||
|
|
||||||
|
// Scroll to form
|
||||||
|
document.getElementById('album-form').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingAlbumId = null;
|
||||||
|
document.getElementById('album-form').reset();
|
||||||
|
document.getElementById('album-thumbnail').value = '';
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML = '';
|
||||||
|
currentTags = [];
|
||||||
|
renderTags();
|
||||||
|
document.getElementById('form-title').textContent = 'Neues Album hinzufügen';
|
||||||
|
document.getElementById('submit-text').textContent = 'Speichern';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlbum(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=album', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, csrf: csrfToken })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadAlbums();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tags ===
|
||||||
|
function renderTags() {
|
||||||
|
const container = document.getElementById('tags-container');
|
||||||
|
const input = document.getElementById('tag-input');
|
||||||
|
|
||||||
|
// Remove existing tag items
|
||||||
|
container.querySelectorAll('.tag-item').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Add tag items before input
|
||||||
|
currentTags.forEach((tag, index) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'tag-item';
|
||||||
|
span.innerHTML = `${escapeHtml(tag)}<button type="button" onclick="removeTag(${index})">×</button>`;
|
||||||
|
container.insertBefore(span, input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(index) {
|
||||||
|
currentTags.splice(index, 1);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tag-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value && !currentTags.includes(value)) {
|
||||||
|
currentTags.push(value);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
document.getElementById('tag-suggestions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tag-input').addEventListener('input', (e) => {
|
||||||
|
const value = e.target.value.toLowerCase();
|
||||||
|
const suggestions = document.getElementById('tag-suggestions');
|
||||||
|
|
||||||
|
if (value.length < 1) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = allTags.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(value) && !currentTags.includes(tag)
|
||||||
|
).slice(0, 5);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.innerHTML = matches.map(tag =>
|
||||||
|
`<div onclick="selectTag('${escapeHtml(tag)}')">${escapeHtml(tag)}</div>`
|
||||||
|
).join('');
|
||||||
|
suggestions.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTag(tag) {
|
||||||
|
if (!currentTags.includes(tag)) {
|
||||||
|
currentTags.push(tag);
|
||||||
|
renderTags();
|
||||||
|
}
|
||||||
|
document.getElementById('tag-input').value = '';
|
||||||
|
document.getElementById('tag-suggestions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Thumbnail Upload ===
|
||||||
|
async function uploadThumbnail() {
|
||||||
|
const fileInput = document.getElementById('thumbnail-file');
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
alert('Bitte wähle zuerst ein Bild aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('thumbnail', fileInput.files[0]);
|
||||||
|
formData.append('csrf', csrfToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=upload_thumbnail', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('album-thumbnail').value = data.path;
|
||||||
|
document.getElementById('thumbnail-preview').innerHTML =
|
||||||
|
`<img src="${escapeHtml(data.path)}" class="h-20 rounded">`;
|
||||||
|
fileInput.value = '';
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Comments ===
|
||||||
|
async function loadAllComments() {
|
||||||
|
const albumsResponse = await fetch('api.php?action=albums');
|
||||||
|
const albumsData = await albumsResponse.json();
|
||||||
|
const albums = albumsData.albums || [];
|
||||||
|
|
||||||
|
const commentsContainer = document.getElementById('comments-list');
|
||||||
|
commentsContainer.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade Kommentare...</p>';
|
||||||
|
|
||||||
|
// Kommentare für alle Alben laden
|
||||||
|
const allComments = [];
|
||||||
|
for (const album of albums) {
|
||||||
|
const response = await fetch(`api.php?action=comments&album_id=${album.id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
(data.comments || []).forEach(comment => {
|
||||||
|
comment.albumTitle = album.title;
|
||||||
|
allComments.push(comment);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Datum sortieren
|
||||||
|
allComments.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||||
|
|
||||||
|
if (allComments.length === 0) {
|
||||||
|
commentsContainer.innerHTML = '<p class="text-center text-gray-500 py-8">Noch keine Kommentare vorhanden</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsContainer.innerHTML = allComments.map(comment => `
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">${escapeHtml(comment.author)}</span>
|
||||||
|
<span class="text-gray-400 text-sm ml-2">${formatDateTime(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="confirmDelete('comment', '${comment.id}', 'diesen Kommentar')" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 mb-2">${escapeHtml(comment.text)}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-images mr-1"></i>${escapeHtml(comment.albumTitle)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('api.php?action=comment', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, csrf: csrfToken })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loadAllComments();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Verbindungsfehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Confirm Modal ===
|
||||||
|
function confirmDelete(type, id, name) {
|
||||||
|
document.getElementById('confirm-message').textContent =
|
||||||
|
`Möchtest du "${name}" wirklich löschen?`;
|
||||||
|
|
||||||
|
confirmCallback = () => {
|
||||||
|
if (type === 'album') {
|
||||||
|
deleteAlbum(id);
|
||||||
|
} else if (type === 'comment') {
|
||||||
|
deleteComment(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('confirm-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-modal').classList.add('hidden');
|
||||||
|
confirmCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirm-btn').addEventListener('click', () => {
|
||||||
|
if (confirmCallback) {
|
||||||
|
confirmCallback();
|
||||||
|
}
|
||||||
|
closeConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
return date.toLocaleDateString('de-CH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
checkAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - API Endpunkte
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// Hilfsfunktion: JSON Response
|
||||||
|
function json_response(array $data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Admin-Check
|
||||||
|
function require_admin(): void {
|
||||||
|
if (empty($_SESSION['admin_logged_in'])) {
|
||||||
|
json_response(['error' => 'Nicht autorisiert'], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ALBEN ===
|
||||||
|
|
||||||
|
if ($action === 'albums' && $method === 'GET') {
|
||||||
|
// Alle Alben abrufen (öffentlich)
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$albums = $data['albums'] ?? [];
|
||||||
|
|
||||||
|
// Filter: Jahr
|
||||||
|
if (!empty($_GET['year'])) {
|
||||||
|
$year = $_GET['year'];
|
||||||
|
$albums = array_filter($albums, fn($a) => substr($a['date'], 0, 4) === $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Monat
|
||||||
|
if (!empty($_GET['month'])) {
|
||||||
|
$month = $_GET['month'];
|
||||||
|
$albums = array_filter($albums, fn($a) => substr($a['date'], 5, 2) === $month);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Suche
|
||||||
|
if (!empty($_GET['search'])) {
|
||||||
|
$search = mb_strtolower($_GET['search']);
|
||||||
|
$albums = array_filter($albums, function($a) use ($search) {
|
||||||
|
$haystack = mb_strtolower($a['title'] . ' ' . $a['description'] . ' ' . implode(' ', $a['tags']));
|
||||||
|
return str_contains($haystack, $search);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortierung
|
||||||
|
$sort = $_GET['sort'] ?? 'newest';
|
||||||
|
usort($albums, function($a, $b) use ($sort) {
|
||||||
|
if ($sort === 'oldest') {
|
||||||
|
return strcmp($a['date'], $b['date']);
|
||||||
|
}
|
||||||
|
return strcmp($b['date'], $a['date']); // newest first
|
||||||
|
});
|
||||||
|
|
||||||
|
json_response(['albums' => array_values($albums)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'POST') {
|
||||||
|
// Album erstellen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($input['title']) || empty($input['url']) || empty($input['date'])) {
|
||||||
|
json_response(['error' => 'Titel, URL und Datum sind Pflichtfelder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = [
|
||||||
|
'id' => generate_uuid(),
|
||||||
|
'title' => trim($input['title']),
|
||||||
|
'url' => trim($input['url']),
|
||||||
|
'date' => $input['date'],
|
||||||
|
'tags' => array_map('trim', $input['tags'] ?? []),
|
||||||
|
'description' => trim($input['description'] ?? ''),
|
||||||
|
'thumbnail' => $input['thumbnail'] ?? '',
|
||||||
|
'created_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$data['albums'][] = $album;
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true, 'album' => $album]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'PUT') {
|
||||||
|
// Album bearbeiten (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($data['albums'] as &$album) {
|
||||||
|
if ($album['id'] === $id) {
|
||||||
|
$album['title'] = trim($input['title'] ?? $album['title']);
|
||||||
|
$album['url'] = trim($input['url'] ?? $album['url']);
|
||||||
|
$album['date'] = $input['date'] ?? $album['date'];
|
||||||
|
$album['tags'] = array_map('trim', $input['tags'] ?? $album['tags']);
|
||||||
|
$album['description'] = trim($input['description'] ?? $album['description']);
|
||||||
|
$album['thumbnail'] = $input['thumbnail'] ?? $album['thumbnail'];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
json_response(['error' => 'Album nicht gefunden'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'album' && $method === 'DELETE') {
|
||||||
|
// Album löschen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$data['albums'] = array_filter($data['albums'], fn($a) => $a['id'] !== $id);
|
||||||
|
$data['albums'] = array_values($data['albums']);
|
||||||
|
write_json(ALBUMS_FILE, $data);
|
||||||
|
|
||||||
|
// Zugehörige Kommentare löschen
|
||||||
|
$comments = read_json(COMMENTS_FILE);
|
||||||
|
$comments['comments'] = array_filter($comments['comments'], fn($c) => $c['album_id'] !== $id);
|
||||||
|
$comments['comments'] = array_values($comments['comments']);
|
||||||
|
write_json(COMMENTS_FILE, $comments);
|
||||||
|
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === KOMMENTARE ===
|
||||||
|
|
||||||
|
if ($action === 'comments' && $method === 'GET') {
|
||||||
|
// Kommentare für Album abrufen (öffentlich)
|
||||||
|
$album_id = $_GET['album_id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$comments = array_filter($data['comments'] ?? [], fn($c) => $c['album_id'] === $album_id);
|
||||||
|
|
||||||
|
// Nach Datum sortieren (neueste zuerst)
|
||||||
|
usort($comments, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
|
|
||||||
|
json_response(['comments' => array_values($comments)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'comment' && $method === 'POST') {
|
||||||
|
// Kommentar erstellen (öffentlich)
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (empty($input['album_id']) || empty($input['author']) || empty($input['text'])) {
|
||||||
|
json_response(['error' => 'Album-ID, Name und Text sind Pflichtfelder'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honeypot-Check (Spam-Schutz)
|
||||||
|
if (!empty($input['website'])) {
|
||||||
|
json_response(['success' => true]); // Fake-Erfolg für Bots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-Limiting: Max 5 Kommentare pro Minute pro IP
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$rate_file = DATA_PATH . 'rate_' . md5($ip) . '.json';
|
||||||
|
$rate_data = read_json($rate_file);
|
||||||
|
$now = time();
|
||||||
|
$rate_data['times'] = array_filter($rate_data['times'] ?? [], fn($t) => $t > $now - 60);
|
||||||
|
|
||||||
|
if (count($rate_data['times']) >= 5) {
|
||||||
|
json_response(['error' => 'Zu viele Kommentare. Bitte warte eine Minute.'], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate_data['times'][] = $now;
|
||||||
|
write_json($rate_file, $rate_data);
|
||||||
|
|
||||||
|
$comment = [
|
||||||
|
'id' => generate_uuid(),
|
||||||
|
'album_id' => $input['album_id'],
|
||||||
|
'author' => trim($input['author']),
|
||||||
|
'text' => trim($input['text']),
|
||||||
|
'created_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$data['comments'][] = $comment;
|
||||||
|
write_json(COMMENTS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true, 'comment' => $comment]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'comment' && $method === 'DELETE') {
|
||||||
|
// Kommentar löschen (Admin)
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!csrf_validate($input['csrf'] ?? '')) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
|
||||||
|
$data = read_json(COMMENTS_FILE);
|
||||||
|
$data['comments'] = array_filter($data['comments'], fn($c) => $c['id'] !== $id);
|
||||||
|
$data['comments'] = array_values($data['comments']);
|
||||||
|
write_json(COMMENTS_FILE, $data);
|
||||||
|
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TAGS ===
|
||||||
|
|
||||||
|
if ($action === 'tags' && $method === 'GET') {
|
||||||
|
// Alle verwendeten Tags abrufen (für Vorschläge)
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$tags = [];
|
||||||
|
|
||||||
|
foreach ($data['albums'] ?? [] as $album) {
|
||||||
|
foreach ($album['tags'] ?? [] as $tag) {
|
||||||
|
$tags[$tag] = ($tags[$tag] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($tags);
|
||||||
|
json_response(['tags' => array_keys($tags)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JAHRE/MONATE ===
|
||||||
|
|
||||||
|
if ($action === 'dates' && $method === 'GET') {
|
||||||
|
// Verfügbare Jahre und Monate
|
||||||
|
$data = read_json(ALBUMS_FILE);
|
||||||
|
$years = [];
|
||||||
|
|
||||||
|
foreach ($data['albums'] ?? [] as $album) {
|
||||||
|
$year = substr($album['date'], 0, 4);
|
||||||
|
$month = substr($album['date'], 5, 2);
|
||||||
|
|
||||||
|
if (!isset($years[$year])) {
|
||||||
|
$years[$year] = [];
|
||||||
|
}
|
||||||
|
if (!in_array($month, $years[$year])) {
|
||||||
|
$years[$year][] = $month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortieren
|
||||||
|
krsort($years);
|
||||||
|
foreach ($years as &$months) {
|
||||||
|
sort($months);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['dates' => $years]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTH ===
|
||||||
|
|
||||||
|
if ($action === 'login' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$password = $input['password'] ?? '';
|
||||||
|
|
||||||
|
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||||
|
$_SESSION['admin_logged_in'] = true;
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
json_response(['success' => true, 'csrf' => $_SESSION['csrf_token']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verzögerung gegen Brute-Force
|
||||||
|
sleep(1);
|
||||||
|
json_response(['error' => 'Falsches Passwort'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'logout' && $method === 'POST') {
|
||||||
|
session_destroy();
|
||||||
|
json_response(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'check_auth' && $method === 'GET') {
|
||||||
|
json_response([
|
||||||
|
'authenticated' => !empty($_SESSION['admin_logged_in']),
|
||||||
|
'csrf' => $_SESSION['csrf_token'] ?? ''
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === THUMBNAIL UPLOAD ===
|
||||||
|
|
||||||
|
if ($action === 'upload_thumbnail' && $method === 'POST') {
|
||||||
|
require_admin();
|
||||||
|
|
||||||
|
if (empty($_POST['csrf']) || !csrf_validate($_POST['csrf'])) {
|
||||||
|
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_FILES['thumbnail']) || $_FILES['thumbnail']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
json_response(['error' => 'Kein Bild hochgeladen'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['thumbnail'];
|
||||||
|
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
if (!in_array($file['type'], $allowed)) {
|
||||||
|
json_response(['error' => 'Nur JPG, PNG, GIF und WebP erlaubt'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > 5 * 1024 * 1024) {
|
||||||
|
json_response(['error' => 'Maximale Dateigrösse: 5MB'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||||
|
$filename = generate_uuid() . '.' . $ext;
|
||||||
|
$path = THUMBNAIL_PATH . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $path)) {
|
||||||
|
json_response(['error' => 'Upload fehlgeschlagen'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response(['success' => true, 'path' => THUMBNAIL_URL . $filename]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekannte Aktion
|
||||||
|
json_response(['error' => 'Unbekannte Aktion'], 404);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Konfiguration
|
||||||
|
*
|
||||||
|
* WICHTIG: Nach erster Installation Passwort ändern!
|
||||||
|
* Neuen Hash generieren: php -r "echo password_hash('deinPasswort', PASSWORD_DEFAULT);"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Standard-Passwort: "familie2024" - BITTE ÄNDERN!
|
||||||
|
define('ADMIN_PASSWORD_HASH', '$2y$10$YxQx8B7GkDqNmPrC4VzKH.qN4tQ8WvX5kF7mZ3hJ9aE1bC2dR6uYO');
|
||||||
|
|
||||||
|
define('SITE_TITLE', 'Familien-Fotoalben');
|
||||||
|
define('DATA_PATH', __DIR__ . '/data/');
|
||||||
|
define('THUMBNAIL_PATH', __DIR__ . '/thumbnails/');
|
||||||
|
define('THUMBNAIL_URL', 'thumbnails/');
|
||||||
|
|
||||||
|
define('ALBUMS_FILE', DATA_PATH . 'albums.json');
|
||||||
|
define('COMMENTS_FILE', DATA_PATH . 'comments.json');
|
||||||
|
|
||||||
|
// Session-Einstellungen
|
||||||
|
define('SESSION_LIFETIME', 3600); // 1 Stunde
|
||||||
|
|
||||||
|
// Zeitzone
|
||||||
|
date_default_timezone_set('Europe/Zurich');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-Datei lesen
|
||||||
|
*/
|
||||||
|
function read_json(string $file): array {
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
return json_decode($content, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-Datei schreiben
|
||||||
|
*/
|
||||||
|
function write_json(string $file, array $data): bool {
|
||||||
|
$dir = dirname($file);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0770, true);
|
||||||
|
}
|
||||||
|
return file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID generieren
|
||||||
|
*/
|
||||||
|
function generate_uuid(): string {
|
||||||
|
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XSS-sichere Ausgabe
|
||||||
|
*/
|
||||||
|
function e(string $str): string {
|
||||||
|
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token generieren
|
||||||
|
*/
|
||||||
|
function csrf_token(): string {
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF-Token validieren
|
||||||
|
*/
|
||||||
|
function csrf_validate(string $token): bool {
|
||||||
|
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisiere Daten-Dateien falls nicht vorhanden
|
||||||
|
if (!file_exists(ALBUMS_FILE)) {
|
||||||
|
write_json(ALBUMS_FILE, ['albums' => []]);
|
||||||
|
}
|
||||||
|
if (!file_exists(COMMENTS_FILE)) {
|
||||||
|
write_json(COMMENTS_FILE, ['comments' => []]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Deny access to all files in this directory
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"albums": [
|
||||||
|
{
|
||||||
|
"id": "demo-001",
|
||||||
|
"title": "Weihnachten 2024",
|
||||||
|
"url": "https://nextcloud.example.com/apps/photos/public/demo",
|
||||||
|
"date": "2024-12-25",
|
||||||
|
"tags": ["weihnachten", "familie", "2024"],
|
||||||
|
"description": "Bescherung und Festessen bei der Familie",
|
||||||
|
"thumbnail": "",
|
||||||
|
"created_at": "2024-12-26T10:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"comments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FamilyAlbums - Öffentliche Ansicht
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$pageTitle = SITE_TITLE;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
.album-card:hover { transform: translateY(-4px); }
|
||||||
|
.tag { transition: all 0.2s; }
|
||||||
|
.tag:hover { transform: scale(1.05); }
|
||||||
|
.modal { transition: opacity 0.3s; }
|
||||||
|
.modal.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
.gradient-placeholder {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold">
|
||||||
|
<i class="fas fa-images mr-2"></i><?= e($pageTitle) ?>
|
||||||
|
</h1>
|
||||||
|
<a href="admin.php" class="text-white/80 hover:text-white text-sm">
|
||||||
|
<i class="fas fa-lock mr-1"></i>Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filter-Bereich -->
|
||||||
|
<div class="bg-white shadow-md sticky top-0 z-10">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<!-- Suche -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="search" placeholder="Album suchen..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jahr -->
|
||||||
|
<select id="filter-year" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Monat -->
|
||||||
|
<select id="filter-month" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" disabled>
|
||||||
|
<option value="">Alle Monate</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Sortierung -->
|
||||||
|
<select id="sort" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
<option value="oldest">Älteste zuerst</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Album-Grid -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<div id="albums-container" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
<!-- Alben werden per JS geladen -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-results" class="hidden text-center py-12 text-gray-500">
|
||||||
|
<i class="fas fa-search text-4xl mb-4"></i>
|
||||||
|
<p class="text-xl">Keine Alben gefunden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="text-center py-12">
|
||||||
|
<i class="fas fa-spinner fa-spin text-4xl text-blue-500"></i>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Album-Detail Modal -->
|
||||||
|
<div id="album-modal" class="modal hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h2 id="modal-title" class="text-2xl font-bold text-gray-800"></h2>
|
||||||
|
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-thumbnail" class="mb-4 rounded-lg overflow-hidden"></div>
|
||||||
|
|
||||||
|
<p id="modal-date" class="text-gray-500 mb-2"></p>
|
||||||
|
<p id="modal-description" class="text-gray-700 mb-4"></p>
|
||||||
|
|
||||||
|
<div id="modal-tags" class="flex flex-wrap gap-2 mb-6"></div>
|
||||||
|
|
||||||
|
<a id="modal-link" href="#" target="_blank"
|
||||||
|
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition mb-6">
|
||||||
|
<i class="fas fa-external-link-alt mr-2"></i>Album öffnen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Kommentare -->
|
||||||
|
<div class="border-t pt-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">
|
||||||
|
<i class="fas fa-comments mr-2"></i>Kommentare
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div id="comments-list" class="space-y-4 mb-6"></div>
|
||||||
|
|
||||||
|
<!-- Kommentar-Formular -->
|
||||||
|
<form id="comment-form" class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<input type="hidden" id="comment-album-id">
|
||||||
|
<!-- Honeypot -->
|
||||||
|
<input type="text" name="website" id="comment-website" class="hidden" tabindex="-1" autocomplete="off">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="comment-author" placeholder="Dein Name" required
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea id="comment-text" placeholder="Dein Kommentar..." required rows="3"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition">
|
||||||
|
<i class="fas fa-paper-plane mr-2"></i>Absenden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-800 text-white py-6 mt-12">
|
||||||
|
<div class="container mx-auto px-4 text-center">
|
||||||
|
<p>© <?= date('Y') ?> <?= e($pageTitle) ?></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// === State ===
|
||||||
|
let allDates = {};
|
||||||
|
let currentAlbumId = null;
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
// === Monatsnamen ===
|
||||||
|
const monthNames = {
|
||||||
|
'01': 'Januar', '02': 'Februar', '03': 'März', '04': 'April',
|
||||||
|
'05': 'Mai', '06': 'Juni', '07': 'Juli', '08': 'August',
|
||||||
|
'09': 'September', '10': 'Oktober', '11': 'November', '12': 'Dezember'
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${parseInt(day)}. ${monthNames[month]} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoStr) {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
return date.toLocaleDateString('de-CH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === API Calls ===
|
||||||
|
async function fetchAlbums() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const year = document.getElementById('filter-year').value;
|
||||||
|
const month = document.getElementById('filter-month').value;
|
||||||
|
const search = document.getElementById('search').value;
|
||||||
|
const sort = document.getElementById('sort').value;
|
||||||
|
|
||||||
|
if (year) params.append('year', year);
|
||||||
|
if (month) params.append('month', month);
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
params.append('sort', sort);
|
||||||
|
|
||||||
|
const response = await fetch(`api.php?action=albums&${params}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDates() {
|
||||||
|
const response = await fetch('api.php?action=dates');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchComments(albumId) {
|
||||||
|
const response = await fetch(`api.php?action=comments&album_id=${encodeURIComponent(albumId)}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment(albumId, author, text, website) {
|
||||||
|
const response = await fetch('api.php?action=comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ album_id: albumId, author, text, website })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Rendering ===
|
||||||
|
function renderAlbums(albums) {
|
||||||
|
const container = document.getElementById('albums-container');
|
||||||
|
const noResults = document.getElementById('no-results');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
noResults.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noResults.classList.add('hidden');
|
||||||
|
|
||||||
|
container.innerHTML = albums.map(album => `
|
||||||
|
<div class="album-card bg-white rounded-xl shadow-md overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-xl"
|
||||||
|
data-album='${JSON.stringify(album).replace(/'/g, "'")}'
|
||||||
|
onclick="openModalFromCard(this)">
|
||||||
|
<div class="aspect-video gradient-placeholder flex items-center justify-center">
|
||||||
|
${album.thumbnail
|
||||||
|
? `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<i class=\\'fas fa-images text-4xl text-white/50\\'></i>'">`
|
||||||
|
: `<i class="fas fa-images text-4xl text-white/50"></i>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-lg text-gray-800 mb-1 line-clamp-2">${escapeHtml(album.title)}</h3>
|
||||||
|
<p class="text-gray-500 text-sm mb-3">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${album.tags.slice(0, 3).map(tag => `
|
||||||
|
<span class="tag bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">${escapeHtml(tag)}</span>
|
||||||
|
`).join('')}
|
||||||
|
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDateFilters(dates) {
|
||||||
|
allDates = dates;
|
||||||
|
const yearSelect = document.getElementById('filter-year');
|
||||||
|
|
||||||
|
yearSelect.innerHTML = '<option value="">Alle Jahre</option>' +
|
||||||
|
Object.keys(dates).map(year => `<option value="${year}">${year}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonthFilter() {
|
||||||
|
const year = document.getElementById('filter-year').value;
|
||||||
|
const monthSelect = document.getElementById('filter-month');
|
||||||
|
|
||||||
|
if (!year || !allDates[year]) {
|
||||||
|
monthSelect.innerHTML = '<option value="">Alle Monate</option>';
|
||||||
|
monthSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthSelect.disabled = false;
|
||||||
|
monthSelect.innerHTML = '<option value="">Alle Monate</option>' +
|
||||||
|
allDates[year].map(month => `<option value="${month}">${monthNames[month]}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComments(comments) {
|
||||||
|
const container = document.getElementById('comments-list');
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center italic">Noch keine Kommentare. Sei der Erste!</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = comments.map(comment => `
|
||||||
|
<div class="bg-white p-3 rounded-lg border">
|
||||||
|
<div class="flex justify-between items-start mb-1">
|
||||||
|
<span class="font-semibold text-gray-800">${escapeHtml(comment.author)}</span>
|
||||||
|
<span class="text-gray-400 text-xs">${formatDateTime(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700">${escapeHtml(comment.text)}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Modal ===
|
||||||
|
function openModalFromCard(element) {
|
||||||
|
const album = JSON.parse(element.dataset.album);
|
||||||
|
openModal(album.id, album);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(id, album) {
|
||||||
|
currentAlbumId = id;
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent = album.title;
|
||||||
|
document.getElementById('modal-date').innerHTML = `<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}`;
|
||||||
|
document.getElementById('modal-description').textContent = album.description || 'Keine Beschreibung';
|
||||||
|
document.getElementById('modal-link').href = album.url;
|
||||||
|
document.getElementById('comment-album-id').value = id;
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
const thumbnailContainer = document.getElementById('modal-thumbnail');
|
||||||
|
if (album.thumbnail) {
|
||||||
|
thumbnailContainer.innerHTML = `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full max-h-64 object-cover">`;
|
||||||
|
} else {
|
||||||
|
thumbnailContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
document.getElementById('modal-tags').innerHTML = album.tags.map(tag =>
|
||||||
|
`<span class="bg-blue-100 text-blue-700 text-sm px-3 py-1 rounded-full">${escapeHtml(tag)}</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Modal anzeigen
|
||||||
|
document.getElementById('album-modal').classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Kommentare laden
|
||||||
|
loadComments(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('album-modal').classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
currentAlbumId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments(albumId) {
|
||||||
|
document.getElementById('comments-list').innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i></p>';
|
||||||
|
const data = await fetchComments(albumId);
|
||||||
|
renderComments(data.comments || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Event Listeners ===
|
||||||
|
document.getElementById('search').addEventListener('input', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-year').addEventListener('change', async () => {
|
||||||
|
updateMonthFilter();
|
||||||
|
document.getElementById('filter-month').value = '';
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-month').addEventListener('change', async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sort').addEventListener('change', async () => {
|
||||||
|
const data = await fetchAlbums();
|
||||||
|
renderAlbums(data.albums || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const albumId = document.getElementById('comment-album-id').value;
|
||||||
|
const author = document.getElementById('comment-author').value.trim();
|
||||||
|
const text = document.getElementById('comment-text').value.trim();
|
||||||
|
const website = document.getElementById('comment-website').value;
|
||||||
|
|
||||||
|
if (!author || !text) return;
|
||||||
|
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Senden...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postComment(albumId, author, text, website);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
alert(result.error);
|
||||||
|
} else {
|
||||||
|
document.getElementById('comment-text').value = '';
|
||||||
|
await loadComments(albumId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Senden des Kommentars');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Absenden';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal schliessen bei Klick ausserhalb
|
||||||
|
document.getElementById('album-modal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'album-modal') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal schliessen mit Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Init ===
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [albumsData, datesData] = await Promise.all([
|
||||||
|
fetchAlbums(),
|
||||||
|
fetchDates()
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderAlbums(albumsData.albums || []);
|
||||||
|
renderDateFilters(datesData.dates || {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden:', err);
|
||||||
|
document.getElementById('loading').innerHTML =
|
||||||
|
'<p class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Fehler beim Laden der Alben</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user