Merge feature/export-1: Datenexport (zip JSON+German CSVs) on /einstellungen + CI flake-cap [god-QA pending result-gate]

# Conflicts:
#	GerbilManager.Tests/GerbilManager.Tests.csproj
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/src/App.tsx
#	gerbil-manager-web/src/components/AppShell.tsx
#	gerbil-manager-web/src/pages/EinstellungenPage.tsx
This commit is contained in:
2026-06-06 08:06:38 +02:00
10 changed files with 488 additions and 3 deletions

View File

@@ -0,0 +1,144 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Export;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests
{
/// <summary>
/// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
/// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
/// </summary>
public class ExportTests
{
// ── CSV-Escaping ───────────────────────────────────────────────────
[Theory]
[InlineData(null, "")]
[InlineData("", "")]
[InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten
[InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten
[InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln
[InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten
[InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV)
public void Escape_behandelt_Sonderfälle(string? input, string expected)
=> Assert.Equal(expected, ExportService.Escape(input));
[Fact]
public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen()
{
var csv = ExportService.BuildCsv(
["Name", "Notizen"],
[["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]);
var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.Equal("Name;Notizen", lines[0]);
Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]);
Assert.Equal("Bo;", lines[2]);
}
// ── Round-Trip über die API ────────────────────────────────────────
[Fact]
public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten()
{
using var factory = new ApiFactory();
// Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht
Guid gerbilId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" };
var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" };
var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 };
var colorVariety = db.ColorVarieties.First(); // HasData-Seed
var gerbil = new Gerbil
{
Id = Guid.NewGuid(),
Name = "Krümel",
Gender = Gender.female,
Status = GerbilStatus.Active,
DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id,
EnclosureId = enclosure.Id,
OriginContactId = contact.Id,
ColorVarietyId = colorVariety.Id,
Genotype = "Aa CC Dd EE GG Pp Spsp rere",
ImportSource = "Stammbaum von Akio Kids.xlsx",
};
gerbilId = gerbil.Id;
db.AddRange(contact, enclosure, litter, gerbil,
new HealthRecord
{
Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15),
Type = HealthRecordType.Vaccination, Description = "Jahresimpfung",
CreatedAt = DateTimeOffset.UtcNow,
},
new WeightRecord
{
Id = Guid.NewGuid(), GerbilId = gerbil.Id,
Date = new DateOnly(2026, 5, 1), WeightGrams = 78,
});
await db.SaveChangesAsync();
}
var client = factory.CreateClient();
var response = await client.GetAsync("/export");
response.EnsureSuccessStatusCode();
Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType);
Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName);
using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read);
string[] expectedEntries =
["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv",
"gesundheit.csv", "gewichte.csv", "LIESMICH.txt"];
foreach (var name in expectedEntries)
Assert.NotNull(zip.GetEntry(name));
// export.json: Zähler + volle Treue (Genotyp, Import-Herkunft)
using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _));
var root = json.RootElement;
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
var g = root.GetProperty("gerbils")[0];
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString());
// tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum
var tiere = ReadEntry(zip, "tiere.csv", out var hadBom);
Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel");
var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]);
var row = lines[1];
Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row);
Assert.Contains("Aktiv", row);
Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row);
// kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen)
var kontakte = ReadEntry(zip, "kontakte.csv", out _);
Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte);
// LIESMICH erklärt den Foto-Speicherort
Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _));
}
private static string ReadEntry(ZipArchive zip, string name, out bool hadBom)
{
using var stream = zip.GetEntry(name)!.Open();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var bytes = ms.ToArray();
hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0));
}
}
}

View File

@@ -0,0 +1,36 @@
using GerbilManagerWebAPI.Export;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// EXPORT-1: GET /export — komplette Datensicherung als Zip
/// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
/// </summary>
public static class ExportEndpoints
{
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
{
var data = new ExportService.ExportData(
await db.Gerbils.AsNoTracking().ToListAsync(ct),
await db.Litters.AsNoTracking().ToListAsync(ct),
await db.Contacts.AsNoTracking().ToListAsync(ct),
await db.Enclosures.AsNoTracking().ToListAsync(ct),
await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
await db.HealthRecords.AsNoTracking().ToListAsync(ct),
await db.WeightRecords.AsNoTracking().ToListAsync(ct),
await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
var today = DateOnly.FromDateTime(DateTime.Now);
var bytes = ExportService.BuildZip(data, today);
return Results.File(bytes, "application/zip",
$"rennmaus-export-{today:yyyy-MM-dd}.zip");
})
.WithTags("Export");
return app;
}
}
}

View File

@@ -0,0 +1,212 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Export
{
/// <summary>
/// EXPORT-1: user-facing Datenexport — builds a zip containing
/// - export.json (full fidelity: every entity incl. genotypes + import provenance)
/// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
/// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
/// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
///
/// Pure function over <see cref="ExportData"/> (no DbContext) so the round-trip
/// and escaping tests run without a database.
/// </summary>
public static class ExportService
{
public sealed record ExportData(
List<Gerbil> Gerbils,
List<Litter> Litters,
List<Contact> Contacts,
List<Enclosure> Enclosures,
List<ColorVariety> ColorVarieties,
List<HealthRecord> HealthRecords,
List<WeightRecord> WeightRecords,
List<GerbilPhoto> Photos);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
};
// German labels for enum values (CSV is the wife-facing backup format;
// the JSON keeps the enum member names for lossless re-import).
private static readonly Dictionary<Gender, string> GenderDe = new()
{
[Gender.unknown] = "Unbekannt",
[Gender.male] = "Männlich",
[Gender.female] = "Weiblich",
};
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
{
[HealthRecordType.Examination] = "Untersuchung",
[HealthRecordType.Treatment] = "Behandlung",
[HealthRecordType.Injury] = "Verletzung",
[HealthRecordType.Vaccination] = "Impfung",
[HealthRecordType.Other] = "Sonstiges",
};
public static byte[] BuildZip(ExportData data, DateOnly exportDate)
{
var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
string? Lookup<TKey>(Dictionary<TKey, string> map, TKey? key) where TKey : struct =>
key is null ? null : map.GetValueOrDefault(key.Value);
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
AddText(zip, "export.json", JsonSerializer.Serialize(new
{
exportedAt = exportDate.ToString("yyyy-MM-dd"),
data.Gerbils,
data.Litters,
data.Contacts,
data.Enclosures,
data.ColorVarieties,
data.HealthRecords,
data.WeightRecords,
data.Photos,
}, JsonOptions), bom: false);
AddCsv(zip, "tiere.csv",
["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
"Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
"Genotyp", "Notizen"],
data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
{
g.Name, GenderDe[g.Gender], StatusDe[g.Status],
De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
g.Genotype, g.Notes,
}));
AddCsv(zip, "wuerfe.csv",
["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
"Voraussichtliches Abgabedatum", "Notizen"],
data.Litters.OrderBy(l => l.Date).Select(l => new[]
{
l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
De(l.ExpectedGoHomeDate), l.Notes,
}));
AddCsv(zip, "kontakte.csv",
["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
data.Contacts.OrderBy(c => c.Name).Select(c => new[]
{
c.Name, c.Email, c.Phone, c.Address, c.Notes,
}));
AddCsv(zip, "gesundheit.csv",
["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
{
gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
HealthTypeDe[h.Type], h.Description, h.Veterinarian,
}));
AddCsv(zip, "gewichte.csv",
["Tier", "Datum", "Gewicht (g)", "Notizen"],
data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
{
gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
}));
AddText(zip, "LIESMICH.txt", $"""
Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
Enthalten:
- export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
- tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
- wuerfe.csv : alle Würfe.
- kontakte.csv : alle Kontakte.
- gesundheit.csv: alle Gesundheitseinträge.
- gewichte.csv : alle Gewichtseinträge.
Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
steht in export.json (Abschnitt "photos").
""", bom: false);
}
return stream.ToArray();
}
// ── CSV building blocks ────────────────────────────────────────────
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
private static string? De(DateOnly? date) =>
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
private static string De(DateOnly date) =>
date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
/// <summary>
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
/// they contain separator/quote/newline; quotes doubled.
/// </summary>
internal static string BuildCsv(string[] header, IEnumerable<string?[]> rows)
{
var sb = new StringBuilder();
sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
foreach (var row in rows)
sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
return sb.ToString();
}
internal static string Escape(string? field)
{
if (string.IsNullOrEmpty(field)) return "";
return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
? $"\"{field.Replace("\"", "\"\"")}\""
: field;
}
private static void AddCsv(ZipArchive zip, string name, string[] header,
IEnumerable<string?[]> rows) =>
AddText(zip, name, BuildCsv(header, rows), bom: true);
private static void AddText(ZipArchive zip, string name, string content, bool bom)
{
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var entryStream = entry.Open();
if (bom)
{
// UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
entryStream.Write(Encoding.UTF8.GetPreamble());
}
var bytes = Encoding.UTF8.GetBytes(content);
entryStream.Write(bytes);
}
}
}

View File

@@ -75,6 +75,7 @@ app.MapSaleAdEndpoints();
app.MapImportEndpoints(); app.MapImportEndpoints();
app.MapContractEndpoints(); app.MapContractEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.Run(); app.Run();

View File

@@ -0,0 +1,20 @@
/** EXPORT-1: Einstellungen — Datenexport-Karte + Download-Smoke. */
import { de, expect, gotoSection, test } from './fixtures'
const t = de.pages.datenexport
test('Einstellungen zeigt die Datenexport-Karte und der Download startet', async ({ page }) => {
await gotoSection(page, de.nav.settings)
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title })).toBeVisible()
// Datenexport-Karte mit deutschem Erklärtext
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
await expect(page.getByText(t.intro)).toBeVisible()
await expect(page.getByText(t.photoNote)).toBeVisible()
// Klick startet den Zip-Download
const downloadPromise = page.waitForEvent('download')
await page.getByRole('link', { name: t.button }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('rennmaus-export')
})

View File

@@ -98,11 +98,37 @@ export async function installMockApi(page: Page): Promise<MockDb> {
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'), 'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
} }
await page.route(`${API_ORIGIN}/**`, async (route) => { const handler = async (route: Route) => {
const request = route.request() const request = route.request()
const url = new URL(request.url()) const url = new URL(request.url())
const method = request.method() const method = request.method()
const path = url.pathname // OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy);
// den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert.
const path = url.pathname.replace(/^\/api(?=\/)/, '')
// EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der
// Download startet; ein leeres Zip = End-of-central-directory-Record).
if (path === '/export' && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/zip',
headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' },
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
})
}
// FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern)
if (path === '/settings/breeder-profile') {
if (method === 'GET') {
return json(route, 200, {
zuchtName: 'Zucht der kleinen Chaoten',
name: 'Frau Erika Muster',
address: 'Musterweg 1, 12345 Musterstadt',
phone: '', email: '', homepage: '', city: 'Musterstadt',
})
}
if (method === 'PUT') return json(route, 204)
}
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge) // Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/) let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
@@ -157,7 +183,15 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 204) return json(route, 204)
} }
return json(route, 405) return json(route, 405)
}) }
// Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy).
// URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests
// (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten.
await page.route(
(url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'),
handler,
)
return db return db
} }

View File

@@ -22,6 +22,10 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
// CI-Flake-Schutz (Dwights Beobachtung, 2× 'Kontakt anlegen'): alle Worker
// teilen sich EINEN Vite-Dev-Server; unbegrenzte Parallelität erzeugt dort
// Timing-Druck (on-demand-Transforms). Lokal bleibt Playwrights Default.
workers: process.env.CI ? 4 : undefined,
reporter: [['list']], reporter: [['list']],
timeout: 30_000, timeout: 30_000,
use: { use: {

View File

@@ -0,0 +1,21 @@
/**
* EXPORT-1: Datenexport-Karte (auf /einstellungen) — lädt GET /export als Zip.
* Schlichter Anker-Download (kein fetch/Blob nötig); der Server setzt
* Content-Disposition mit Datumsdateinamen.
*/
import { API_BASE_URL } from '../api/client'
import { de } from '../strings/de'
export default function DatenexportCard() {
const t = de.pages.datenexport
return (
<section>
<h3>{t.title}</h3>
<p className="muted">{t.intro}</p>
<p className="muted">{t.photoNote}</p>
<a className="btn btn--primary" href={`${API_BASE_URL}/export`} download>
{t.button}
</a>
</section>
)
}

View File

@@ -13,6 +13,8 @@ import {
type BreederProfile, type BreederProfile,
} from '../api/settings' } from '../api/settings'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
// EXPORT-1 (Oscar): Datenexport-Karte
import DatenexportCard from '../components/DatenexportCard'
export default function EinstellungenPage() { export default function EinstellungenPage() {
const t = de.pages.einstellungen const t = de.pages.einstellungen
@@ -95,6 +97,8 @@ export default function EinstellungenPage() {
{saved && <span className="muted">{tz.saved}</span>} {saved && <span className="muted">{tz.saved}</span>}
</div> </div>
</form> </form>
<DatenexportCard />
</section> </section>
) )
} }

View File

@@ -565,6 +565,15 @@ export const de = {
}, },
}, },
}, },
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
datenexport: {
title: 'Datenexport',
intro:
'Sicherung deiner Daten — alle Tiere, Würfe und Kontakte als Tabellen (CSV für Excel) plus eine vollständige Datensicherung (JSON), gebündelt als Zip.',
button: 'Export herunterladen',
photoNote:
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
},
}, },
// ── HELP-1: In-App-Anleitung ── // ── HELP-1: In-App-Anleitung ──
hilfe: { hilfe: {