Files
fotospiel-app/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
Codex Agent cc11e024f0
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add photobooth folder presets
2026-01-13 12:00:39 +01:00

1351 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using System.Net.Http.Headers;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Platform;
using Avalonia.Threading;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
namespace PhotoboothUploader;
public partial class MainWindow : Window
{
private const string DefaultBaseUrl = "https://fotospiel.app";
private PhotoboothConnectClient _client;
private readonly SettingsStore _settingsStore = new();
private readonly UploadService _uploadService = new();
private PhotoboothSettings _settings;
private FileSystemWatcher? _watcher;
private readonly Dictionary<string, UploadItem> _uploadsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _failedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly string _userAgent;
private int _queuedCount;
private int _uploadingCount;
private int _failedCount;
private DateTimeOffset? _lastSuccessAt;
private bool _advancedVisible;
private readonly DispatcherTimer _liveTimer = new();
private readonly List<string> _logBuffer = new();
public ObservableCollection<UploadItem> RecentUploads { get; } = new();
public MainWindow()
{
InitializeComponent();
_settings = _settingsStore.Load();
EnsureSettingsCollections();
_settings.BaseUrl = NormalizeBaseUrl(_settings.BaseUrl) ?? DefaultBaseUrl;
if (_settings.MaxConcurrentUploads <= 0)
{
_settings.MaxConcurrentUploads = 2;
}
_userAgent = $"FotospielPhotoboothUploader/{GetAppVersion()}";
_client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
_uploadService.Configure(_userAgent);
_settingsStore.Save(_settings);
DataContext = this;
_liveTimer.Interval = TimeSpan.FromSeconds(30);
_liveTimer.Tick += (_, _) => UpdateLiveStatus();
_liveTimer.Start();
Opened += OnWindowOpened;
Closing += OnWindowClosing;
ApplySettings();
}
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
{
var code = (CodeBox.Text ?? string.Empty).Trim();
if (!IsValidCode(code))
{
StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
return;
}
ConnectButton.IsEnabled = false;
ReconnectButton.IsEnabled = false;
StatusText.Text = "Verbinde...";
await RedeemCodeAsync(code);
ConnectButton.IsEnabled = true;
ReconnectButton.IsEnabled = true;
}
private async void ReconnectButton_Click(object? sender, RoutedEventArgs e)
{
var code = (CodeBox.Text ?? string.Empty).Trim();
if (!IsValidCode(code))
{
StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
return;
}
ReconnectButton.IsEnabled = false;
StatusText.Text = "Verbindung erneuern...";
await RedeemCodeAsync(code);
ReconnectButton.IsEnabled = true;
}
private async Task RedeemCodeAsync(string code)
{
var normalizedBaseUrl = NormalizeBaseUrl(_settings.BaseUrl);
if (normalizedBaseUrl is null)
{
UpdateStatus("Ungültige Basis-URL. Bitte Support kontaktieren.");
SetLastError("Ungültige Basis-URL.");
return;
}
if (!string.Equals(normalizedBaseUrl, _settings.BaseUrl, StringComparison.OrdinalIgnoreCase))
{
_settings.BaseUrl = normalizedBaseUrl;
_client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
}
var response = await _client.RedeemAsync(code);
if (response.Data is null)
{
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
SetLastError(response.Message ?? "Verbindung fehlgeschlagen.");
return;
}
ClearLastError();
_settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
_settings.Username = response.Data.Username;
_settings.Password = response.Data.Password;
_settings.ResponseFormat = response.Data.ResponseFormat;
_settings.EventName = response.Data.EventName;
_settings.ConnectExpiresAt = response.Data.ExpiresAt;
_settingsStore.Save(_settings);
StatusText.Text = "Verbunden. Upload bereit.";
AppendLog("Verbunden mit Event.");
PickFolderButton.IsEnabled = true;
TestUploadButton.IsEnabled = true;
ReconnectButton.IsEnabled = true;
UpdateAdvancedLockState();
UpdateDiagnostics();
StartUploadPipelineIfReady();
}
private async void PickFolderButton_Click(object? sender, RoutedEventArgs e)
{
var options = new FolderPickerOpenOptions
{
Title = "Upload-Ordner auswählen",
AllowMultiple = false,
};
var folders = await StorageProvider.OpenFolderPickerAsync(options);
var folder = folders.FirstOrDefault();
var localPath = folder?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
return;
}
_settings.WatchFolder = localPath;
_settingsStore.Save(_settings);
FolderText.Text = localPath;
UpdateFolderHealth();
StartUploadPipelineIfReady();
}
private void DslrBoothPresetButton_Click(object? sender, RoutedEventArgs e)
{
ApplyPresetFolder(GetDslrBoothFolder());
}
private void SparkboothPresetButton_Click(object? sender, RoutedEventArgs e)
{
ApplyPresetFolder(GetSparkboothFolder());
}
private void ApplySettings()
{
if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
FolderText.Text = _settings.WatchFolder;
}
BaseUrlBox.Text = _settings.BaseUrl ?? DefaultBaseUrl;
MaxUploadsBox.Text = _settings.MaxConcurrentUploads.ToString();
UploadTempoBox.SelectedIndex = ResolveUploadTempoIndex(_settings.UploadDelayMs);
IncludePatternsBox.Text = _settings.IncludePatterns ?? string.Empty;
ExcludePatternsBox.Text = _settings.ExcludePatterns ?? string.Empty;
ResponseFormatBox.SelectedIndex = ResolveResponseFormatIndex(_settings.ResponseFormat);
ManualUploadUrlBox.Text = _settings.UploadUrl ?? string.Empty;
ManualUsernameBox.Text = _settings.Username ?? string.Empty;
ManualPasswordBox.Text = string.Empty;
SettingsUnlockToggle.IsChecked = false;
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
TestUploadButton.IsEnabled = true;
ReconnectButton.IsEnabled = true;
StartUploadPipelineIfReady();
}
UpdateCountersText();
UpdateFolderHealth();
UpdateDiagnostics();
UpdateSteps();
RefreshProfiles();
UpdateAdvancedLockState();
UpdatePresetButtons();
}
private void OnWindowOpened(object? sender, EventArgs e)
{
ApplyWindowSize();
}
private void OnWindowClosing(object? sender, WindowClosingEventArgs e)
{
_settings.WindowWidth = Width;
_settings.WindowHeight = Height;
_settingsStore.Save(_settings);
}
private void ApplyWindowSize()
{
if (_settings.WindowWidth > 0)
{
Width = Math.Max(MinWidth, _settings.WindowWidth);
}
if (_settings.WindowHeight > 0)
{
Height = Math.Max(MinHeight, _settings.WindowHeight);
}
}
private void StartUploadPipelineIfReady()
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
UpdateSteps();
return;
}
ResetCounters();
_uploadService.Start(_settings, OnQueued, OnUploading, OnSuccess, OnFailure);
StartWatcher(_settings.WatchFolder);
RestorePendingUploads();
UpdateSteps();
}
private void StartWatcher(string folder)
{
_watcher?.Dispose();
_watcher = new FileSystemWatcher(folder)
{
IncludeSubdirectories = false,
EnableRaisingEvents = true,
};
_watcher.Created += OnFileChanged;
_watcher.Changed += OnFileChanged;
_watcher.Renamed += OnFileRenamed;
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
if (ShouldSkipUpload(e.FullPath))
{
return;
}
RecordLastSeen(e.FullPath);
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
if (ShouldSkipUpload(e.FullPath))
{
return;
}
RecordLastSeen(e.FullPath);
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private bool IsSupportedImage(string path)
{
var extension = Path.GetExtension(path)?.ToLowerInvariant();
return extension is ".jpg" or ".jpeg" or ".png" or ".webp";
}
private void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() => StatusText.Text = message);
}
private void OnQueued(string path)
{
Interlocked.Increment(ref _queuedCount);
UpdateUpload(path, UploadStatus.Queued);
AddPendingUpload(path);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
AppendLog($"Wartet: {Path.GetFileName(path)}");
UpdateCountersText();
}
private void OnUploading(string path)
{
Interlocked.Decrement(ref _queuedCount);
Interlocked.Increment(ref _uploadingCount);
UpdateUpload(path, UploadStatus.Uploading);
UpdateStatusIfAllowed($"Upload läuft: {Path.GetFileName(path)}", false);
UpdateCountersText();
}
private void OnSuccess(string path)
{
_failedPaths.Remove(path);
Interlocked.Decrement(ref _uploadingCount);
_lastSuccessAt = DateTimeOffset.Now;
UpdateUpload(path, UploadStatus.Success);
RemovePendingUpload(path);
MarkUploaded(path);
UpdateStatusIfAllowed($"Hochgeladen: {Path.GetFileName(path)}", false);
AppendLog($"Hochgeladen: {Path.GetFileName(path)}");
UpdateCountersText();
UpdateLiveStatus();
}
private void OnFailure(string path, string message)
{
_failedPaths.Add(path);
Interlocked.Decrement(ref _uploadingCount);
Interlocked.Increment(ref _failedCount);
UpdateUpload(path, UploadStatus.Failed);
RemovePendingUpload(path);
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
SetLastError($"{Path.GetFileName(path)} {message}");
AppendLog($"Fehlgeschlagen: {Path.GetFileName(path)} {message}");
UpdateRetryButton();
UpdateCountersText();
}
private void UpdateUpload(string path, UploadStatus status)
{
Dispatcher.UIThread.Post(() =>
{
if (!_uploadsByPath.TryGetValue(path, out var item))
{
item = new UploadItem(path);
_uploadsByPath[path] = item;
RecentUploads.Insert(0, item);
}
item.Status = status;
LastUploadText.Text = status == UploadStatus.Success
? $"Letzter Upload: {item.UpdatedLabel}"
: LastUploadText.Text;
while (RecentUploads.Count > 3)
{
var last = RecentUploads[^1];
_uploadsByPath.Remove(last.Path);
RecentUploads.RemoveAt(RecentUploads.Count - 1);
}
UpdateRetryButton();
});
}
private void UpdateStatusIfAllowed(string message, bool important)
{
var quiet = QuietToggle?.IsChecked ?? false;
if (quiet && !important)
{
return;
}
UpdateStatus(message);
}
private void UpdateRetryButton()
{
RetryFailedButton.IsEnabled = _failedPaths.Count > 0;
ClearFailedButton.IsEnabled = _failedPaths.Count > 0;
}
private void RetryFailedButton_Click(object? sender, RoutedEventArgs e)
{
if (_failedPaths.Count == 0)
{
return;
}
var retried = _failedPaths.Count;
foreach (var path in _failedPaths.ToList())
{
_uploadService.Enqueue(path, OnQueued);
}
_failedPaths.Clear();
Interlocked.Add(ref _failedCount, -retried);
Interlocked.Add(ref _queuedCount, retried);
UpdateRetryButton();
UpdateCountersText();
}
private void ClearFailedButton_Click(object? sender, RoutedEventArgs e)
{
if (_failedPaths.Count == 0)
{
return;
}
var cleared = _failedPaths.Count;
_failedPaths.Clear();
Interlocked.Add(ref _failedCount, -cleared);
UpdateRetryButton();
UpdateCountersText();
UpdateStatusIfAllowed("Fehlerliste geleert.", false);
}
private void UpdateSteps()
{
var hasCode = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
var hasFolder = !string.IsNullOrWhiteSpace(_settings.WatchFolder);
var ready = hasCode && hasFolder;
StepCodeText.Text = hasCode ? "1. Code eingeben ✓" : "1. Code eingeben";
StepFolderText.Text = hasFolder ? "2. Upload-Ordner wählen ✓" : "2. Upload-Ordner wählen";
StepReadyText.Text = ready ? "3. Upload läuft ✓" : "3. Upload läuft";
}
private void CodeBox_TextChanged(object? sender, TextChangedEventArgs e)
{
var code = (CodeBox.Text ?? string.Empty).Trim();
var enabled = IsValidCode(code);
ReconnectButton.IsEnabled = enabled;
}
private void SettingsUnlockToggle_Changed(object? sender, RoutedEventArgs e)
{
UpdateAdvancedLockState();
}
private void RestorePendingUploads()
{
EnsureSettingsCollections();
if (_settings.PendingUploads.Count == 0)
{
return;
}
var pending = _settings.PendingUploads.ToList();
var changed = false;
foreach (var path in pending)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path) || ShouldSkipUpload(path))
{
_settings.PendingUploads.Remove(path);
changed = true;
continue;
}
_uploadService.Enqueue(path, OnQueued);
}
if (changed)
{
_settingsStore.Save(_settings);
}
}
private bool ShouldSkipUpload(string path)
{
var fileName = Path.GetFileName(path);
if (!MatchesIncludePatterns(fileName))
{
return true;
}
if (MatchesExcludePatterns(fileName))
{
return true;
}
var signature = GetUploadSignature(path);
if (signature is null)
{
return true;
}
return _settings.UploadedFiles.TryGetValue(path, out var recorded) && recorded == signature;
}
private string? GetUploadSignature(string path)
{
if (!File.Exists(path))
{
return null;
}
var info = new FileInfo(path);
return $"{info.Length}:{info.LastWriteTimeUtc.Ticks}";
}
private void AddPendingUpload(string path)
{
EnsureSettingsCollections();
if (!_settings.PendingUploads.Contains(path))
{
_settings.PendingUploads.Add(path);
_settingsStore.Save(_settings);
}
}
private void RemovePendingUpload(string path)
{
EnsureSettingsCollections();
if (_settings.PendingUploads.Remove(path))
{
_settingsStore.Save(_settings);
}
}
private void MarkUploaded(string path)
{
var signature = GetUploadSignature(path);
if (signature is null)
{
return;
}
EnsureSettingsCollections();
_settings.UploadedFiles[path] = signature;
_settingsStore.Save(_settings);
}
private void TitleText_PointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (e.ClickCount < 2)
{
return;
}
_advancedVisible = !_advancedVisible;
AdvancedPanel.IsVisible = _advancedVisible;
}
private void SaveAdvancedButton_Click(object? sender, RoutedEventArgs e)
{
var normalizedBaseUrl = NormalizeBaseUrl(BaseUrlBox.Text);
if (normalizedBaseUrl is null)
{
UpdateStatus("Ungültige Basis-URL.");
SetLastError("Ungültige Basis-URL.");
return;
}
if (!int.TryParse(MaxUploadsBox.Text, out var maxUploads) || maxUploads < 1 || maxUploads > 5)
{
UpdateStatus("Max. parallele Uploads muss zwischen 1 und 5 liegen.");
SetLastError("Ungültige Parallel-Uploads.");
return;
}
var manualUploadUrl = (ManualUploadUrlBox.Text ?? string.Empty).Trim();
var manualUsername = (ManualUsernameBox.Text ?? string.Empty).Trim();
var manualPassword = (ManualPasswordBox.Text ?? string.Empty).Trim();
_settings.BaseUrl = normalizedBaseUrl;
_settings.MaxConcurrentUploads = maxUploads;
_settings.UploadDelayMs = ResolveUploadDelay(UploadTempoBox.SelectedIndex);
_settings.IncludePatterns = NormalizePatternInput(IncludePatternsBox.Text);
_settings.ExcludePatterns = NormalizePatternInput(ExcludePatternsBox.Text);
_settings.ResponseFormat = ResolveResponseFormat(ResponseFormatBox.SelectedIndex);
if (!string.IsNullOrWhiteSpace(manualUploadUrl))
{
_settings.UploadUrl = ResolveUploadUrl(manualUploadUrl);
}
if (!string.IsNullOrWhiteSpace(manualUsername))
{
_settings.Username = manualUsername;
}
if (!string.IsNullOrWhiteSpace(manualPassword))
{
_settings.Password = manualPassword;
}
_settingsStore.Save(_settings);
_client = new PhotoboothConnectClient(_settings.BaseUrl, _userAgent);
_uploadService.Configure(_userAgent);
UpdateDiagnostics();
UpdateFolderHealth();
RestartUploadPipeline();
UpdateSteps();
UpdateAdvancedLockState();
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
TestUploadButton.IsEnabled = true;
ReconnectButton.IsEnabled = true;
}
UpdateStatus("Einstellungen gespeichert.");
}
private async void TestConnectionButton_Click(object? sender, RoutedEventArgs e)
{
var targetUrl = ResolveTestUrl();
if (targetUrl is null)
{
UpdateStatus("Bitte eine gültige Upload-URL oder Basis-URL speichern.");
return;
}
UpdateStatus("Verbindung wird getestet...");
try
{
using var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(8),
};
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Head, targetUrl);
using var response = await client.SendAsync(request);
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
UpdateStatus($"Server erreichbar ({status}).");
}
catch (TaskCanceledException)
{
UpdateStatus("Verbindungstest: Zeitüberschreitung.");
}
catch (HttpRequestException)
{
UpdateStatus("Verbindungstest: Netzwerkfehler.");
}
}
private async void TestUploadButton_Click(object? sender, RoutedEventArgs e)
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
UpdateStatus("Bitte zuerst verbinden.");
return;
}
try
{
var tempPath = await CreateSampleUploadAsync();
_uploadService.Enqueue(tempPath, OnQueued);
UpdateStatusIfAllowed("Test-Upload hinzugefügt.", false);
}
catch
{
UpdateStatus("Test-Upload konnte nicht erstellt werden.");
SetLastError("Test-Upload konnte nicht erstellt werden.");
}
}
private async Task<string> CreateSampleUploadAsync()
{
var uri = new Uri("avares://PhotoboothUploader/Assets/sample-upload.png");
await using var source = AssetLoader.Open(uri);
var tempPath = Path.Combine(Path.GetTempPath(), $"fotospiel-test-{DateTimeOffset.Now:yyyyMMddHHmmss}.png");
await using var target = File.Create(tempPath);
await source.CopyToAsync(target);
return tempPath;
}
private void ResetCounters()
{
Interlocked.Exchange(ref _queuedCount, 0);
Interlocked.Exchange(ref _uploadingCount, 0);
Interlocked.Exchange(ref _failedCount, 0);
_failedPaths.Clear();
_lastSuccessAt = null;
UpdateCountersText();
UpdateRetryButton();
UpdateLiveStatus();
}
private void RestartUploadPipeline()
{
_uploadService.Stop();
StartUploadPipelineIfReady();
}
private void UpdateFolderHealth()
{
var folder = _settings.WatchFolder;
if (string.IsNullOrWhiteSpace(folder))
{
FolderHealthText.Text = "Ordner: —";
DiskFreeText.Text = "Freier Speicher: —";
LastSeenText.Text = "Letzte Datei: —";
return;
}
if (!Directory.Exists(folder))
{
FolderHealthText.Text = "Ordner: fehlt";
DiskFreeText.Text = "Freier Speicher: —";
LastSeenText.Text = "Letzte Datei: —";
return;
}
try
{
_ = Directory.EnumerateFileSystemEntries(folder).FirstOrDefault();
FolderHealthText.Text = CanWriteToFolder(folder) ? "Ordner: OK (schreibbar)" : "Ordner: OK (nur lesen)";
}
catch (UnauthorizedAccessException)
{
FolderHealthText.Text = "Ordner: Keine Berechtigung";
}
catch
{
FolderHealthText.Text = "Ordner: Fehler";
}
DiskFreeText.Text = FormatDiskFree(folder);
LastSeenText.Text = FormatLastSeen();
}
private void UpdateLiveStatus()
{
Dispatcher.UIThread.Post(() =>
{
if (_lastSuccessAt is null)
{
LiveStatusText.Text = "Live: —";
UpdateDiagnostics();
return;
}
var age = DateTimeOffset.Now - _lastSuccessAt.Value;
var isLive = age <= TimeSpan.FromMinutes(5);
var label = isLive ? "Live: Ja" : "Live: Nein";
LiveStatusText.Text = $"{label} (letzter Upload {FormatRelativeAge(age)})";
UpdateDiagnostics();
});
}
private static string FormatRelativeAge(TimeSpan age)
{
if (age.TotalMinutes < 1)
{
return "gerade eben";
}
if (age.TotalHours < 1)
{
return $"vor {Math.Round(age.TotalMinutes)} min";
}
if (age.TotalDays < 1)
{
return $"vor {Math.Round(age.TotalHours)} h";
}
return $"vor {Math.Round(age.TotalDays)} d";
}
private static string FormatDiskFree(string folder)
{
var root = Path.GetPathRoot(folder);
if (string.IsNullOrWhiteSpace(root))
{
return "Freier Speicher: —";
}
var drive = new DriveInfo(root);
if (!drive.IsReady)
{
return "Freier Speicher: —";
}
var freeGb = drive.AvailableFreeSpace / (1024d * 1024d * 1024d);
var label = freeGb < 5 ? "niedrig" : "ok";
return $"Freier Speicher: {freeGb:0.0} GB ({label})";
}
private string? ResolveUploadUrl(string? uploadUrl)
{
if (string.IsNullOrWhiteSpace(uploadUrl))
{
return uploadUrl;
}
if (Uri.TryCreate(uploadUrl, UriKind.Absolute, out _))
{
return uploadUrl;
}
var baseUri = new Uri(_settings.BaseUrl ?? DefaultBaseUrl, UriKind.Absolute);
return new Uri(baseUri, uploadUrl).ToString();
}
private static string? NormalizeBaseUrl(string? baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return null;
}
var trimmed = baseUrl.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var absolute))
{
return absolute.GetLeftPart(UriPartial.Authority);
}
if (Uri.TryCreate($"https://{trimmed}", UriKind.Absolute, out absolute))
{
return absolute.GetLeftPart(UriPartial.Authority);
}
return null;
}
private void UpdateCountersText()
{
Dispatcher.UIThread.Post(() =>
{
var queued = Volatile.Read(ref _queuedCount);
var uploading = Volatile.Read(ref _uploadingCount);
var failed = Volatile.Read(ref _failedCount);
QueueStatusText.Text = $"Warteschlange: {Math.Max(0, queued)} · Läuft: {Math.Max(0, uploading)} · Fehlgeschlagen: {Math.Max(0, failed)}";
});
}
private void UpdateDiagnostics()
{
Dispatcher.UIThread.Post(() =>
{
EventNameText.Text = $"Event: {_settings.EventName ?? ""}";
BaseUrlText.Text = $"Basis-URL: {_settings.BaseUrl ?? ""}";
VersionText.Text = $"App-Version: {GetAppVersion()}";
ConnectExpiryText.Text = FormatConnectExpiry();
LastErrorText.Text = $"Letzter Fehler: {FormatLastError()}";
});
}
private void SetLastError(string message)
{
_settings.LastError = message;
_settings.LastErrorAt = DateTimeOffset.Now.ToString("O");
_settingsStore.Save(_settings);
UpdateDiagnostics();
}
private void ClearLastError()
{
_settings.LastError = null;
_settings.LastErrorAt = null;
_settingsStore.Save(_settings);
UpdateDiagnostics();
}
private string FormatLastError()
{
if (string.IsNullOrWhiteSpace(_settings.LastError))
{
return "—";
}
if (DateTimeOffset.TryParse(_settings.LastErrorAt, out var timestamp))
{
return $"{timestamp:dd.MM.yyyy HH:mm} {_settings.LastError}";
}
return _settings.LastError;
}
private static string GetAppVersion()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
return version is null ? "0.0.0" : version.ToString();
}
private static bool IsValidCode(string code)
{
return code.Length == 6 && code.All(ch => ch is >= '0' and <= '9');
}
private void EnsureSettingsCollections()
{
_settings.PendingUploads ??= new List<string>();
_settings.UploadedFiles ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_settings.Profiles ??= new List<PhotoboothProfile>();
}
private void RecordLastSeen(string path)
{
_settings.LastSeenFile = Path.GetFileName(path);
_settings.LastSeenAt = DateTimeOffset.Now.ToString("O");
_settingsStore.Save(_settings);
UpdateFolderHealth();
}
private bool CanWriteToFolder(string folder)
{
try
{
var testFile = Path.Combine(folder, $".fotospiel-write-test-{Guid.NewGuid():N}.tmp");
File.WriteAllText(testFile, "ok");
File.Delete(testFile);
return true;
}
catch
{
return false;
}
}
private string FormatLastSeen()
{
if (string.IsNullOrWhiteSpace(_settings.LastSeenAt) || string.IsNullOrWhiteSpace(_settings.LastSeenFile))
{
return "Letzte Datei: —";
}
if (DateTimeOffset.TryParse(_settings.LastSeenAt, out var timestamp))
{
return $"Letzte Datei: {_settings.LastSeenFile} ({timestamp:HH:mm})";
}
return $"Letzte Datei: {_settings.LastSeenFile}";
}
private string FormatConnectExpiry()
{
if (string.IsNullOrWhiteSpace(_settings.ConnectExpiresAt))
{
return "Verbindungscode: —";
}
if (!DateTimeOffset.TryParse(_settings.ConnectExpiresAt, out var expiry))
{
return "Verbindungscode: —";
}
var remaining = expiry - DateTimeOffset.Now;
if (remaining <= TimeSpan.Zero)
{
return "Verbindungscode: abgelaufen";
}
if (remaining.TotalHours >= 1)
{
return $"Verbindungscode: {Math.Ceiling(remaining.TotalHours)} h gueltig";
}
return $"Verbindungscode: {Math.Ceiling(remaining.TotalMinutes)} min gueltig";
}
private string? ResolveTestUrl()
{
var manual = NormalizeUrl(ManualUploadUrlBox.Text);
if (!string.IsNullOrWhiteSpace(manual))
{
return manual;
}
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
return _settings.UploadUrl;
}
var normalizedBase = NormalizeBaseUrl(BaseUrlBox.Text);
return normalizedBase;
}
private static string? NormalizeUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out _))
{
return trimmed;
}
return null;
}
private static int ResolveUploadDelay(int selectedIndex)
{
return selectedIndex switch
{
0 => 0,
2 => 1500,
_ => 500,
};
}
private static int ResolveUploadTempoIndex(int delay)
{
if (delay <= 0)
{
return 0;
}
if (delay >= 1500)
{
return 2;
}
return 1;
}
private static string? ResolveResponseFormat(int selectedIndex)
{
return selectedIndex switch
{
1 => "json",
2 => "xml",
_ => null,
};
}
private static int ResolveResponseFormatIndex(string? format)
{
if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
return 1;
}
if (string.Equals(format, "xml", StringComparison.OrdinalIgnoreCase))
{
return 2;
}
return 0;
}
private static string NormalizePatternInput(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return string.Empty;
}
var parts = SplitPatterns(input);
return string.Join(';', parts);
}
private bool MatchesIncludePatterns(string fileName)
{
var patterns = SplitPatterns(_settings.IncludePatterns);
if (patterns.Count == 0)
{
return true;
}
return patterns.Any(pattern => IsPatternMatch(fileName, pattern));
}
private bool MatchesExcludePatterns(string fileName)
{
var patterns = SplitPatterns(_settings.ExcludePatterns);
if (patterns.Count == 0)
{
return false;
}
return patterns.Any(pattern => IsPatternMatch(fileName, pattern));
}
private static List<string> SplitPatterns(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return new List<string>();
}
return input
.Split(new[] { ';', ',', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(pattern => pattern.Length > 0)
.ToList();
}
private static bool IsPatternMatch(string fileName, string pattern)
{
var span = fileName.AsSpan();
var token = pattern.AsSpan();
return IsPatternMatch(span, token);
}
private static bool IsPatternMatch(ReadOnlySpan<char> text, ReadOnlySpan<char> pattern)
{
var textIndex = 0;
var patternIndex = 0;
var starIndex = -1;
var matchIndex = 0;
while (textIndex < text.Length)
{
if (patternIndex < pattern.Length
&& (pattern[patternIndex] == '?' || char.ToLowerInvariant(pattern[patternIndex]) == char.ToLowerInvariant(text[textIndex])))
{
textIndex++;
patternIndex++;
continue;
}
if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
{
starIndex = patternIndex;
matchIndex = textIndex;
patternIndex++;
continue;
}
if (starIndex != -1)
{
patternIndex = starIndex + 1;
matchIndex++;
textIndex = matchIndex;
continue;
}
return false;
}
while (patternIndex < pattern.Length && pattern[patternIndex] == '*')
{
patternIndex++;
}
return patternIndex == pattern.Length;
}
private void UpdateAdvancedLockState()
{
var connected = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
var unlocked = SettingsUnlockToggle?.IsChecked ?? false;
var enabled = !connected || unlocked;
BaseUrlBox.IsEnabled = enabled;
MaxUploadsBox.IsEnabled = enabled;
UploadTempoBox.IsEnabled = enabled;
IncludePatternsBox.IsEnabled = enabled;
ExcludePatternsBox.IsEnabled = enabled;
ResponseFormatBox.IsEnabled = enabled;
ManualUploadUrlBox.IsEnabled = enabled;
ManualUsernameBox.IsEnabled = enabled;
ManualPasswordBox.IsEnabled = enabled;
TestConnectionButton.IsEnabled = enabled;
SaveAdvancedButton.IsEnabled = enabled;
}
private void RefreshProfiles()
{
ProfilesBox.ItemsSource = _settings.Profiles.Select(profile => profile.DisplayName).ToList();
}
private void LoadProfileButton_Click(object? sender, RoutedEventArgs e)
{
var index = ProfilesBox.SelectedIndex;
if (index < 0 || index >= _settings.Profiles.Count)
{
UpdateStatus("Bitte zuerst ein Profil auswaehlen.");
return;
}
var profile = _settings.Profiles[index];
_settings.BaseUrl = NormalizeBaseUrl(profile.BaseUrl) ?? _settings.BaseUrl;
_settings.UploadUrl = profile.UploadUrl;
_settings.Username = profile.Username;
_settings.Password = profile.Password;
_settings.ResponseFormat = profile.ResponseFormat;
_settings.WatchFolder = profile.WatchFolder;
_settings.IncludePatterns = profile.IncludePatterns;
_settings.ExcludePatterns = profile.ExcludePatterns;
_settings.MaxConcurrentUploads = profile.MaxConcurrentUploads > 0 ? profile.MaxConcurrentUploads : _settings.MaxConcurrentUploads;
_settings.UploadDelayMs = profile.UploadDelayMs;
_settingsStore.Save(_settings);
ApplySettings();
UpdateStatus("Profil geladen.");
}
private void SaveProfileButton_Click(object? sender, RoutedEventArgs e)
{
var label = _settings.EventName ?? "Neues Profil";
var profile = new PhotoboothProfile
{
Label = label,
EventName = _settings.EventName,
BaseUrl = _settings.BaseUrl,
UploadUrl = _settings.UploadUrl,
Username = _settings.Username,
Password = _settings.Password,
ResponseFormat = _settings.ResponseFormat,
WatchFolder = _settings.WatchFolder,
IncludePatterns = _settings.IncludePatterns,
ExcludePatterns = _settings.ExcludePatterns,
MaxConcurrentUploads = _settings.MaxConcurrentUploads,
UploadDelayMs = _settings.UploadDelayMs,
};
_settings.Profiles.RemoveAll(existing => string.Equals(existing.DisplayName, profile.DisplayName, StringComparison.OrdinalIgnoreCase));
_settings.Profiles.Insert(0, profile);
_settingsStore.Save(_settings);
RefreshProfiles();
ProfilesBox.SelectedIndex = 0;
UpdateStatus("Profil gespeichert.");
}
private async void LogCopyButton_Click(object? sender, RoutedEventArgs e)
{
var content = ReadLogForCopy();
if (string.IsNullOrWhiteSpace(content))
{
UpdateStatus("Log ist leer.");
return;
}
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null)
{
UpdateStatus("Zwischenablage nicht verfuegbar.");
return;
}
await clipboard.SetTextAsync(content);
UpdateStatus("Log kopiert.");
}
private void AppendLog(string message)
{
var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss} {message}";
_logBuffer.Add(line);
while (_logBuffer.Count > 200)
{
_logBuffer.RemoveAt(0);
}
try
{
File.AppendAllLines(_settingsStore.LogPath, new[] { line });
}
catch
{
// ignore file errors
}
}
private string ReadLogForCopy()
{
try
{
if (File.Exists(_settingsStore.LogPath))
{
var lines = File.ReadAllLines(_settingsStore.LogPath);
return string.Join(Environment.NewLine, lines.TakeLast(200));
}
}
catch
{
// ignore
}
return _logBuffer.Count > 0 ? string.Join(Environment.NewLine, _logBuffer) : string.Empty;
}
private void UpdatePresetButtons()
{
DslrBoothPresetButton.IsVisible = OperatingSystem.IsWindows();
SparkboothPresetButton.IsVisible = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
}
private void ApplyPresetFolder(string? folder)
{
if (string.IsNullOrWhiteSpace(folder))
{
UpdateStatus("Preset-Ordner nicht verfügbar.");
return;
}
try
{
Directory.CreateDirectory(folder);
}
catch
{
UpdateStatus("Preset-Ordner konnte nicht erstellt werden.");
return;
}
_settings.WatchFolder = folder;
_settingsStore.Save(_settings);
FolderText.Text = folder;
UpdateFolderHealth();
StartUploadPipelineIfReady();
}
private static string? GetDslrBoothFolder()
{
return OperatingSystem.IsWindows() ? @"C:\dslrBooth" : null;
}
private static string? GetSparkboothFolder()
{
var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (string.IsNullOrWhiteSpace(documents))
{
return null;
}
return Path.Combine(documents, "sparkbooth");
}
}