Files
Rozpisky/Rozpisky/Data/CiselnikyStore.cs
T
marekandClaude Opus 5 6cd41ee5a3 Číselníky ve dvou vrstvách: dodávaná a uživatelská
Dosud byl ciselniky.json jediný soubor vedle .exe, který si aplikace zároveň
dodávala i přepisovala. Přeinstalace novou verzí by uživateli přepsala
záznamy, které si sám nastřádal – ručně v záložce Číselníky i „samoučením“
jmen z projektů. Nově:

- dodávaná vrstva  ciselniky.vychozi.json vedle .exe, jen ke čtení; každá
  instalace ji obnoví, takže s novou verzí smí růst a měnit se,
- uživatelská vrstva  ciselniky.user.json v %APPDATA%\Rozpisky, kde je
  uložený jen rozdíl proti dodávané: Vlastni (přidané a upravené položky)
  a Skryte (klíče dodávaných položek, které si uživatel odklidil).

Slučování zachovává pořadí dodávaných položek, upravenou položku přepíše
na jejím místě a vlastní řadí za ně. Klíč se bere podle typu (Nazev /
Hodnota / Oznaceni), porovnává se bez ohledu na velikost písmen – stejně
jako to už dělá samoučení jmen. Obsah se porovnává serializací, takže
nová pole položek jsou pokrytá automaticky.

Migrace: chybí-li uživatelská vrstva a vedle .exe leží starý ciselniky.json,
vezme se jeho obsah jako aktuální stav, dopočítá se z něj rozdíl a originál
se přejmenuje na .migrovano. Nic se nemaže.

Backfill krajů v MainViewModelu tím ztratil smysl (kraje přicházejí
z dodávané vrstvy) a je odstraněný.

Slučování a dopočet rozdílu jsou zpřístupněné testům (InternalsVisibleTo) –
chyba v nich se projeví tichou ztrátou dat, takže je pokrývá 14 nových testů
včetně kolečka uložit→načíst a scénáře aktualizace programu.

Ověřeno i ručně: migrace 7,6 kB souboru na 708 B rozdílu, přežití smazání
celého bin/ a přebuildu, běh s nezapisovatelnou instalační složkou.

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

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

270 lines
12 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ě.</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!))).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);
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();
}
}