feat: add Nextcloud CalDAV calendar sync
Implements a provider-agnostic calendar sync feature with Nextcloud as the first alternative to Google Calendar. - Add ICalendarService interface and provider-agnostic API endpoints - Implement NextcloudCalendarService using CalDAV (Ical.Net) - Add CalendarEventBuilder to share event/transport logic across providers - Add Nextcloud sync button to the UI (Index page) - Centralise TargetFramework, Nullable and ImplicitUsings in Directory.Build.props (net9.0) - Add .worktrees to .gitignore and Docker/Nextcloud config docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
71717cccc1
commit
e8e7e07ffc
23 changed files with 2316 additions and 286 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -63,3 +63,6 @@ nunit-*.xml
|
||||||
# Configuration files (uncomment to avoid committing development configs)
|
# Configuration files (uncomment to avoid committing development configs)
|
||||||
config/
|
config/
|
||||||
**/config/
|
**/config/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn export-btn" @onclick="ExportToIcs" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-calendar" aria-hidden="true"></span> Export to ICS</button>
|
<button class="btn export-btn" @onclick="ExportToIcs" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-calendar" aria-hidden="true"></span> Export to ICS</button>
|
||||||
<button class="btn export-btn" @onclick="SyncToGoogleCalendar" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Google Calendar</button>
|
<button class="btn export-btn" @onclick="SyncToGoogleCalendar" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Google Calendar</button>
|
||||||
|
<button class="btn export-btn" @onclick="SyncToNextcloud" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Nextcloud</button>
|
||||||
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-document" aria-hidden="true"></span>Export to PDF</button>
|
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-document" aria-hidden="true"></span>Export to PDF</button>
|
||||||
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
|
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,7 +39,7 @@
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Select Google Calendar</h5>
|
<h5 class="modal-title">Select @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google") Calendar</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CloseCalendarSelector"></button>
|
<button type="button" class="btn-close" @onclick="CloseCalendarSelector"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -87,7 +88,7 @@ else if (_isSyncing)
|
||||||
<div class="loading-overlay">
|
<div class="loading-overlay">
|
||||||
<div class="loading-content">
|
<div class="loading-content">
|
||||||
<div class="loading-spinner">⏳</div>
|
<div class="loading-spinner">⏳</div>
|
||||||
<p>Syncing with google calendar...</p>
|
<p>Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -218,10 +219,3 @@ else
|
||||||
OnClose="HideConfiguration"
|
OnClose="HideConfiguration"
|
||||||
OnConfigurationChanged="OnConfigurationChanged" />
|
OnConfigurationChanged="OnConfigurationChanged" />
|
||||||
|
|
||||||
@code {
|
|
||||||
// Helper method to check if a specific day is loading transport data
|
|
||||||
private bool IsLoadingTransportForDay(DateTime day)
|
|
||||||
{
|
|
||||||
return _isLoadingTransportPerDay.GetValueOrDefault(day, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,10 @@ namespace ShiftScheduler.Client.Pages
|
||||||
private bool _showConfigDialog = false;
|
private bool _showConfigDialog = false;
|
||||||
private bool _isSyncing = false;
|
private bool _isSyncing = false;
|
||||||
private bool _showCalendarSelector = false;
|
private bool _showCalendarSelector = false;
|
||||||
|
private string _syncProvider = string.Empty;
|
||||||
private string _errorMessage = string.Empty;
|
private string _errorMessage = string.Empty;
|
||||||
private string _successMessage = string.Empty;
|
private string _successMessage = string.Empty;
|
||||||
private List<GoogleCalendar> _availableCalendars = new();
|
private List<CalendarInfo> _availableCalendars = new();
|
||||||
|
|
||||||
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
|
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
|
||||||
|
|
||||||
|
|
@ -31,14 +32,11 @@ namespace ShiftScheduler.Client.Pages
|
||||||
{
|
{
|
||||||
_isLoadingInitial = true;
|
_isLoadingInitial = true;
|
||||||
|
|
||||||
// Load shifts first to show the calendar immediately
|
|
||||||
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
|
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
|
||||||
|
|
||||||
// Show calendar immediately, don't wait for transport data
|
|
||||||
_isLoadingInitial = false;
|
_isLoadingInitial = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
// Load schedule and transport data in the background
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await LoadScheduleFromStorage();
|
await LoadScheduleFromStorage();
|
||||||
|
|
@ -64,7 +62,6 @@ namespace ShiftScheduler.Client.Pages
|
||||||
{
|
{
|
||||||
SelectedSchedule[day] = shiftName;
|
SelectedSchedule[day] = shiftName;
|
||||||
|
|
||||||
// Get transport information for this shift and date - per day loading
|
|
||||||
_isLoadingTransportPerDay[day] = true;
|
_isLoadingTransportPerDay[day] = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
|
|
@ -77,15 +74,12 @@ namespace ShiftScheduler.Client.Pages
|
||||||
{
|
{
|
||||||
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
|
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
|
||||||
if (shiftWithTransport != null)
|
if (shiftWithTransport != null)
|
||||||
{
|
|
||||||
SelectedShiftsWithTransport[day] = shiftWithTransport;
|
SelectedShiftsWithTransport[day] = shiftWithTransport;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error fetching transport data: {ex.Message}");
|
Console.WriteLine($"Error fetching transport data: {ex.Message}");
|
||||||
// Create a fallback with just the shift data
|
|
||||||
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
||||||
if (shift != null)
|
if (shift != null)
|
||||||
{
|
{
|
||||||
|
|
@ -172,34 +166,35 @@ namespace ShiftScheduler.Client.Pages
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SyncToGoogleCalendar()
|
private Task SyncToGoogleCalendar() => StartCalendarSync("google");
|
||||||
|
private Task SyncToNextcloud() => StartCalendarSync("nextcloud");
|
||||||
|
|
||||||
|
private async Task StartCalendarSync(string provider)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_errorMessage = string.Empty;
|
_errorMessage = string.Empty;
|
||||||
_successMessage = string.Empty;
|
_successMessage = string.Empty;
|
||||||
|
_syncProvider = provider;
|
||||||
_isSyncing = true;
|
_isSyncing = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
// Get available calendars
|
var response = await HttpClient.GetAsync($"api/shift/calendars?provider={provider}");
|
||||||
var response = await HttpClient.GetAsync("api/shift/google_calendars");
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_availableCalendars = await response.Content.ReadFromJsonAsync<List<GoogleCalendar>>() ?? new();
|
_availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? new();
|
||||||
|
|
||||||
if (_availableCalendars.Count == 1)
|
if (_availableCalendars.Count == 1)
|
||||||
{
|
{
|
||||||
// If only one calendar, sync directly
|
|
||||||
await PerformCalendarSync(_availableCalendars[0].Id);
|
await PerformCalendarSync(_availableCalendars[0].Id);
|
||||||
}
|
}
|
||||||
else if (_availableCalendars.Count > 1)
|
else if (_availableCalendars.Count > 1)
|
||||||
{
|
{
|
||||||
// Show calendar selector
|
|
||||||
_showCalendarSelector = true;
|
_showCalendarSelector = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_errorMessage = "No writable calendars found in your Google account.";
|
_errorMessage = "No writable calendars found.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -210,7 +205,7 @@ namespace ShiftScheduler.Client.Pages
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"Error accessing Google Calendar: {ex.Message}";
|
_errorMessage = $"Error accessing calendar: {ex.Message}";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -234,27 +229,27 @@ namespace ShiftScheduler.Client.Pages
|
||||||
|
|
||||||
var request = new
|
var request = new
|
||||||
{
|
{
|
||||||
|
Provider = _syncProvider,
|
||||||
CalendarId = calendarId,
|
CalendarId = calendarId,
|
||||||
Year = SelectedDate.Year,
|
Year = SelectedDate.Year,
|
||||||
Month = SelectedDate.Month
|
Month = SelectedDate.Month
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_google_calendar", request);
|
var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_calendar", request);
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var result = await response.Content.ReadFromJsonAsync<dynamic>();
|
_successMessage = "Shifts synced successfully!";
|
||||||
_successMessage = "Shifts synced to Google Calendar successfully!";
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
_errorMessage = $"Failed to sync to Google Calendar: {response.StatusCode}. {errorContent}";
|
_errorMessage = $"Failed to sync: {response.StatusCode}. {errorContent}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"Error syncing to Google Calendar: {ex.Message}";
|
_errorMessage = $"Error syncing: {ex.Message}";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -298,8 +293,6 @@ namespace ShiftScheduler.Client.Pages
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new();
|
SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new();
|
||||||
|
|
||||||
// Reload transport data for all selected shifts
|
|
||||||
await ReloadTransportDataForSchedule();
|
await ReloadTransportDataForSchedule();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -318,18 +311,13 @@ namespace ShiftScheduler.Client.Pages
|
||||||
private async Task ReloadTransportDataForSchedule()
|
private async Task ReloadTransportDataForSchedule()
|
||||||
{
|
{
|
||||||
SelectedShiftsWithTransport.Clear();
|
SelectedShiftsWithTransport.Clear();
|
||||||
|
|
||||||
// Clear existing per-day loading states
|
|
||||||
_isLoadingTransportPerDay.Clear();
|
_isLoadingTransportPerDay.Clear();
|
||||||
|
|
||||||
// Mark all days as loading
|
|
||||||
foreach (var kvp in SelectedSchedule)
|
foreach (var kvp in SelectedSchedule)
|
||||||
{
|
|
||||||
_isLoadingTransportPerDay[kvp.Key] = true;
|
_isLoadingTransportPerDay[kvp.Key] = true;
|
||||||
}
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
// Create parallel tasks for all transport data requests
|
|
||||||
var transportTasks = SelectedSchedule.Select(async kvp =>
|
var transportTasks = SelectedSchedule.Select(async kvp =>
|
||||||
{
|
{
|
||||||
var day = kvp.Key;
|
var day = kvp.Key;
|
||||||
|
|
@ -344,15 +332,12 @@ namespace ShiftScheduler.Client.Pages
|
||||||
{
|
{
|
||||||
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
|
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
|
||||||
if (shiftWithTransport != null)
|
if (shiftWithTransport != null)
|
||||||
{
|
|
||||||
SelectedShiftsWithTransport[day] = shiftWithTransport;
|
SelectedShiftsWithTransport[day] = shiftWithTransport;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
|
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
|
||||||
// Create fallback without transport
|
|
||||||
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
||||||
if (shift != null)
|
if (shift != null)
|
||||||
{
|
{
|
||||||
|
|
@ -367,13 +352,11 @@ namespace ShiftScheduler.Client.Pages
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// Mark this day as no longer loading
|
|
||||||
_isLoadingTransportPerDay[day] = false;
|
_isLoadingTransportPerDay[day] = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
// Wait for all transport data to load
|
|
||||||
await Task.WhenAll(transportTasks);
|
await Task.WhenAll(transportTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,7 +375,6 @@ namespace ShiftScheduler.Client.Pages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to get transport summary for display
|
|
||||||
private string GetTransportSummary(DateTime date)
|
private string GetTransportSummary(DateTime date)
|
||||||
{
|
{
|
||||||
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
|
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
|
||||||
|
|
@ -408,20 +390,18 @@ namespace ShiftScheduler.Client.Pages
|
||||||
if (!string.IsNullOrEmpty(afternoonTransport))
|
if (!string.IsNullOrEmpty(afternoonTransport))
|
||||||
summaries.Add(afternoonTransport);
|
summaries.Add(afternoonTransport);
|
||||||
|
|
||||||
if (summaries.Count == 0) { return string.Empty; }
|
if (summaries.Count == 0) return string.Empty;
|
||||||
|
|
||||||
return "🚂 " + string.Join(" | ", summaries);
|
return "🚂 " + string.Join(" | ", summaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to get shift times for display
|
|
||||||
private string GetShiftTimes(DateTime date)
|
private string GetShiftTimes(DateTime date)
|
||||||
{
|
{
|
||||||
if (!SelectedSchedule.TryGetValue(date, out var shiftName))
|
if (!SelectedSchedule.TryGetValue(date, out var shiftName))
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
||||||
if (shift == null)
|
if (shift == null) return string.Empty;
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var times = new List<string>();
|
var times = new List<string>();
|
||||||
|
|
||||||
|
|
@ -448,9 +428,11 @@ namespace ShiftScheduler.Client.Pages
|
||||||
|
|
||||||
private async Task OnConfigurationChanged()
|
private async Task OnConfigurationChanged()
|
||||||
{
|
{
|
||||||
// Reload shifts after configuration changes
|
|
||||||
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
|
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsLoadingTransportForDay(DateTime day) =>
|
||||||
|
_isLoadingTransportPerDay.GetValueOrDefault(day, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.8" />
|
||||||
|
|
|
||||||
BIN
Client/wwwroot/icons/F1_PF.png
Normal file
BIN
Client/wwwroot/icons/F1_PF.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -62,3 +62,17 @@ No migration needed - existing installations will automatically:
|
||||||
## File Format
|
## File Format
|
||||||
|
|
||||||
Configuration files use standard JSON format and can be edited directly if needed. Changes take effect after application restart.
|
Configuration files use standard JSON format and can be edited directly if needed. Changes take effect after application restart.
|
||||||
|
|
||||||
|
## Nextcloud Calendar Sync
|
||||||
|
|
||||||
|
To enable Nextcloud CalDAV sync, populate the `Nextcloud` section in the production `appsettings.json` (not committed to git):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Nextcloud": {
|
||||||
|
"BaseUrl": "https://your-nextcloud-instance.example.com",
|
||||||
|
"Username": "your-nextcloud-username",
|
||||||
|
"AppPassword": "your-nextcloud-app-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate an app password in Nextcloud: **Settings → Security → Devices & sessions → Create new app password**.
|
||||||
7
Directory.Build.props
Normal file
7
Directory.Build.props
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -15,19 +15,22 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
private readonly ITransportService _transportService;
|
private readonly ITransportService _transportService;
|
||||||
private readonly IConfigurationService _configurationService;
|
private readonly IConfigurationService _configurationService;
|
||||||
private readonly IGoogleCalendarService _googleCalendarService;
|
private readonly IGoogleCalendarService _googleCalendarService;
|
||||||
|
private readonly INextcloudCalendarService _nextcloudCalendarService;
|
||||||
|
|
||||||
public ShiftController(
|
public ShiftController(
|
||||||
IcsExportService icsService,
|
IcsExportService icsService,
|
||||||
PdfExportService pdfExportService,
|
PdfExportService pdfExportService,
|
||||||
ITransportService transportService,
|
ITransportService transportService,
|
||||||
IConfigurationService configurationService,
|
IConfigurationService configurationService,
|
||||||
IGoogleCalendarService googleCalendarService)
|
IGoogleCalendarService googleCalendarService,
|
||||||
|
INextcloudCalendarService nextcloudCalendarService)
|
||||||
{
|
{
|
||||||
_icsService = icsService;
|
_icsService = icsService;
|
||||||
_pdfExportService = pdfExportService;
|
_pdfExportService = pdfExportService;
|
||||||
_transportService = transportService;
|
_transportService = transportService;
|
||||||
_configurationService = configurationService;
|
_configurationService = configurationService;
|
||||||
_googleCalendarService = googleCalendarService;
|
_googleCalendarService = googleCalendarService;
|
||||||
|
_nextcloudCalendarService = nextcloudCalendarService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("shifts")]
|
[HttpGet("shifts")]
|
||||||
|
|
@ -41,9 +44,7 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
{
|
{
|
||||||
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName);
|
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName);
|
||||||
if (shift == null)
|
if (shift == null)
|
||||||
{
|
|
||||||
return NotFound($"Shift '{request.ShiftName}' not found");
|
return NotFound($"Shift '{request.ShiftName}' not found");
|
||||||
}
|
|
||||||
|
|
||||||
var transportConfig = _configurationService.GetTransportConfiguration();
|
var transportConfig = _configurationService.GetTransportConfiguration();
|
||||||
TransportConnection? morningTransport = null;
|
TransportConnection? morningTransport = null;
|
||||||
|
|
@ -51,49 +52,30 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
|
|
||||||
var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
|
var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
|
||||||
|
|
||||||
// Get transport for morning shift if it has morning time
|
if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningStart.HasValue)
|
||||||
if (!string.IsNullOrEmpty(shift.MorningTime))
|
|
||||||
{
|
|
||||||
if (shifTimes.MorningStart.HasValue)
|
|
||||||
{
|
|
||||||
morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
|
morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get transport for afternoon shift if it has afternoon time
|
|
||||||
// But only if the break between morning and afternoon is long enough
|
|
||||||
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
{
|
{
|
||||||
var shouldLoadAfternoonTransport = true;
|
var shouldLoad = true;
|
||||||
|
|
||||||
// If both morning and afternoon shifts exist, check break duration
|
if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
|
||||||
if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime))
|
|
||||||
{
|
{
|
||||||
if (shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
|
var breakMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
|
||||||
{
|
shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
|
||||||
var breakDurationMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
|
|
||||||
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLoadAfternoonTransport)
|
if (shouldLoad && shifTimes.AfternoonStart.HasValue)
|
||||||
{
|
|
||||||
if (shifTimes.AfternoonStart.HasValue)
|
|
||||||
{
|
|
||||||
afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
|
afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var shiftWithTransport = new ShiftWithTransport
|
return Ok(new ShiftWithTransport
|
||||||
{
|
{
|
||||||
Date = request.Date,
|
Date = request.Date,
|
||||||
Shift = shift,
|
Shift = shift,
|
||||||
MorningTransport = morningTransport,
|
MorningTransport = morningTransport,
|
||||||
AfternoonTransport = afternoonTransport,
|
AfternoonTransport = afternoonTransport
|
||||||
};
|
});
|
||||||
|
|
||||||
return Ok(shiftWithTransport);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("export_ics")]
|
[HttpPost("export_ics")]
|
||||||
|
|
@ -154,13 +136,22 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("google_calendars")]
|
[HttpGet("calendars")]
|
||||||
public async Task<IActionResult> GetGoogleCalendars()
|
public async Task<IActionResult> GetCalendars([FromQuery] string provider)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var calendars = await _googleCalendarService.GetCalendarsAsync();
|
ICalendarService service = provider switch
|
||||||
return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary }));
|
{
|
||||||
|
"google" => (ICalendarService)_googleCalendarService,
|
||||||
|
"nextcloud" => _nextcloudCalendarService,
|
||||||
|
_ => null!
|
||||||
|
};
|
||||||
|
if (service is null)
|
||||||
|
return BadRequest($"Unknown provider '{provider}'. Supported values: google, nextcloud.");
|
||||||
|
|
||||||
|
var calendars = await service.GetCalendarsAsync();
|
||||||
|
return Ok(calendars);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -168,18 +159,28 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sync_to_google_calendar")]
|
[HttpPost("sync_to_calendar")]
|
||||||
public async Task<IActionResult> SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request)
|
public async Task<IActionResult> SyncToCalendar([FromBody] SyncToCalendarRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
|
var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
|
||||||
await _googleCalendarService.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
|
|
||||||
return Ok(new { message = "Shifts synced to Google Calendar successfully!" });
|
ICalendarService service = request.Provider switch
|
||||||
|
{
|
||||||
|
"google" => (ICalendarService)_googleCalendarService,
|
||||||
|
"nextcloud" => _nextcloudCalendarService,
|
||||||
|
_ => null!
|
||||||
|
};
|
||||||
|
if (service is null)
|
||||||
|
return BadRequest($"Unknown provider '{request.Provider}'. Supported values: google, nextcloud.");
|
||||||
|
|
||||||
|
await service.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
|
||||||
|
return Ok(new { message = "Shifts synced successfully!" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return StatusCode(500, $"Error syncing to Google Calendar: {ex.Message}");
|
return StatusCode(500, $"Error syncing to calendar: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,36 +196,28 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
var shiftName = kvp.Value;
|
var shiftName = kvp.Value;
|
||||||
|
|
||||||
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName);
|
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName);
|
||||||
if (shift == null)
|
if (shift == null) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
var shiftTimes = _configurationService.ParseShiftTimes(date, shift);
|
var shiftTimes = _configurationService.ParseShiftTimes(date, shift);
|
||||||
TransportConnection? morningTransport = null;
|
TransportConnection? morningTransport = null;
|
||||||
TransportConnection? afternoonTransport = null;
|
TransportConnection? afternoonTransport = null;
|
||||||
|
|
||||||
// Get transport for morning shift if it has morning time
|
|
||||||
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue)
|
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue)
|
||||||
{
|
|
||||||
morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value);
|
morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value);
|
||||||
}
|
|
||||||
|
|
||||||
// Get transport for afternoon shift if it has afternoon time
|
|
||||||
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
{
|
{
|
||||||
var shouldLoadAfternoonTransport = true;
|
var shouldLoad = true;
|
||||||
|
|
||||||
// If both morning and afternoon shifts exist, check break duration
|
|
||||||
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningEnd.HasValue && shiftTimes.AfternoonStart.HasValue)
|
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningEnd.HasValue && shiftTimes.AfternoonStart.HasValue)
|
||||||
{
|
{
|
||||||
var breakDurationMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
|
var breakMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
|
||||||
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
|
shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLoadAfternoonTransport && shiftTimes.AfternoonStart.HasValue)
|
if (shouldLoad && shiftTimes.AfternoonStart.HasValue)
|
||||||
{
|
|
||||||
afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value);
|
afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
shiftsWithTransport.Add(new ShiftWithTransport
|
shiftsWithTransport.Add(new ShiftWithTransport
|
||||||
{
|
{
|
||||||
|
|
@ -258,8 +251,9 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SyncToGoogleCalendarRequest
|
public class SyncToCalendarRequest
|
||||||
{
|
{
|
||||||
|
public string Provider { get; set; } = string.Empty;
|
||||||
public string CalendarId { get; set; } = string.Empty;
|
public string CalendarId { get; set; } = string.Empty;
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
||||||
builder.Services.AddSingleton<ITransportService, TransportService>();
|
builder.Services.AddSingleton<ITransportService, TransportService>();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
|
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
|
||||||
|
builder.Services.AddScoped<INextcloudCalendarService, NextcloudCalendarService>();
|
||||||
|
|
||||||
// Configure authentication
|
// Configure authentication
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
|
||||||
|
|
@ -17,5 +11,4 @@
|
||||||
<ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" />
|
<ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"Nextcloud": {
|
||||||
|
"BaseUrl": "https://your-nextcloud-instance.example.com",
|
||||||
|
"Username": "YOUR_NEXTCLOUD_USERNAME",
|
||||||
|
"AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
|
||||||
|
},
|
||||||
|
|
||||||
"Transport": {
|
"Transport": {
|
||||||
"StartStation": "Zurich",
|
"StartStation": "Zurich",
|
||||||
"EndStation": "Basel",
|
"EndStation": "Basel",
|
||||||
|
|
|
||||||
53
Services/CalendarEventBuilder.cs
Normal file
53
Services/CalendarEventBuilder.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
using ShiftScheduler.Shared;
|
||||||
|
|
||||||
|
namespace ShiftScheduler.Services;
|
||||||
|
|
||||||
|
public record CalendarEventData(string Summary, string Description);
|
||||||
|
|
||||||
|
public static class CalendarEventBuilder
|
||||||
|
{
|
||||||
|
public static CalendarEventData BuildEventContent(Shift shift, TransportConnection? transport)
|
||||||
|
{
|
||||||
|
var description = transport != null
|
||||||
|
? $"Transport: {FormatTransportInfo(transport)}"
|
||||||
|
: string.Empty;
|
||||||
|
return new CalendarEventData(shift.Name, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatTransportInfo(TransportConnection transport)
|
||||||
|
{
|
||||||
|
var departure = transport.DepartureTime.ToString("HH:mm");
|
||||||
|
var arrival = transport.ArrivalTime.ToString("HH:mm");
|
||||||
|
return $"{transport.Platform}: {departure} → {arrival} ({(int)transport.Duration.TotalMinutes} Minutes)";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, int maxRetries = 3)
|
||||||
|
{
|
||||||
|
var delay = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt <= maxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await operation();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.Message.Contains("429") && attempt < maxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay);
|
||||||
|
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex.Message.Contains("Rate Limit") && attempt < maxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay);
|
||||||
|
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex.Message.Contains("Forbidden") && ex.Message.Contains("quota") && attempt < maxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay);
|
||||||
|
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await operation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,41 +8,32 @@ using ShiftScheduler.Shared;
|
||||||
|
|
||||||
namespace ShiftScheduler.Services;
|
namespace ShiftScheduler.Services;
|
||||||
|
|
||||||
public interface IGoogleCalendarService
|
|
||||||
{
|
|
||||||
Task<List<CalendarListEntry>> GetCalendarsAsync();
|
|
||||||
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
|
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
|
||||||
: IGoogleCalendarService
|
: IGoogleCalendarService
|
||||||
{
|
{
|
||||||
public async Task<List<CalendarListEntry>> GetCalendarsAsync()
|
public async Task<List<CalendarInfo>> GetCalendarsAsync()
|
||||||
{
|
{
|
||||||
var service = await CreateCalendarServiceAsync();
|
var service = await CreateCalendarServiceAsync();
|
||||||
var request = service.CalendarList.List();
|
var request = service.CalendarList.List();
|
||||||
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
|
var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
|
||||||
|
|
||||||
return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? [];
|
return response.Items?
|
||||||
|
.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer")
|
||||||
|
.Select(c => new CalendarInfo { Id = c.Id, Summary = c.Summary })
|
||||||
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts)
|
public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts)
|
||||||
{
|
{
|
||||||
var service = await CreateCalendarServiceAsync();
|
var service = await CreateCalendarServiceAsync();
|
||||||
|
|
||||||
// Get date range for deletion
|
|
||||||
if (shifts.Count == 0) return;
|
if (shifts.Count == 0) return;
|
||||||
|
|
||||||
var minDate = shifts.Min(s => s.Date).Date;
|
var minDate = shifts.Min(s => s.Date).Date;
|
||||||
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
|
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
|
||||||
|
|
||||||
// Delete existing shift events in the date range
|
|
||||||
await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate);
|
await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate);
|
||||||
|
|
||||||
// Add a small delay to avoid rate limiting
|
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
|
|
||||||
// Create new events for each shift with rate limiting protection
|
|
||||||
await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
|
await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,23 +45,22 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
||||||
request.SingleEvents = true;
|
request.SingleEvents = true;
|
||||||
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
|
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
|
||||||
|
|
||||||
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
|
var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
|
||||||
|
|
||||||
if (response.Items != null)
|
if (response.Items != null)
|
||||||
{
|
{
|
||||||
var shiftsToDelete = response.Items.Where(e =>
|
var shiftsToDelete = response.Items
|
||||||
e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList();
|
.Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// Delete events with rate limiting protection
|
|
||||||
foreach (var eventToDelete in shiftsToDelete)
|
foreach (var eventToDelete in shiftsToDelete)
|
||||||
{
|
{
|
||||||
await ExecuteWithRetryAsync(async () =>
|
await CalendarEventBuilder.ExecuteWithRetryAsync(async () =>
|
||||||
{
|
{
|
||||||
await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync();
|
await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync();
|
||||||
return true; // Return something for the generic method
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Small delay between deletions to avoid rate limiting
|
|
||||||
if (shiftsToDelete.Count > 1)
|
if (shiftsToDelete.Count > 1)
|
||||||
await Task.Delay(200);
|
await Task.Delay(200);
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +73,6 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
||||||
{
|
{
|
||||||
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
|
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
|
||||||
|
|
||||||
// Add delay between shift processing to avoid rate limiting
|
|
||||||
if (shifts.Count > 1)
|
if (shifts.Count > 1)
|
||||||
await Task.Delay(300);
|
await Task.Delay(300);
|
||||||
}
|
}
|
||||||
|
|
@ -94,77 +83,52 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
||||||
var shift = shiftWithTransport.Shift;
|
var shift = shiftWithTransport.Shift;
|
||||||
var date = shiftWithTransport.Date;
|
var date = shiftWithTransport.Date;
|
||||||
|
|
||||||
// Skip shifts with no time information
|
|
||||||
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
|
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Create morning event if it exists
|
|
||||||
if (!string.IsNullOrEmpty(shift.MorningTime))
|
if (!string.IsNullOrEmpty(shift.MorningTime))
|
||||||
{
|
{
|
||||||
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
|
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
|
||||||
var morningEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.MorningTransport);
|
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
|
||||||
await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync());
|
var googleEvent = CreateGoogleEvent(eventData, startTime, endTime);
|
||||||
|
await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create afternoon event if it exists
|
|
||||||
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
{
|
{
|
||||||
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
|
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
|
||||||
var afternoonEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.AfternoonTransport);
|
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
|
||||||
await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync());
|
var googleEvent = CreateGoogleEvent(eventData, startTime, endTime);
|
||||||
|
await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event CreateEventFromShift(Shift shift, DateTimeOffset startTime, DateTimeOffset endTime, TransportConnection? transport)
|
private static Event CreateGoogleEvent(CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime)
|
||||||
{
|
{
|
||||||
var summary = $"{shift.Name}";
|
|
||||||
var description = "";
|
|
||||||
|
|
||||||
if (transport != null)
|
|
||||||
{
|
|
||||||
var transportInfo = FormatTransportInfo(transport);
|
|
||||||
description = $"Transport: {transportInfo}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Event
|
return new Event
|
||||||
{
|
{
|
||||||
Summary = summary,
|
Summary = eventData.Summary,
|
||||||
Description = description,
|
Description = eventData.Description,
|
||||||
Start = new EventDateTime
|
Start = new EventDateTime { DateTimeDateTimeOffset = startTime },
|
||||||
{
|
End = new EventDateTime { DateTimeDateTimeOffset = endTime },
|
||||||
DateTimeDateTimeOffset = startTime
|
|
||||||
},
|
|
||||||
End = new EventDateTime
|
|
||||||
{
|
|
||||||
DateTimeDateTimeOffset = endTime
|
|
||||||
},
|
|
||||||
ExtendedProperties = new Event.ExtendedPropertiesData
|
ExtendedProperties = new Event.ExtendedPropertiesData
|
||||||
{
|
{
|
||||||
Private__ = new Dictionary<string, string>
|
Private__ = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["shiftSchedulerEvent"] = "true",
|
["shiftSchedulerEvent"] = "true",
|
||||||
["shiftName"] = shift.Name
|
["shiftName"] = eventData.Summary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatTransportInfo(TransportConnection transport)
|
|
||||||
{
|
|
||||||
var departure = transport.DepartureTime.ToString("HH:mm");
|
|
||||||
var arrival = transport.ArrivalTime.ToString("HH:mm");
|
|
||||||
return $"{transport.Platform}: {departure} → {arrival} ({transport.Duration.TotalMinutes} Minutes)";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<CalendarService> CreateCalendarServiceAsync()
|
private async Task<CalendarService> CreateCalendarServiceAsync()
|
||||||
{
|
{
|
||||||
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
|
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
|
||||||
|
|
||||||
var accessToken = await httpContext.GetTokenAsync("access_token");
|
var accessToken = await httpContext.GetTokenAsync("access_token");
|
||||||
if (string.IsNullOrEmpty(accessToken))
|
if (string.IsNullOrEmpty(accessToken))
|
||||||
{
|
|
||||||
throw new UnauthorizedAccessException("No access token available");
|
throw new UnauthorizedAccessException("No access token available");
|
||||||
}
|
|
||||||
|
|
||||||
var credential = GoogleCredential.FromAccessToken(accessToken);
|
var credential = GoogleCredential.FromAccessToken(accessToken);
|
||||||
|
|
||||||
|
|
@ -174,38 +138,4 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
||||||
ApplicationName = "ShiftScheduler"
|
ApplicationName = "ShiftScheduler"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, int maxRetries = 3)
|
|
||||||
{
|
|
||||||
var delay = TimeSpan.FromSeconds(1);
|
|
||||||
|
|
||||||
for (var attempt = 0; attempt <= maxRetries; attempt++)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await operation();
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex) when (ex.Message.Contains("429") && attempt < maxRetries)
|
|
||||||
{
|
|
||||||
// Rate limit exceeded (HTTP 429), wait with exponential backoff
|
|
||||||
await Task.Delay(delay);
|
|
||||||
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex.Message.Contains("Rate Limit") && attempt < maxRetries)
|
|
||||||
{
|
|
||||||
// Another form of rate limit error
|
|
||||||
await Task.Delay(delay);
|
|
||||||
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex.Message.Contains("Forbidden") && ex.Message.Contains("quota") && attempt < maxRetries)
|
|
||||||
{
|
|
||||||
// Quota exceeded error
|
|
||||||
await Task.Delay(delay);
|
|
||||||
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, all retries failed, execute one more time to get the actual exception
|
|
||||||
return await operation();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
15
Services/ICalendarService.cs
Normal file
15
Services/ICalendarService.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using ShiftScheduler.Shared;
|
||||||
|
|
||||||
|
namespace ShiftScheduler.Services;
|
||||||
|
|
||||||
|
public interface ICalendarService
|
||||||
|
{
|
||||||
|
Task<List<CalendarInfo>> GetCalendarsAsync();
|
||||||
|
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marker interface for DI disambiguation.</summary>
|
||||||
|
public interface IGoogleCalendarService : ICalendarService { }
|
||||||
|
|
||||||
|
/// <summary>Marker interface for DI disambiguation.</summary>
|
||||||
|
public interface INextcloudCalendarService : ICalendarService { }
|
||||||
232
Services/NextcloudCalendarService.cs
Normal file
232
Services/NextcloudCalendarService.cs
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
using Ical.Net;
|
||||||
|
using Ical.Net.CalendarComponents;
|
||||||
|
using Ical.Net.DataTypes;
|
||||||
|
using Ical.Net.Serialization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ShiftScheduler.Shared;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace ShiftScheduler.Services;
|
||||||
|
|
||||||
|
public class NextcloudCalendarService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfigurationService configurationService) : INextcloudCalendarService
|
||||||
|
{
|
||||||
|
private readonly string _baseUrl = configuration["Nextcloud:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("Nextcloud:BaseUrl not configured");
|
||||||
|
private readonly string _username = configuration["Nextcloud:Username"]
|
||||||
|
?? throw new InvalidOperationException("Nextcloud:Username not configured");
|
||||||
|
private readonly string _appPassword = configuration["Nextcloud:AppPassword"]
|
||||||
|
?? throw new InvalidOperationException("Nextcloud:AppPassword not configured");
|
||||||
|
|
||||||
|
private static readonly XNamespace DavNs = "DAV:";
|
||||||
|
private static readonly XNamespace CalDavNs = "urn:ietf:params:xml:ns:caldav";
|
||||||
|
|
||||||
|
public async Task<List<CalendarInfo>> GetCalendarsAsync()
|
||||||
|
{
|
||||||
|
var path = $"/remote.php/dav/calendars/{_username}/";
|
||||||
|
var body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname/>
|
||||||
|
<D:resourcetype/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>
|
||||||
|
""";
|
||||||
|
|
||||||
|
var request = CreateRequest(HttpMethod.Parse("PROPFIND"), path);
|
||||||
|
request.Headers.Add("Depth", "1");
|
||||||
|
request.Content = new StringContent(body, Encoding.UTF8, "application/xml");
|
||||||
|
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var xml = await response.Content.ReadAsStringAsync();
|
||||||
|
return ParseCalendarsFromPropfind(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts)
|
||||||
|
{
|
||||||
|
if (shifts.Count == 0) return;
|
||||||
|
|
||||||
|
var minDate = shifts.Min(s => s.Date).Date;
|
||||||
|
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
|
||||||
|
|
||||||
|
await DeleteExistingShiftEventsAsync(calendarId, minDate, maxDate);
|
||||||
|
await Task.Delay(500);
|
||||||
|
await CreateShiftEventsAsync(calendarId, shifts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CalendarInfo> ParseCalendarsFromPropfind(string xml)
|
||||||
|
{
|
||||||
|
var doc = XDocument.Parse(xml);
|
||||||
|
var calendars = new List<CalendarInfo>();
|
||||||
|
|
||||||
|
foreach (var responseElement in doc.Descendants(DavNs + "response"))
|
||||||
|
{
|
||||||
|
var resourceType = responseElement.Descendants(DavNs + "resourcetype").FirstOrDefault();
|
||||||
|
if (resourceType?.Element(CalDavNs + "calendar") == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var href = responseElement.Element(DavNs + "href")?.Value ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(href)) continue;
|
||||||
|
|
||||||
|
var displayName = responseElement.Descendants(DavNs + "displayname").FirstOrDefault()?.Value ?? href;
|
||||||
|
|
||||||
|
calendars.Add(new CalendarInfo { Id = href, Summary = displayName });
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteExistingShiftEventsAsync(string calendarId, DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
var startUtc = startDate.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
|
||||||
|
var endUtc = endDate.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
|
||||||
|
|
||||||
|
var body = $"""
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:time-range start="{startUtc}" end="{endUtc}"/>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>
|
||||||
|
""";
|
||||||
|
|
||||||
|
var reportRequest = CreateRequest(HttpMethod.Parse("REPORT"), calendarId);
|
||||||
|
reportRequest.Headers.Add("Depth", "1");
|
||||||
|
reportRequest.Content = new StringContent(body, Encoding.UTF8, "application/xml");
|
||||||
|
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
var reportResponse = await client.SendAsync(reportRequest);
|
||||||
|
reportResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var xml = await reportResponse.Content.ReadAsStringAsync();
|
||||||
|
var eventUrls = ParseShiftEventUrls(xml);
|
||||||
|
|
||||||
|
foreach (var url in eventUrls)
|
||||||
|
{
|
||||||
|
var deleteRequest = CreateRequest(HttpMethod.Delete, url);
|
||||||
|
var deleteResponse = await client.SendAsync(deleteRequest);
|
||||||
|
|
||||||
|
if (!deleteResponse.IsSuccessStatusCode &&
|
||||||
|
deleteResponse.StatusCode != System.Net.HttpStatusCode.NotFound)
|
||||||
|
deleteResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
if (eventUrls.Count > 1)
|
||||||
|
await Task.Delay(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> ParseShiftEventUrls(string xml)
|
||||||
|
{
|
||||||
|
var doc = XDocument.Parse(xml);
|
||||||
|
var urls = new List<string>();
|
||||||
|
|
||||||
|
foreach (var responseElement in doc.Descendants(DavNs + "response"))
|
||||||
|
{
|
||||||
|
var calendarData = responseElement
|
||||||
|
.Descendants(CalDavNs + "calendar-data")
|
||||||
|
.FirstOrDefault()?.Value;
|
||||||
|
|
||||||
|
if (calendarData == null || !calendarData.Contains("X-SHIFT-SCHEDULER:TRUE"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var href = responseElement.Element(DavNs + "href")?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(href))
|
||||||
|
urls.Add(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateShiftEventsAsync(string calendarId, List<ShiftWithTransport> shifts)
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
foreach (var shiftWithTransport in shifts)
|
||||||
|
{
|
||||||
|
var shift = shiftWithTransport.Shift;
|
||||||
|
var date = shiftWithTransport.Date;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(shift.MorningTime))
|
||||||
|
{
|
||||||
|
var (startTime, endTime) = configurationService.GetZurichTime(
|
||||||
|
DateOnly.FromDateTime(date), shift.MorningTime);
|
||||||
|
var uid = $"shift-{SanitizeForUid(shift.Name)}-{date:yyyyMMdd}-morning";
|
||||||
|
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
|
||||||
|
var icsContent = BuildIcsContent(uid, eventData, startTime, endTime);
|
||||||
|
|
||||||
|
var putRequest = CreateRequest(HttpMethod.Put, $"{calendarId}{Uri.EscapeDataString(uid)}.ics");
|
||||||
|
putRequest.Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar");
|
||||||
|
(await client.SendAsync(putRequest)).EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
|
{
|
||||||
|
var (startTime, endTime) = configurationService.GetZurichTime(
|
||||||
|
DateOnly.FromDateTime(date), shift.AfternoonTime);
|
||||||
|
var uid = $"shift-{SanitizeForUid(shift.Name)}-{date:yyyyMMdd}-afternoon";
|
||||||
|
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
|
||||||
|
var icsContent = BuildIcsContent(uid, eventData, startTime, endTime);
|
||||||
|
|
||||||
|
var putRequest = CreateRequest(HttpMethod.Put, $"{calendarId}{Uri.EscapeDataString(uid)}.ics");
|
||||||
|
putRequest.Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar");
|
||||||
|
(await client.SendAsync(putRequest)).EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shifts.Count > 1)
|
||||||
|
await Task.Delay(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildIcsContent(string uid, CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime)
|
||||||
|
{
|
||||||
|
var calendar = new Calendar();
|
||||||
|
var vevent = new CalendarEvent
|
||||||
|
{
|
||||||
|
Uid = uid,
|
||||||
|
Summary = eventData.Summary,
|
||||||
|
Description = eventData.Description,
|
||||||
|
DtStart = new CalDateTime(startTime.UtcDateTime, hasTime: true),
|
||||||
|
DtEnd = new CalDateTime(endTime.UtcDateTime, hasTime: true)
|
||||||
|
};
|
||||||
|
vevent.Properties.Add(new Ical.Net.CalendarProperty("X-SHIFT-SCHEDULER", "TRUE"));
|
||||||
|
calendar.Events.Add(vevent);
|
||||||
|
return new CalendarSerializer().SerializeToString(calendar)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeForUid(string name)
|
||||||
|
{
|
||||||
|
return new string(name.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage CreateRequest(HttpMethod method, string path)
|
||||||
|
{
|
||||||
|
// path may be a URL-path from a CalDAV href ("/remote.php/...") or a full URL
|
||||||
|
var uri = path.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? new Uri(path)
|
||||||
|
: new Uri($"{_baseUrl.TrimEnd('/')}{path}");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(method, uri);
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_username}:{_appPassword}"));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
|
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
@ -15,6 +9,8 @@
|
||||||
<PackageReference Include="Ical.Net" Version="5.1.0" />
|
<PackageReference Include="Ical.Net" Version="5.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
|
||||||
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
7
Shared/CalendarInfo.cs
Normal file
7
Shared/CalendarInfo.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace ShiftScheduler.Shared;
|
||||||
|
|
||||||
|
public class CalendarInfo
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,2 @@
|
||||||
|
// Class renamed to CalendarInfo — see Shared/CalendarInfo.cs
|
||||||
namespace ShiftScheduler.Shared;
|
namespace ShiftScheduler.Shared;
|
||||||
|
|
||||||
public class GoogleCalendar
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public string Summary { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<SupportedPlatform Include="browser" />
|
<SupportedPlatform Include="browser" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
69
ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs
Normal file
69
ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
using ShiftScheduler.Services;
|
||||||
|
using ShiftScheduler.Shared;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ShiftScheduler.Services.Tests;
|
||||||
|
|
||||||
|
public class CalendarEventBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildEventContent_WithTransport_IncludesTransportInDescription()
|
||||||
|
{
|
||||||
|
var shift = new Shift { Name = "Früh" };
|
||||||
|
var transport = new TransportConnection
|
||||||
|
{
|
||||||
|
Platform = "Gleis 3",
|
||||||
|
DepartureTime = DateTimeOffset.Parse("2026-04-01T05:30:00+02:00"),
|
||||||
|
ArrivalTime = DateTimeOffset.Parse("2026-04-01T05:55:00+02:00"),
|
||||||
|
Duration = TimeSpan.FromMinutes(25)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = CalendarEventBuilder.BuildEventContent(shift, transport);
|
||||||
|
|
||||||
|
result.Summary.ShouldBe("Früh");
|
||||||
|
result.Description.ShouldBe("Transport: Gleis 3: 05:30 → 05:55 (25 Minutes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildEventContent_WithoutTransport_EmptyDescription()
|
||||||
|
{
|
||||||
|
var shift = new Shift { Name = "Spät" };
|
||||||
|
|
||||||
|
var result = CalendarEventBuilder.BuildEventContent(shift, null);
|
||||||
|
|
||||||
|
result.Summary.ShouldBe("Spät");
|
||||||
|
result.Description.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTransportInfo_FormatsCorrectly()
|
||||||
|
{
|
||||||
|
var transport = new TransportConnection
|
||||||
|
{
|
||||||
|
Platform = "Gleis 3",
|
||||||
|
DepartureTime = DateTimeOffset.Parse("2026-04-01T05:30:00+02:00"),
|
||||||
|
ArrivalTime = DateTimeOffset.Parse("2026-04-01T05:55:00+02:00"),
|
||||||
|
Duration = TimeSpan.FromMinutes(25)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = CalendarEventBuilder.FormatTransportInfo(transport);
|
||||||
|
|
||||||
|
result.ShouldBe("Gleis 3: 05:30 → 05:55 (25 Minutes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTransportInfo_WithNullPlatform_UsesEmptyString()
|
||||||
|
{
|
||||||
|
var transport = new TransportConnection
|
||||||
|
{
|
||||||
|
Platform = null,
|
||||||
|
DepartureTime = DateTimeOffset.Parse("2026-04-01T05:30:00+02:00"),
|
||||||
|
ArrivalTime = DateTimeOffset.Parse("2026-04-01T05:55:00+02:00"),
|
||||||
|
Duration = TimeSpan.FromMinutes(25)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = CalendarEventBuilder.FormatTransportInfo(transport);
|
||||||
|
|
||||||
|
result.ShouldBe(": 05:30 → 05:55 (25 Minutes)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|
|
||||||
1614
docs/superpowers/plans/2026-04-10-nextcloud-calendar-sync.md
Normal file
1614
docs/superpowers/plans/2026-04-10-nextcloud-calendar-sync.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Nextcloud Calendar Sync — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-10
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a second calendar sync target (Nextcloud CalDAV) alongside the existing Google Calendar sync. Shared logic is extracted into a common interface and a static helper, keeping both service implementations independent and self-contained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Shared interface: `ICalendarService`
|
||||||
|
|
||||||
|
Replaces `IGoogleCalendarService`. Both providers implement this interface:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface ICalendarService
|
||||||
|
{
|
||||||
|
Task<List<CalendarInfo>> GetCalendarsAsync();
|
||||||
|
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CalendarInfo
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Summary { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared helper: `CalendarEventBuilder` (static class)
|
||||||
|
|
||||||
|
Extracts pure data-transformation logic currently inside `GoogleCalendarService`:
|
||||||
|
|
||||||
|
- `FormatTransportInfo(TransportConnection)` → string
|
||||||
|
- `BuildEventContent(Shift, DateTimeOffset start, DateTimeOffset end, TransportConnection?)` → returns a shared `CalendarEventData` record (summary + description)
|
||||||
|
|
||||||
|
No orchestration logic lives here — just pure functions with no side effects.
|
||||||
|
|
||||||
|
### `GoogleCalendarService`
|
||||||
|
|
||||||
|
- Implements `ICalendarService`
|
||||||
|
- Refactored to use `CalendarEventBuilder` for event content
|
||||||
|
- All Google client library calls remain here
|
||||||
|
- `GetCalendarsAsync()` returns `List<CalendarInfo>` mapped from Google's `CalendarListEntry`
|
||||||
|
- Identification of owned events via `ExtendedProperties.Private["shiftSchedulerEvent"]` unchanged
|
||||||
|
|
||||||
|
### `NextcloudCalendarService`
|
||||||
|
|
||||||
|
- Implements `ICalendarService`
|
||||||
|
- Uses `HttpClient` with Basic Auth (username + app password from configuration)
|
||||||
|
- CalDAV operations:
|
||||||
|
- **List calendars**: `PROPFIND` on `/remote.php/dav/calendars/<username>/`, depth 1, parse `displayname` and `resourcetype`
|
||||||
|
- **Delete existing events in range**: `REPORT` (CalDAV `calendar-query`) to find VEVENTs with `X-SHIFT-SCHEDULER:true` property in the date range, then `DELETE` each by URL
|
||||||
|
- **Create events**: `PUT` a VEVENT (iCalendar format using the existing `Ical.Net` dependency) to `<calendar-url>/<uid>.ics`. Each event includes `X-SHIFT-SCHEDULER:true` as a custom property for future identification.
|
||||||
|
- UIDs are generated deterministically: `shift-<shiftName>-<dateISO>-<morning|afternoon>` to allow idempotent re-sync if needed
|
||||||
|
- Retry logic mirrors Google's exponential backoff, extracted from `GoogleCalendarService` into a shared `RetryHelper` static method in `CalendarEventBuilder`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controller
|
||||||
|
|
||||||
|
`ShiftController` is updated:
|
||||||
|
|
||||||
|
- Both `IGoogleCalendarService` and `INextcloudCalendarService` injected. These are empty marker interfaces that extend `ICalendarService`, registered with their concrete types in DI. This avoids DI ambiguity while keeping the shared interface as the contract:
|
||||||
|
```csharp
|
||||||
|
public interface IGoogleCalendarService : ICalendarService { }
|
||||||
|
public interface INextcloudCalendarService : ICalendarService { }
|
||||||
|
```
|
||||||
|
- Old endpoints `GET /api/shift/google_calendars` and `POST /api/shift/sync_to_google_calendar` are **replaced** by:
|
||||||
|
- `GET /api/shift/calendars?provider=google|nextcloud` → `List<CalendarInfo>`
|
||||||
|
- `POST /api/shift/sync_to_calendar` → body: `{ Provider, CalendarId, Year, Month }`
|
||||||
|
- Provider routing via a `switch` on the `provider` string — no factory abstraction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client UI
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
Two separate sync buttons in `Index.razor`:
|
||||||
|
- **Sync to Google Calendar** (existing, unchanged label)
|
||||||
|
- **Sync to Nextcloud** (new)
|
||||||
|
|
||||||
|
Both buttons are disabled while `_isSyncing` is true.
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
New field `_syncProvider` (`"google"` or `"nextcloud"`) tracks which provider triggered the current flow.
|
||||||
|
|
||||||
|
Existing `_availableCalendars`, `_showCalendarSelector`, and the calendar picker modal are reused for both providers. The modal title changes to "Select Google Calendar" or "Select Nextcloud Calendar" based on `_syncProvider`.
|
||||||
|
|
||||||
|
### Client DTO
|
||||||
|
|
||||||
|
`GoogleCalendar` record on the client is renamed to `CalendarInfo` to match the server DTO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### `appsettings.json` (placeholder — committed to git)
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Nextcloud": {
|
||||||
|
"BaseUrl": "https://your-nextcloud-instance.example.com",
|
||||||
|
"Username": "YOUR_NEXTCLOUD_USERNAME",
|
||||||
|
"AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production appsettings (not committed, mounted via Docker)
|
||||||
|
|
||||||
|
Contains real values for `BaseUrl`, `Username`, and `AppPassword`.
|
||||||
|
|
||||||
|
Read in `Program.cs` via `IConfiguration`, injected into `NextcloudCalendarService` — same pattern as `Authentication:Google`.
|
||||||
|
|
||||||
|
### `DOCKER_CONFIG.md`
|
||||||
|
|
||||||
|
Add a note that the `Nextcloud` section must be populated in the production `appsettings.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
No new NuGet packages required:
|
||||||
|
- CalDAV HTTP calls: plain `HttpClient`
|
||||||
|
- iCalendar VEVENT generation: `Ical.Net` (already in `ShiftScheduler.Services.csproj`)
|
||||||
|
- XML parsing for CalDAV responses: `System.Xml` (built-in)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Two-way sync (read events from calendar back into the scheduler)
|
||||||
|
- Nextcloud OAuth — app password is sufficient for a personal instance
|
||||||
|
- Caching of calendar lists
|
||||||
Loading…
Reference in a new issue