Čí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>
This commit is contained in:
2026-09-07 10:48:43 +02:00
co-authored by Claude Opus 5
parent 3c0e30bf35
commit 6cd41ee5a3
5 changed files with 432 additions and 60 deletions
+191
View File
@@ -0,0 +1,191 @@
using System.Collections.ObjectModel;
using Rozpisky.Data;
using Rozpisky.Models;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Slučování dodávané a uživatelské vrstvy číselníků a dopočet rozdílu
/// (<see cref="CiselnikyStore.Sluc{T}"/> / <see cref="CiselnikyStore.Rozdil{T}"/>).
/// 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í.
/// </summary>
public class CiselnikyVrstvyTesty
{
private static List<Jmeno> Vychozi(params string[] jmena)
=> jmena.Select(j => new Jmeno { Hodnota = j }).ToList();
private static ObservableCollection<Jmeno> Sluc(
List<Jmeno> vychozi, CiselnikyStore.Vrstva<Jmeno>? uziv)
{
var cil = new ObservableCollection<Jmeno>();
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<Jmeno> { 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<Jmeno> { 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<Organizace>
{
new() { Nazev = "SŽ Praha", Adresa = "stará" },
new() { Nazev = "SŽ Brno", Adresa = "Kounicova 26" },
};
var uziv = new CiselnikyStore.Vrstva<Organizace>
{
Vlastni = { new Organizace { Nazev = "SŽ Praha", Adresa = "Dlážděná 1003/7" } },
};
var cil = new ObservableCollection<Organizace>();
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<Jmeno> { 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<Jmeno> { 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<Firma>
{
new() { Nazev = "EXprojekt s.r.o.", Telefon = "+420 533 312 000" },
};
var stav = new List<Firma>
{
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ý" }));
}
}
+224 -41
View File
@@ -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;
/// <summary>
/// Načítání/ukládání sdílených číselníků (organizace, firmy, jména, verze, části)
/// do JSON souboru <b>vedle .exe</b> (portable). Číselníky jsou společné všem projektům
/// (na rozdíl od <c>.rzp</c> přes <see cref="ProjektStore"/>), 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 <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
{
public const string SouborNazev = "ciselniky.json";
/// <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 \uXXXX.
// Diakritika čitelně (ě š č…), ne jako escape sekvence.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>Výchozí umístění: soubor vedle .exe (portable). Lze přepsat parametrem cesty.</summary>
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);
/// <summary>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).</summary>
public static bool LoadInto(Ciselniky target, string? path = null)
/// <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))
{
path ??= DefaultPath;
if (!File.Exists(path)) return false;
try
{
// Čteme do DTO se settable List<T>: System.Text.Json neplní get-only ObservableCollection.
var data = JsonSerializer.Deserialize<Dto>(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;
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)
{
// 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;
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>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í).</summary>
public static void Save(Ciselniky c, string? path = null)
=> JsonConfigFile.ZapisAtomicky(path ?? DefaultPath, JsonSerializer.Serialize(c, Opts));
/// <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)
{
@@ -72,8 +221,11 @@ public static class CiselnikyStore
foreach (var x in src) target.Add(x);
}
/// <summary>Přechodný tvar pro čtení settable List&lt;T&gt; (na rozdíl od get-only kolekcí v
/// <see cref="Ciselniky"/>). Názvy vlastností musí odpovídat <see cref="Ciselniky"/> i zápisu.</summary>
// ---- 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();
@@ -82,5 +234,36 @@ public static class CiselnikyStore
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();
}
}
+9 -2
View File
@@ -22,6 +22,12 @@
<Resource Include="Rozpisky.ico" />
</ItemGroup>
<ItemGroup>
<!-- Slučování vrstev číselníků (CiselnikyStore.Sluc/Rozdil) je čistá logika, na které závisí,
jestli uživatel po aktualizaci najde svoje záznamy testuje se přímo, bez souborů. -->
<InternalsVisibleTo Include="Rozpisky.Tests" />
</ItemGroup>
<!-- Podklady dodávané s programem (Podklady\) kopírují se vedle .exe do ploché struktury
(+ podsložka loga\). Link určuje umístění ve výstupu; zdrojový strom je tím oddělený
od rozvržení instalace. Aplikace je jen čte, nikdy do nich nezapisuje. -->
@@ -32,8 +38,9 @@
<None Update="Podklady\SEZNAM.xlsx" Link="SEZNAM.xlsx" CopyToOutputDirectory="PreserveNewest" />
<!-- Závazný manuál SŽ (PDF) otevírá se z nabídky Nápověda. -->
<None Update="Podklady\SZ_SM011_P10_Manual_v6.pdf" Link="SZ_SM011_P10_Manual_v6.pdf" CopyToOutputDirectory="PreserveNewest" />
<!-- Výchozí číselníky (data z původního Excelu). -->
<None Update="Podklady\ciselniky.json" Link="ciselniky.json" CopyToOutputDirectory="PreserveNewest" />
<!-- Dodávaná vrstva číselníků (data z původního Excelu). Aplikace ji jen čte uživatelovy
záznamy žijí odděleně v ciselniky.user.json v jeho profilu, viz CiselnikyStore. -->
<None Update="Podklady\ciselniky.vychozi.json" Link="ciselniky.vychozi.json" CopyToOutputDirectory="PreserveNewest" />
<!-- Mapování „pole programu → DXF atribut“ (RozpiskaValues přes reflexi). Editovatelné vedle .exe. -->
<None Update="Podklady\mapovani.json" Link="mapovani.json" CopyToOutputDirectory="PreserveNewest" />
<!-- Loga dodávaná s programem (DXF) do podsložky loga\ vedle .exe. -->
+8 -17
View File
@@ -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) ----
/// <summary>Načte číselníky z <c>ciselniky.json</c> vedle .exe; když chybí, nasází výchozí obsah
/// a soubor vytvoří. Pak zapne autosave při přidání/odebrání položky.</summary>
/// <summary>Načte číselníky: dodávaná vrstva vedle .exe + rozdíl uživatele z jeho složky
/// (<see cref="CiselnikyStore"/>). Pak zapne autosave při přidání/odebrání položky.</summary>
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();
}
/// <summary>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,
/// <summary>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.</summary>
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}."; }
}
/// <summary>„Samoučení" našeptávače: jména použitá v projektu/přílohách, která v číselníku