diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 9df5781..4d5987c 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -40,6 +40,9 @@ public static class TestSupport return new McpSettingsService(tempPath); } + /// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst. + public static McpClientRegistrationService BuildMcpClientRegistrationService() => new(); + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. public static WebUntisSettingsService BuildWebUntisSettingsService() { diff --git a/LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs b/LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs new file mode 100644 index 0000000..74a6bd1 --- /dev/null +++ b/LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs @@ -0,0 +1,132 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services.Mcp.Tools; +using LehrerApp.Templating; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class LetterTemplateToolsTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}"); + public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory); + public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + + private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) => + new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([])); + + private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions) + { + var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage"); + var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] }; + var lines = new List { "PAGE 210 297 mm" }; + var y = 20; + foreach (var definition in definitions) + { + var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT"; + lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}"); + y += 20; + } + TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary()); + var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); + store.Import(source); + return store; + } + + private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new() + { + FirstName = "Lena", LastName = "Beispiel", + Contacts = [new Contact + { + Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation, + Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt", + }], + }; + + [Fact] + public void ListLetterTemplates_ListetVorlageMitPlatzhaltern() + { + var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)); + var tool = BuildTool(store); + + var result = Assert.Single(tool.ListLetterTemplates()); + + Assert.Equal("Elternbrief", result.Name); + Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required); + } + + [Fact] + public void RenderLetter_UnbekannteVorlage_LiefertFehler() + { + var student = StudentWithContact(); + var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student])); + + var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer"); + + Assert.False(result.Success); + Assert.Null(result.Base64Pdf); + } + + [Fact] + public void RenderLetter_UnbekannterSchueler_LiefertFehler() + { + var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)); + var installed = Assert.Single(store.GetTemplates()); + var tool = BuildTool(store); + + var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer"); + + Assert.False(result.Success); + } + + [Fact] + public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf() + { + var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)); + var installed = Assert.Single(store.GetTemplates()); + var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt + var tool = BuildTool(store, new FakeStudents([student])); + + var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer"); + + Assert.False(result.Success); + Assert.Contains("Anrede", result.Message); + Assert.Null(result.Base64Pdf); + } + + [Fact] + public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf() + { + var store = StoreWithTemplate("Elternbrief", + new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), + new PlaceholderDefinition("Datum", PlaceholderType.Date, true), + new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true)); + var installed = Assert.Single(store.GetTemplates()); + var student = StudentWithContact(); + var tool = BuildTool(store, new FakeStudents([student])); + + var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer"); + + Assert.True(result.Success); + Assert.NotNull(result.Base64Pdf); + var bytes = Convert.FromBase64String(result.Base64Pdf!); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4)); + Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName); + } + + [Fact] + public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter() + { + var store = StoreWithTemplate("Elternbrief", + new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), + new PlaceholderDefinition("Betreff", PlaceholderType.Text, true)); + var installed = Assert.Single(store.GetTemplates()); + var student = StudentWithContact(); + var tool = BuildTool(store, new FakeStudents([student])); + + var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer", + extraValues: new Dictionary { ["Betreff"] = "Elternabend" }); + + Assert.True(result.Success); + Assert.NotNull(result.Base64Pdf); + } +} diff --git a/LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs b/LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs new file mode 100644 index 0000000..194852f --- /dev/null +++ b/LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Nodes; +using LehrerApp.Desktop.Services.Mcp; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class McpClientRegistrationServiceTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}"); + public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory); + public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + + private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json"); + + private string BuildFakeBridge() + { + var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe"); + File.WriteAllText(bridgePath, "fake"); + return bridgePath; + } + + [Fact] + public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts() + { + var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe")); + + var result = service.Register(); + + Assert.False(result.Success); + Assert.False(File.Exists(ConfigPath)); + } + + [Fact] + public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn() + { + var bridgePath = BuildFakeBridge(); + var service = new McpClientRegistrationService(ConfigPath, bridgePath); + + var result = service.Register(); + + Assert.True(result.Success); + Assert.True(service.IsRegistered()); + var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!; + Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue()); + } + + [Fact] + public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten() + { + File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}"""); + var bridgePath = BuildFakeBridge(); + var service = new McpClientRegistrationService(ConfigPath, bridgePath); + + var result = service.Register(); + + Assert.True(result.Success); + var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!; + Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue()); + Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue()); + Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue()); + } + + [Fact] + public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben() + { + File.WriteAllText(ConfigPath, "{ das ist kein json"); + var bridgePath = BuildFakeBridge(); + var service = new McpClientRegistrationService(ConfigPath, bridgePath); + + var result = service.Register(); + + Assert.False(result.Success); + Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath)); + } + + [Fact] + public void Unregister_EntferntNurEigenenEintrag() + { + File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}"""); + var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge()); + + var result = service.Unregister(); + + Assert.True(result.Success); + Assert.False(service.IsRegistered()); + Assert.Contains("andererServer", File.ReadAllText(ConfigPath)); + } + + [Fact] + public void IsRegistered_OhneDatei_IstFalse() + { + var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge()); + + Assert.False(service.IsRegistered()); + } +} diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index 7115736..bf8699a 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -16,7 +16,8 @@ public sealed class McpToolsTests new[] { "download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans", - "get_schedule", "get_students", "get_time_entries", + "get_schedule", "get_students", "get_time_entries", "list_letter_templates", + "render_letter", }, McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal)); } diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index a0586f2..d4b7fce 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -37,6 +37,7 @@ public sealed class SettingsViewModelTests new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildMcpSettingsService(), + TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), @@ -343,6 +344,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildMcpSettingsService(), + TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), @@ -372,6 +374,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildMcpSettingsService(), + TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), @@ -405,6 +408,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildMcpSettingsService(), + TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 4802d59..d8464c8 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -225,7 +225,9 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ───────── var untisSettings = new WebUntisSettingsService(appData); diff --git a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs new file mode 100644 index 0000000..5887794 --- /dev/null +++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs @@ -0,0 +1,41 @@ +using LehrerApp.Core.Models; +using LehrerApp.Templating; + +namespace LehrerApp.Desktop.Services; + +/// +/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse, +/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von +/// , hierher extrahiert, damit +/// LetterTemplateTools (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu +/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften +/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander. +/// +public static class LetterPlaceholderBuilder +{ + public static Dictionary BuildStandardValues( + Student student, Contact? contact, LearningGroup? group, DateOnly date, + string letterText, string teacherName) + { + var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x))); + var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x))); + return new Dictionary(StringComparer.Ordinal) + { + ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date), + ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""), + ["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName), + ["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName), + ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address), + ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""), + ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""), + ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""), + }; + } + + public static bool IsEmpty(PlaceholderValue value) => value switch + { + TextValue x => string.IsNullOrWhiteSpace(x.Value), + MultilineValue x => string.IsNullOrWhiteSpace(x.Value), + _ => false, + }; +} diff --git a/LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs b/LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs new file mode 100644 index 0000000..4d87483 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs @@ -0,0 +1,157 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace LehrerApp.Desktop.Services.Mcp; + +public record McpRegistrationResult(bool Success, string Message); + +/// +/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe +/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige +/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention +/// hat (mcpServers-Objekt in claude_desktop_config.json) — andere lokale Clients +/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten +/// unterstützen ließe. +/// +/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim +/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer +/// externer Seiteneffekt und gehört nicht in den normalen Startpfad. +/// +/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe +/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen +/// separaten bin-Ordner, findet sie dann nicht. +/// +public class McpClientRegistrationService +{ + private const string ServerName = "lehrerapp"; + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + private readonly string? _bridgePath; + + public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { } + + /// Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte + /// Sonderordner/Installation vorzugeben (siehe / + /// für das produktive Verhalten). + public McpClientRegistrationService(string? configPath, string? bridgePath) + { + ConfigPath = configPath; + _bridgePath = bridgePath; + } + + public string? ConfigPath { get; } + + /// Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die + /// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist. + public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath); + + public bool IsRegistered() => + TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null; + + public McpRegistrationResult Register() + { + if (ConfigPath is null) + return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt."); + + var bridgePath = _bridgePath; + if (bridgePath is null || !File.Exists(bridgePath)) + return new(false, + $"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " + + "Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build."); + + if (!TryLoadRoot(out var root, out var error)) + return new(false, error!); + + if (root["mcpServers"] is not JsonObject servers) + { + servers = new JsonObject(); + root["mcpServers"] = servers; + } + servers[ServerName] = new JsonObject + { + ["command"] = bridgePath, + ["args"] = new JsonArray(), + }; + + var detectedBefore = IsClaudeDesktopDetected; + var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt."); + if (result.Success && !detectedBefore) + return result with + { + Message = result.Message + + " Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " + + "greift erst, sobald Claude Desktop installiert ist.", + }; + return result; + } + + public McpRegistrationResult Unregister() + { + if (ConfigPath is null || !File.Exists(ConfigPath)) + return new(true, "Nichts einzutragen gefunden."); + if (!TryLoadRoot(out var root, out var error)) + return new(false, error!); + if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName)) + return new(true, "War nicht eingetragen."); + return WriteRoot(root, "Eintrag entfernt."); + } + + private bool TryLoadRoot(out JsonObject root, out string? error) + { + error = null; + if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; } + try + { + root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject(); + return true; + } + catch (JsonException ex) + { + // Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet, + // der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad. + root = new JsonObject(); + error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " + + $"Bitte manuell prüfen: {ConfigPath}"; + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben. + root = new JsonObject(); + error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}"; + return false; + } + } + + private McpRegistrationResult WriteRoot(JsonObject root, string successMessage) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!); + File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions)); + return new(true, successMessage); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}"); + } + } + + private static string? ResolveBridgePath() + { + var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge"; + return Path.Combine(AppContext.BaseDirectory, bridgeFile); + } + + private static string? ResolveConfigPath() + { + if (OperatingSystem.IsWindows()) + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Claude", "claude_desktop_config.json"); + if (OperatingSystem.IsMacOS()) + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support", "Claude", "claude_desktop_config.json"); + return null; // Linux: kein offizieller Claude-Desktop-Client bekannt. + } +} diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs index 1ea9ab8..aad1b81 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -31,13 +31,13 @@ public sealed class McpServerHostedService : IAsyncDisposable McpSettingsService settings, AppLogger logger, StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, - GroupMembershipTools groupMembershipTools) + GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools) { _settings = settings; _logger = logger; _serverOptions = BuildServerOptions( studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools, - groupMembershipTools); + groupMembershipTools, letterTemplateTools); } /// Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls @@ -113,7 +113,7 @@ public sealed class McpServerHostedService : IAsyncDisposable private static McpServerOptions BuildServerOptions( StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, - GroupMembershipTools groupMembershipTools) + GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools) { var toolCollection = new McpServerPrimitiveCollection(); @@ -155,6 +155,10 @@ public sealed class McpServerHostedService : IAsyncDisposable "Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum."); AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment", "Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter."); + AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates", + "Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern."); + AddReadTool(letterTemplateTools.RenderLetter, "render_letter", + "Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF."); AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry", "Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig)."); diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs index 7db7614..68dc797 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -19,6 +19,8 @@ public static class McpToolScope "get_time_entries", "get_lesson_plans", "download_lesson_attachment", + "list_letter_templates", + "render_letter", ]; /// Write-Tools (Phase 2+3) — jeder Aufruf läuft über , diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index b9cf75f..97829fb 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -52,3 +52,12 @@ public record GroupMembershipDto( /// Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): ist /// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat. public record WriteResultDto(bool Applied, Guid? Id, string Message); + +public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant); + +/// "Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den +/// importierten Elternbrief-Vorlagen (.lavorlage, LehrerApp.Templating) — es gibt kein +/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools. +public record LetterTemplateDto(string Id, string Name, string Description, List Placeholders); + +public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName); diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs new file mode 100644 index 0000000..e031329 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs @@ -0,0 +1,135 @@ +using System.ComponentModel; +using System.Globalization; +using LehrerApp.Core.Interfaces; +using LehrerApp.Desktop.Services; +using LehrerApp.Templating; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt +/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor — +/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte +/// .lavorlage-Elternbrief-Vorlagen (), an Schüler+Kontakt +/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog +/// (CreateLetterDialogViewModel). Bewusst NICHT umgesetzt: "upload_worksheet"/ +/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine +/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich +/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind +/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den +/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen. +public class LetterTemplateTools( + TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer, + IStudentRepository students, IGroupRepository groups) +{ + [Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")] + public List ListLetterTemplates() + { + var result = new List(); + foreach (var installed in templates.GetTemplates()) + { + try + { + var loaded = templates.Load(installed); + result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description, + loaded.Manifest.Placeholders + .Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant)) + .ToList())); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) + { + // Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt + // auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen. + } + } + return result; + } + + [Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")] + public LetterRenderResultDto RenderLetter( + [Description("Vorlagen-ID, aus list_letter_templates.")] string templateId, + [Description("Schüler-ID.")] Guid studentId, + [Description("Brieftext (Fließtext).")] string letterText, + [Description("Name der unterschreibenden Lehrkraft.")] string teacherName, + [Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null, + [Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null, + [Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null, + [Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")] + Dictionary? extraValues = null) + { + var student = students.GetById(studentId); + if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null); + + var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId); + if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null); + + LoadedTemplate loaded; + try { loaded = templates.Load(installed); } + catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) + { return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); } + + var contact = contactId is { } cid + ? student.Contacts.FirstOrDefault(c => c.Id == cid) + : student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue); + var group = groupId is { } gid ? groups.GetById(gid) : null; + var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today); + + var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName); + foreach (var (name, raw) in extraValues ?? []) + { + var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name); + if (definition is null || definition.IsConstant) continue; + if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted; + } + var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values); + + var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type)); + var missing = loaded.Manifest.Placeholders + .Where(p => !p.IsConstant && p.Required && + (!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v))) + .Select(p => p.Name).ToList(); + if (!validation.IsValid || missing.Count > 0) + { + var issues = validation.Issues.Select(i => i.Message) + .Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt.")); + return new LetterRenderResultDto(false, + "Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null); + } + + byte[] pdf; + try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); } + catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) + { return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); } + + var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf"); + return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName); + } + + private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch + { + PlaceholderType.Text => new TextValue(raw), + PlaceholderType.Multiline => new MultilineValue(raw), + PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null, + PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null, + // Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar. + _ => null, + }; + + private static DateOnly? TryParseDate(string raw) => + DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) || + DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null; + + private static decimal? TryParseNumber(string raw) => + decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) || + decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null; + + private static string SanitizeFileName(string value) + { + foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); + return value; + } + + private sealed class DictionaryDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider + { + public IReadOnlyDictionary GetValues() => values; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs index f7bbc69..293ab1a 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs @@ -1,5 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.Services.Mcp; namespace LehrerApp.Desktop.ViewModels.Settings; @@ -14,4 +16,27 @@ public partial class SettingsViewModel // Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird // nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart. partial void OnMcpEnabledChanged(bool value) => _mcpSettings.SetEnabled(value); + + // ── MCP: Registrierung bei Claude Desktop ───────────────────────────────── + + [ObservableProperty] private string _mcpRegistrationStatus = ""; + [ObservableProperty] private bool _mcpIsRegisteredWithClaude; + + private void LoadMcpRegistrationStatus() => McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered(); + + [RelayCommand] + private void RegisterWithClaudeDesktop() + { + var result = _mcpRegistration.Register(); + McpRegistrationStatus = result.Message; + McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered(); + } + + [RelayCommand] + private void UnregisterFromClaudeDesktop() + { + var result = _mcpRegistration.Unregister(); + McpRegistrationStatus = result.Message; + McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered(); + } } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index f35e007..73e666b 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -75,6 +75,7 @@ public partial class SettingsViewModel : ObservableObject private readonly AiSettingsService _aiSettings; private readonly AiPlanningService _aiPlanning; private readonly McpSettingsService _mcpSettings; + private readonly Services.Mcp.McpClientRegistrationService _mcpRegistration; private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisIntegrationService? _untisIntegration; private readonly UntisSyncService? _untisSync; @@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings, + Services.Mcp.McpClientRegistrationService mcpRegistration, WebUntisSettingsService untisSettings, AnnualPlanSettingsService annualPlanSettings, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, @@ -140,6 +142,7 @@ public partial class SettingsViewModel : ObservableObject _aiSettings = aiSettings; _aiPlanning = aiPlanning; _mcpSettings = mcpSettings; + _mcpRegistration = mcpRegistration; _untisSettings = untisSettings; _untisIntegration = untisIntegration; _untisSync = untisSync; @@ -168,6 +171,7 @@ public partial class SettingsViewModel : ObservableObject LoadLetterTemplates(); LoadAiSettings(); LoadMcpSettings(); + LoadMcpRegistrationStatus(); LoadUntisSettings(); LoadAnnualPlanSettings(); LoadSyncSettings(); diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index b4723cf..f305dc6 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; using LehrerApp.Templating; using System.Collections.ObjectModel; @@ -88,27 +89,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject OnPropertyChanged(nameof(HasIssues)); } - private IReadOnlyDictionary BuildValues() - { - var contact = SelectedContact?.Model; var group = SelectedGroup?.Model; - var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x))); - var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x))); - var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime); - return new Dictionary(StringComparer.Ordinal) - { - ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date), - ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""), - ["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName), - ["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName), - ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address), - ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""), - ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""), - ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""), - }; - } + private IReadOnlyDictionary BuildValues() => LetterPlaceholderBuilder.BuildStandardValues( + _student, SelectedContact?.Model, SelectedGroup?.Model, + DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName); - private static bool IsEmpty(PlaceholderValue value) => value switch - { TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false }; + private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value); private static string SanitizeFileName(string value) { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; } } diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index 9230507..0f318ae 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -1143,17 +1143,33 @@ - + + Text="Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Zugriff auf Schüler, Klausuren, Noten, Stundenplan, Zeiterfassung, Unterrichtsplanung und Elternbrief-Vorlagen dieses Geräts über eine lokale Named Pipe. Es verlassen keine Daten das Gerät; Gesprächsnotizen, Vorfälle und Förderpläne sind nicht erreichbar. Schreibende Aktionen zeigen immer erst einen Bestätigungsdialog, bevor etwas gespeichert wird. Eine Änderung hier wirkt erst nach einem Neustart der App."/> + + + + + +