Add PocketBase client with contracts and sharp build tooling

This commit is contained in:
tleininger committed 2026-09-22 19:32:32 +02:00
1 parent 56922707ea
commit 251f2d818b
24 files changed
+578 -20

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
# EditorConfig for the Elternbeirat solution.
# https://editorconfig.org / https://learn.microsoft.com/dotnet/fundamentals/code-analysis/code-style-rule-options
#
# Code-style rules are added here over time. Kept intentionally minimal for now;
# the .NET analyzers (see Directory.Build.props) already run at their sharpest.
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{cs,csproj,props,targets}]
indent_size = 4
[*.{json,yml,yaml}]
indent_size = 2
[*.cs]
# CA1716 flags type names that collide with Visual Basic keywords (e.g. Event).
# This app is not consumed from VB, and Event/Post are the project's deliberate
# domain names (see CLAUDE.md: "im Code Event, nicht Termin"). Renaming would
# break that convention for a problem this codebase does not have.
dotnet_diagnostic.CA1716.severity = none
# CA1515 suggests making types internal because an app's types are not referenced
# from outside its assembly. That is a library-author rule with no benefit here:
# this is an application, not a reusable package. Razor components (Home, PostList,
# ...) must stay public so Blazor can render them, and the Contracts records are
# consumed from another project. Turning types internal would gain nothing.
dotnet_diagnostic.CA1515.severity = none
# CA1062 wants public methods to null-check their arguments. The public surface
# here is DI constructors and Blazor components, never called by foreign code with
# raw arguments; the container always supplies its dependencies. With #nullable on,
# a non-nullable parameter already carries the "never null" contract in its type.
dotnet_diagnostic.CA1062.severity = none
# CA2007 (ConfigureAwait) targets libraries with a SynchronizationContext (WinForms,
# WPF, classic ASP.NET). ASP.NET Core has none, so ConfigureAwait(false) would be
# pure noise here. Microsoft's own project templates disable this rule.
dotnet_diagnostic.CA2007.severity = none
# CA1812 flags types "never instantiated". The JSON DTOs (e.g. RecordList<T>) are
# only created by the deserializer via reflection, which the analyzer cannot see.
# This is a known false positive for deserialization types.
dotnet_diagnostic.CA1812.severity = none
# CA1724 flags a type name that matches part of its namespace (Home in
# ...Features.Home). That collision is a deliberate result of the feature-folder
# layout and is harmless (Blazor routing is not namespace-based). Renaming would
# break the folder convention for no benefit.
dotnet_diagnostic.CA1724.severity = none
+7
View File
@@ -9,3 +9,10 @@ GITEA_TOKEN=
# Base URL of the Gitea instance and the repo path issues belong to.
GITEA_URL=https://gitea.anticarnist.de
GITEA_REPO=Tom/Elternbeirat
# PocketBase superuser login, used to read/write records via the REST API
# (e.g. migrating content) without loosening the collection API rules -- a
# superuser bypasses them. Create the superuser in the dashboard at <PB_URL>/_/.
PB_URL=http://<unraid-host>:8090
PB_ADMIN_EMAIL=
PB_ADMIN_PASSWORD=
+3
View File
@@ -4,6 +4,9 @@ obj/
riderModule.iml
/_ReSharper.Caches/
.idea/
# Per-user Rider/ReSharper settings (personal, not shared).
*.sln.DotSettings.user
*.DotSettings.user
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
+20
View File
@@ -0,0 +1,20 @@
<Project>
<!-- Solution-wide build settings, applied to every project. Package versions
live in Directory.Packages.props; this file carries code-quality settings. -->
<PropertyGroup>
<!-- Turn on the full set of built-in .NET analyzers (code quality + style),
including the opt-in rules. -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>All</AnalysisMode>
<AnalysisLevel>latest-all</AnalysisLevel>
<!-- Enforce .editorconfig code-style rules as part of the build, not just
in the IDE. -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- Any warning fails the build, so quality issues cannot be ignored. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
<Project>
<!-- Central Package Management: NuGet versions live here, the project files
reference packages by name only. See
https://learn.microsoft.com/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Markdig" Version="0.38.0" />
<PackageVersion Include="YamlDotNet" Version="16.2.1" />
</ItemGroup>
<!-- Test-only packages. -->
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
</Project>
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+39
View File
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>A calendar entry. Sorted by <see cref="Start"/>.</summary>
public record Event
{
/// <summary>PocketBase record id.</summary>
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>
/// Start of the event with date and time. Stored as UTC by PocketBase but
/// read as local time (Europe/Berlin) by convention. An all-day event uses
/// 00:00 as the time.
/// </summary>
[JsonPropertyName("start")]
public DateTime Start { get; init; }
/// <summary>Optional end of the event; null when unset.</summary>
[JsonPropertyName("end")]
public DateTime? End { get; init; }
/// <summary>Event name, e.g. "Elternbeiratssitzung".</summary>
[JsonPropertyName("title")]
public string Title { get; init; } = "";
/// <summary>Optional location, e.g. "Aula".</summary>
[JsonPropertyName("location")]
public string Location { get; init; } = "";
/// <summary>Optional note, e.g. "Anmeldung erforderlich".</summary>
[JsonPropertyName("note")]
public string Note { get; init; } = "";
/// <summary>Whether the event is visible to visitors.</summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
}
+29
View File
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>
/// A single question and answer, grouped on the FAQ page by <see cref="Topic"/>.
/// </summary>
public record Faq
{
/// <summary>PocketBase record id.</summary>
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>The question as a parent would phrase it.</summary>
[JsonPropertyName("question")]
public string Question { get; init; } = "";
/// <summary>The answer in Markdown.</summary>
[JsonPropertyName("answer")]
public string Answer { get; init; } = "";
/// <summary>Topic the question is grouped under, e.g. "mensa".</summary>
[JsonPropertyName("topic")]
public string Topic { get; init; } = "";
/// <summary>Whether the question is visible to visitors.</summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
}
+47
View File
@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>
/// A content page. Pages also drive the site navigation: <see cref="Location"/>
/// and <see cref="Order"/> decide where and in which order a page appears in the
/// header or footer menu, and <see cref="Embed"/> lists dynamic blocks (posts,
/// events, faqs) rendered below the page body.
/// </summary>
public record Page
{
/// <summary>PocketBase record id.</summary>
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>Heading shown to visitors; may contain umlauts and spaces.</summary>
[JsonPropertyName("title")]
public string Title { get; init; } = "";
/// <summary>Page body in Markdown.</summary>
[JsonPropertyName("body")]
public string Body { get; init; } = "";
/// <summary>Where the page appears in the navigation: "header" or "footer".</summary>
[JsonPropertyName("location")]
public string Location { get; init; } = "";
/// <summary>Sort order within its navigation location; smaller is earlier.</summary>
[JsonPropertyName("order")]
public double Order { get; init; }
/// <summary>URL slug (lowercase, no umlauts), e.g. "board" -> /board.</summary>
[JsonPropertyName("slug")]
public string Slug { get; init; } = "";
/// <summary>
/// Dynamic blocks to render below the body: any of "posts", "events", "faqs".
/// Empty for a plain text page.
/// </summary>
[JsonPropertyName("embed")]
public IReadOnlyList<string> Embed { get; init; } = [];
/// <summary>Whether the page is visible to visitors.</summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
}
+34
View File
@@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>A news post. Sorted by <see cref="Date"/>, newest first.</summary>
public record Post
{
/// <summary>PocketBase record id.</summary>
[JsonPropertyName("id")]
public string Id { get; init; } = "";
/// <summary>
/// Publication date. Stored as UTC by PocketBase but read as local time
/// (Europe/Berlin) by convention; only the date part is shown.
/// </summary>
[JsonPropertyName("date")]
public DateTime Date { get; init; }
/// <summary>Post heading shown to visitors; may contain umlauts and spaces.</summary>
[JsonPropertyName("title")]
public string Title { get; init; } = "";
/// <summary>Post body in Markdown.</summary>
[JsonPropertyName("body")]
public string Body { get; init; } = "";
/// <summary>URL slug (lowercase, no umlauts), e.g. "herbstbasar" -> /posts/herbstbasar.</summary>
[JsonPropertyName("slug")]
public string Slug { get; init; } = "";
/// <summary>Whether the post is visible to visitors.</summary>
[JsonPropertyName("public")]
public bool Public { get; init; }
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Elternbeirat.Contracts\Elternbeirat.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,75 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase;
/// <summary>
/// Shared parsing of a PocketBase date string as wall-clock time. The trailing
/// "Z" is stripped rather than honoured, so the number is taken at face value and
/// the result carries <see cref="DateTimeKind.Unspecified"/> -- no timezone shift.
/// </summary>
internal static class WallClock
{
public static DateTime Parse(string raw)
{
// Drop a trailing "Z" so DateTime.Parse does not treat the value as UTC
// and convert it to local time (which would shift 19:30 to 20:30/21:30).
var value = raw.EndsWith('Z') ? raw[..^1] : raw;
var parsed = DateTime.Parse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None);
return DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
}
}
/// <summary>
/// Reads PocketBase date values as local wall-clock time.
/// <para>
/// PocketBase stores every date in UTC and serializes it with a trailing "Z"
/// (e.g. "2026-10-08 19:30:00.000Z"). By project convention the stored number
/// IS the local time (Europe/Berlin) and the "Z" is ignored -- see the timezone
/// decision in the data model. This converter therefore parses the value and
/// returns it as an <see cref="DateTimeKind.Unspecified"/> instant, so no
/// timezone shift is ever applied when the value is later formatted.
/// </para>
/// </summary>
public sealed class LocalDateTimeConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString();
return string.IsNullOrEmpty(raw)
? default
: WallClock.Parse(raw);
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
CultureInfo.InvariantCulture));
}
/// <summary>
/// Nullable counterpart of <see cref="LocalDateTimeConverter"/>. PocketBase sends
/// an empty string for an unset optional date (e.g. an event without an end);
/// that maps to null.
/// </summary>
public sealed class NullableLocalDateTimeConverter : JsonConverter<DateTime?>
{
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString();
if (string.IsNullOrEmpty(raw))
return null;
return WallClock.Parse(raw);
}
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
{
if (value is null)
writer.WriteStringValue("");
else
writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss.fff'Z'",
CultureInfo.InvariantCulture));
}
}
@@ -0,0 +1,59 @@
using System.Net.Http.Json;
using System.Text.Json;
using Elternbeirat.Contracts;
namespace Elternbeirat.PocketBase;
/// <summary>
/// Reads published content from a PocketBase instance over its REST API.
/// <para>
/// One method per collection (pages, posts, events, faqs). Each returns only
/// records with <c>public = true</c> and lets PocketBase do the filtering and
/// sorting via query parameters. The <see cref="HttpClient"/> is expected to have
/// its <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL, so it
/// is registered as a typed client via <c>AddHttpClient</c>.
/// </para>
/// </summary>
public sealed class PocketBaseClient(HttpClient http)
{
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
};
options.Converters.Add(new LocalDateTimeConverter());
options.Converters.Add(new NullableLocalDateTimeConverter());
return options;
}
/// <summary>Gets all public pages, ordered for navigation.</summary>
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
=> GetRecordsAsync<Page>("pages", "order", ct);
/// <summary>Gets all public posts, newest first.</summary>
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Post>("posts", "-date", ct);
/// <summary>Gets all public events, earliest start first.</summary>
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Event>("events", "start", ct);
/// <summary>Gets all public FAQ entries.</summary>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(string collection, string sort, CancellationToken ct)
{
// filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume.
var url = $"/api/collections/{collection}/records"
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}";
var result = await http.GetFromJsonAsync<RecordList<T>>(url, JsonOptions, ct);
return result?.Items ?? [];
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.PocketBase;
/// <summary>
/// The envelope PocketBase wraps a records list response in. Only <see cref="Items"/>
/// is used; the paging fields are ignored because content volumes are small and the
/// client requests a large page size in a single call.
/// </summary>
/// <typeparam name="T">The record type inside <c>items</c>.</typeparam>
internal sealed record RecordList<T>
{
[JsonPropertyName("items")]
public IReadOnlyList<T> Items { get; init; } = [];
}
+8
View File
@@ -0,0 +1,8 @@
# Test-project-only overrides. This file inherits from the root .editorconfig
# (root = true there) and applies on top of it for everything under this folder.
[*.cs]
# Test methods use Given_When_Then style names with underscores, which is the
# common, readable convention for tests. CA1707 (no underscores in member names)
# stays enforced in production code, but is turned off here.
dotnet_diagnostic.CA1707.severity = none
@@ -8,19 +8,22 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
<ProjectReference Include="..\Elternbeirat.PocketBase\Elternbeirat.PocketBase.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,111 @@
using Elternbeirat.PocketBase;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Integration tests that hit a live PocketBase instance. They read the base URL
/// from the PB_URL environment variable (falling back to the repo .env file) and
/// skip themselves when no URL is configured, so CI without PocketBase stays green.
/// </summary>
public sealed class PocketBaseClientTests : IDisposable
{
// One HttpClient per test instance; xUnit creates a fresh instance per test,
// so this is disposed in Dispose() when the test finishes.
private readonly HttpClient _http = new();
public void Dispose() => _http.Dispose();
private static string? ResolvePocketBaseUrl()
{
var fromEnv = Environment.GetEnvironmentVariable("PB_URL");
if (!string.IsNullOrWhiteSpace(fromEnv))
return fromEnv;
// Fall back to the repo .env (gitignored, holds PB_URL locally).
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var envPath = Path.Combine(dir.FullName, ".env");
if (File.Exists(envPath))
{
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("PB_URL=", StringComparison.Ordinal))
return trimmed["PB_URL=".Length..].Trim();
}
}
dir = dir.Parent;
}
return null;
}
private PocketBaseClient? CreateClient()
{
var url = ResolvePocketBaseUrl();
if (string.IsNullOrWhiteSpace(url))
return null;
_http.BaseAddress = new Uri(url);
return new PocketBaseClient(_http);
}
[Fact]
public async Task Events_load_from_live_instance()
{
var client = CreateClient();
if (client is null) return; // PB_URL not configured; skip live test.
var events = await client.GetEventsAsync();
events.ShouldNotBeEmpty();
// Every event must at least have a title and a start.
events.ShouldAllBe(e => !string.IsNullOrWhiteSpace(e.Title) && e.Start != default);
}
[Fact]
public async Task Event_time_is_read_as_wall_clock_not_shifted()
{
var client = CreateClient();
if (client is null) return; // PB_URL not configured; skip live test.
var events = await client.GetEventsAsync();
// The Elternbeiratssitzung is stored as 19:30; by the timezone convention
// the number is taken at face value, so no shift to 20:30/21:30 happens.
var meeting = events.FirstOrDefault(@event =>
@event.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
meeting.ShouldNotBeNull();
meeting.Start.Hour.ShouldBe(19);
meeting.Start.Minute.ShouldBe(30);
meeting.Start.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public async Task Pages_load_with_navigation_fields()
{
var client = CreateClient();
if (client is null) return; // PB_URL not configured; skip live test.
var pages = await client.GetPagesAsync();
pages.ShouldNotBeEmpty();
// Navigation depends on location + slug being present on every page.
pages.ShouldAllBe(p =>
!string.IsNullOrWhiteSpace(p.Slug) && (p.Location == "header" || p.Location == "footer"));
}
[Fact]
public async Task Posts_and_faqs_load_from_live_instance()
{
var client = CreateClient();
if (client is null) return; // PB_URL not configured; skip live test.
var posts = await client.GetPostsAsync();
var faqs = await client.GetFaqsAsync();
posts.ShouldNotBeEmpty();
faqs.ShouldNotBeEmpty();
}
}
+4 -4
View File
@@ -48,9 +48,9 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
{
var client = _factory.CreateClient();
var response = await client.GetAsync(route);
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Theory]
@@ -60,8 +60,8 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
{
var client = _factory.CreateClient();
var response = await client.GetAsync(route);
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
}
+2 -2
View File
@@ -18,8 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="YamlDotNet" Version="16.2.1" />
<PackageReference Include="Markdig" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<!-- Content ships as files inside the image, not in a volume. Copy it to
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Elternbeirat.Web.Services;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// A single calendar entry (parents' evening, meeting, deadline). Read from
@@ -1,8 +1,9 @@
using System.Globalization;
using Elternbeirat.Web.Services;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Elternbeirat.Web.Services;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
@@ -1,8 +1,9 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Elternbeirat.Web.Services;
namespace Elternbeirat.Web.Services;
namespace Elternbeirat.Web.Features.Events;
/// <summary>
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
@@ -82,7 +83,7 @@ public static class IcsCalendar
{
var seed = $"{ev.Title}|{ev.Start:O}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh";
return $"{Convert.ToHexString(hash)[..16]}@elternbeirat-igmh";
}
private static string Local(DateTime value) =>
@@ -93,11 +94,11 @@ public static class IcsCalendar
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
private static string Escape(string value) => value
.Replace("\\", "\\\\")
.Replace(";", "\\;")
.Replace(",", "\\,")
.Replace("\r\n", "\\n")
.Replace("\n", "\\n");
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace(";", "\\;", StringComparison.Ordinal)
.Replace(",", "\\,", StringComparison.Ordinal)
.Replace("\r\n", "\\n", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
// ICS lines are terminated with CRLF regardless of platform.
private static void AppendLine(StringBuilder sb, string line) =>
+1
View File
@@ -1,4 +1,5 @@
using Elternbeirat.Web.Components;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Services;
using Microsoft.AspNetCore.HttpOverrides;
+2
View File
@@ -1,4 +1,6 @@
<Solution>
<Project Path="Elternbeirat.Contracts/Elternbeirat.Contracts.csproj" />
<Project Path="Elternbeirat.PocketBase/Elternbeirat.PocketBase.csproj" />
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
</Solution>