Files
Rozpisky/Rozpisky/Data/CiselnikyStore.cs
T
marekandClaude Opus 5 f086e623d6 Oprava: úprava dodávané položky číselníku se tiše ztrácela
Nacti() uložilo dodávanou vrstvu do referenčního snímku _vychozi a tytéž
objekty vložilo do kolekcí, na které se váže mřížka. Úprava buňky tak
mutovala i ten snímek – UlozRozdil pak porovnával stav sám se sebou, rozdíl
vyšel prázdný a oprava dodávané položky zmizela. Mazání a přidávání přitom
fungovalo (tam se reference nesdílí), takže se chyba dobře schovala.

Sluc nově vkládá dodávané položky jako hluboké kopie (serializací, stejně
jako klonování přílohy), takže snímek zůstane netknutý.

Nahlášeno na přejmenování kraje Karlovarský – přesně ten scénář teď hlídají
dva regresní testy; bez opravy oba padají.

Build 0 chyb / 0 varování, 57 testů zelených.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:26:10 +02:00

281 lines
13 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.Collections.ObjectModel;
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using Rozpisky.Models;
namespace Rozpisky.Data;
/// <summary>
/// Sdílené číselníky (organizace, firmy, jména, verze, části, kraje) ve <b>dvou vrstvách</b>:
/// <list type="bullet">
/// <item><b>dodávaná</b> <c>ciselniky.vychozi.json</c> vedle .exe, jen ke čtení; každá instalace
/// ji obnoví, takže smí s novou verzí programu růst a měnit se;</item>
/// <item><b>uživatelská</b> <c>ciselniky.user.json</c> v uživatelské složce
/// (<see cref="UzivatelskaData"/>), kde je uložený jen <b>rozdíl</b> proti dodávané vrstvě.</item>
/// </list>
/// Důvod rozdělení: číselníky uživatel průběžně doplňuje (ručně i „samoučením“ jmen z projektů).
/// Kdyby žily v jednom souboru vedle .exe, přeinstalace novou verzí by je přepsala a uživatel by
/// o svoje záznamy přišel. Uložením rozdílu se obojí smíří uživatel si nese své, program své.
/// <para>Bez NuGet závislostí System.Text.Json je součást .NET.</para>
/// </summary>
public static class CiselnikyStore
{
/// <summary>Dodávaná vrstva vedle .exe (jen ke čtení).</summary>
public const string VychoziNazev = "ciselniky.vychozi.json";
/// <summary>Uživatelská vrstva (rozdíl proti dodávané) v uživatelské složce.</summary>
public const string UzivatelskyNazev = "ciselniky.user.json";
/// <summary>Jednovrstvý soubor starších verzí vedle .exe zdroj pro jednorázovou migraci.</summary>
public const string StaryNazev = "ciselniky.json";
private static readonly JsonSerializerOptions Opts = new()
{
WriteIndented = true,
// Diakritika čitelně (ě š č…), ne jako escape sekvence.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
public static string VychoziPath => Path.Combine(AppContext.BaseDirectory, VychoziNazev);
public static string UzivatelskyPath => UzivatelskaData.Cesta(UzivatelskyNazev);
public static string StaryPath => Path.Combine(AppContext.BaseDirectory, StaryNazev);
/// <summary>Dodávaná vrstva načtená při startu. Drží se po celý běh, protože je referenčním
/// bodem pro dopočet rozdílu při každém uložení kdyby se načítala znovu, změna souboru
/// za běhu by rozdíl rozhodila.</summary>
private static Dto? _vychozi;
/// <summary>Načte číselníky: dodávaná vrstva + rozdíl uživatele. Když dodávaná vrstva chybí
/// (poškozená instalace), použije se vestavěný <see cref="DemoData.SeedCiselniky"/>, ať má
/// uživatel s čím pracovat. Před prvním načtením proběhne migrace ze starého jednovrstvého
/// souboru.</summary>
public static void Nacti(Ciselniky cil)
{
_vychozi = NactiVychozi();
MigrujStarySoubor();
var uziv = NactiUzivatelskou();
Sluc(cil.Organizace, _vychozi.Organizace, uziv?.Organizace);
Sluc(cil.Firmy, _vychozi.Firmy, uziv?.Firmy);
Sluc(cil.Jmena, _vychozi.Jmena, uziv?.Jmena);
Sluc(cil.VerzeDokumentace, _vychozi.VerzeDokumentace, uziv?.VerzeDokumentace);
Sluc(cil.Casti, _vychozi.Casti, uziv?.Casti);
Sluc(cil.Kraje, _vychozi.Kraje, uziv?.Kraje);
}
/// <summary>Uloží <b>rozdíl</b> aktuálního stavu proti dodávané vrstvě. Chyby zápisu propustí
/// volajícímu, ať je ošetří dle kontextu (autosave je spolkne, ruční uložení zahlásí).</summary>
public static void UlozRozdil(Ciselniky c)
{
var v = _vychozi ??= NactiVychozi();
var dto = new UzivatelskyDto
{
Organizace = Rozdil(c.Organizace, v.Organizace),
Firmy = Rozdil(c.Firmy, v.Firmy),
Jmena = Rozdil(c.Jmena, v.Jmena),
VerzeDokumentace = Rozdil(c.VerzeDokumentace, v.VerzeDokumentace),
Casti = Rozdil(c.Casti, v.Casti),
Kraje = Rozdil(c.Kraje, v.Kraje),
};
JsonConfigFile.ZapisAtomicky(UzivatelskyPath, JsonSerializer.Serialize(dto, Opts));
}
// ---- Vrstvy ----
private static Dto NactiVychozi()
{
if (File.Exists(VychoziPath))
{
try
{
var data = JsonSerializer.Deserialize<Dto>(File.ReadAllText(VychoziPath), Opts);
if (data is not null) return data;
JsonConfigFile.ZalohujAOhlasPoskozeny(VychoziPath);
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
JsonConfigFile.ZalohujAOhlasPoskozeny(VychoziPath, ex);
}
}
// Chybějící/poškozená dodávaná vrstva se nezapisuje zpátky (je součástí instalace)
// jen se nahradí vestavěným obsahem, ať aplikace není bez číselníků.
var nahrada = new Ciselniky();
DemoData.SeedCiselniky(nahrada);
return Dto.Z(nahrada);
}
private static UzivatelskyDto? NactiUzivatelskou()
{
if (!File.Exists(UzivatelskyPath)) return null;
try
{
return JsonSerializer.Deserialize<UzivatelskyDto>(File.ReadAllText(UzivatelskyPath), Opts);
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
// Ruční editace s překlepem nesmí uživatelovy záznamy tiše zahodit zazálohovat a ohlásit.
JsonConfigFile.ZalohujAOhlasPoskozeny(UzivatelskyPath, ex);
return null;
}
}
/// <summary>Jednorázový převod ze starého jednovrstvého <c>ciselniky.json</c> vedle .exe:
/// jeho obsah je „aktuální stav“ uživatele, takže se z něj dopočítá rozdíl proti dodávané
/// vrstvě a uloží jako uživatelská vrstva. Originál se jen přejmenuje, nikdy nemaže.</summary>
private static void MigrujStarySoubor()
{
if (File.Exists(UzivatelskyPath) || !File.Exists(StaryPath)) return;
try
{
var stary = JsonSerializer.Deserialize<Dto>(File.ReadAllText(StaryPath), Opts);
if (stary is null) return;
var stav = new Ciselniky();
Replace(stav.Organizace, stary.Organizace);
Replace(stav.Firmy, stary.Firmy);
Replace(stav.Jmena, stary.Jmena);
Replace(stav.VerzeDokumentace, stary.VerzeDokumentace);
Replace(stav.Casti, stary.Casti);
Replace(stav.Kraje, stary.Kraje);
UlozRozdil(stav);
Migrace.OznacJakoPrenesene(StaryPath);
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
// Nepovedená migrace nesmí zabránit startu; starý soubor zůstane a zkusí se příště.
}
}
// ---- Slučování a rozdíl ----
/// <summary>Klíč položky podle něj se poznává „tatáž“ položka napříč vrstvami.
/// Porovnává se bez ohledu na velikost písmen, stejně jako to dělá samoučení jmen.</summary>
internal static string Klic(object polozka) => polozka switch
{
Organizace o => o.Nazev,
Firma f => f.Nazev,
Jmeno j => j.Hodnota,
VerzeDokumentace v => v.Hodnota,
Cast c => c.Oznaceni,
Kraj k => k.Nazev,
_ => "",
};
/// <summary>Dodávaná vrstva → odebrat skryté → přepsat/doplnit vlastními. Pořadí dodávaných
/// položek zůstane zachované, vlastní se řadí za ně.
/// <para>
/// Dodávané položky se do cíle vkládají jako <b>kopie</b>. Kdyby se sdílely s referenčním
/// snímkem <c>_vychozi</c>, úprava v mřížce by mutovala i ten snímek rozdíl by se pak počítal
/// proti už změněným datům, vyšel by prázdný a uživatelova oprava dodávané položky by se tiše
/// ztratila (mazání a přidávání by přitom fungovalo, což tu chybu dobře schová).
/// </para></summary>
internal static void Sluc<T>(ObservableCollection<T> cil, List<T> vychozi, Vrstva<T>? uziv)
{
var skryte = new HashSet<string>(uziv?.Skryte ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
var vysledek = vychozi.Where(x => !skryte.Contains(Klic(x!))).Select(Klon).ToList();
foreach (var vlastni in uziv?.Vlastni ?? new List<T>())
{
var klic = Klic(vlastni!);
// Prázdný klíč = rozepsaný řádek; ten se nikdy nespáruje, jen připojí (jinak by se
// dva rozepsané řádky slily do jednoho).
int i = string.IsNullOrEmpty(klic)
? -1
: vysledek.FindIndex(x => string.Equals(Klic(x!), klic, StringComparison.OrdinalIgnoreCase));
if (i >= 0) vysledek[i] = vlastni; else vysledek.Add(vlastni);
}
Replace(cil, vysledek);
}
/// <summary>Rozdíl aktuálního stavu proti dodávané vrstvě: co v ní není nebo se od ní liší,
/// jde do <c>Vlastni</c>; dodávaná položka, která ve stavu chybí, jde do <c>Skryte</c>.</summary>
internal static Vrstva<T> Rozdil<T>(IEnumerable<T> aktualni, List<T> vychozi)
{
var stav = aktualni.ToList();
var vychoziMapa = vychozi
.GroupBy(x => Klic(x!), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var klicStavu = new HashSet<string>(stav.Select(x => Klic(x!)), StringComparer.OrdinalIgnoreCase);
var vrstva = new Vrstva<T>();
foreach (var polozka in stav)
{
var klic = Klic(polozka!);
if (string.IsNullOrEmpty(klic) ||
!vychoziMapa.TryGetValue(klic, out var vzor) ||
!StejnyObsah(polozka, vzor))
vrstva.Vlastni.Add(polozka);
}
foreach (var klic in vychoziMapa.Keys)
if (!string.IsNullOrEmpty(klic) && !klicStavu.Contains(klic))
vrstva.Skryte.Add(klic);
return vrstva;
}
/// <summary>Porovnání obsahu přes serializaci jedna metoda místo šesti implementací rovnosti
/// a automaticky pokryje i pole, která k položkám teprve přibudou.</summary>
private static bool StejnyObsah<T>(T a, T b)
=> JsonSerializer.Serialize(a, Opts) == JsonSerializer.Serialize(b, Opts);
/// <summary>Hluboká kopie položky (přes serializaci, stejně jako to dělá klonování přílohy).
/// Drží oddělený referenční snímek dodávané vrstvy od objektů, které edituje uživatel.</summary>
private static T Klon<T>(T polozka)
=> JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(polozka, Opts), Opts)!;
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> src)
{
target.Clear();
foreach (var x in src) target.Add(x);
}
// ---- Přenosové tvary ----
/// <summary>Tvar dodávané vrstvy (i starého jednovrstvého souboru) settable List&lt;T&gt;
/// na rozdíl od get-only kolekcí v <see cref="Ciselniky"/>, které System.Text.Json neplní.
/// Názvy vlastností musí odpovídat <see cref="Ciselniky"/>.</summary>
private sealed class Dto
{
public List<Organizace> Organizace { get; set; } = new();
public List<Firma> Firmy { get; set; } = new();
public List<Jmeno> Jmena { get; set; } = new();
public List<VerzeDokumentace> VerzeDokumentace { get; set; } = new();
public List<Cast> Casti { get; set; } = new();
public List<Kraj> Kraje { get; set; } = new();
public static Dto Z(Ciselniky c) => new()
{
Organizace = c.Organizace.ToList(),
Firmy = c.Firmy.ToList(),
Jmena = c.Jmena.ToList(),
VerzeDokumentace = c.VerzeDokumentace.ToList(),
Casti = c.Casti.ToList(),
Kraje = c.Kraje.ToList(),
};
}
/// <summary>Jeden číselník v uživatelské vrstvě: co uživatel přidal nebo upravil
/// (<see cref="Vlastni"/>) a které dodávané položky si odklidil (<see cref="Skryte"/> klíče).</summary>
internal sealed class Vrstva<T>
{
public List<T> Vlastni { get; set; } = new();
public List<string> Skryte { get; set; } = new();
}
/// <summary>Tvar uživatelské vrstvy. <see cref="Verze"/> je rezerva pro budoucí změnu formátu
/// bez ní by starší soubor nešlo odlišit od nového.</summary>
private sealed class UzivatelskyDto
{
public int Verze { get; set; } = 1;
public Vrstva<Organizace> Organizace { get; set; } = new();
public Vrstva<Firma> Firmy { get; set; } = new();
public Vrstva<Jmeno> Jmena { get; set; } = new();
public Vrstva<VerzeDokumentace> VerzeDokumentace { get; set; } = new();
public Vrstva<Cast> Casti { get; set; } = new();
public Vrstva<Kraj> Kraje { get; set; } = new();
}
}