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)
|
||||
config/
|
||||
**/config/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<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="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 config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
|
||||
</div>
|
||||
|
|
@ -38,7 +39,7 @@
|
|||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
@ -87,7 +88,7 @@ else if (_isSyncing)
|
|||
<div class="loading-overlay">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner">⏳</div>
|
||||
<p>Syncing with google calendar...</p>
|
||||
<p>Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -218,10 +219,3 @@ else
|
|||
OnClose="HideConfiguration"
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
[Inject] private HttpClient HttpClient { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
|
||||
|
||||
private List<Shift> Shifts { get; set; } = new();
|
||||
private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new();
|
||||
private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new();
|
||||
|
|
@ -21,24 +21,22 @@ namespace ShiftScheduler.Client.Pages
|
|||
private bool _showConfigDialog = false;
|
||||
private bool _isSyncing = false;
|
||||
private bool _showCalendarSelector = false;
|
||||
private string _syncProvider = string.Empty;
|
||||
private string _errorMessage = 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();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_isLoadingInitial = true;
|
||||
|
||||
// Load shifts first to show the calendar immediately
|
||||
|
||||
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
|
||||
|
||||
// Show calendar immediately, don't wait for transport data
|
||||
|
||||
_isLoadingInitial = false;
|
||||
StateHasChanged();
|
||||
|
||||
// Load schedule and transport data in the background
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await LoadScheduleFromStorage();
|
||||
|
|
@ -63,29 +61,25 @@ namespace ShiftScheduler.Client.Pages
|
|||
private async Task SelectShift(DateTime day, string shiftName)
|
||||
{
|
||||
SelectedSchedule[day] = shiftName;
|
||||
|
||||
// Get transport information for this shift and date - per day loading
|
||||
|
||||
_isLoadingTransportPerDay[day] = true;
|
||||
StateHasChanged();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var request = new { ShiftName = shiftName, Date = day };
|
||||
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
|
||||
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
|
||||
if (shiftWithTransport != null)
|
||||
{
|
||||
SelectedShiftsWithTransport[day] = shiftWithTransport;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error fetching transport data: {ex.Message}");
|
||||
// Create a fallback with just the shift data
|
||||
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
||||
if (shift != null)
|
||||
{
|
||||
|
|
@ -103,7 +97,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
_isLoadingTransportPerDay[day] = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
|
||||
await SaveScheduleToStorage();
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +111,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
|
||||
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
|
||||
var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request);
|
||||
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
|
|
@ -136,7 +130,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
{
|
||||
_errorMessage = $"Error exporting ICS file: {ex.Message}";
|
||||
}
|
||||
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +144,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
|
||||
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
|
||||
var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request);
|
||||
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
|
|
@ -168,38 +162,39 @@ namespace ShiftScheduler.Client.Pages
|
|||
{
|
||||
_errorMessage = $"Error exporting PDF file: {ex.Message}";
|
||||
}
|
||||
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SyncToGoogleCalendar()
|
||||
private Task SyncToGoogleCalendar() => StartCalendarSync("google");
|
||||
private Task SyncToNextcloud() => StartCalendarSync("nextcloud");
|
||||
|
||||
private async Task StartCalendarSync(string provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
_errorMessage = string.Empty;
|
||||
_successMessage = string.Empty;
|
||||
_syncProvider = provider;
|
||||
_isSyncing = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Get available calendars
|
||||
var response = await HttpClient.GetAsync("api/shift/google_calendars");
|
||||
var response = await HttpClient.GetAsync($"api/shift/calendars?provider={provider}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_availableCalendars = await response.Content.ReadFromJsonAsync<List<GoogleCalendar>>() ?? new();
|
||||
|
||||
_availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? new();
|
||||
|
||||
if (_availableCalendars.Count == 1)
|
||||
{
|
||||
// If only one calendar, sync directly
|
||||
await PerformCalendarSync(_availableCalendars[0].Id);
|
||||
}
|
||||
else if (_availableCalendars.Count > 1)
|
||||
{
|
||||
// Show calendar selector
|
||||
_showCalendarSelector = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = "No writable calendars found in your Google account.";
|
||||
_errorMessage = "No writable calendars found.";
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -210,7 +205,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Error accessing Google Calendar: {ex.Message}";
|
||||
_errorMessage = $"Error accessing calendar: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -234,27 +229,27 @@ namespace ShiftScheduler.Client.Pages
|
|||
|
||||
var request = new
|
||||
{
|
||||
Provider = _syncProvider,
|
||||
CalendarId = calendarId,
|
||||
Year = SelectedDate.Year,
|
||||
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)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<dynamic>();
|
||||
_successMessage = "Shifts synced to Google Calendar successfully!";
|
||||
_successMessage = "Shifts synced successfully!";
|
||||
}
|
||||
else
|
||||
{
|
||||
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)
|
||||
{
|
||||
_errorMessage = $"Error syncing to Google Calendar: {ex.Message}";
|
||||
_errorMessage = $"Error syncing: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -280,7 +275,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
Month = SelectedDate.Month,
|
||||
Schedule = SelectedSchedule
|
||||
};
|
||||
|
||||
|
||||
await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request);
|
||||
}
|
||||
catch (Exception)
|
||||
|
|
@ -294,12 +289,10 @@ namespace ShiftScheduler.Client.Pages
|
|||
try
|
||||
{
|
||||
var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
|
||||
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new();
|
||||
|
||||
// Reload transport data for all selected shifts
|
||||
await ReloadTransportDataForSchedule();
|
||||
}
|
||||
else
|
||||
|
|
@ -318,41 +311,33 @@ namespace ShiftScheduler.Client.Pages
|
|||
private async Task ReloadTransportDataForSchedule()
|
||||
{
|
||||
SelectedShiftsWithTransport.Clear();
|
||||
|
||||
// Clear existing per-day loading states
|
||||
_isLoadingTransportPerDay.Clear();
|
||||
|
||||
// Mark all days as loading
|
||||
|
||||
foreach (var kvp in SelectedSchedule)
|
||||
{
|
||||
_isLoadingTransportPerDay[kvp.Key] = true;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
// Create parallel tasks for all transport data requests
|
||||
var transportTasks = SelectedSchedule.Select(async kvp =>
|
||||
{
|
||||
var day = kvp.Key;
|
||||
var shiftName = kvp.Value;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var request = new { ShiftName = shiftName, Date = day };
|
||||
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
|
||||
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
|
||||
if (shiftWithTransport != null)
|
||||
{
|
||||
SelectedShiftsWithTransport[day] = shiftWithTransport;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
|
||||
// Create fallback without transport
|
||||
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
||||
if (shift != null)
|
||||
{
|
||||
|
|
@ -367,13 +352,11 @@ namespace ShiftScheduler.Client.Pages
|
|||
}
|
||||
finally
|
||||
{
|
||||
// Mark this day as no longer loading
|
||||
_isLoadingTransportPerDay[day] = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}).ToArray();
|
||||
|
||||
// Wait for all transport data to load
|
||||
await Task.WhenAll(transportTasks);
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +364,7 @@ namespace ShiftScheduler.Client.Pages
|
|||
{
|
||||
SelectedSchedule.Clear();
|
||||
SelectedShiftsWithTransport.Clear();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
await HttpClient.DeleteAsync($"api/shift/delete_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
|
||||
|
|
@ -392,45 +375,42 @@ namespace ShiftScheduler.Client.Pages
|
|||
}
|
||||
}
|
||||
|
||||
// Helper method to get transport summary for display
|
||||
private string GetTransportSummary(DateTime date)
|
||||
{
|
||||
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
|
||||
return string.Empty;
|
||||
|
||||
var summaries = new List<string>();
|
||||
|
||||
|
||||
var morningTransport = shiftWithTransport.GetMorningTransportSummary();
|
||||
if (!string.IsNullOrEmpty(morningTransport))
|
||||
summaries.Add(morningTransport);
|
||||
|
||||
|
||||
var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary();
|
||||
if (!string.IsNullOrEmpty(afternoonTransport))
|
||||
summaries.Add(afternoonTransport);
|
||||
|
||||
if (summaries.Count == 0) { return string.Empty; }
|
||||
|
||||
|
||||
if (summaries.Count == 0) return string.Empty;
|
||||
|
||||
return "🚂 " + string.Join(" | ", summaries);
|
||||
}
|
||||
|
||||
// Helper method to get shift times for display
|
||||
private string GetShiftTimes(DateTime date)
|
||||
{
|
||||
if (!SelectedSchedule.TryGetValue(date, out var shiftName))
|
||||
return string.Empty;
|
||||
|
||||
|
||||
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
|
||||
if (shift == null)
|
||||
return string.Empty;
|
||||
|
||||
if (shift == null) return string.Empty;
|
||||
|
||||
var times = new List<string>();
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime))
|
||||
times.Add($"🌅 {shift.MorningTime}");
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||
times.Add($"🌆 {shift.AfternoonTime}");
|
||||
|
||||
|
||||
return string.Join(" | ", times);
|
||||
}
|
||||
|
||||
|
|
@ -448,9 +428,11 @@ namespace ShiftScheduler.Client.Pages
|
|||
|
||||
private async Task OnConfigurationChanged()
|
||||
{
|
||||
// Reload shifts after configuration changes
|
||||
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool IsLoadingTransportForDay(DateTime day) =>
|
||||
_isLoadingTransportPerDay.GetValueOrDefault(day, false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" 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 |
|
|
@ -61,4 +61,18 @@ No migration needed - existing installations will automatically:
|
|||
|
||||
## 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 IConfigurationService _configurationService;
|
||||
private readonly IGoogleCalendarService _googleCalendarService;
|
||||
private readonly INextcloudCalendarService _nextcloudCalendarService;
|
||||
|
||||
public ShiftController(
|
||||
IcsExportService icsService,
|
||||
PdfExportService pdfExportService,
|
||||
IcsExportService icsService,
|
||||
PdfExportService pdfExportService,
|
||||
ITransportService transportService,
|
||||
IConfigurationService configurationService,
|
||||
IGoogleCalendarService googleCalendarService)
|
||||
IGoogleCalendarService googleCalendarService,
|
||||
INextcloudCalendarService nextcloudCalendarService)
|
||||
{
|
||||
_icsService = icsService;
|
||||
_pdfExportService = pdfExportService;
|
||||
_transportService = transportService;
|
||||
_configurationService = configurationService;
|
||||
_googleCalendarService = googleCalendarService;
|
||||
_nextcloudCalendarService = nextcloudCalendarService;
|
||||
}
|
||||
|
||||
[HttpGet("shifts")]
|
||||
|
|
@ -41,59 +44,38 @@ namespace ShiftScheduler.Server.Controllers
|
|||
{
|
||||
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName);
|
||||
if (shift == null)
|
||||
{
|
||||
return NotFound($"Shift '{request.ShiftName}' not found");
|
||||
}
|
||||
|
||||
var transportConfig = _configurationService.GetTransportConfiguration();
|
||||
TransportConnection? morningTransport = null;
|
||||
TransportConnection? afternoonTransport = null;
|
||||
|
||||
|
||||
var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
|
||||
|
||||
// Get transport for morning shift if it has morning time
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime))
|
||||
{
|
||||
if (shifTimes.MorningStart.HasValue)
|
||||
{
|
||||
morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningStart.HasValue)
|
||||
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))
|
||||
{
|
||||
var shouldLoadAfternoonTransport = true;
|
||||
|
||||
// If both morning and afternoon shifts exist, check break duration
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime))
|
||||
var shouldLoad = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
|
||||
{
|
||||
if (shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
|
||||
{
|
||||
var breakDurationMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
|
||||
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLoadAfternoonTransport)
|
||||
{
|
||||
if (shifTimes.AfternoonStart.HasValue)
|
||||
{
|
||||
afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
|
||||
}
|
||||
var breakMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
|
||||
shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
|
||||
}
|
||||
|
||||
if (shouldLoad && shifTimes.AfternoonStart.HasValue)
|
||||
afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
|
||||
}
|
||||
|
||||
var shiftWithTransport = new ShiftWithTransport
|
||||
return Ok(new ShiftWithTransport
|
||||
{
|
||||
Date = request.Date,
|
||||
Shift = shift,
|
||||
MorningTransport = morningTransport,
|
||||
AfternoonTransport = afternoonTransport,
|
||||
};
|
||||
|
||||
return Ok(shiftWithTransport);
|
||||
AfternoonTransport = afternoonTransport
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("export_ics")]
|
||||
|
|
@ -154,13 +136,22 @@ namespace ShiftScheduler.Server.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpGet("google_calendars")]
|
||||
public async Task<IActionResult> GetGoogleCalendars()
|
||||
[HttpGet("calendars")]
|
||||
public async Task<IActionResult> GetCalendars([FromQuery] string provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
var calendars = await _googleCalendarService.GetCalendarsAsync();
|
||||
return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary }));
|
||||
ICalendarService service = provider switch
|
||||
{
|
||||
"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)
|
||||
{
|
||||
|
|
@ -168,18 +159,28 @@ namespace ShiftScheduler.Server.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPost("sync_to_google_calendar")]
|
||||
public async Task<IActionResult> SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request)
|
||||
[HttpPost("sync_to_calendar")]
|
||||
public async Task<IActionResult> SyncToCalendar([FromBody] SyncToCalendarRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
return StatusCode(500, $"Error syncing to Google Calendar: {ex.Message}");
|
||||
return StatusCode(500, $"Error syncing to calendar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,37 +194,29 @@ namespace ShiftScheduler.Server.Controllers
|
|||
{
|
||||
var date = kvp.Key;
|
||||
var shiftName = kvp.Value;
|
||||
|
||||
|
||||
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName);
|
||||
if (shift == null)
|
||||
continue;
|
||||
if (shift == null) continue;
|
||||
|
||||
var shiftTimes = _configurationService.ParseShiftTimes(date, shift);
|
||||
TransportConnection? morningTransport = null;
|
||||
TransportConnection? afternoonTransport = null;
|
||||
|
||||
// Get transport for morning shift if it has morning time
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue)
|
||||
{
|
||||
morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value);
|
||||
}
|
||||
|
||||
// Get transport for afternoon shift if it has afternoon time
|
||||
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||
{
|
||||
var shouldLoadAfternoonTransport = true;
|
||||
|
||||
// If both morning and afternoon shifts exist, check break duration
|
||||
var shouldLoad = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningEnd.HasValue && shiftTimes.AfternoonStart.HasValue)
|
||||
{
|
||||
var breakDurationMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
|
||||
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
|
||||
var breakMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
|
||||
shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
|
||||
}
|
||||
|
||||
if (shouldLoadAfternoonTransport && shiftTimes.AfternoonStart.HasValue)
|
||||
{
|
||||
|
||||
if (shouldLoad && shiftTimes.AfternoonStart.HasValue)
|
||||
afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value);
|
||||
}
|
||||
}
|
||||
|
||||
shiftsWithTransport.Add(new ShiftWithTransport
|
||||
|
|
@ -258,8 +251,9 @@ namespace ShiftScheduler.Server.Controllers
|
|||
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 int Year { get; set; }
|
||||
public int Month { get; set; }
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
|||
builder.Services.AddSingleton<ITransportService, TransportService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
|
||||
builder.Services.AddScoped<INextcloudCalendarService, NextcloudCalendarService>();
|
||||
|
||||
// Configure authentication
|
||||
builder.Services.AddAuthentication(options =>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" 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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@
|
|||
]
|
||||
},
|
||||
|
||||
"Nextcloud": {
|
||||
"BaseUrl": "https://your-nextcloud-instance.example.com",
|
||||
"Username": "YOUR_NEXTCLOUD_USERNAME",
|
||||
"AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
|
||||
},
|
||||
|
||||
"Transport": {
|
||||
"StartStation": "Zurich",
|
||||
"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;
|
||||
|
||||
public interface IGoogleCalendarService
|
||||
{
|
||||
Task<List<CalendarListEntry>> GetCalendarsAsync();
|
||||
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
|
||||
}
|
||||
|
||||
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
|
||||
: IGoogleCalendarService
|
||||
{
|
||||
public async Task<List<CalendarListEntry>> GetCalendarsAsync()
|
||||
public async Task<List<CalendarInfo>> GetCalendarsAsync()
|
||||
{
|
||||
var service = await CreateCalendarServiceAsync();
|
||||
var request = service.CalendarList.List();
|
||||
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
|
||||
|
||||
return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? [];
|
||||
var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
|
||||
|
||||
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)
|
||||
{
|
||||
var service = await CreateCalendarServiceAsync();
|
||||
|
||||
// Get date range for deletion
|
||||
|
||||
if (shifts.Count == 0) return;
|
||||
|
||||
|
||||
var minDate = shifts.Min(s => s.Date).Date;
|
||||
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
|
||||
|
||||
// Delete existing shift events in the date range
|
||||
|
||||
await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate);
|
||||
|
||||
// Add a small delay to avoid rate limiting
|
||||
await Task.Delay(500);
|
||||
|
||||
// Create new events for each shift with rate limiting protection
|
||||
await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
|
||||
}
|
||||
|
||||
|
|
@ -53,24 +44,23 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
|||
request.TimeMaxDateTimeOffset = endDate;
|
||||
request.SingleEvents = true;
|
||||
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)
|
||||
{
|
||||
var shiftsToDelete = response.Items.Where(e =>
|
||||
e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList();
|
||||
|
||||
// Delete events with rate limiting protection
|
||||
var shiftsToDelete = response.Items
|
||||
.Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true)
|
||||
.ToList();
|
||||
|
||||
foreach (var eventToDelete in shiftsToDelete)
|
||||
{
|
||||
await ExecuteWithRetryAsync(async () =>
|
||||
await CalendarEventBuilder.ExecuteWithRetryAsync(async () =>
|
||||
{
|
||||
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)
|
||||
await Task.Delay(200);
|
||||
}
|
||||
|
|
@ -82,8 +72,7 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
|||
foreach (var shiftWithTransport in shifts)
|
||||
{
|
||||
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
|
||||
|
||||
// Add delay between shift processing to avoid rate limiting
|
||||
|
||||
if (shifts.Count > 1)
|
||||
await Task.Delay(300);
|
||||
}
|
||||
|
|
@ -94,118 +83,59 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
|||
var shift = shiftWithTransport.Shift;
|
||||
var date = shiftWithTransport.Date;
|
||||
|
||||
// Skip shifts with no time information
|
||||
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
|
||||
return;
|
||||
|
||||
// Create morning event if it exists
|
||||
if (!string.IsNullOrEmpty(shift.MorningTime))
|
||||
{
|
||||
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
|
||||
var morningEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.MorningTransport);
|
||||
await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync());
|
||||
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
|
||||
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))
|
||||
{
|
||||
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
|
||||
var afternoonEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.AfternoonTransport);
|
||||
await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync());
|
||||
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
|
||||
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
|
||||
{
|
||||
Summary = summary,
|
||||
Description = description,
|
||||
Start = new EventDateTime
|
||||
{
|
||||
DateTimeDateTimeOffset = startTime
|
||||
},
|
||||
End = new EventDateTime
|
||||
{
|
||||
DateTimeDateTimeOffset = endTime
|
||||
},
|
||||
Summary = eventData.Summary,
|
||||
Description = eventData.Description,
|
||||
Start = new EventDateTime { DateTimeDateTimeOffset = startTime },
|
||||
End = new EventDateTime { DateTimeDateTimeOffset = endTime },
|
||||
ExtendedProperties = new Event.ExtendedPropertiesData
|
||||
{
|
||||
Private__ = new Dictionary<string, string>
|
||||
{
|
||||
["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()
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
|
||||
|
||||
|
||||
var accessToken = await httpContext.GetTokenAsync("access_token");
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
throw new UnauthorizedAccessException("No access token available");
|
||||
}
|
||||
|
||||
var credential = GoogleCredential.FromAccessToken(accessToken);
|
||||
|
||||
|
||||
return new CalendarService(new BaseClientService.Initializer
|
||||
{
|
||||
HttpClientInitializer = credential,
|
||||
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">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
@ -15,6 +9,8 @@
|
|||
<PackageReference Include="Ical.Net" Version="5.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.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="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||
</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 @@
|
|||
namespace ShiftScheduler.Shared;
|
||||
|
||||
public class GoogleCalendar
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
}
|
||||
// Class renamed to CalendarInfo — see Shared/CalendarInfo.cs
|
||||
namespace ShiftScheduler.Shared;
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
</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>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
@ -15,7 +12,7 @@
|
|||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" />
|
||||
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
|
||||
|
|
|
|||
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