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
-171
View File
@@ -244,177 +244,6 @@ public static class Endpoints
});
}
// ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ──────
public static void MapWebUntisEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/webuntis").RequireAuthorization();
group.MapGet("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid));
});
group.MapPost("/connection", async (WebUntisConnectRequest request, ClaimsPrincipal user,
WebUntisConnectionStore connections, CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(request.School) || string.IsNullOrWhiteSpace(request.Username) ||
string.IsNullOrWhiteSpace(request.Password))
return Results.BadRequest("Schule, Benutzername und Passwort sind erforderlich.");
return await WebUntisResult(() => connections.ConnectAsync(uid, request, cancellationToken));
});
group.MapDelete("/connection", async (ClaimsPrincipal user, WebUntisConnectionStore connections) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
await connections.DisconnectAsync(uid);
return Results.NoContent();
});
group.MapGet("/schoolyears", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback,
client => client.GetSchoolYearsAsync(cancellationToken)));
group.MapGet("/classes", ([FromQuery] int schoolyearId, ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback,
CancellationToken cancellationToken) => schoolyearId <= 0
? Task.FromResult<IResult>(Results.BadRequest("schoolyearId muss größer als 0 sein."))
: WithWebUntisClient(user, connections, fallback,
client => client.GetClassesAsync(schoolyearId, cancellationToken)));
group.MapGet("/teachers", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback, client => client.GetTeachersAsync(cancellationToken)));
group.MapGet("/student-report", ([FromQuery] string? className, ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback,
client => client.GetStudentReportAsync(className, cancellationToken)));
group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken)));
group.MapGet("/timegrid", (ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback, client => client.GetTimeGridAsync(cancellationToken)));
group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate,
[FromQuery] int? departmentId, ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
{
if (!ValidDateRange(startDate, endDate, 31, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WithWebUntisClient(user, connections, fallback,
client => client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken));
});
group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId,
[FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
{
if (!Enum.TryParse<UntisTimetableElementType>(elementType, true, out var parsedType) ||
!Enum.IsDefined(parsedType))
return Task.FromResult<IResult>(Results.BadRequest(
"elementType muss class, teacher, subject, room oder student sein."));
if (elementId <= 0)
return Task.FromResult<IResult>(Results.BadRequest("elementId muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 62, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WithWebUntisClient(user, connections, fallback,
client => client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken));
});
group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate,
[FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections,
WebUntisClient fallback, CancellationToken cancellationToken) =>
{
if (studentKey <= 0)
return Task.FromResult<IResult>(Results.BadRequest("studentKey muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 400, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WithWebUntisClient(user, connections, fallback,
client => client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken));
});
group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId,
[FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
{
if (studentId <= 0)
return Task.FromResult<IResult>(Results.BadRequest("studentId muss größer als 0 sein."));
if (!ValidDateRange(startDate, endDate, 400, out var error))
return Task.FromResult<IResult>(Results.BadRequest(error));
return WithWebUntisClient(user, connections, fallback,
client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken));
});
group.MapGet("/class-register/categories", (ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback,
client => client.GetClassRegisterCategoriesAsync(cancellationToken)));
group.MapGet("/class-register/category-groups", (ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) =>
WithWebUntisClient(user, connections, fallback,
client => client.GetClassRegisterCategoryGroupsAsync(cancellationToken)));
}
private static Task<IResult> WithWebUntisClient<T>(ClaimsPrincipal user,
WebUntisConnectionStore connections, WebUntisClient fallback, Func<WebUntisClient, Task<T>> operation)
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Task.FromResult<IResult>(Results.Unauthorized());
return WebUntisResult(() => operation(connections.GetClient(uid) ?? fallback));
}
private static async Task<IResult> WebUntisResult<T>(Func<Task<T>> operation)
{
try { return Results.Ok(await operation()); }
catch (WebUntisConfigurationException exception)
{
return Results.Problem(exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
}
catch (WebUntisException exception)
{
return Results.Problem(exception.Message, statusCode: StatusCodes.Status502BadGateway);
}
catch (InvalidDataException exception)
{
return Results.Problem($"Der WebUntis-Report ist ungültig: {exception.Message}",
statusCode: StatusCodes.Status502BadGateway);
}
}
private static bool ValidDateRange(int startDate, int endDate, int maximumDays, out string error)
{
error = "";
if (!DateOnly.TryParseExact(startDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
!DateOnly.TryParseExact(endDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
{
error = "startDate und endDate müssen gültige Datumswerte im Format yyyyMMdd sein.";
return false;
}
if (end < start)
{
error = "endDate darf nicht vor startDate liegen.";
return false;
}
if (end.DayNumber - start.DayNumber + 1 > maximumDays)
{
error = $"Der Zeitraum darf höchstens {maximumDays} Tage umfassen.";
return false;
}
return true;
}
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
{
var errors = new Dictionary<string, string[]>();
-24
View File
@@ -97,29 +97,6 @@ builder.Services.AddHttpClient("dwd", client =>
});
builder.Services.AddSingleton(sp => new DwdWeatherService(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
builder.Services.Configure<WebUntisOptions>(options =>
{
builder.Configuration.GetSection("WebUntis").Bind(options);
options.School = builder.Configuration["WEBUNTIS_SCHOOL"] ?? options.School;
options.Host = builder.Configuration["WEBUNTIS_HOST"] ?? options.Host;
options.Username = builder.Configuration["WEBUNTIS_USER"] ?? options.Username;
options.Password = builder.Configuration["WEBUNTIS_PASSWORD"] ?? options.Password;
options.Client = builder.Configuration["WEBUNTIS_CLIENT"] ?? options.Client;
if (int.TryParse(builder.Configuration["WEBUNTIS_SESSION_IDLE_MINUTES"], out var idleMinutes))
options.SessionIdleTimeoutMinutes = idleMinutes;
});
builder.Services.AddHttpClient("webuntis", client =>
{
// Die einzelnen WebUntis-Schritte besitzen eigene Timeouts; insbesondere ein asynchron
// erzeugter Schülerreport darf länger als der HttpClient-Standardtimeout pollen.
client.Timeout = Timeout.InfiniteTimeSpan;
});
// Der Client hält eine WebUntis-Session über mehrere API-Aufrufe hinweg. Deshalb muss seine
// Lebensdauer der Serveranwendung entsprechen und darf nicht pro HTTP-Anfrage neu beginnen.
builder.Services.AddSingleton(sp => new WebUntisClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("webuntis"),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WebUntisOptions>>()));
builder.Services.AddSingleton<WebUntisConnectionStore>();
var app = builder.Build();
app.UseForwardedHeaders();
@@ -135,6 +112,5 @@ app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints();
app.MapWebUntisEndpoints();
app.Run();
return 0;
-680
View File
@@ -1,680 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
namespace LehrerApp.Api;
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, IOptions<WebUntisOptions> options)
{
_http = http;
_options = options.Value;
_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 query = new Dictionary<string, string?>
{
["name"] = "Student", ["format"] = "csv", ["klasseId"] = "-1",
["studentsForDate"] = "true", ["context"] = "klasseId",
};
var uri = QueryHelpers.AddQueryString($"https://{GetConfiguration().Host}/WebUntis/reports.do", query);
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 = QueryHelpers.ParseQuery(schoolLocation.Query).TryGetValue("school", out var querySchool) &&
!string.IsNullOrWhiteSpace(querySchool)
? querySchool.ToString().Trim()
: 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) &&
QueryHelpers.ParseQuery(hostLocation.Query).TryGetValue("school", out var querySchool) &&
!string.IsNullOrWhiteSpace(querySchool))
school = querySchool.ToString().Trim();
}
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 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;
}
}
}
-75
View File
@@ -1,75 +0,0 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
namespace LehrerApp.Api;
public sealed record WebUntisConnectRequest(
string School,
string? Host,
string Username,
string Password);
public sealed record WebUntisConnectionStatus(bool Connected, string? School, string? Username);
/// <summary>
/// Hält persönliche WebUntis-Verbindungen ausschließlich im Arbeitsspeicher des Servers.
/// Passwörter werden weder protokolliert noch serverseitig persistiert. Der enthaltene Client
/// verwaltet die eigentliche WebUntis-Session und meldet sie nach Inaktivität wieder ab.
/// </summary>
public sealed class WebUntisConnectionStore(IHttpClientFactory httpClientFactory) : IAsyncDisposable
{
private sealed record Entry(WebUntisClient Client, string School, string Username);
private readonly ConcurrentDictionary<string, Entry> _connections = new();
public WebUntisClient? GetClient(string userId) =>
_connections.TryGetValue(userId, out var entry) ? entry.Client : null;
public WebUntisConnectionStatus GetStatus(string userId) =>
_connections.TryGetValue(userId, out var entry)
? new(true, entry.School, entry.Username)
: new(false, null, null);
public async Task<WebUntisConnectionStatus> ConnectAsync(string userId, WebUntisConnectRequest request,
CancellationToken cancellationToken)
{
var options = new WebUntisOptions
{
School = request.School,
Host = request.Host ?? "",
Username = request.Username,
Password = request.Password,
Client = "LehrerApp",
SessionIdleTimeoutMinutes = 10,
};
var client = new WebUntisClient(httpClientFactory.CreateClient("webuntis"), Options.Create(options));
try
{
// Authentifiziert wirklich gegen WebUntis; ungültige Daten werden nicht gespeichert.
await client.GetSchoolYearsAsync(cancellationToken);
}
catch
{
await client.DisposeAsync();
throw;
}
var entry = new Entry(client, request.School.Trim(), request.Username.Trim());
if (_connections.TryGetValue(userId, out var previous))
await previous.Client.DisposeAsync();
_connections[userId] = entry;
return new(true, entry.School, entry.Username);
}
public async Task DisconnectAsync(string userId)
{
if (_connections.TryRemove(userId, out var entry)) await entry.Client.DisposeAsync();
}
public async ValueTask DisposeAsync()
{
var entries = _connections.Values.ToList();
_connections.Clear();
foreach (var entry in entries) await entry.Client.DisposeAsync();
}
}
-184
View File
@@ -1,184 +0,0 @@
using System.Text.Json;
namespace LehrerApp.Api;
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);
@@ -1,133 +0,0 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Api;
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;
}
}