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>
270 lines
12 KiB
C#
270 lines
12 KiB
C#
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<T>
|
||
/// 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();
|
||
}
|
||
}
|