Čí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:
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -7,64 +7,213 @@ using Rozpisky.Models;
|
|||||||
namespace Rozpisky.Data;
|
namespace Rozpisky.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Načítání/ukládání sdílených číselníků (organizace, firmy, jména, verze, části)
|
/// Sdílené číselníky (organizace, firmy, jména, verze, části, kraje) ve <b>dvou vrstvách</b>:
|
||||||
/// do JSON souboru <b>vedle .exe</b> (portable). Číselníky jsou společné všem projektům
|
/// <list type="bullet">
|
||||||
/// (na rozdíl od <c>.rzp</c> přes <see cref="ProjektStore"/>), takže žijí mimo projekt.
|
/// <item><b>dodávaná</b> – <c>ciselniky.vychozi.json</c> vedle .exe, jen ke čtení; každá instalace
|
||||||
/// Bez NuGet závislostí – System.Text.Json je součást .NET.
|
/// 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>
|
/// </summary>
|
||||||
public static class CiselnikyStore
|
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()
|
private static readonly JsonSerializerOptions Opts = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
// Diakritika čitelně (ě š č…), ne jako \uXXXX.
|
// Diakritika čitelně (ě š č…), ne jako escape sekvence.
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Výchozí umístění: soubor vedle .exe (portable). Lze přepsat parametrem cesty.</summary>
|
public static string VychoziPath => Path.Combine(AppContext.BaseDirectory, VychoziNazev);
|
||||||
public static string DefaultPath => Path.Combine(AppContext.BaseDirectory, SouborNazev);
|
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
|
/// <summary>Dodávaná vrstva načtená při startu. Drží se po celý běh, protože je referenčním
|
||||||
/// nebo je poškozený – volající pak nasází výchozí obsah (a uloží nový soubor).</summary>
|
/// bodem pro dopočet rozdílu při každém uložení – kdyby se načítala znovu, změna souboru
|
||||||
public static bool LoadInto(Ciselniky target, string? path = null)
|
/// 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)
|
||||||
{
|
{
|
||||||
path ??= DefaultPath;
|
_vychozi = NactiVychozi();
|
||||||
if (!File.Exists(path)) return false;
|
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
|
try
|
||||||
{
|
{
|
||||||
// Čteme do DTO se settable List<T>: System.Text.Json neplní get-only ObservableCollection.
|
return JsonSerializer.Deserialize<UzivatelskyDto>(File.ReadAllText(UzivatelskyPath), Opts);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
|
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
|
// Ruční editace s překlepem nesmí uživatelovy záznamy tiše zahodit – zazálohovat a ohlásit.
|
||||||
// obsahem, proto se originál nejdřív zazálohuje a uživatel dozví (jinak by o své
|
JsonConfigFile.ZalohujAOhlasPoskozeny(UzivatelskyPath, ex);
|
||||||
// číselníky přišel tiše).
|
return null;
|
||||||
JsonConfigFile.ZalohujAOhlasPoskozeny(path, ex);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Uloží číselníky do JSON (atomicky přes .tmp – pád uprostřed zápisu nesmí soubor
|
/// <summary>Jednorázový převod ze starého jednovrstvého <c>ciselniky.json</c> vedle .exe:
|
||||||
/// poškodit). Chyby zápisu (např. read-only složka u .exe v Program Files) propustí volajícímu,
|
/// jeho obsah je „aktuální stav“ uživatele, takže se z něj dopočítá rozdíl proti dodávané
|
||||||
/// ať je může ošetřit dle kontextu (autosave je spolkne, ruční uložení zahlásí).</summary>
|
/// vrstvě a uloží jako uživatelská vrstva. Originál se jen přejmenuje, nikdy nemaže.</summary>
|
||||||
public static void Save(Ciselniky c, string? path = null)
|
private static void MigrujStarySoubor()
|
||||||
=> JsonConfigFile.ZapisAtomicky(path ?? DefaultPath, JsonSerializer.Serialize(c, Opts));
|
{
|
||||||
|
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)
|
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);
|
foreach (var x in src) target.Add(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Přechodný tvar pro čtení – settable List<T> (na rozdíl od get-only kolekcí v
|
// ---- Přenosové tvary ----
|
||||||
/// <see cref="Ciselniky"/>). Názvy vlastností musí odpovídat <see cref="Ciselniky"/> i zápisu.</summary>
|
|
||||||
|
/// <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
|
private sealed class Dto
|
||||||
{
|
{
|
||||||
public List<Organizace> Organizace { get; set; } = new();
|
public List<Organizace> Organizace { get; set; } = new();
|
||||||
@@ -82,5 +234,36 @@ public static class CiselnikyStore
|
|||||||
public List<VerzeDokumentace> VerzeDokumentace { get; set; } = new();
|
public List<VerzeDokumentace> VerzeDokumentace { get; set; } = new();
|
||||||
public List<Cast> Casti { get; set; } = new();
|
public List<Cast> Casti { get; set; } = new();
|
||||||
public List<Kraj> Kraje { 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
<Resource Include="Rozpisky.ico" />
|
<Resource Include="Rozpisky.ico" />
|
||||||
</ItemGroup>
|
</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
|
<!-- 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ý
|
(+ 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. -->
|
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" />
|
<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. -->
|
<!-- 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" />
|
<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). -->
|
<!-- Dodávaná vrstva číselníků (data z původního Excelu). Aplikace ji jen čte – uživatelovy
|
||||||
<None Update="Podklady\ciselniky.json" Link="ciselniky.json" CopyToOutputDirectory="PreserveNewest" />
|
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. -->
|
<!-- Mapování „pole programu → DXF atribut“ (RozpiskaValues přes reflexi). Editovatelné vedle .exe. -->
|
||||||
<None Update="Podklady\mapovani.json" Link="mapovani.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Update="Podklady\mapovani.json" Link="mapovani.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<!-- Loga dodávaná s programem (DXF) – do podsložky loga\ vedle .exe. -->
|
<!-- Loga dodávaná s programem (DXF) – do podsložky loga\ vedle .exe. -->
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -511,33 +511,24 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
// ---- Sdílené číselníky (mimo projekt, vedle .exe) ----
|
// ---- 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
|
/// <summary>Načte číselníky: dodávaná vrstva vedle .exe + rozdíl uživatele z jeho složky
|
||||||
/// a soubor vytvoří. Pak zapne autosave při přidání/odebrání položky.</summary>
|
/// (<see cref="CiselnikyStore"/>). Pak zapne autosave při přidání/odebrání položky.</summary>
|
||||||
private void NactiCiselniky()
|
private void NactiCiselniky()
|
||||||
{
|
{
|
||||||
if (!CiselnikyStore.LoadInto(Ciselniky))
|
CiselnikyStore.Nacti(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();
|
|
||||||
}
|
|
||||||
// Autosave na přidání/odebrání položky; úpravy v buňkách se uloží při zavření okna.
|
// 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[]
|
foreach (var col in new INotifyCollectionChanged[]
|
||||||
{ Ciselniky.Organizace, Ciselniky.Firmy, Ciselniky.Jmena, Ciselniky.VerzeDokumentace, Ciselniky.Casti, Ciselniky.Kraje })
|
{ Ciselniky.Organizace, Ciselniky.Firmy, Ciselniky.Jmena, Ciselniky.VerzeDokumentace, Ciselniky.Casti, Ciselniky.Kraje })
|
||||||
col.CollectionChanged += (_, _) => UlozCiselniky();
|
col.CollectionChanged += (_, _) => UlozCiselniky();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Uloží číselníky vedle .exe. Při autosave záměrně bez dialogu (read-only složka u .exe
|
/// <summary>Uloží rozdíl číselníků proti dodávané vrstvě do složky uživatele. Při autosave
|
||||||
/// v Program Files nesmí spamovat MessageBoxy) – selhání se ale ukáže ve stavovém řádku,
|
/// 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>
|
/// ať uživatel ví, že se změny číselníků neukládají. Volá se i při zavření okna.</summary>
|
||||||
public void UlozCiselniky()
|
public void UlozCiselniky()
|
||||||
{
|
{
|
||||||
try { CiselnikyStore.Save(Ciselniky); }
|
try { CiselnikyStore.UlozRozdil(Ciselniky); }
|
||||||
catch { StavovaZprava = "Číselníky se nepodařilo uložit (složka aplikace je jen pro čtení?)."; }
|
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
|
/// <summary>„Samoučení" našeptávače: jména použitá v projektu/přílohách, která v číselníku
|
||||||
|
|||||||
Reference in New Issue
Block a user