diff --git a/.claude/agents/render-dxf.md b/.claude/agents/render-dxf.md index f9f1d1a..be6e7b6 100644 --- a/.claude/agents/render-dxf.md +++ b/.claude/agents/render-dxf.md @@ -2,6 +2,7 @@ name: render-dxf description: Specialista na kreslicí a DXF vrstvu Rozpisek (Rozpisky/Rendering/, Rozpisky/Dxf/). Použij pro změny v renderování (Skia, PNG, PDF, DXF výstup), ve čtečce DXF šablony, v mapování hodnot do polí, v kódech příloh a v práci s logy. Nepoužívej pro UI, ViewModely ani COM (CAD/Excel). tools: Read, Edit, Write, Grep, Glob, Bash, PowerShell +model: sonnet --- Jsi specialista na kreslicí jádro aplikace Rozpisky (C# / .NET 10, česky psaný kód). @@ -50,6 +51,6 @@ Needituj produkční data: `Rozpisky/Podklady/Rozpiska.dxf`, `vzorky/schema.dxf` 3. Ověř: `dotnet build "Rozpisky.sln" -nologo -v q -clp:ErrorsOnly` a `dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q` - (baseline je 50 zelených testů — nesmí ubýt). + (baseline je 55 zelených testů — nesmí ubýt). 4. V odpovědi vrať: co jsi změnil (soubor:řádek), proč, a výsledek buildu a testů. Když něco neprošlo, řekni to rovnou i s chybovou hláškou — nezakrývej to. diff --git a/.claude/agents/verifikator.md b/.claude/agents/verifikator.md index 2f0c41b..d4a089f 100644 --- a/.claude/agents/verifikator.md +++ b/.claude/agents/verifikator.md @@ -2,6 +2,7 @@ name: verifikator description: Read-only ověřovatel — spustí build a testy Rozpisek a vrátí strukturovaný verdikt. Použij po každé nezanedbatelné změně kódu, nebo když je potřeba nezávisle potvrdit, že projekt je v pořádku. Nic needituje. tools: Read, Grep, Glob, Bash, PowerShell +model: haiku --- Jsi nezávislý ověřovatel projektu Rozpisky. **Nesmíš nic měnit** — nemáš nástroje na editaci @@ -17,7 +18,7 @@ dotnet test "C:\Users\marek\_Osobní\C sharp\EXE\Rozpisky\Rozpisky.Tests\Rozpis Testy spusť i tehdy, když build projde s varováními. Když build spadne, testy nespouštěj a rovnou hlas chyby buildu. -Baseline, se kterou porovnáváš: **build bez chyb a bez varování, 50 testů zelených, 0 přeskočených.** +Baseline, se kterou porovnáváš: **build bez chyb a bez varování, 55 testů zelených, 0 přeskočených.** Volitelně (jen když o to volající požádá) ověř, že aplikace naběhne: spusť `Rozpisky\bin\Debug\net10.0-windows\Rozpisky.exe`, počkej ~8 s, zkontroluj, že proces diff --git a/.claude/agents/wpf-ui.md b/.claude/agents/wpf-ui.md index a8e165b..b56fec4 100644 --- a/.claude/agents/wpf-ui.md +++ b/.claude/agents/wpf-ui.md @@ -2,6 +2,7 @@ name: wpf-ui description: Specialista na WPF prezentační vrstvu Rozpisek (ViewModels/, Views/, Themes/, Behaviors/, Converters/, Theming/). Použij pro změny obrazovek, XAML, bindingů, příkazů, chování gridů a vzhledu. Nepoužívej pro kreslicí/DXF jádro ani pro COM vrstvu. tools: Read, Edit, Write, Grep, Glob, Bash, PowerShell +model: sonnet --- Jsi specialista na WPF vrstvu aplikace Rozpisky (C# / .NET 10, MVVM bez frameworku, česky psaný kód). @@ -35,7 +36,7 @@ nebo `Rozpisky/Xlsx/` jen popiš v odpovědi a nech je na volajícím. začneš měnit UI. 2. `MainViewModel.cs` je velký; hledej v něm grepem, nečti ho celý zbytečně. 3. Ověř: `dotnet build "Rozpisky.sln" -nologo -v q -clp:ErrorsOnly` a - `dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q` (baseline 50 zelených). + `dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q` (baseline 55 zelených). Build musí být **bez varování** — chyby v XAML se často projeví jen jako varování. 4. WPF chyby v bindingu build nezachytí. Pokud změna může binding rozbít, řekni to explicitně a doporuč ruční spuštění aplikace. diff --git a/CLAUDE.md b/CLAUDE.md index 993add9..67e1d74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q dotnet run --project "Rozpisky\Rozpisky.csproj" ``` -Baseline: build bez chyb a varování, **50 testů zelených**. Před hlášením hotovo obojí spustit +Baseline: build bez chyb a varování, **55 testů zelených**. Před hlášením hotovo obojí spustit (nebo použít `/overit`). ## Architektura — jediné pravidlo, které se nesmí porušit @@ -38,8 +38,11 @@ jen relevantní kapitolu. - Uživatelská data (číselníky, výběr CADu, logy) jdou přes [`UzivatelskaData`](Rozpisky/Data/UzivatelskaData.cs) do `%APPDATA%\Rozpisky`. Do složky u `.exe` se **nezapisuje** — může být jen pro čtení a přeinstalace ji přepíše. Číselníky navíc žijí ve dvou - vrstvách ([`CiselnikyStore`](Rozpisky/Data/CiselnikyStore.cs)): dodávaná + uložený rozdíl - uživatele. Změnu slučování vždy pokrýt testem — chyba tam se projeví tichou ztrátou dat. + vrstvách ([`CiselnikyStore`](Rozpisky/Data/CiselnikyStore.cs)) a loga ve dvou složkách + ([`LogaResolver`](Rozpisky/Dxf/LogaResolver.cs)): dodávané + uživatelské navrch. + **Při konfliktu vyhrává uživatelská vrstva** — jinak by se jeho úprava tiše neprojevila. + Výjimka jsou data, která drží správnost výstupu (`mapovani.json`, `Rozpiska.dxf`) — tam vyhrává + program. Změnu slučování vždy pokrýt testem: chyba tam se projeví tichou ztrátou dat. ## Rozvržení složky — tři druhy podkladů diff --git a/Rozpisky.Tests/LogaVrstvyTesty.cs b/Rozpisky.Tests/LogaVrstvyTesty.cs new file mode 100644 index 0000000..7e5af7f --- /dev/null +++ b/Rozpisky.Tests/LogaVrstvyTesty.cs @@ -0,0 +1,80 @@ +using System.IO; +using Rozpisky.Dxf; +using Xunit; + +namespace Rozpisky.Tests; + +/// +/// Nabídka log ze dvou složek (): dodávaná vedle .exe +/// a uživatelská, na kterou přeinstalace nesahá. Stejná hierarchie jako u číselníků – +/// uživatelovo logo stejného jména přebíjí dodávané. +/// +public class LogaVrstvyTesty : IDisposable +{ + private readonly string _koren = Path.Combine(Path.GetTempPath(), "rozpisky-loga-testy-" + Guid.NewGuid().ToString("N")); + private readonly string _dodavana; + private readonly string _uzivatelska; + + public LogaVrstvyTesty() + { + _dodavana = Path.Combine(_koren, "dodavana"); + _uzivatelska = Path.Combine(_koren, "uzivatelska"); + Directory.CreateDirectory(_dodavana); + Directory.CreateDirectory(_uzivatelska); + } + + public void Dispose() + { + try { Directory.Delete(_koren, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private void Zaloz(string dir, params string[] soubory) + { + foreach (var s in soubory) File.WriteAllText(Path.Combine(dir, s), "0\nEOF\n"); + } + + [Fact] + public void BezUzivatelskychLogSeVratiJenDodavana() + { + Zaloz(_dodavana, "exprojekt.dxf", "mco.dxf"); + Assert.Equal(new[] { "exprojekt.dxf", "mco.dxf" }, + LogaResolver.SlozNabidku(_uzivatelska, _dodavana)); + } + + [Fact] + public void UzivatelskaLogaSeDoplniKDodavanym() + { + Zaloz(_dodavana, "exprojekt.dxf"); + Zaloz(_uzivatelska, "nova_firma.dxf"); + Assert.Equal(new[] { "exprojekt.dxf", "nova_firma.dxf" }, + LogaResolver.SlozNabidku(_uzivatelska, _dodavana)); + } + + [Fact] + public void StejneJmenoSeVNabidceNezdvoji() + { + // Uživatel si opravil dodávané logo a uložil ho pod stejným jménem k sobě – + // v comboboxu musí zůstat jedna položka, ne dvě nerozlišitelné. + Zaloz(_dodavana, "exprojekt.dxf"); + Zaloz(_uzivatelska, "exprojekt.dxf"); + Assert.Equal(new[] { "exprojekt.dxf" }, LogaResolver.SlozNabidku(_uzivatelska, _dodavana)); + } + + [Fact] + public void ChybejiciUzivatelskaSlozkaNevadi() + { + Zaloz(_dodavana, "exprojekt.dxf"); + var neexistuje = Path.Combine(_koren, "tady-nic-neni"); + Assert.Equal(new[] { "exprojekt.dxf" }, LogaResolver.SlozNabidku(neexistuje, _dodavana)); + } + + [Fact] + public void NabidkaJeAbecedniAIgnorujeJineNezDxf() + { + Zaloz(_dodavana, "tesia.dxf", "README.txt", "mco.dxf"); + Zaloz(_uzivatelska, "alfa.dxf"); + Assert.Equal(new[] { "alfa.dxf", "mco.dxf", "tesia.dxf" }, + LogaResolver.SlozNabidku(_uzivatelska, _dodavana)); + } +} diff --git a/Rozpisky/Dxf/LogaResolver.cs b/Rozpisky/Dxf/LogaResolver.cs index 83c6673..89ae0d7 100644 --- a/Rozpisky/Dxf/LogaResolver.cs +++ b/Rozpisky/Dxf/LogaResolver.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.IO; +using Rozpisky.Data; using Rozpisky.Models; namespace Rozpisky.Dxf; @@ -8,19 +9,67 @@ namespace Rozpisky.Dxf; /// Pravidla (kap. 4.3 manuálu SŽ): /// /// Rozpiska_logo1/2 – financující instituce (dotační vlevo / státní vpravo), -/// projektové, soubor ze složky loga\ vedle .exe, +/// projektové, soubor ze složky loga\, /// Rozpiska_logo3 – logo investora (číselník Organizace dle Projekt.Investor), /// Rozpiska_logo4 – logo zhotovitele díla (číselník Firmy dle Projekt.ZhotovitelSOD), /// Rozpiska_logo5 – logo zhotovitele objektu (číselník Firmy dle Priloha.ZhotovitelCasti), /// Rozpiska_Orientační schéma – DXF u přílohy (Priloha.SchemaSoubor), relativně k .rzp. /// /// Položky bez vyplněného / neexistujícího souboru se vynechají. +/// +/// Loga se hledají ve dvou složkách se stejnou logikou jako číselníky: nejdřív +/// loga\ v uživatelské složce (viz ), pak loga\ vedle +/// .exe. Uživatelské logo stejného jména přebíjí dodávané. Bez toho by si uživatel, který přidá +/// do číselníku firmu s vlastním logem, musel soubor kopírovat do instalační složky – tu ale +/// přeinstalace přepíše a v Program Files do ní nejde psát. +/// /// public static class LogaResolver { - /// Složka s logy dodávanými s programem (vedle .exe). + /// Složka s logy dodávanými s programem (vedle .exe). Jen ke čtení – instalace ji přepisuje. public static string LogaDir => Path.Combine(AppContext.BaseDirectory, "loga"); + /// Složka pro loga, která si přidal uživatel. Přeinstalace na ni nesahá. + public static string UzivatelskaLogaDir => UzivatelskaData.Cesta("loga"); + + /// Najde soubor loga: absolutní cesta se bere, jak je, jinak se hledá nejdřív mezi + /// uživatelskými logy a pak mezi dodávanými. Vrací null, když soubor nikde není. + public static string? Najdi(string? soubor) + { + if (string.IsNullOrWhiteSpace(soubor)) return null; + if (Path.IsPathRooted(soubor)) return File.Exists(soubor) ? soubor : null; + + var uzivatelske = Path.Combine(UzivatelskaLogaDir, soubor); + if (File.Exists(uzivatelske)) return uzivatelske; + + var dodavane = Path.Combine(LogaDir, soubor); + return File.Exists(dodavane) ? dodavane : null; + } + + /// Jména dostupných log z obou složek dohromady, abecedně. Uživatelské logo stejného + /// jména dodávané nezdvojuje – v nabídce je jednou a použije se to uživatelovo. + public static IReadOnlyList DostupnaLoga() => SlozNabidku(UzivatelskaLogaDir, LogaDir); + + /// Vlastní slučování nabídky – s cestami v parametru, ať jde otestovat bez profilu uživatele. + internal static List SlozNabidku(string uzivatelskaDir, string dodavanaDir) + { + var jmena = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var dir in new[] { dodavanaDir, uzivatelskaDir }) + { + try + { + if (!Directory.Exists(dir)) continue; + foreach (var soubor in Directory.GetFiles(dir, "*.dxf")) + jmena.Add(Path.GetFileName(soubor)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Nedostupná složka (odpojený síťový disk apod.) nesmí shodit nabídku. + } + } + return jmena.ToList(); + } + /// Složka aktuálního souboru .rzp (pro relativní cestu k schématu); null = neuloženo. public static IReadOnlyDictionary Resolve(Projekt p, Priloha r, Ciselniky c, string? rzpDir) { @@ -40,9 +89,8 @@ public static class LogaResolver private static void PridejZeSlozky(IDictionary d, string hladina, string? soubor) { - if (string.IsNullOrWhiteSpace(soubor)) return; - var cesta = Path.IsPathRooted(soubor) ? soubor : Path.Combine(LogaDir, soubor); - if (File.Exists(cesta)) d[hladina] = cesta; + var cesta = Najdi(soubor); + if (cesta is not null) d[hladina] = cesta; } /// Cesta ke schématu: absolutní beze změny, jinak relativně ke složce .rzp. diff --git a/Rozpisky/ViewModels/MainViewModel.cs b/Rozpisky/ViewModels/MainViewModel.cs index faa7abd..e020911 100644 --- a/Rozpisky/ViewModels/MainViewModel.cs +++ b/Rozpisky/ViewModels/MainViewModel.cs @@ -215,20 +215,14 @@ public sealed class MainViewModel : ObservableObject public IReadOnlyDictionary ResolveLoga(Priloha r) => LogaResolver.Resolve(Projekt, r, Ciselniky, RzpSlozka); - /// Jména DXF souborů ve složce loga\ vedle .exe (prázdná položka = „žádné") – - /// nabídka pro výběr log financujících institucí v Údajích projektu. + /// Jména DXF log z obou složek loga\ – uživatelské i dodávané (prázdná položka + /// = „žádné") – nabídka pro výběr log financujících institucí v Údajích projektu. public IReadOnlyList DostupnaLoga { get { var list = new List { "" }; - try - { - if (Directory.Exists(LogaResolver.LogaDir)) - list.AddRange(Directory.GetFiles(LogaResolver.LogaDir, "*.dxf") - .Select(Path.GetFileName)!); - } - catch { /* nedostupná složka → jen prázdná položka */ } + list.AddRange(LogaResolver.DostupnaLoga()); return list; } } @@ -311,6 +305,9 @@ public sealed class MainViewModel : ObservableObject public ICommand ExportSeznamuCmd { get; } public ICommand OtevritNapoveduCmd { get; } public ICommand OtevritManualCmd { get; } + + /// Otevře v Průzkumníku složku, kam si uživatel dává vlastní loga. + public ICommand OtevritSlozkuLogCmd { get; } public ICommand OProgramuCmd { get; } public MainViewModel() @@ -407,6 +404,7 @@ public sealed class MainViewModel : ObservableObject ExportSeznamuCmd = new RelayCommand(ExportujSeznamDoXlsx, () => Prilohy.Count > 0); OtevritNapoveduCmd = new RelayCommand(OtevritNapovedu); OtevritManualCmd = new RelayCommand(OtevritManual); + OtevritSlozkuLogCmd = new RelayCommand(OtevritSlozkuLog); OProgramuCmd = new RelayCommand(OProgramu); SledujZmeny(); @@ -1399,4 +1397,21 @@ public sealed class MainViewModel : ObservableObject "Nápověda", MessageBoxButton.OK, MessageBoxImage.Error); } } + + /// Otevře v Průzkumníku složku pro uživatelská loga (a založí ji, když ještě není). + /// Vlastní loga patří sem, ne do složky programu – tu přeinstalace přepíše. + private void OtevritSlozkuLog() + { + var slozka = LogaResolver.UzivatelskaLogaDir; + try + { + Directory.CreateDirectory(slozka); + Process.Start(new ProcessStartInfo(slozka) { UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show("Složku s logy se nepodařilo otevřít:\n" + slozka + "\n\n" + ex.Message, + "Loga", MessageBoxButton.OK, MessageBoxImage.Error); + } + } } diff --git a/Rozpisky/Views/NastaveniView.xaml b/Rozpisky/Views/NastaveniView.xaml index 480bc8e..d204cfb 100644 --- a/Rozpisky/Views/NastaveniView.xaml +++ b/Rozpisky/Views/NastaveniView.xaml @@ -1,4 +1,4 @@ - + @@ -39,6 +40,14 @@ + + + +