Untis-API von Server zu Client

This commit is contained in:
2026-08-24 22:00:26 +02:00
parent 0f10f754d0
commit 34a9fdf73b
21 changed files with 243 additions and 445 deletions
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
+685
View File
@@ -0,0 +1,685 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace LehrerApp.WebUntis;
public sealed class WebUntisClient : IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private readonly HttpClient _http;
private readonly WebUntisOptions _options;
private readonly SemaphoreSlim _sessionGate = new(1, 1);
private readonly Timer _sessionExpiryTimer;
private string? _sessionId;
private DateTimeOffset _sessionExpiresAt;
private int _activeRequests;
private bool _disposed;
public WebUntisClient(HttpClient http, WebUntisOptions options)
{
_http = http;
_options = options;
_sessionExpiryTimer = new Timer(
static state => _ = ((WebUntisClient)state!).CloseExpiredSessionAsync(),
this,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
}
public Task<IReadOnlyList<UntisSchoolYear>> GetSchoolYearsAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getSchoolyears", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Schuljahr-Liste geliefert.");
return (IReadOnlyList<UntisSchoolYear>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Schuljahr {id} hat keinen Namen.");
return new UntisSchoolYear(id, name, RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClass>> GetClassesAsync(int schoolYearId, CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getKlassen", new { schoolyearId = schoolYearId }, sessionId,
cancellationToken), "WebUntis hat keine gültige Klassen-Liste geliefert.");
return (IReadOnlyList<UntisClass>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Klasse {id} hat keinen Namen.");
return new UntisClass(id, name, OptionalString(entry, "longName"),
OptionalString(entry, "foreColor"), OptionalString(entry, "backColor"),
OptionalInt(entry, "did"), OptionalInt(entry, "teacher1"), OptionalInt(entry, "teacher2"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTeacher>> GetTeachersAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getTeachers", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Lehrer-Liste geliefert.");
return (IReadOnlyList<UntisTeacher>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Lehrer {id} hat kein Kürzel.");
var departments = TryProperty(entry, "dids", out var dids) && dids.ValueKind == JsonValueKind.Array
? dids.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
.Select(value => value!.Value).Distinct().Order().ToList()
: [];
return new UntisTeacher(id, name, OptionalString(entry, "foreName"),
OptionalString(entry, "longName"), OptionalString(entry, "title"),
OptionalBoolean(entry, "active"), departments);
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisHoliday>> GetHolidaysAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getHolidays", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültige Ferien-Liste geliefert.");
return (IReadOnlyList<UntisHoliday>)entries.Select(entry =>
{
var id = RequiredInt(entry, "id");
var name = OptionalString(entry, "name")
?? throw new WebUntisException($"WebUntis-Ferien {id} haben keinen Namen.");
return new UntisHoliday(id, name, OptionalString(entry, "longName"),
RequiredInt(entry, "startDate"), RequiredInt(entry, "endDate"));
}).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTimeGridDay>> GetTimeGridAsync(CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getTimegridUnits", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Zeitraster-Daten geliefert.");
return (IReadOnlyList<UntisTimeGridDay>)entries.Select(entry =>
{
var units = TryProperty(entry, "timeUnits", out var values) && values.ValueKind == JsonValueKind.Array
? values.EnumerateArray().Select(unit => new UntisTimeUnit(
OptionalString(unit, "name") ?? "", RequiredInt(unit, "startTime"), RequiredInt(unit, "endTime")))
.ToList()
: [];
return new UntisTimeGridDay(RequiredInt(entry, "day"), units, entry.Clone());
}).ToList();
}, cancellationToken);
public Task<UntisStudentReport> GetStudentReportAsync(string? classNameFilter,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var reportData = await RequestReportAsync(sessionId, cancellationToken)
?? await PollReportAsync(sessionId, cancellationToken);
var reportText = await FetchReportTextAsync(sessionId, reportData, cancellationToken);
var allStudents = WebUntisStudentReportParser.Parse(reportText);
var normalizedFilter = string.IsNullOrWhiteSpace(classNameFilter) ? null : classNameFilter.Trim();
var students = normalizedFilter is null
? allStudents
: allStudents.Where(student => string.Equals(student.ClassName, normalizedFilter,
StringComparison.OrdinalIgnoreCase)).ToList();
return new UntisStudentReport(students.Count, normalizedFilter, students);
}, cancellationToken);
public Task<IReadOnlyList<UntisSubstitution>> GetSubstitutionsAsync(int startDate, int endDate,
int? departmentId, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getSubstitutions", new
{
startDate,
endDate,
departmentId = departmentId ?? 0,
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Vertretungsplan-Daten geliefert.");
return (IReadOnlyList<UntisSubstitution>)entries.Select(entry => new UntisSubstitution(
OptionalString(entry, "type") ?? "unknown",
OptionalInt(entry, "lsid"),
OptionalString(entry, "lstype"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalString(entry, "txt"),
Entities(entry, "kl"),
Entities(entry, "te"),
Entities(entry, "su"),
Entities(entry, "ro"),
ParseReschedule(entry),
entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisTimetablePeriod>> GetTimetableAsync(UntisTimetableElementType elementType,
int elementId, int startDate, int endDate, CancellationToken cancellationToken) =>
WithSessionAsync(async sessionId =>
{
var options = new
{
element = new { id = elementId, type = (int)elementType },
startDate,
endDate,
showBooking = false,
showInfo = true,
showSubstText = true,
showLsText = true,
showLsNumber = true,
showStudentgroup = true,
};
var entries = RequireArray(await RpcAsync("getTimetable", new { options }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Stundenplan-Daten geliefert.");
return (IReadOnlyList<UntisTimetablePeriod>)entries.Select(entry => new UntisTimetablePeriod(
RequiredInt(entry, "id"), RequiredInt(entry, "date"), RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"), OptionalString(entry, "code"), OptionalString(entry, "activityType"),
OptionalString(entry, "info"), OptionalString(entry, "lstext"), OptionalString(entry, "substText"),
OptionalString(entry, "sg"), Entities(entry, "kl"), Entities(entry, "te"), Entities(entry, "su"),
Entities(entry, "ro"), entry.Clone())).ToList();
}, cancellationToken);
public Task<UntisStudentAbsenceReport> GetStudentAbsencesAsync(int studentKey, int startDate, int endDate,
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var result = await RpcAsync("getTimetableWithAbsences", new
{
options = new { startDate, endDate },
}, sessionId, cancellationToken);
if (!TryProperty(result, "periodsWithAbsences", out var periods) || periods.ValueKind != JsonValueKind.Array)
throw new WebUntisException("WebUntis hat keine gültigen Fehlzeiten-Daten geliefert.");
var absences = periods.EnumerateArray()
.Where(entry => OptionalInt(entry, "studentId") == studentKey)
.Select(entry => new UntisStudentAbsence(
RequiredInt(entry, "studentId"),
RequiredInt(entry, "date"),
RequiredInt(entry, "startTime"),
RequiredInt(entry, "endTime"),
OptionalInt(entry, "absentTime") ?? 0,
OptionalBoolean(entry, "checked"),
OptionalString(entry, "absenceReason"),
OptionalString(entry, "excuseStatus"),
OptionalInt(entry, "subjectId"),
IntArray(entry, "teacherIds"),
OptionalString(entry, "studentGroup"),
entry.Clone()))
.OrderBy(entry => entry.Date)
.ThenBy(entry => entry.StartTime)
.ToList();
return new UntisStudentAbsenceReport(studentKey, startDate, endDate, absences.Count,
absences.Sum(entry => entry.AbsentMinutes), absences);
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterEntry>> GetClassRegisterEntriesAsync(int studentId,
int startDate, int endDate, CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregEvents", new
{
startDate,
endDate,
id = studentId,
type = (int)UntisTimetableElementType.Student,
}, sessionId, cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Daten geliefert.");
return (IReadOnlyList<UntisClassRegisterEntry>)entries.Select(entry =>
{
var surname = OptionalString(entry, "surname");
var foreName = OptionalString(entry, "forname");
var displayName = string.Join(' ', new[] { foreName, surname }.Where(value => value is not null));
return new UntisClassRegisterEntry(OptionalInt(entry, "studentid"), surname, foreName,
displayName, RequiredInt(entry, "date"), OptionalString(entry, "subject"),
OptionalInt(entry, "categoryId"), OptionalString(entry, "reason"),
OptionalString(entry, "text"), entry.Clone());
}).OrderBy(entry => entry.Date).ThenBy(entry => entry.DisplayName).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterCategory>> GetClassRegisterCategoriesAsync(
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregCategories", new { }, sessionId, cancellationToken),
"WebUntis hat keine gültigen Klassenbuch-Kategorien geliefert.");
return (IReadOnlyList<UntisClassRegisterCategory>)entries.Select(entry =>
new UntisClassRegisterCategory(RequiredInt(entry, "id"), OptionalString(entry, "name") ?? "",
OptionalString(entry, "longName"), OptionalInt(entry, "groupId"), entry.Clone())).ToList();
}, cancellationToken);
public Task<IReadOnlyList<UntisClassRegisterCategoryGroup>> GetClassRegisterCategoryGroupsAsync(
CancellationToken cancellationToken) => WithSessionAsync(async sessionId =>
{
var entries = RequireArray(await RpcAsync("getClassregCategoryGroups", new { }, sessionId,
cancellationToken), "WebUntis hat keine gültigen Klassenbuch-Kategoriegruppen geliefert.");
return (IReadOnlyList<UntisClassRegisterCategoryGroup>)entries.Select(entry =>
new UntisClassRegisterCategoryGroup(RequiredInt(entry, "id"),
OptionalString(entry, "name") ?? "", entry.Clone())).ToList();
}, cancellationToken);
private async Task<T> WithSessionAsync<T>(Func<string, Task<T>> action, CancellationToken cancellationToken)
{
await using var lease = await AcquireSessionAsync(cancellationToken);
return await action(lease.SessionId);
}
private async Task<SessionLease> AcquireSessionAsync(CancellationToken cancellationToken)
{
await _sessionGate.WaitAsync(cancellationToken);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
_sessionExpiryTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
// Falls der Timer durch Threadpool-Last verspätet ausgeführt wird, darf eine bereits
// abgelaufene Sitzung nicht noch einmal für einen neuen Abruf verwendet werden.
if (_sessionId is not null && _activeRequests == 0 && _sessionExpiresAt <= DateTimeOffset.UtcNow)
{
var expiredSessionId = _sessionId;
_sessionId = null;
await TryLogoutAsync(expiredSessionId);
}
if (_sessionId is null)
{
var configuration = GetConfiguration();
var result = await RpcAsync("authenticate", new
{
user = configuration.Username,
password = configuration.Password,
client = configuration.Client,
}, null, cancellationToken);
_sessionId = OptionalString(result, "sessionId")
?? throw new WebUntisException(
$"WebUntis-Login fehlgeschlagen: {configuration.Host} hat für die Schulkennung " +
$"„{configuration.School}“ keine Sitzung geliefert. Bitte insbesondere Server und " +
"Schulkennung mit der WebUntis-Anmeldeseite vergleichen.");
}
_activeRequests++;
return new SessionLease(this, _sessionId);
}
finally
{
_sessionGate.Release();
}
}
private async ValueTask ReleaseSessionAsync()
{
await _sessionGate.WaitAsync();
try
{
if (_activeRequests > 0) _activeRequests--;
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
var timeout = SessionIdleTimeout;
_sessionExpiresAt = DateTimeOffset.UtcNow.Add(timeout);
_sessionExpiryTimer.Change(timeout, Timeout.InfiniteTimeSpan);
}
finally
{
_sessionGate.Release();
}
}
private async Task CloseExpiredSessionAsync()
{
string? sessionId = null;
await _sessionGate.WaitAsync();
try
{
if (_disposed || _activeRequests != 0 || _sessionId is null) return;
var remaining = _sessionExpiresAt - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero)
{
_sessionExpiryTimer.Change(remaining, Timeout.InfiniteTimeSpan);
return;
}
sessionId = _sessionId;
_sessionId = null;
}
finally
{
_sessionGate.Release();
}
if (sessionId is not null) await TryLogoutAsync(sessionId);
}
private async Task TryLogoutAsync(string sessionId)
{
try { await RpcAsync("logout", new { }, sessionId, CancellationToken.None); }
catch { /* Ein fehlgeschlagener Logout darf Abrufe und Shutdown nicht fehlschlagen lassen. */ }
}
public async ValueTask DisposeAsync()
{
string? sessionId;
await _sessionGate.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
sessionId = _sessionId;
_sessionId = null;
}
finally
{
_sessionGate.Release();
}
// Außerhalb des Gates warten: ein bereits laufender Timer-Callback könnte selbst gerade
// auf dieses Gate warten und würde sonst den Shutdown blockieren.
await _sessionExpiryTimer.DisposeAsync();
if (sessionId is not null) await TryLogoutAsync(sessionId);
}
private TimeSpan SessionIdleTimeout =>
TimeSpan.FromMinutes(Math.Clamp(_options.SessionIdleTimeoutMinutes, 1, 30));
private async Task<JsonElement> RpcAsync(string method, object parameters, string? sessionId,
CancellationToken cancellationToken)
{
var configuration = GetConfiguration();
var uri = $"https://{configuration.Host}/WebUntis/jsonrpc.do?school={Uri.EscapeDataString(configuration.School)}";
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = JsonContent.Create(new { id = "lehrerapp-webuntis", method, @params = parameters, jsonrpc = "2.0" },
options: JsonOptions),
};
if (sessionId is not null) request.Headers.Add("Cookie", $"JSESSIONID={sessionId}");
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"WebUntis RPC ({method}) fehlgeschlagen: {ErrorMessage(payload, response)}");
if (payload.ValueKind != JsonValueKind.Object)
throw new WebUntisException($"Leere Antwort von WebUntis RPC ({method}).");
if (TryProperty(payload, "error", out var error) && error.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
throw new WebUntisException($"WebUntis RPC-Fehler ({method}): {ErrorMessage(error, response)}");
if (!TryProperty(payload, "result", out var result))
throw new WebUntisException($"WebUntis RPC ({method}) ohne Ergebnis.");
return result.Clone();
}
private async Task<ReportData?> RequestReportAsync(string sessionId, CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/reports.do" +
"?name=Student&format=csv&klasseId=-1&studentsForDate=true&context=klasseId";
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (TryProperty(payload, "errors", out var errors) && errors.ValueKind == JsonValueKind.Array &&
errors.EnumerateArray().Any(error => OptionalString(error, "code") == "4" || OptionalInt(error, "code") == 4))
return null;
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
}
if (!TryProperty(payload, "data", out var data) || data.ValueKind != JsonValueKind.Object ||
(TryProperty(data, "error", out var reportError) && reportError.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined))
throw new WebUntisException($"Report-Anfrage fehlgeschlagen: {ErrorMessage(payload, response)}");
if (!OptionalBoolean(data, "finished")) return null;
return ReportData.From(data);
}
private async Task<ReportData> PollReportAsync(string sessionId, CancellationToken cancellationToken)
{
var uri = $"https://{GetConfiguration().Host}/WebUntis/api/polling/REPORT";
for (var attempt = 0; attempt < 60; attempt++)
{
using var request = ReportRequest(uri, sessionId, acceptJson: true);
using var response = await SendAsync(request, TimeSpan.FromSeconds(20), cancellationToken);
var payload = await ReadJsonAsync(response, cancellationToken);
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"Report-Polling fehlgeschlagen: HTTP {(int)response.StatusCode}.");
if (TryProperty(payload, "data", out var data) && TryProperty(data, "pollingJobs", out var jobs) &&
jobs.ValueKind == JsonValueKind.Array)
{
foreach (var job in jobs.EnumerateArray().Where(job => OptionalBoolean(job, "isJobFinished")))
{
if (OptionalBoolean(job, "hasJobError"))
throw new WebUntisException("Report-Polling enthält einen Job-Fehler.");
if (TryProperty(job, "data", out var jobData)) return ReportData.From(jobData);
}
}
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
throw new WebUntisException("Report wurde nicht rechtzeitig fertiggestellt.");
}
private async Task<string> FetchReportTextAsync(string sessionId, ReportData reportData,
CancellationToken cancellationToken)
{
var uri = reportData.ReportParams is not null
? $"https://{GetConfiguration().Host}/WebUntis/reports.do?{reportData.ReportParams}"
: $"https://{GetConfiguration().Host}/WebUntis/reports.do?msgId={Uri.EscapeDataString(reportData.MessageId!)}";
using var request = ReportRequest(uri, sessionId, acceptJson: false);
using var response = await SendAsync(request, TimeSpan.FromSeconds(60), cancellationToken);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (response.Content.Headers.ContentType?.MediaType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
var payload = ParseJson(bytes);
throw new WebUntisException($"Report-Abruf fehlgeschlagen: {ErrorMessage(payload, response)}");
}
if (!response.IsSuccessStatusCode)
throw new WebUntisException($"Report-Abruf fehlgeschlagen: HTTP {(int)response.StatusCode}.");
try { return StrictUtf8.GetString(bytes); }
catch (DecoderFallbackException) { return Encoding.Latin1.GetString(bytes); }
}
private HttpRequestMessage ReportRequest(string uri, string sessionId, bool acceptJson)
{
var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (acceptJson) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("Cookie", $"JSESSIONID={sessionId}; schoolname=\"_{SchoolCookie()}\"");
return request;
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
try { return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new WebUntisException($"Timeout nach {timeout.TotalSeconds:0} Sekunden.", exception);
}
catch (HttpRequestException exception)
{
throw new WebUntisException("WebUntis ist momentan nicht erreichbar.", exception);
}
}
private Config GetConfiguration()
{
var schoolValue = _options.School.Trim();
var username = _options.Username.Trim();
// Passwörter dürfen führende/abschließende Leerzeichen enthalten und werden deshalb
// anders als Schule/Benutzername nicht normalisiert.
var password = _options.Password;
if (schoolValue.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt.");
if (username.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_USER fehlt.");
if (password.Length == 0) throw new WebUntisConfigurationException("WEBUNTIS_PASSWORD fehlt.");
var (school, host) = ResolveLocation(schoolValue, _options.Host);
if (host.Contains('/') || !Uri.CheckHostName(host).Equals(UriHostNameType.Dns) ||
!(host.Equals("webuntis.com", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".webuntis.com", StringComparison.OrdinalIgnoreCase)))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
return new Config(school, host, username, password,
string.IsNullOrWhiteSpace(_options.Client) ? "LehrerApp" : _options.Client.Trim());
}
private static (string School, string Host) ResolveLocation(string schoolValue, string? hostValue)
{
var school = schoolValue.Trim();
string? hostFromSchool = null;
if (LooksLikeLocation(school) && TryParseLocation(school, out var schoolLocation))
{
hostFromSchool = schoolLocation.Host;
school = QueryValue(schoolLocation, "school") is { Length: > 0 } querySchool
? querySchool
: schoolLocation.Host.Split('.')[0];
}
string host;
if (!string.IsNullOrWhiteSpace(hostValue))
{
if (!TryParseLocation(hostValue, out var hostLocation))
throw new WebUntisConfigurationException("WEBUNTIS_HOST ist ungültig.");
host = hostLocation.Host;
// Komfortfall: In das Serverfeld wurde die vollständige Login-URL kopiert. Eine
// explizit im Schulfeld angegebene Kennung behält trotzdem Vorrang.
if (LooksLikeLocation(schoolValue) && QueryValue(hostLocation, "school") is { Length: > 0 } querySchool)
school = querySchool;
}
else
{
host = hostFromSchool ?? $"{school}.webuntis.com";
}
if (school.Length == 0)
throw new WebUntisConfigurationException("WEBUNTIS_SCHOOL fehlt oder ist ungültig.");
return (school, host);
}
private static bool LooksLikeLocation(string value) => value.Contains('.') || value.Contains('/') ||
value.StartsWith("http:", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("https:", StringComparison.OrdinalIgnoreCase);
private static bool TryParseLocation(string value, out Uri location)
{
var candidate = value.Trim();
if (!candidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!candidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
candidate = $"https://{candidate}";
return Uri.TryCreate(candidate, UriKind.Absolute, out location!) && !string.IsNullOrWhiteSpace(location.Host);
}
private static string? QueryValue(Uri uri, string key)
{
foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var pair = part.Split('=', 2);
if (!Uri.UnescapeDataString(pair[0]).Equals(key, StringComparison.OrdinalIgnoreCase)) continue;
return pair.Length == 2
? Uri.UnescapeDataString(pair[1].Replace('+', ' ')).Trim()
: "";
}
return null;
}
private string SchoolCookie() => Convert.ToBase64String(Encoding.UTF8.GetBytes(GetConfiguration().School));
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken token) =>
ParseJson(await response.Content.ReadAsByteArrayAsync(token));
private static JsonElement ParseJson(byte[] bytes)
{
try { return JsonSerializer.Deserialize<JsonElement>(bytes, JsonOptions); }
catch (JsonException) { return default; }
}
private static IReadOnlyList<JsonElement> RequireArray(JsonElement value, string error) =>
value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Select(item => item.Clone()).ToList()
: throw new WebUntisException(error);
private static IReadOnlyList<UntisEntity> Entities(JsonElement parent, string property) =>
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
? entries.EnumerateArray().Select(entry => new UntisEntity(OptionalInt(entry, "id") ?? 0,
OptionalString(entry, "name") ?? "", OptionalInt(entry, "orgid"),
OptionalString(entry, "orgname"), OptionalString(entry, "externalkey"))).ToList()
: [];
private static IReadOnlyList<int> IntArray(JsonElement parent, string property) =>
TryProperty(parent, property, out var entries) && entries.ValueKind == JsonValueKind.Array
? entries.EnumerateArray().Select(OptionalInt).Where(value => value is not null)
.Select(value => value!.Value).ToList()
: [];
private static UntisReschedule? ParseReschedule(JsonElement parent)
{
if (!TryProperty(parent, "reschedule", out var value) || value.ValueKind != JsonValueKind.Object) return null;
var date = OptionalInt(value, "date");
var start = OptionalInt(value, "startTime");
var end = OptionalInt(value, "endTime");
return date is not null && start is not null && end is not null
? new UntisReschedule(date.Value, start.Value, end.Value)
: null;
}
private static bool TryProperty(JsonElement value, string name, out JsonElement property)
{
property = default;
return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out property);
}
private static int RequiredInt(JsonElement value, string property) => OptionalInt(value, property)
?? throw new WebUntisException($"WebUntis-Feld \"{property}\" ist ungültig.");
private static int? OptionalInt(JsonElement parent, string property) =>
TryProperty(parent, property, out var value) ? OptionalInt(value) : null;
private static int? OptionalInt(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
return value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out number) ? number : null;
}
private static string? OptionalString(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value)) return null;
var text = value.ValueKind == JsonValueKind.String ? value.GetString() :
value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False ? value.ToString() : null;
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
}
private static bool OptionalBoolean(JsonElement parent, string property)
{
if (!TryProperty(parent, property, out var value)) return false;
if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) return value.GetBoolean();
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number != 0;
return value.ValueKind == JsonValueKind.String &&
(string.Equals(value.GetString(), "true", StringComparison.OrdinalIgnoreCase) || value.GetString() == "1");
}
private static string ErrorMessage(JsonElement payload, HttpResponseMessage response)
{
if (payload.ValueKind == JsonValueKind.String) return payload.GetString() ?? "Unerwartete Antwort.";
if (payload.ValueKind == JsonValueKind.Object)
{
if (TryProperty(payload, "error", out var error) && OptionalString(error, "message") is { } errorMessage)
return errorMessage;
if (OptionalString(payload, "message") is { } message) return message;
if (TryProperty(payload, "data", out var data) && OptionalString(data, "message") is { } dataMessage)
return dataMessage;
}
return response.IsSuccessStatusCode ? "Unerwartete JSON-Antwort." : $"HTTP {(int)response.StatusCode}";
}
private sealed record Config(string School, string Host, string Username, string Password, string Client);
private sealed class SessionLease(WebUntisClient owner, string sessionId) : IAsyncDisposable
{
private int _released;
public string SessionId { get; } = sessionId;
public ValueTask DisposeAsync() => Interlocked.Exchange(ref _released, 1) == 0
? owner.ReleaseSessionAsync()
: ValueTask.CompletedTask;
}
private sealed record ReportData(string? ReportParams, string? MessageId)
{
public static ReportData From(JsonElement data)
{
var result = new ReportData(OptionalString(data, "reportParams"), OptionalString(data, "messageId"));
return result.ReportParams is null && result.MessageId is null
? throw new WebUntisException("Report-Antwort enthält weder reportParams noch messageId.")
: result;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
using System.Text.Json;
namespace LehrerApp.WebUntis;
public sealed class WebUntisOptions
{
public string School { get; set; } = "";
public string Host { get; set; } = "";
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string Client { get; set; } = "LehrerApp";
public int SessionIdleTimeoutMinutes { get; set; } = 10;
}
public sealed record UntisSchoolYear(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisClass(
int UntisId,
string Name,
string? LongName,
string? ForeColor,
string? BackColor,
int? DepartmentUntisId,
int? Teacher1UntisId,
int? Teacher2UntisId);
public sealed record UntisTeacher(
int UntisId,
string Name,
string? ForeName,
string? LongName,
string? Title,
bool Active,
IReadOnlyList<int> DepartmentUntisIds);
public sealed record UntisStudentAddress(
string? Email,
string? Mobile,
string? Phone,
string? City,
string? PostCode,
string? Street);
public sealed record UntisStudent(
int UntisId,
int ExternKey,
string ClassName,
string? Name,
string? LongName,
string? ForeName,
string DisplayName,
string? Gender,
int? BirthDate,
string? BirthDateRaw,
int? EntryDate,
string? EntryDateRaw,
int? ExitDate,
string? ExitDateRaw,
string? Text,
string? MedicalReportDuty,
string? Schulpflicht,
string? Majority,
UntisStudentAddress Address,
string? AttributeIL);
public sealed record UntisStudentReport(
int Count,
string? ClassNameFilter,
IReadOnlyList<UntisStudent> Students);
public sealed record UntisTimeUnit(string Name, int StartTime, int EndTime);
public sealed record UntisTimeGridDay(
int Day,
IReadOnlyList<UntisTimeUnit> TimeUnits,
JsonElement Raw);
public sealed record UntisHoliday(
int UntisId,
string Name,
string? LongName,
int StartDate,
int EndDate);
public sealed record UntisEntity(
int Id,
string Name,
int? OriginalId,
string? OriginalName,
string? ExternalKey);
public sealed record UntisReschedule(int Date, int StartTime, int EndTime);
public sealed record UntisSubstitution(
string Type,
int? LessonId,
string? LessonType,
int Date,
int StartTime,
int EndTime,
string? Text,
IReadOnlyList<UntisEntity> Classes,
IReadOnlyList<UntisEntity> Teachers,
IReadOnlyList<UntisEntity> Subjects,
IReadOnlyList<UntisEntity> Rooms,
UntisReschedule? Reschedule,
JsonElement Raw);
public enum UntisTimetableElementType
{
Class = 1,
Teacher = 2,
Subject = 3,
Room = 4,
Student = 5,
}
public sealed record UntisTimetablePeriod(
int Id,
int Date,
int StartTime,
int EndTime,
string? Code,
string? ActivityType,
string? Info,
string? LessonText,
string? SubstitutionText,
string? StudentGroup,
IReadOnlyList<UntisEntity> Classes,
IReadOnlyList<UntisEntity> Teachers,
IReadOnlyList<UntisEntity> Subjects,
IReadOnlyList<UntisEntity> Rooms,
JsonElement Raw);
public sealed record UntisStudentAbsence(
int StudentKey,
int Date,
int StartTime,
int EndTime,
int AbsentMinutes,
bool Checked,
string? AbsenceReason,
string? ExcuseStatus,
int? SubjectId,
IReadOnlyList<int> TeacherIds,
string? StudentGroup,
JsonElement Raw);
public sealed record UntisStudentAbsenceReport(
int StudentKey,
int StartDate,
int EndDate,
int EntryCount,
int AbsentMinutes,
IReadOnlyList<UntisStudentAbsence> Absences);
public sealed record UntisClassRegisterEntry(
int? StudentKey,
string? Surname,
string? ForeName,
string DisplayName,
int Date,
string? Subject,
int? CategoryId,
string? Reason,
string? Text,
JsonElement Raw);
public sealed record UntisClassRegisterCategory(
int Id,
string Name,
string? LongName,
int? GroupId,
JsonElement Raw);
public sealed record UntisClassRegisterCategoryGroup(
int Id,
string Name,
JsonElement Raw);
public sealed class WebUntisException(string message, Exception? innerException = null)
: Exception(message, innerException);
public sealed class WebUntisConfigurationException(string message) : Exception(message);
@@ -0,0 +1,133 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.WebUntis;
public static class WebUntisStudentReportParser
{
public static IReadOnlyList<UntisStudent> Parse(string content)
{
var rows = ParseSeparatedRows(content, '\t');
if (rows.Count == 0) return [];
var headers = rows[0]
.Select((header, index) => (index == 0 ? header.TrimStart('\uFEFF') : header).Trim())
.ToArray();
var result = new List<UntisStudent>();
foreach (var row in rows.Skip(1))
{
if (row.All(string.IsNullOrWhiteSpace)) continue;
var values = new Dictionary<string, string?>(StringComparer.Ordinal);
for (var index = 0; index < headers.Length; index++)
values[headers[index]] = index < row.Count ? row[index].Trim() : null;
var untisId = RequiredInt(Get(values, "id"), "id");
var externalKey = RequiredInt(Get(values, "externKey"), "externKey");
var name = Optional(Get(values, "name"));
var lastName = Optional(Get(values, "longName"));
var firstName = Optional(Get(values, "foreName"));
var displayName = string.Join(' ', new[] { firstName, lastName }.Where(value => value is not null));
if (string.IsNullOrWhiteSpace(displayName)) displayName = name ?? $"Schüler {untisId}";
result.Add(new UntisStudent(
untisId,
externalKey,
Get(values, "klasse.name")?.Trim() ?? "",
name,
lastName,
firstName,
displayName,
Optional(Get(values, "gender")),
GermanDate(Get(values, "birthDate")),
Optional(Get(values, "birthDate")),
GermanDate(Get(values, "entryDate")),
Optional(Get(values, "entryDate")),
GermanDate(Get(values, "exitDate")),
Optional(Get(values, "exitDate")),
Optional(Get(values, "text")),
Optional(Get(values, "medicalReportDuty")),
Optional(Get(values, "schulpflicht")),
Optional(Get(values, "majority")),
new UntisStudentAddress(
Optional(Get(values, "adress.email")),
Optional(Get(values, "adress.mobile")),
Optional(Get(values, "adress.phone")),
Optional(Get(values, "adress.city")),
Optional(Get(values, "adress.postCode")),
Optional(Get(values, "adress.street"))),
Optional(Get(values, "attribute.iL"))));
}
return result;
}
private static List<List<string>> ParseSeparatedRows(string content, char separator)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
for (var index = 0; index < content.Length; index++)
{
var character = content[index];
if (character == '"')
{
if (inQuotes && index + 1 < content.Length && content[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && character == separator)
{
row.Add(field.ToString());
field.Clear();
continue;
}
if (!inQuotes && character == '\n')
{
row.Add(field.ToString());
rows.Add(row);
row = [];
field.Clear();
continue;
}
if (!inQuotes && character == '\r') continue;
field.Append(character);
}
row.Add(field.ToString());
if (row.Count > 1 || !string.IsNullOrWhiteSpace(row[0])) rows.Add(row);
return rows;
}
private static string? Get(IReadOnlyDictionary<string, string?> values, string key) =>
values.TryGetValue(key, out var value) ? value : null;
private static string? Optional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static int RequiredInt(string? value, string field) =>
int.TryParse(value?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new InvalidDataException($"Ungültige Zahl in Spalte \"{field}\".");
private static int? GermanDate(string? value)
{
if (!DateOnly.TryParseExact(value?.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date))
return null;
return date.Year * 10_000 + date.Month * 100 + date.Day;
}
}