Files
Elternbeirat/Elternbeirat.Web/Shared/TeamMember.cs
T

373 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using Markdig.Extensions.CustomContainers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// One person in a <c>::: team</c> block, read from a list item that follows the
/// convention the board page has always used.
/// </summary>
/// <param name="Name">The person's name: the bold start of the item.</param>
/// <param name="Role">
/// The role after the name, without its parentheses, or <see langword="null"/>
/// when the item has none (most of the "Beisitz" members).
/// </param>
/// <param name="Duties">
/// The responsibilities from the italic second line, split at commas; empty when
/// there is no such line.
/// </param>
/// <remarks>
/// The convention, one list item per person:
/// <code>
/// - **Maik Palm** (Vorsitzender)
/// *Schulkonferenz, Mensarat, Homepage*
/// Mitglied im Gesamtelternbeirat.
/// </code>
/// The bold name becomes the card's heading, the role a chip, every duty a small
/// tag and any further line the note. Line breaks may be hard (two spaces) or soft:
/// editors forget the two spaces, and the card should not depend on them.
/// <para>
/// The board stays Markdown in the page body on purpose: a dedicated
/// collection would be cleaner, but there is no way yet to bring a schema
/// change to production (Gitea #39). Reading the convention costs the editors
/// nothing they do not already write.
/// </para>
/// <para>
/// An item that does not start with a bold name is not an error: it becomes a
/// plain card showing its text as written (<see cref="PlainCardClass"/>).
/// </para>
/// </remarks>
/// <seealso cref="Markdown"/>
public sealed record TeamMember(string Name, string? Role, IReadOnlyList<string> Duties)
{
/// <summary>
/// The block name editors type: <c>::: team</c>.
/// </summary>
public const string ContainerName = "team";
/// <summary>
/// The modifier for larger cards, <c>::: team gross</c>; also the class it adds
/// to the block.
/// </summary>
/// <remarks>
/// A word after the block name rather than a block of its own
/// (<c>::: team-gross</c>): the block stays <c>team</c>, so a typo in the
/// modifier only costs the larger size, never the whole grid. <c>groß</c> is
/// accepted too; <c>gross</c> is the documented form because it types the same
/// on every keyboard.
/// </remarks>
public const string LargeModifier = "gross";
/// <summary>
/// The class on every card, i.e. on every list item in a <c>::: team</c> block.
/// </summary>
public const string CardClass = "team-card";
/// <summary>
/// The extra class on a card whose item does not follow the convention.
/// </summary>
public const string PlainCardClass = "team-card-plain";
/// <summary>
/// How many avatar colours there are; <c>app.css</c> defines
/// <c>--color-avatar-1</c> to <c>--color-avatar-5</c> to match.
/// </summary>
public const int ColorCount = 5;
/// <summary>
/// FNV-1a offset basis and prime (32 bit), see <see cref="Fnv"/>.
/// </summary>
private const uint FnvOffset = 2166136261;
private const uint FnvPrime = 16777619;
/// <summary>
/// The letters on the avatar, see <see cref="InitialsOf"/>.
/// </summary>
public string Initials => InitialsOf(Name);
/// <summary>
/// The avatar colour, from <c>1</c> to <see cref="ColorCount"/>, see
/// <see cref="ColorOf"/>.
/// </summary>
public int Color => ColorOf(Name);
/// <summary>
/// Builds the initials shown on a person's avatar.
/// </summary>
/// <param name="name">The person's name.</param>
/// <returns>
/// The first letter of the first and of the last word, upper case; one letter
/// for a single word; an empty string when the name holds no letter at all.
/// </returns>
/// <remarks>
/// A double name counts as one word, so it adds one letter, not two: two
/// letters are what fits the circle, and they are what people expect from a
/// name like <c>Reger-Stilgenbauer</c>. Letters outside A–Z (<c>Ö</c>,
/// <c>Ş</c>) are kept as they are, and leading punctuation is skipped.
/// </remarks>
/// <example>
/// <code>
/// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR"
/// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ"
/// TeamMember.InitialsOf("Isabell"); // "I"
/// </code>
/// </example>
public static string InitialsOf(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) switch
{
[] => "",
[var only] => Initial(only),
[var first, .., var last] => Initial(first) + Initial(last),
};
/// <summary>
/// Picks a person's avatar colour from the name.
/// </summary>
/// <param name="name">The person's name.</param>
/// <returns>A number from <c>1</c> to <see cref="ColorCount"/>.</returns>
/// <remarks>
/// The same name always gets the same colour, on every request and after every
/// restart, so a person does not change colour between visits. That is why this
/// is a fixed hash (FNV-1a) and not <see cref="string.GetHashCode()"/>, which
/// .NET randomises per process. Surrounding whitespace does not count.
/// <para>
/// FNV-1a alone spreads short names badly over its low bits, which the
/// remainder by <see cref="ColorCount"/> reads: four of the six "Beisitz"
/// members shared one colour. The MurmurHash3 finaliser (<see cref="Mix"/>)
/// stirs all bits into the low ones first.
/// </para>
/// </remarks>
public static int ColorOf(string name) =>
(int)(Mix(Fnv(name.Trim())) % ColorCount) + 1;
/// <summary>
/// Reads a person from the first paragraph of a list item.
/// </summary>
/// <param name="paragraph">The paragraph to read; not changed.</param>
/// <returns>
/// The person and the inlines that form the note (everything after the name
/// line and, if present, the duties line; possibly empty), or
/// <see langword="null"/> when the paragraph does not start with a bold name.
/// </returns>
/// <remarks>
/// The duties line is only recognised when it is nothing but one italic span;
/// any other second line is part of the note, so no text is lost.
/// </remarks>
internal static (TeamMember Member, Inline[] Note)? Read(ParagraphBlock paragraph) =>
Lines(paragraph).ToArray() is [var head, .. var rest]
&& Content(head) is [EmphasisInline { DelimiterCount: 2 } name, .. var role]
&& Markdown.PlainText(name) is { Length: > 0 } nameText
? rest is [var second, .. var after] && Content(second) is [EmphasisInline { DelimiterCount: 1 } duties]
? (new TeamMember(nameText, RoleOf(role), DutiesOf(duties)), [.. after.SelectMany(line => line)])
: (new TeamMember(nameText, RoleOf(role), []), [.. rest.SelectMany(line => line)])
: null;
/// <summary>
/// Turns one list item of a <c>::: team</c> block into a person card.
/// </summary>
/// <param name="item">The list item; changed in place.</param>
/// <remarks>
/// The first paragraph is rebuilt from new inlines: raw HTML inlines for the
/// tags and literal inlines for the text, so Markdig escapes everything the
/// editor wrote. The note keeps its original inlines, so links and emphasis in it
/// still render. Any further blocks in the item stay untouched below.
/// </remarks>
internal static void ToCard(ListItemBlock item)
{
var attributes = item.GetAttributes();
attributes.AddClass(CardClass);
if (item.FirstOrDefault() is ParagraphBlock { Inline: not null } paragraph && Read(paragraph) is { } card)
{
foreach (var inline in card.Note)
{
inline.Remove();
}
paragraph.Inline = card.Member.Head()
.Concat(card.Note.Length > 0 ? [Open("span", "team-note"), .. card.Note, Close("span")] : [])
.Aggregate(new ContainerInline(), (container, inline) => container.AppendChild(inline));
}
else
{
attributes.AddClass(PlainCardClass);
}
}
/// <summary>
/// Whether a <c>:::</c> block is a team block.
/// </summary>
/// <param name="container">The block to inspect.</param>
/// <returns><see langword="true"/> for <c>::: team</c>, with or without modifier.</returns>
internal static bool IsTeam(CustomContainer container) =>
string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a team block asks for larger cards (<c>::: team gross</c>).
/// </summary>
/// <param name="container">The team block.</param>
/// <returns>
/// <see langword="true"/> when one of the words after the name is
/// <c>gross</c> or <c>groß</c>, in any case.
/// </returns>
internal static bool IsLarge(CustomContainer container) =>
(container.Arguments ?? "")
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Any(word => word.Equals(LargeModifier, StringComparison.OrdinalIgnoreCase)
|| word.Equals("groß", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// The card's head: avatar, name, role chip and duty tags, as inlines.
/// </summary>
/// <returns>The inlines, in reading order; missing parts are left out.</returns>
/// <remarks>
/// The avatar is hidden from screen readers: it only repeats the name next to
/// it.
/// </remarks>
private Inline[] Head() =>
[
.. Initials is { Length: > 0 } initials
? [Open("span", $"team-avatar team-avatar-{Color.ToString(CultureInfo.InvariantCulture)}", " aria-hidden=\"true\""), new LiteralInline(initials), Close("span")]
: Array.Empty<Inline>(),
.. Element("team-name", Name, "strong"),
.. Role is { } role ? Element("team-role", role) : [],
.. Duties.Count > 0
? [Open("span", "team-duties"), .. Duties.SelectMany(Duty), Close("span")]
: Array.Empty<Inline>(),
];
/// <summary>
/// The 32-bit FNV-1a hash of a text's UTF-16 code units.
/// </summary>
/// <param name="text">The text.</param>
/// <returns>The hash.</returns>
private static uint Fnv(string text) =>
unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime));
/// <summary>
/// The MurmurHash3 finaliser (<c>fmix32</c>): spreads every input bit over all
/// output bits.
/// </summary>
/// <param name="hash">The hash to mix.</param>
/// <returns>The mixed hash.</returns>
private static uint Mix(uint hash) =>
unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16));
/// <summary>
/// Folds the high bits of a value into its low bits.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="shift">How far to shift right before the XOR.</param>
/// <returns><paramref name="value"/> XOR <paramref name="value"/> shifted right.</returns>
private static uint XorShift(uint value, int shift) => value ^ (value >> shift);
/// <summary>
/// One duty tag, led by a space so screen readers keep the tags apart.
/// </summary>
/// <param name="duty">The duty.</param>
/// <returns>The space and the tag.</returns>
private static Inline[] Duty(string duty) =>
[new LiteralInline(" "), .. Element("team-duty", duty)];
/// <summary>
/// An element with escaped text inside.
/// </summary>
/// <param name="classes">The value of the class attribute; written as is.</param>
/// <param name="text">The text; escaped by Markdig on rendering.</param>
/// <param name="tag">The element name.</param>
/// <returns>Opening tag, text and closing tag.</returns>
private static Inline[] Element(string classes, string text, string tag = "span") =>
[Open(tag, classes), new LiteralInline(text), Close(tag)];
/// <summary>
/// An opening tag with a class, as a raw HTML inline.
/// </summary>
/// <param name="tag">The element name.</param>
/// <param name="classes">The value of the class attribute; written as is.</param>
/// <param name="attributes">
/// Further attributes, written as is after the class, with a leading space.
/// </param>
/// <returns>The tag.</returns>
/// <remarks>
/// Only ever called with constants from this class, never with editor text:
/// nothing here is escaped.
/// </remarks>
private static HtmlInline Open(string tag, string classes, string attributes = "") =>
new($"<{tag} class=\"{classes}\"{attributes}>");
/// <summary>
/// A closing tag, as a raw HTML inline.
/// </summary>
/// <param name="tag">The element name.</param>
/// <returns>The tag.</returns>
private static HtmlInline Close(string tag) => new($"</{tag}>");
/// <summary>
/// The first letter of a word, upper case.
/// </summary>
/// <param name="word">One word of a name.</param>
/// <returns>The letter, or an empty string if the word has none.</returns>
private static string Initial(string word) =>
word.FirstOrDefault(char.IsLetter) is var letter and not '\0'
? letter.ToString().ToUpper(Cultures.German)
: "";
/// <summary>
/// The text after the name, without separators and parentheses.
/// </summary>
/// <param name="inlines">The inlines after the bold name on the first line.</param>
/// <returns>The role, or <see langword="null"/> if nothing is left.</returns>
private static string? RoleOf(IEnumerable<Inline> inlines) =>
Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text
&& (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role
? role
: null;
/// <summary>
/// Splits the italic duties line at its commas.
/// </summary>
/// <param name="duties">The italic span.</param>
/// <returns>The duties, trimmed, without empty entries.</returns>
private static string[] DutiesOf(EmphasisInline duties) =>
Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
/// <summary>
/// The inlines of a line that carry content: no whitespace, no line break.
/// </summary>
/// <param name="line">One line from <see cref="Lines"/>.</param>
/// <returns>The content inlines, in order.</returns>
private static Inline[] Content(IEnumerable<Inline> line) =>
[.. line.Where(inline => !Markdown.IsBlank(inline))];
/// <summary>
/// Splits a paragraph's inlines into lines.
/// </summary>
/// <param name="paragraph">The paragraph.</param>
/// <returns>
/// The lines; each keeps the line break that ends it, so the note can be moved
/// with its breaks intact.
/// </returns>
private static IEnumerable<List<Inline>> Lines(ParagraphBlock paragraph)
{
var line = new List<Inline>();
foreach (var inline in paragraph.Inline?.ToList() ?? [])
{
line.Add(inline);
if (inline is LineBreakInline)
{
yield return line;
line = [];
}
}
if (line.Count > 0)
{
yield return line;
}
}
}