From 2916f0983afe50881f85c948103755c798ce15e7 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:07:47 +0200 Subject: [PATCH] Add meeting countdown and agenda plugins --- .../BuiltInPlugins/AgendaPlugin.cs | 279 ++++++++++++++++++ .../NextMeetingCountdownPlugin.cs | 233 +++++++++++++++ src/DesktopMagic/DesktopMagic.csproj | 1 + src/DesktopMagic/Helpers/IcsCalendarHelper.cs | 89 ++++++ src/DesktopMagic/Manager.cs | 4 +- .../Resources/Strings/StringResources.de.xaml | 2 + .../Resources/Strings/StringResources.en.xaml | 2 + 7 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 src/DesktopMagic/BuiltInPlugins/AgendaPlugin.cs create mode 100644 src/DesktopMagic/BuiltInPlugins/NextMeetingCountdownPlugin.cs create mode 100644 src/DesktopMagic/Helpers/IcsCalendarHelper.cs diff --git a/src/DesktopMagic/BuiltInPlugins/AgendaPlugin.cs b/src/DesktopMagic/BuiltInPlugins/AgendaPlugin.cs new file mode 100644 index 0000000..d2eef39 --- /dev/null +++ b/src/DesktopMagic/BuiltInPlugins/AgendaPlugin.cs @@ -0,0 +1,279 @@ +using DesktopMagic.Api; +using DesktopMagic.Api.Settings; +using DesktopMagic.Helpers; + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Text; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace DesktopMagic.BuiltInPlugins; + +internal class AgendaPlugin : AsyncPlugin +{ + [Setting("agenda-calendar-url", "iCal URL")] + private readonly TextBox calendarUrl = new TextBox(string.Empty); + + [Setting("agenda-refresh-button", "Refresh Calendar")] + private readonly Button refreshButton = new Button("Refresh"); + + [Setting("agenda-refresh-interval", "Refresh Every (minutes)")] + private readonly IntegerUpDown refreshMinutes = new IntegerUpDown(1, 120, 10); + + [Setting("agenda-lookahead-hours", "Look Ahead (hours)")] + private readonly IntegerUpDown lookAheadHours = new IntegerUpDown(1, 336, 24); + + [Setting("agenda-max-events", "Maximum Events")] + private readonly IntegerUpDown maxEvents = new IntegerUpDown(1, 15, 5); + + [Setting("agenda-include-all-day", "Include All-Day Events")] + private readonly CheckBox includeAllDayEvents = new CheckBox(true); + + [Setting("agenda-show-date", "Show Date")] + private readonly CheckBox showDate = new CheckBox(true); + + public override int UpdateInterval { get; set; } = 30000; + + private List upcomingEvents = []; + private DateTime nextRefreshTime = DateTime.MinValue; + private bool forceRefresh = true; + private bool themeChanged = true; + private string? errorText; + + public override Task StartAsync(CancellationToken cancellationToken) + { + refreshButton.OnClick += () => + { + forceRefresh = true; + Application.UpdateWindow(); + }; + + calendarUrl.OnValueChanged += MarkForRefresh; + refreshMinutes.OnValueChanged += MarkForRefresh; + lookAheadHours.OnValueChanged += MarkForRefresh; + maxEvents.OnValueChanged += MarkForRefresh; + includeAllDayEvents.OnValueChanged += MarkForRefresh; + showDate.OnValueChanged += Application.UpdateWindow; + + return Task.CompletedTask; + } + + public override async Task MainAsync(CancellationToken cancellationToken) + { + if (themeChanged || forceRefresh || DateTime.Now >= nextRefreshTime) + { + await RefreshEventsAsync(cancellationToken); + themeChanged = false; + } + + return RenderAgendaBitmap(); + } + + public override void OnThemeChanged() + { + themeChanged = true; + } + + public override void OnSettingsChanged() + { + Application.UpdateWindow(); + } + + private void MarkForRefresh() + { + forceRefresh = true; + Application.UpdateWindow(); + } + + private async Task RefreshEventsAsync(CancellationToken cancellationToken) + { + forceRefresh = false; + nextRefreshTime = DateTime.Now.AddMinutes(Math.Max(1, refreshMinutes.Value)); + + if (string.IsNullOrWhiteSpace(calendarUrl.Value)) + { + upcomingEvents = []; + errorText = "Set iCal URL in settings"; + return; + } + + try + { + DateTime now = DateTime.Now; + DateTime windowEnd = now.AddHours(Math.Max(1, lookAheadHours.Value)); + + upcomingEvents = await IcsCalendarHelper.GetUpcomingEventsAsync( + calendarUrl.Value.Trim(), + now, + windowEnd, + maxEvents.Value, + includeAllDayEvents.Value, + cancellationToken); + + errorText = null; + } + catch (HttpRequestException ex) + { + upcomingEvents = []; + errorText = $"Calendar unavailable: {ex.Message}"; + } + catch (TaskCanceledException ex) + { + upcomingEvents = []; + errorText = $"Calendar unavailable: {ex.Message}"; + } + catch (InvalidOperationException ex) + { + upcomingEvents = []; + errorText = $"Calendar unavailable: {ex.Message}"; + } + } + + private Bitmap RenderAgendaBitmap() + { + List<(string Day, string Date, string Time, string Title, bool IsMessage)> rows = []; + + if (!string.IsNullOrWhiteSpace(errorText)) + { + rows.Add((string.Empty, string.Empty, string.Empty, errorText, true)); + } + else if (upcomingEvents.Count == 0) + { + rows.Add((string.Empty, string.Empty, string.Empty, "No events in selected window", true)); + } + else + { + foreach (CalendarEventEntry eventEntry in upcomingEvents) + { + string dayPart = showDate.Value ? eventEntry.StartLocal.ToString("ddd") : string.Empty; + string datePart = showDate.Value ? eventEntry.StartLocal.ToString("dd MMM yyyy") : string.Empty; + string timePart = eventEntry.IsAllDay ? "All day" : eventEntry.StartLocal.ToShortTimeString(); + + rows.Add((dayPart, datePart, timePart, eventEntry.Title, false)); + } + } + + using Font font = new Font(Application.Theme.Font, 70); + using Bitmap measureBitmap = new Bitmap(1, 1); + using Graphics measureGraphics = Graphics.FromImage(measureBitmap); + measureGraphics.TextRenderingHint = TextRenderingHint.AntiAlias; + + bool hasDayColumn = rows.Any(row => !row.IsMessage && !string.IsNullOrWhiteSpace(row.Day)); + bool hasDateColumn = rows.Any(row => !row.IsMessage && !string.IsNullOrWhiteSpace(row.Date)); + bool hasTimeColumn = rows.Any(row => !row.IsMessage && !string.IsNullOrWhiteSpace(row.Time)); + float maxDayWidth = 0; + float maxDateWidth = 0; + float maxTimeWidth = 0; + float maxTitleWidth = 0; + float lineHeight = 0; + + foreach ((string day, string date, string time, string title, bool isMessage) in rows) + { + if (isMessage) + { + SizeF messageSize = measureGraphics.MeasureString(title, font); + maxTitleWidth = Math.Max(maxTitleWidth, messageSize.Width); + lineHeight = Math.Max(lineHeight, messageSize.Height); + continue; + } + + if (hasDayColumn) + { + SizeF daySize = measureGraphics.MeasureString(day, font); + maxDayWidth = Math.Max(maxDayWidth, daySize.Width); + lineHeight = Math.Max(lineHeight, daySize.Height); + } + + if (hasDateColumn) + { + SizeF dateSize = measureGraphics.MeasureString(date, font); + maxDateWidth = Math.Max(maxDateWidth, dateSize.Width); + lineHeight = Math.Max(lineHeight, dateSize.Height); + } + + if (hasTimeColumn) + { + SizeF timeSize = measureGraphics.MeasureString(time, font); + maxTimeWidth = Math.Max(maxTimeWidth, timeSize.Width); + lineHeight = Math.Max(lineHeight, timeSize.Height); + } + + SizeF titleSize = measureGraphics.MeasureString(title, font); + maxTitleWidth = Math.Max(maxTitleWidth, titleSize.Width); + lineHeight = Math.Max(lineHeight, titleSize.Height); + } + + float columnSpacing = measureGraphics.MeasureString(" ", font).Width; + + float prefixWidth = 0; + bool hasAnyPrefix = false; + if (hasDayColumn) + { + prefixWidth += maxDayWidth; + hasAnyPrefix = true; + } + if (hasDateColumn) + { + prefixWidth += (hasAnyPrefix ? columnSpacing : 0) + maxDateWidth; + hasAnyPrefix = true; + } + if (hasTimeColumn) + { + prefixWidth += (hasAnyPrefix ? columnSpacing : 0) + maxTimeWidth; + hasAnyPrefix = true; + } + + float totalWidth = (hasAnyPrefix ? prefixWidth + columnSpacing : 0) + maxTitleWidth; + + int width = Math.Max(1, (int)Math.Ceiling(totalWidth)); + int height = Math.Max(1, (int)Math.Ceiling(lineHeight * rows.Count)); + + Bitmap bitmap = new Bitmap(width, height); + bitmap.SetResolution(100, 100); + + using Graphics graphics = Graphics.FromImage(bitmap); + using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor); + graphics.TextRenderingHint = TextRenderingHint.AntiAlias; + graphics.Clear(Color.Transparent); + + float dayColumnX = 0; + float dateColumnX = hasDayColumn ? dayColumnX + maxDayWidth + columnSpacing : dayColumnX; + float timeColumnX = hasDateColumn ? dateColumnX + maxDateWidth + columnSpacing : dateColumnX; + float titleColumnX = hasAnyPrefix ? timeColumnX + (hasTimeColumn ? maxTimeWidth : 0) + columnSpacing : 0; + + for (int index = 0; index < rows.Count; index++) + { + (string day, string date, string time, string title, bool isMessage) = rows[index]; + float y = index * lineHeight; + + if (isMessage) + { + graphics.DrawString(title, font, brush, 0, y); + continue; + } + + if (hasDayColumn && !string.IsNullOrWhiteSpace(day)) + { + graphics.DrawString(day, font, brush, dayColumnX, y); + } + + if (hasDateColumn && !string.IsNullOrWhiteSpace(date)) + { + graphics.DrawString(date, font, brush, dateColumnX, y); + } + + if (hasTimeColumn && !string.IsNullOrWhiteSpace(time)) + { + graphics.DrawString(time, font, brush, timeColumnX, y); + } + + graphics.DrawString(title, font, brush, titleColumnX, y); + } + + return bitmap; + } +} diff --git a/src/DesktopMagic/BuiltInPlugins/NextMeetingCountdownPlugin.cs b/src/DesktopMagic/BuiltInPlugins/NextMeetingCountdownPlugin.cs new file mode 100644 index 0000000..da85aee --- /dev/null +++ b/src/DesktopMagic/BuiltInPlugins/NextMeetingCountdownPlugin.cs @@ -0,0 +1,233 @@ +using DesktopMagic.Api; +using DesktopMagic.Api.Settings; +using DesktopMagic.Helpers; + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Text; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace DesktopMagic.BuiltInPlugins; + +internal class NextMeetingCountdownPlugin : AsyncPlugin +{ + [Setting("meeting-calendar-url", "iCal URL")] + private readonly TextBox calendarUrl = new TextBox(string.Empty); + + [Setting("meeting-refresh-button", "Refresh Calendar")] + private readonly Button refreshButton = new Button("Refresh"); + + [Setting("meeting-refresh-interval", "Refresh Every (minutes)")] + private readonly IntegerUpDown refreshMinutes = new IntegerUpDown(1, 120, 10); + + [Setting("meeting-lookahead-hours", "Look Ahead (hours)")] + private readonly IntegerUpDown lookAheadHours = new IntegerUpDown(1, 336, 48); + + [Setting("meeting-include-all-day", "Include All-Day Events")] + private readonly CheckBox includeAllDayEvents = new CheckBox(false); + + [Setting("meeting-time-display", "Start Time Format")] + private readonly ComboBox startTimeDisplay = new ComboBox("None", "Date and time", "Time only"); + + [Setting("meeting-show-location", "Show Location")] + private readonly CheckBox showLocation = new CheckBox(false); + + [Setting("meeting-center-lines", "Center Lines")] + private readonly CheckBox centerLines = new CheckBox(false); + + public override int UpdateInterval { get; set; } = 1000; + + private List upcomingEvents = []; + private DateTime nextRefreshTime = DateTime.MinValue; + private bool forceRefresh = true; + private bool themeChanged = true; + private string? errorText; + + public override Task StartAsync(CancellationToken cancellationToken) + { + refreshButton.OnClick += () => + { + forceRefresh = true; + Application.UpdateWindow(); + }; + + calendarUrl.OnValueChanged += MarkForRefresh; + refreshMinutes.OnValueChanged += MarkForRefresh; + lookAheadHours.OnValueChanged += MarkForRefresh; + includeAllDayEvents.OnValueChanged += MarkForRefresh; + startTimeDisplay.OnValueChanged += Application.UpdateWindow; + showLocation.OnValueChanged += Application.UpdateWindow; + centerLines.OnValueChanged += Application.UpdateWindow; + + return Task.CompletedTask; + } + + public override async Task MainAsync(CancellationToken cancellationToken) + { + if (themeChanged || forceRefresh || DateTime.Now >= nextRefreshTime) + { + await RefreshEventsAsync(cancellationToken); + themeChanged = false; + } + + return RenderCountdownBitmap(); + } + + public override void OnThemeChanged() + { + themeChanged = true; + } + + public override void OnSettingsChanged() + { + Application.UpdateWindow(); + } + + private void MarkForRefresh() + { + forceRefresh = true; + Application.UpdateWindow(); + } + + private async Task RefreshEventsAsync(CancellationToken cancellationToken) + { + forceRefresh = false; + nextRefreshTime = DateTime.Now.AddMinutes(Math.Max(1, refreshMinutes.Value)); + + if (string.IsNullOrWhiteSpace(calendarUrl.Value)) + { + upcomingEvents = []; + errorText = "Set iCal URL in settings"; + return; + } + + try + { + DateTime now = DateTime.Now; + DateTime windowEnd = now.AddHours(Math.Max(1, lookAheadHours.Value)); + + upcomingEvents = await IcsCalendarHelper.GetUpcomingEventsAsync( + calendarUrl.Value.Trim(), + now, + windowEnd, + 20, + includeAllDayEvents.Value, + cancellationToken); + + errorText = null; + } + catch (HttpRequestException ex) + { + upcomingEvents = []; + errorText = $"Calendar unavailable: {ex.Message}"; + } + catch (TaskCanceledException ex) + { + upcomingEvents = []; + errorText = $"Calendar unavailable: {ex.Message}"; + } + catch (InvalidOperationException ex) + { + upcomingEvents = []; + errorText = $"Calendar unavailable: {ex.Message}"; + } + } + + private Bitmap RenderCountdownBitmap() + { + List lines = []; + + if (!string.IsNullOrWhiteSpace(errorText)) + { + lines.Add(errorText); + } + else + { + DateTime now = DateTime.Now; + CalendarEventEntry? nextEvent = upcomingEvents.FirstOrDefault(eventEntry => eventEntry.EndLocal > now); + + if (nextEvent is null) + { + lines.Add("No upcoming meetings"); + } + else + { + lines.Add(nextEvent.Title); + + if (startTimeDisplay.Value != "None") + { + string timeText = nextEvent.IsAllDay + ? "All day" + : startTimeDisplay.Value == "Time only" + ? nextEvent.StartLocal.ToString("t") + : $"{nextEvent.StartLocal:D} {nextEvent.StartLocal:t}"; + lines.Add(timeText); + } + + if (showLocation.Value && !string.IsNullOrWhiteSpace(nextEvent.Location)) + { + lines.Add(nextEvent.Location!); + } + + if (now < nextEvent.StartLocal) + { + lines.Add($"Starts in {FormatCountdown(nextEvent.StartLocal - now)}"); + } + else + { + lines.Add("In progress"); + } + } + } + + using Font font = new Font(Application.Theme.Font, 90); + using Bitmap measureBitmap = new Bitmap(1, 1); + using Graphics measureGraphics = Graphics.FromImage(measureBitmap); + measureGraphics.TextRenderingHint = TextRenderingHint.AntiAlias; + + float maxWidth = 0; + float lineHeight = 0; + foreach (string line in lines) + { + SizeF size = measureGraphics.MeasureString(line, font); + maxWidth = Math.Max(maxWidth, size.Width); + lineHeight = Math.Max(lineHeight, size.Height); + } + + int width = Math.Max(1, (int)Math.Ceiling(maxWidth)); + int height = Math.Max(1, (int)Math.Ceiling(lineHeight * lines.Count)); + + Bitmap bitmap = new Bitmap(width, height); + bitmap.SetResolution(100, 100); + + using Graphics graphics = Graphics.FromImage(bitmap); + using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor); + graphics.TextRenderingHint = TextRenderingHint.AntiAlias; + graphics.Clear(Color.Transparent); + + for (int index = 0; index < lines.Count; index++) + { + string line = lines[index]; + float x = 0; + if (centerLines.Value) + { + SizeF lineSize = graphics.MeasureString(line, font); + x = Math.Max(0, (width - lineSize.Width) / 2f); + } + + graphics.DrawString(line, font, brush, x, index * lineHeight); + } + + return bitmap; + } + + private static string FormatCountdown(TimeSpan timeSpan) + { + int totalHours = (int)timeSpan.TotalHours; + return $"{totalHours:00}:{timeSpan.Minutes:00}:{timeSpan.Seconds:00}"; + } +} diff --git a/src/DesktopMagic/DesktopMagic.csproj b/src/DesktopMagic/DesktopMagic.csproj index bc1d2c3..4b5a4b8 100644 --- a/src/DesktopMagic/DesktopMagic.csproj +++ b/src/DesktopMagic/DesktopMagic.csproj @@ -47,6 +47,7 @@ + diff --git a/src/DesktopMagic/Helpers/IcsCalendarHelper.cs b/src/DesktopMagic/Helpers/IcsCalendarHelper.cs new file mode 100644 index 0000000..4bad589 --- /dev/null +++ b/src/DesktopMagic/Helpers/IcsCalendarHelper.cs @@ -0,0 +1,89 @@ +using Ical.Net; +using Ical.Net.CalendarComponents; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace DesktopMagic.Helpers; + +public sealed class CalendarEventEntry +{ + public string Title { get; init; } = "Untitled"; + public string? Location { get; init; } + public DateTime StartLocal { get; init; } + public DateTime EndLocal { get; init; } + public bool IsAllDay { get; init; } +} + +public static class IcsCalendarHelper +{ + private static readonly HttpClient httpClient = new() + { + Timeout = TimeSpan.FromSeconds(12) + }; + + static IcsCalendarHelper() + { + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("DesktopMagic"); + httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/calendar"); + } + + public static async Task> GetUpcomingEventsAsync( + string calendarUrl, + DateTime windowStartLocal, + DateTime windowEndLocal, + int maxEvents, + bool includeAllDayEvents, + CancellationToken cancellationToken) + { + using HttpResponseMessage response = await httpClient.GetAsync(calendarUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + string icsContent = await response.Content.ReadAsStringAsync(cancellationToken); + + Calendar calendar = Calendar.Load(icsContent); + DateTime windowStartUtc = windowStartLocal.ToUniversalTime(); + DateTime windowEndUtc = windowEndLocal.ToUniversalTime(); + + List results = []; + + foreach (var occurrence in calendar.GetOccurrences(windowStartUtc, windowEndUtc)) + { + if (occurrence.Source is not CalendarEvent calendarEvent) + { + continue; + } + + DateTime startLocal = occurrence.Period.StartTime.AsSystemLocal; + DateTime endLocal = occurrence.Period.EndTime?.AsSystemLocal ?? startLocal.AddHours(1); + bool isAllDay = !occurrence.Period.StartTime.HasTime; + + if (!includeAllDayEvents && isAllDay) + { + continue; + } + + if (endLocal <= windowStartLocal) + { + continue; + } + + results.Add(new CalendarEventEntry + { + Title = string.IsNullOrWhiteSpace(calendarEvent.Summary) ? "Untitled" : calendarEvent.Summary, + Location = string.IsNullOrWhiteSpace(calendarEvent.Location) ? null : calendarEvent.Location, + StartLocal = startLocal, + EndLocal = endLocal, + IsAllDay = isAllDay + }); + } + + return results + .OrderBy(eventEntry => eventEntry.StartLocal) + .Take(Math.Max(1, maxEvents)) + .ToList(); + } +} diff --git a/src/DesktopMagic/Manager.cs b/src/DesktopMagic/Manager.cs index ac00f83..c7bac72 100644 --- a/src/DesktopMagic/Manager.cs +++ b/src/DesktopMagic/Manager.cs @@ -44,6 +44,8 @@ public sealed class Manager {new((string)App.LanguageDictionary["date"],3) { Author = "Stone_Red" }, typeof(DatePlugin)}, {new((string)App.LanguageDictionary["cpuUsage"], 4) { Author = "Stone_Red" }, typeof(CpuMonitorPlugin)}, {new((string)App.LanguageDictionary["weather"], 5) { Author = "Stone_Red" }, typeof(WeatherPlugin)}, + {new((string)App.LanguageDictionary["nextMeetingCountdown"], 6) { Author = "Stone_Red" }, typeof(NextMeetingCountdownPlugin)}, + {new((string)App.LanguageDictionary["agenda"], 7) { Author = "Stone_Red" }, typeof(AgendaPlugin)}, }; // Window management @@ -373,4 +375,4 @@ public enum PluginType { DotNet, Web -} \ No newline at end of file +} diff --git a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml index 1fa8a43..96d9736 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml @@ -38,6 +38,8 @@ CPU Auslastung Musik Visualisierer Wetter + Nächstes Meeting Countdown + Agenda Signalverstärkung: Neues Layout Layout Löschen diff --git a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml index d003a1c..a048d3d 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml @@ -38,6 +38,8 @@ CPU Usage Music Visualizer Weather + Next Meeting Countdown + Agenda Signal Amplification: New Layout Delete Layout