From 6cd41ee5a3354ace49638fc227daf7ce25ec3efd Mon Sep 17 00:00:00 2001 From: Josef Marek Date: Mon, 7 Sep 2026 10:48:43 +0200 Subject: [PATCH] =?UTF-8?q?=C4=8C=C3=ADseln=C3=ADky=20ve=20dvou=20vrstv?= =?UTF-8?q?=C3=A1ch:=20dod=C3=A1van=C3=A1=20a=20u=C5=BEivatelsk=C3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Rozpisky.Tests/CiselnikyVrstvyTesty.cs | 191 +++++++++++++ Rozpisky/Data/CiselnikyStore.cs | 265 +++++++++++++++--- ...{ciselniky.json => ciselniky.vychozi.json} | 0 Rozpisky/Rozpisky.csproj | 11 +- Rozpisky/ViewModels/MainViewModel.cs | 25 +- 5 files changed, 432 insertions(+), 60 deletions(-) create mode 100644 Rozpisky.Tests/CiselnikyVrstvyTesty.cs rename Rozpisky/Podklady/{ciselniky.json => ciselniky.vychozi.json} (100%) diff --git a/Rozpisky.Tests/CiselnikyVrstvyTesty.cs b/Rozpisky.Tests/CiselnikyVrstvyTesty.cs new file mode 100644 index 0000000..b3e9d1f --- /dev/null +++ b/Rozpisky.Tests/CiselnikyVrstvyTesty.cs @@ -0,0 +1,191 @@ +using System.Collections.ObjectModel; +using Rozpisky.Data; +using Rozpisky.Models; +using Xunit; + +namespace Rozpisky.Tests; + +/// +/// Slučování dodávané a uživatelské vrstvy číselníků a dopočet rozdílu +/// ( / ). +/// Na téhle logice stojí, jestli uživatel po aktualizaci programu najde svoje záznamy – +/// chyba tady se projeví tichou ztrátou dat, kterou nikdo nenahlásí. +/// +public class CiselnikyVrstvyTesty +{ + private static List Vychozi(params string[] jmena) + => jmena.Select(j => new Jmeno { Hodnota = j }).ToList(); + + private static ObservableCollection Sluc( + List vychozi, CiselnikyStore.Vrstva? uziv) + { + var cil = new ObservableCollection(); + CiselnikyStore.Sluc(cil, vychozi, uziv); + return cil; + } + + [Fact] + public void BezUzivatelskeVrstvyZustaneDodavanaBezeZmeny() + { + var vysledek = Sluc(Vychozi("Novák", "Svoboda"), null); + Assert.Equal(new[] { "Novák", "Svoboda" }, vysledek.Select(j => j.Hodnota)); + } + + [Fact] + public void VlastniPolozkySeRadiZaDodavane() + { + var uziv = new CiselnikyStore.Vrstva { Vlastni = Vychozi("Dvořák") }; + var vysledek = Sluc(Vychozi("Novák", "Svoboda"), uziv); + Assert.Equal(new[] { "Novák", "Svoboda", "Dvořák" }, vysledek.Select(j => j.Hodnota)); + } + + [Fact] + public void SkrytaDodavanaPolozkaSeNeobjevi() + { + var uziv = new CiselnikyStore.Vrstva { Skryte = { "Svoboda" } }; + var vysledek = Sluc(Vychozi("Novák", "Svoboda"), uziv); + Assert.Equal(new[] { "Novák" }, vysledek.Select(j => j.Hodnota)); + } + + [Fact] + public void UpravenaDodavanaPolozkaSePrepiseNaSvemMiste() + { + // Uživatel opravil adresu dodávané organizace – musí zůstat na původní pozici, + // jinak by se mu po každé aktualizaci přeházelo pořadí v našeptávači. + var vychozi = new List + { + new() { Nazev = "SŽ Praha", Adresa = "stará" }, + new() { Nazev = "SŽ Brno", Adresa = "Kounicova 26" }, + }; + var uziv = new CiselnikyStore.Vrstva + { + Vlastni = { new Organizace { Nazev = "SŽ Praha", Adresa = "Dlážděná 1003/7" } }, + }; + + var cil = new ObservableCollection(); + CiselnikyStore.Sluc(cil, vychozi, uziv); + + Assert.Equal(new[] { "SŽ Praha", "SŽ Brno" }, cil.Select(o => o.Nazev)); + Assert.Equal("Dlážděná 1003/7", cil[0].Adresa); + } + + [Fact] + public void ParovaniIgnorujeVelikostPismen() + { + var uziv = new CiselnikyStore.Vrstva { Vlastni = Vychozi("NOVÁK") }; + var vysledek = Sluc(Vychozi("Novák"), uziv); + Assert.Single(vysledek); + Assert.Equal("NOVÁK", vysledek[0].Hodnota); + } + + [Fact] + public void RozepsaneRadkySeNeslijouDoJednoho() + { + // Dva prázdné řádky přidané tlačítkem „Přidat záznam“ mají stejný (prázdný) klíč – + // nesmí se spárovat, jinak by uživateli jeden zmizel dřív, než ho stihne vyplnit. + var uziv = new CiselnikyStore.Vrstva { Vlastni = Vychozi("", "") }; + var vysledek = Sluc(Vychozi("Novák"), uziv); + Assert.Equal(3, vysledek.Count); + } + + [Fact] + public void RozdilZachytiPridaniUpravuISkryti() + { + var vychozi = Vychozi("Novák", "Svoboda", "Černý"); + // Uživatel: Svobodu smazal, přidal Dvořáka, Novák zůstal beze změny. + var stav = Vychozi("Novák", "Černý", "Dvořák"); + + var rozdil = CiselnikyStore.Rozdil(stav, vychozi); + + Assert.Equal(new[] { "Dvořák" }, rozdil.Vlastni.Select(j => j.Hodnota)); + Assert.Equal(new[] { "Svoboda" }, rozdil.Skryte); + } + + [Fact] + public void RozdilNepovazujeNezmenenouPolozkuZaVlastni() + { + var vychozi = Vychozi("Novák", "Svoboda"); + var rozdil = CiselnikyStore.Rozdil(Vychozi("Novák", "Svoboda"), vychozi); + + Assert.Empty(rozdil.Vlastni); + Assert.Empty(rozdil.Skryte); + } + + [Fact] + public void RozdilZachytiZmenuJinehoNezKlicovehoPole() + { + var vychozi = new List + { + new() { Nazev = "EXprojekt s.r.o.", Telefon = "+420 533 312 000" }, + }; + var stav = new List + { + new() { Nazev = "EXprojekt s.r.o.", Telefon = "+420 111 222 333" }, + }; + + var rozdil = CiselnikyStore.Rozdil(stav, vychozi); + + Assert.Single(rozdil.Vlastni); + Assert.Equal("+420 111 222 333", rozdil.Vlastni[0].Telefon); + Assert.Empty(rozdil.Skryte); // klíč zůstal, nejde o skrytí + } + + [Fact] + public void PrejmenovaniDodavanePolozkySeUloziJakoSkrytiAPridani() + { + var vychozi = Vychozi("Nowak"); + var rozdil = CiselnikyStore.Rozdil(Vychozi("Novák"), vychozi); + + Assert.Equal(new[] { "Novák" }, rozdil.Vlastni.Select(j => j.Hodnota)); + Assert.Equal(new[] { "Nowak" }, rozdil.Skryte); + } + + [Fact] + public void RozdilASlouceniDavajiZpetPuvodniStav() + { + // Kolečko uložit → načíst nesmí nic ztratit ani přidat, ať se s číselníkem stalo cokoli. + var vychozi = Vychozi("Novák", "Svoboda", "Černý"); + var stav = Vychozi("Novák", "Černý", "Dvořák", "Horák"); + + var vysledek = Sluc(vychozi, CiselnikyStore.Rozdil(stav, vychozi)); + + Assert.Equal(stav.Select(j => j.Hodnota), vysledek.Select(j => j.Hodnota)); + } + + [Fact] + public void NovaDodavanaPolozkaSePoAktualizaciObjeviAVlastniZustanou() + { + // Jádro celé změny: program vydá novou verzi s doplněným číselníkem, uživatelův + // rozdíl je pořád ten starý – nová položka musí přibýt, vlastní nesmí zmizet. + var stareVychozi = Vychozi("Novák"); + var rozdilUzivatele = CiselnikyStore.Rozdil(Vychozi("Novák", "Dvořák"), stareVychozi); + + var noveVychozi = Vychozi("Novák", "Svoboda"); + var vysledek = Sluc(noveVychozi, rozdilUzivatele); + + Assert.Equal(new[] { "Novák", "Svoboda", "Dvořák" }, vysledek.Select(j => j.Hodnota)); + } + + [Fact] + public void SkrytiPrezijeAktualizaciDodavaneVrstvy() + { + var stareVychozi = Vychozi("Novák", "Svoboda"); + var rozdilUzivatele = CiselnikyStore.Rozdil(Vychozi("Novák"), stareVychozi); + + // Nová verze programu Svobodu pořád dodává – uživatel ho ale jednou odklidil. + var vysledek = Sluc(Vychozi("Novák", "Svoboda", "Černý"), rozdilUzivatele); + + Assert.Equal(new[] { "Novák", "Černý" }, vysledek.Select(j => j.Hodnota)); + } + + [Fact] + public void KlicSeBerePodleTypuPolozky() + { + Assert.Equal("SŽ", CiselnikyStore.Klic(new Organizace { Nazev = "SŽ" })); + Assert.Equal("EXprojekt", CiselnikyStore.Klic(new Firma { Nazev = "EXprojekt" })); + Assert.Equal("Novák", CiselnikyStore.Klic(new Jmeno { Hodnota = "Novák" })); + Assert.Equal("DPS", CiselnikyStore.Klic(new VerzeDokumentace { Hodnota = "DPS" })); + Assert.Equal("D", CiselnikyStore.Klic(new Cast { Oznaceni = "D", Nazev = "Mosty" })); + Assert.Equal("Jihomoravský", CiselnikyStore.Klic(new Kraj { Nazev = "Jihomoravský" })); + } +} diff --git a/Rozpisky/Data/CiselnikyStore.cs b/Rozpisky/Data/CiselnikyStore.cs index 4224fbb..e74ed3b 100644 --- a/Rozpisky/Data/CiselnikyStore.cs +++ b/Rozpisky/Data/CiselnikyStore.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.IO; using System.Text.Encodings.Web; using System.Text.Json; @@ -7,64 +7,213 @@ using Rozpisky.Models; namespace Rozpisky.Data; /// -/// Načítání/ukládání sdílených číselníků (organizace, firmy, jména, verze, části) -/// do JSON souboru vedle .exe (portable). Číselníky jsou společné všem projektům -/// (na rozdíl od .rzp přes ), takže žijí mimo projekt. -/// Bez NuGet závislostí – System.Text.Json je součást .NET. +/// Sdílené číselníky (organizace, firmy, jména, verze, části, kraje) ve dvou vrstvách: +/// +/// dodávanáciselniky.vychozi.json vedle .exe, jen ke čtení; každá instalace +/// ji obnoví, takže smí s novou verzí programu růst a měnit se; +/// uživatelskáciselniky.user.json v uživatelské složce +/// (), kde je uložený jen rozdíl proti dodávané vrstvě. +/// +/// 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é. +/// Bez NuGet závislostí – System.Text.Json je součást .NET. /// public static class CiselnikyStore { - public const string SouborNazev = "ciselniky.json"; + /// Dodávaná vrstva vedle .exe (jen ke čtení). + public const string VychoziNazev = "ciselniky.vychozi.json"; + + /// Uživatelská vrstva (rozdíl proti dodávané) v uživatelské složce. + public const string UzivatelskyNazev = "ciselniky.user.json"; + + /// Jednovrstvý soubor starších verzí vedle .exe – zdroj pro jednorázovou migraci. + public const string StaryNazev = "ciselniky.json"; private static readonly JsonSerializerOptions Opts = new() { WriteIndented = true, - // Diakritika čitelně (ě š č…), ne jako \uXXXX. + // Diakritika čitelně (ě š č…), ne jako escape sekvence. Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; - /// Výchozí umístění: soubor vedle .exe (portable). Lze přepsat parametrem cesty. - public static string DefaultPath => Path.Combine(AppContext.BaseDirectory, SouborNazev); + 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); - /// Naplní cílové číselníky obsahem z JSON. Vrací false, když soubor neexistuje - /// nebo je poškozený – volající pak nasází výchozí obsah (a uloží nový soubor). - public static bool LoadInto(Ciselniky target, string? path = null) + /// 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. + private static Dto? _vychozi; + + /// Načte číselníky: dodávaná vrstva + rozdíl uživatele. Když dodávaná vrstva chybí + /// (poškozená instalace), použije se vestavěný , ať má + /// uživatel s čím pracovat. Před prvním načtením proběhne migrace ze starého jednovrstvého + /// souboru. + public static void Nacti(Ciselniky cil) { - path ??= DefaultPath; - if (!File.Exists(path)) return false; + _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); + } + + /// Uloží rozdíl 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í). + 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(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 { - // Čteme do DTO se settable List: System.Text.Json neplní get-only ObservableCollection. - var data = JsonSerializer.Deserialize(File.ReadAllText(path), Opts); - if (data is null) - { - // Soubor existuje, ale nenese data („null") → stejné zacházení jako s rozbitým JSON. - JsonConfigFile.ZalohujAOhlasPoskozeny(path); - return false; - } - Replace(target.Organizace, data.Organizace); - Replace(target.Firmy, data.Firmy); - Replace(target.Jmena, data.Jmena); - Replace(target.VerzeDokumentace, data.VerzeDokumentace); - Replace(target.Casti, data.Casti); - Replace(target.Kraje, data.Kraje); - return true; + return JsonSerializer.Deserialize(File.ReadAllText(UzivatelskyPath), Opts); } catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { - // Poškozený / nečitelný soubor → fallback na seed. Volající pak soubor přepíše výchozím - // obsahem, proto se originál nejdřív zazálohuje a uživatel dozví (jinak by o své - // číselníky přišel tiše). - JsonConfigFile.ZalohujAOhlasPoskozeny(path, ex); - return false; + // 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; } } - /// Uloží číselníky do JSON (atomicky přes .tmp – pád uprostřed zápisu nesmí soubor - /// poškodit). Chyby zápisu (např. read-only složka u .exe v Program Files) propustí volajícímu, - /// ať je může ošetřit dle kontextu (autosave je spolkne, ruční uložení zahlásí). - public static void Save(Ciselniky c, string? path = null) - => JsonConfigFile.ZapisAtomicky(path ?? DefaultPath, JsonSerializer.Serialize(c, Opts)); + /// Jednorázový převod ze starého jednovrstvého ciselniky.json 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. + private static void MigrujStarySoubor() + { + if (File.Exists(UzivatelskyPath) || !File.Exists(StaryPath)) return; + try + { + var stary = JsonSerializer.Deserialize(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 ---- + + /// 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. + 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, + _ => "", + }; + + /// 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ě. + internal static void Sluc(ObservableCollection cil, List vychozi, Vrstva? uziv) + { + var skryte = new HashSet(uziv?.Skryte ?? new List(), StringComparer.OrdinalIgnoreCase); + var vysledek = vychozi.Where(x => !skryte.Contains(Klic(x!))).ToList(); + + foreach (var vlastni in uziv?.Vlastni ?? new List()) + { + 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); + } + + /// Rozdíl aktuálního stavu proti dodávané vrstvě: co v ní není nebo se od ní liší, + /// jde do Vlastni; dodávaná položka, která ve stavu chybí, jde do Skryte. + internal static Vrstva Rozdil(IEnumerable aktualni, List 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(stav.Select(x => Klic(x!)), StringComparer.OrdinalIgnoreCase); + + var vrstva = new Vrstva(); + 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; + } + + /// 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. + private static bool StejnyObsah(T a, T b) + => JsonSerializer.Serialize(a, Opts) == JsonSerializer.Serialize(b, Opts); private static void Replace(ObservableCollection target, IEnumerable src) { @@ -72,8 +221,11 @@ public static class CiselnikyStore foreach (var x in src) target.Add(x); } - /// Přechodný tvar pro čtení – settable List<T> (na rozdíl od get-only kolekcí v - /// ). Názvy vlastností musí odpovídat i zápisu. + // ---- Přenosové tvary ---- + + /// Tvar dodávané vrstvy (i starého jednovrstvého souboru) – settable List<T> + /// na rozdíl od get-only kolekcí v , které System.Text.Json neplní. + /// Názvy vlastností musí odpovídat . private sealed class Dto { public List Organizace { get; set; } = new(); @@ -82,5 +234,36 @@ public static class CiselnikyStore public List VerzeDokumentace { get; set; } = new(); public List Casti { get; set; } = new(); public List 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(), + }; + } + + /// Jeden číselník v uživatelské vrstvě: co uživatel přidal nebo upravil + /// () a které dodávané položky si odklidil ( – klíče). + internal sealed class Vrstva + { + public List Vlastni { get; set; } = new(); + public List Skryte { get; set; } = new(); + } + + /// Tvar uživatelské vrstvy. je rezerva pro budoucí změnu formátu – + /// bez ní by starší soubor nešlo odlišit od nového. + private sealed class UzivatelskyDto + { + public int Verze { get; set; } = 1; + public Vrstva Organizace { get; set; } = new(); + public Vrstva Firmy { get; set; } = new(); + public Vrstva Jmena { get; set; } = new(); + public Vrstva VerzeDokumentace { get; set; } = new(); + public Vrstva Casti { get; set; } = new(); + public Vrstva Kraje { get; set; } = new(); } } diff --git a/Rozpisky/Podklady/ciselniky.json b/Rozpisky/Podklady/ciselniky.vychozi.json similarity index 100% rename from Rozpisky/Podklady/ciselniky.json rename to Rozpisky/Podklady/ciselniky.vychozi.json diff --git a/Rozpisky/Rozpisky.csproj b/Rozpisky/Rozpisky.csproj index db9d136..e4ac35a 100644 --- a/Rozpisky/Rozpisky.csproj +++ b/Rozpisky/Rozpisky.csproj @@ -22,6 +22,12 @@ + + + + + @@ -32,8 +38,9 @@ - - + + diff --git a/Rozpisky/ViewModels/MainViewModel.cs b/Rozpisky/ViewModels/MainViewModel.cs index 8000c27..faa7abd 100644 --- a/Rozpisky/ViewModels/MainViewModel.cs +++ b/Rozpisky/ViewModels/MainViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; @@ -511,33 +511,24 @@ public sealed class MainViewModel : ObservableObject // ---- Sdílené číselníky (mimo projekt, vedle .exe) ---- - /// Načte číselníky z ciselniky.json vedle .exe; když chybí, nasází výchozí obsah - /// a soubor vytvoří. Pak zapne autosave při přidání/odebrání položky. + /// Načte číselníky: dodávaná vrstva vedle .exe + rozdíl uživatele z jeho složky + /// (). Pak zapne autosave při přidání/odebrání položky. private void NactiCiselniky() { - if (!CiselnikyStore.LoadInto(Ciselniky)) - { - DemoData.SeedCiselniky(Ciselniky); - UlozCiselniky(); // vytvoř výchozí soubor - } - else if (Ciselniky.Kraje.Count == 0) - { - DemoData.SeedKraje(Ciselniky); // kraje přibyly později → doplň i do starších ciselniky.json - UlozCiselniky(); - } + CiselnikyStore.Nacti(Ciselniky); // Autosave na přidání/odebrání položky; úpravy v buňkách se uloží při zavření okna. foreach (var col in new INotifyCollectionChanged[] { Ciselniky.Organizace, Ciselniky.Firmy, Ciselniky.Jmena, Ciselniky.VerzeDokumentace, Ciselniky.Casti, Ciselniky.Kraje }) col.CollectionChanged += (_, _) => UlozCiselniky(); } - /// Uloží číselníky vedle .exe. Při autosave záměrně bez dialogu (read-only složka u .exe - /// v Program Files nesmí spamovat MessageBoxy) – selhání se ale ukáže ve stavovém řádku, + /// Uloží rozdíl číselníků proti dodávané vrstvě do složky uživatele. Při autosave + /// záměrně bez dialogu (nesmí spamovat MessageBoxy) – selhání se ale ukáže ve stavovém řádku, /// ať uživatel ví, že se změny číselníků neukládají. Volá se i při zavření okna. public void UlozCiselniky() { - try { CiselnikyStore.Save(Ciselniky); } - catch { StavovaZprava = "Číselníky se nepodařilo uložit (složka aplikace je jen pro čtení?)."; } + try { CiselnikyStore.UlozRozdil(Ciselniky); } + catch { StavovaZprava = $"Číselníky se nepodařilo uložit do složky {UzivatelskaData.Slozka}."; } } /// „Samoučení" našeptávače: jména použitá v projektu/přílohách, která v číselníku