|
|
-
Natknąłem się dziś na dość irytującą cechę klasy XCData, reprezentującej element CDATA w dokumencie XML. Otóż jeśli w poniższym kodzie
string xml = "<node><![CDATA[line1\r\nline2]]></node>"; XDocument doc = XDocument.Parse(xml); XCData data = doc.Element("node").FirstNode as XCData; string value = data.Value;
przeanalizujemy wartość zmiennej value, to okaże się, że nie zawiera ona, jak możnaby oczekiwać, łańcucha znaków line1\r\nline2 a line1\nline2. Okazuje się, że takie zachowanie jest jak najbardziej zgodne ze standardem XML, co można znaleźć tutaj.
Są dwie możliwości obejścia tego problemu. Można wymusić, by znaki \r nie zostały usuwane z całego dokumentu poprzez użycie XmlTextReadera:
string xml = "<node><![CDATA[line1\r\nline2]]></node>"; XDocument doc = XDocument.Parse(new XmlTextReader(new StringReader(xml)));
bądź skorzystanie z informacji zawartej w specyfikacji. Specyfikacja gwarantuje, że w zmiennej value nie znajdzie się znak \r, można więc zamienić wszystkie wystąpienia \n na \r\n.
Co ciekawe problem ten nie występuje w standardowych klasach do obsługi dokumentów XML (XmlDocument / XmlCDataSection).
|
-
Można by przypuszczać, że skoro wypuszczona w 2002 roku wersja Visual Studio ma „.net” w nazwie, to bazuje w większej części na technologii .NET. Cóż, tak naprawdę, przynajmniej jeśli chodzi o programowanie rozszerzeń środowiska, nazwa powinna mieć końcówkę .COM zamiast .NET. Bo Visual Studio .NET miało dotneta tylko w nazwie, a tak naprawdę cały model obiektowy funkcjonował,jak i poprzednich wersjach, na bazie interfejsów COM-owskich. Konsekwencją tego jest fakt, że środowisko nie jest tak intuicyjne jak powinno być. Ot chociażby interfejs użytkownika dla addinu: zarówno okienko z ustawieniami jak i okienko sterujące musiały być kontrolkami ActiveX.
Ironicznie, pierwsza edycja nowego Visual Studio bez dotneta w nazwie, Visual Studio 2005, zaczęła być tą naprawdę dotnetową. A przynajmniej pod względem projektowania interfejsu użytkownika dla addinów. Koniec z modyfikowaniem rejestru, koniec z kontrolkami ActiveX / od tej pory używamy plików XML i natywnych kontrolek wyprowadzanych z System.Windows.Forms.Control. Ale nie koniec niestety z COM-em. W każdej chwili wyskoczyć może COMException, a wszystkie praktycznie interfejsy, które będziemy używać to interfejsy COM-owskie. Przypominam, że warunkiem widzialności addina dla Visual Studio było opakowanie assembly atrybutem ComVisible. Polecam się też przyjrzeć w jaki sposób ustawiany był obrazek na pasku narzędzi. Tak, tak, IPictureDisp to bynajmniej nie jest czysto dotnetowski interfejs.
Ale dość tego już nieco przydługawego wstępu, wracajmy do naszego addina. Zanim jednak zacznę opisywać projektowanie interfejsu użytkownika – mała uwaga. Zdaję sobie sprawę, że tak naprawdę kolejność projektowania aplikacji jest odwrotna – czyli najpierw projektuje się funkcjonalność a potem interfejs, ale opisując technologię chcę umożliwić czytelnikom jak najszybsze przejście do własnych prób, a w tym wypadku szeroko rozumiana funkcjonalność addinu jest chyba najmniej interesująca.
Interfejs użytkownika najwygodniej upakować jest w okienku narzędziowym, takim w jakim oferowane są standardowe narzędzia VS, jak Solution Explorer, Class View czy Server Explorer. Samo stworzenie takiego okienka nie jest trudne. Po pierwsze, tworzymy nową kontrolkę, wyprowadzoną z UserControl i umieszczamy na niej potrzebne nam akcesoria:

Dla wygody i logicznego podziału addinu wszystkie elementy interfejsu użytkownika umieścimy w oddzielnej bibliotece, ZineVersion.UI.dll. Nie muszę chyba dodawać, że w opcjach projektu należy zaznaczyć haczyk przy „Register for COM Interop". Dodatkowo w przypadku tej kontrolki bardzo ważne jest by jej klasa posiadała atrybut ComVisible z wartością true. Po co – o tym za chwilę. Do projektu dodać musimy jeszcze odnośniki do EnvDTE.dll i EnvDTE80.dll oraz zdefiniować właściwości ToolWindow i DTE, gdzie będziemy przechowywać odpowiednio informacje o hoście kontrolki i środowisku:
public Window2 ToolWindow { get; set; } public DTE2 DTE { get; set; }
Aby wyświetlić właśnie zdefiniowaną kontrolke musimy wrócić do klasy Connect i jej metody Exec(). Zaimplementowanego tam beepa zastępujemy następującym kodem:
if (CmdName == "ZineVersion.Connect.ZineTool") { if (ctl != null) { ctl.ToolWindow.Visible = true; Handled = true; } else { Window2 wnd = null; object refObj = null; string assemblyLocation = Assembly.GetCallingAssembly().Location; string currentDir = Path.GetDirectoryName(assemblyLocation);
try { Windows2 wnds = (Windows2)(_applicationObject.Windows); wnd = wnds.CreateToolWindow2( _addInInstance, Path.Combine(currentDir, "ZineVersion.UI.dll"), "ZineVersion.UI.ZineWindowCtl", "Zine Version", ZineGuid.ToString("B"), ref refObj) as Window2; ctl = refObj as IZineVersionCtl; ctl.ToolWindow = wnd; ctl.DTE = _applicationObject; wnd.Visible = true; Handled = true; } catch (Exception ex) { Debug.Write(ex.Message); } } }
Dodatkowo w nagłówku będziemy potrzebować kilku deklaracji:
private Guid ZineGuid = new Guid("{6E20BA3F-D4DC-4a48-A219-72BB8BB4BEEE}"); private IZineVersionCtl ctl;
Krótkie omówienie tego, co się powyżej dzieje. Aktywując addin mamy do czynienia z dwoma mozliwymi sytuacjami:
- okienko addinu było już wcześniej otwarte i teraz jest zamknięte – wystarczy je więc ponownie pokazać
- otwieramy okienko poraz pierwszy dla tej instancji VS
W drugim przypadku musimy utworzyć najpierw okienko hostujące kontrolkę – robimy to używając metody CreateToolWindow2(). Różni się ona od obecnej w poprzednich wersjach Visual Studio metody CreateToolWindow() tym, że poprawnie obsługuje hostowanie kontrolek .NET – poprzednia wymagała kontrolki ActiveX.
Ponieważ kontrolka znajduje się w innym pliku, wymagane jest podanie ścieżki do niego. Jeśli klasa kontrolki nie zostałaby oznaczona jako ComVisible, to obiekt reprezentujący kontrolkę nie zostałby zwrócony – wartość refObj będzie równa NULL. Stąd też moja wcześniejsza uwaga na temat tego atrybutu. Klucz ZineGuid jest używany do identyfikacji kontrolki w VS i może mieć dowolną wartość. Co ciekawe, jest on przekazywany jako łańcuch znaków i to łańcuch w konkretnym formacie – próba użycia wyniku standardowego wywołania ToString() skończy się mało mówiącym wyjątkiem COM Exception.
Zwracany obiekt refObj to instancja nowoutworzonej kontrolki. Jeśli z jakiegoś powodu nie chcemy dodawać odwołania do System.Windows.Forms.dll do biblioteki addinu można interesującą nas funkcjonalność opakować w interfejs, tak jak zostało to zaprezentowane powyżej. Po wszystkim pozostaje jedynie ustawić atrybut Visible utworzonej kontrolki na true i cieszyć się nowym okienkiem w systemie, które możemy sobie zadokować np. przy Solution Explorerze. Tytuł okienka pojawi się wówczas w zakładce pod okienkiem, obok mało interesującego obrazka. Aby zmienić ten obrazek należy użyć metody SetTabPicture(). Oczekuje ona bitmapy w formacie IPicture. Co ciekawe format ten nie obsługuje przezroczystości, więc jakiś kolor musi zostać ustalony jako przezroczysty. Jaki? Oto jest pytanie...
W wersji 2002/2003 był to kolor (0, 254, 0). W wersji 2005 i 2008 do RC były to kolory (254, 0, 254) i (255, 0, 255). W wersji finalnej żaden z nich nie działa i nikt nie wie dlaczego. Osobiście wypróbowałem kolor biały i stwierdziłem że nieźle zlewa się on z tłem zakładki.
... ctl.DTE = _applicationObject; wnd.SetTabPicture(GetTabPicture()); wnd.Visible = true; ...
private object GetTabPicture() { Bitmap img = Properties.Resources.BitmapVersionTool; Color transparent = Color.FromArgb(0, 255, 255, 255);
if (_applicationObject.Version == "8.0") transparent = Color.FromArgb(254, 0, 254);
for (int x = 0; x < img.Width; x++) for (int y = 0; y < img.Height; y++) if (img.GetPixel(x, y) == Color.FromArgb(192, 192, 192)) img.SetPixel(x, y, transparent);
stdole.IPicture ret = Support.ImageToIPicture(img) as stdole.IPicture; return ret; }
W powyższym kodzie zastępuję domyślny kolor tła (szary) kolorem białym. Uwaga: żeby trick zadziałał, plik musi być 24-bitową bitmapą.
Update
Zgodnie z sugestią Wojtka wypróbowałem, bez przekonania przyznaję, metodę MakeTransparent(). Jakie było moje zdziwienie, gdy okazało się, że metoda działa możecie sobie sami wyobrazić. Co ciekawe, zostało to najprawdopodobniej zmienione w wersji RTM Visual Studio 2008, bo wersja RC (nie wspominając już VS 2005) potrzebowała jeszcze konkretnego koloru i wywołanie metody MakeTransparent() nie przynosiło żadnego skutku.
Tak więc nasza metoda GetTabPicture() może zostać nieco zmodyfikowana:
private object GetTabPicture() { Bitmap img = Properties.Resources.BitmapVersionTool; Color transparent = Color.FromArgb(0, 254, 0, 254);
if (_applicationObject.Version == "8.0") { for (int x = 0; x < img.Width; x++) for (int y = 0; y < img.Height; y++) if (img.GetPixel(x, y) == Color.FromArgb(192, 192, 192)) img.SetPixel(x, y, transparent); } else img.MakeTransparent(Color.FromArgb(192, 192, 192));
stdole.IPicture ret = Support.ImageToIPicture(img) as stdole.IPicture; return ret; }
Mógłbym tutaj jeszcze na koniec dodać, że najprostsze rozwiązania są najmniej oczywiste...
c.d.n.
|
-
Jakiś czas temu, w pierwszym numerze zine, popełniłem artykuł traktujący o sprawdzaniu czy dana wartość reprezentowana poprzez łańcuch znaków konwertuje się do interesując ego mnie typu danych. W długim na półtorej strony artykule przedstawiłem skomplikowaną metodę bazującą na refleksji i szukaniu odpowiednich wersji metody Parse(). Całkiem niedawno okazało się, że wyważałem już dawno otwarte drzwi. Całą pracę załatwia tutaj TypeConverter.
TypeConverterAttribute jest atrybutem na poziomie klasy definiującym dla niej konwerter typów. Najważniejsze metody konwertera to ConvertTo(), ConvertFrom(), CanConvertTo() i CanConvertFrom(). Sam konwerter dla danego typu otrzymuje się za pomocą klasy TypeDescriptor w następujący sposób:
TypeConverter c = TypeDescriptor.GetConverter(type);
Prosto i elegancko. Ale trzeba najpierw o tym wiedzieć. Oczywiście wszystkie typy podstawowe mają zdefiniowane konwertery typów i nie ma sensu próbować wykonywać konwersji ręcznie.
|
-
Troch ę się ostatnio bawiłem z Visual Studio 2008 i wersją 3.0 języka C#. A i w tym najbardziej reklamowanymi nowościami: LINQ i wyrażeniami lambda. I muszę powiedzieć, że wrażenia są bardzo, a to bardzo pozytywne.
Mówiąc szczerze na początku nieco się obawiałem, czy będę w stanie przekonać się do składni labda expressions, a samo ich wprowadzenie wydawało mi się pewnym rodzaju udziwnieniem. Ale wystarczyły mi dwa dni pracy z LINQ To Objects (czyli m.in. rozszerzeniem interfejsu IEnumerable<T>) i składnię opanowałem w stopniu conajmniej zadowalającym. I teraz zamiast:
string found = null; foreach (string el in lista) { if (el.StartsWith("ABC")) { found = el; break; } }
piszę po prostu:
string found = lista.FirstOrDefault(el => el.StartsWith("ABC"));
O Statement Lambdas i ich zastosowaniu napiszę jeszcze kiedyś, bo to większy temat. A żeby było coś jeszcze o LINQ to mój faworyt: iteracja obiektów (nie wierszy!) zaznaczonych w gridzie posiadając ych niepustą interesującą mnie wartość:
foreach (IProjectInfo project in from DataGridViewRow p in dgProjects.SelectedRows let prj = p.DataBoundItem as IProjectInfo where !String.IsNullOrEmpty(prj.Version) select prj) { DoAction(prj);
Może nie wszyscy posiadający VS 2008 to wiedzą, ale środowisko zawiera świetny tutorial dla LINQ – polecam rozpakować sobie plik <Program Files>\Microsoft Visual Studio 9.0\Samples\1033\CSharpSamples.zip i skompilować projekt LinqSamples.sln. Naprawdę warto!
|
-
Visual Studio oferuje kilka sposobów rozszerzenia własnej funkcjonalności. Często wykonywane polecenia można nagrać sobie w postaci makra. Akcje wykonywane w ramach projektu można zamknąć w postaci asystenta (bądź Wizarda jak kto woli). Bardziej skomplikowane polecenia i akcje można natomiat zaimplementować w addinie. I o tym jak stworzyć własnego addina chciałbym Wam napisać w krótkiej serii artykułów. Sposób tworzenia i konfiguracji Addinów zmienił sie między wersjami 7.1 i 8.0 środowiska Visual Studio. Ja tu skupię się na stworzeniu addina dla Visual Studio 2005/2008, jeśli jednak będzie takie zapotrzebowanie mogę pokrótce przedstawić też wersję dla środowiska Visual Studio .NET. Addin przeze mnie opisywany będzie służyć konkretnemu celowi: zmianie wersji projektu napisanego w C++. Informacja o wersji może być zapisana albo w odpowiednim pliku *.rc albo w pliku nagłówkowym o nazwie zapisanej w opcjach środowiska. Zmiana wersji może nastąpić albo poprzez akcję użytkownika wewnątrz środowiska, albo automatycznie, podczas udanej kompilacji odpowiedniej konfiguracji projektu. Oczywiście część wizualna addinu powinna umożliwiać zmianę wersji więcej niż jednego projektu na raz.
Raz, dwa, trzy, zaczynamy... Są dwa podejścia utworzenia nowego addina. Pierwsze polega na wybraniu odpowiedniego typu nowego projektu:  Drugie, nazywane przeze mnie „podejściem Ch. Petzolda” (kto zna jego książki z pewnością odgadnie dlaczego), polega na stworzeniu addina od zera, zaczynając od pustego projektu typu Class Library. I to podejście wykorzystam, gdyż pozwoli mi ono opisać dokładnie strukturę addina. Do utworzonego, pustego Class Library należy najpierw dodać referencje do kilku plików:
- EnvDTE
- EnvDTE80
- EnvDTE90 (jeśli zamierzamy korzystać z obiektów specyficznych tylko dla VS 2008)
- Extensibility
- Microsoft.VisualStudio.CommandBars
- stdole
Będą one potrzebne zarówno do implementacji addinu jak i interakcji ze środowiskiem VS. Podstawowa klasa addina, stanowiąca łącze z VS, nazywa się zazwyczaj Connect i musi implementować interfejsy IDTExtensibility2 i IDTCommandTarget. Niech więc tak nazywa się pierwszy plik nowoutworzonego projektu: using System; using Extensibility; using EnvDTE; namespace ZineVersion { public class Connect : IDTExtensibility2, IDTCommandTarget { public Connect() { } #region IDTExtensibility2 Members public void OnAddInsUpdate(ref Array custom) { } public void OnBeginShutdown(ref Array custom) { } public void OnConnection(object Application, ext_ConnectMode ConnectMode, object AddInInst, ref Array custom) { } public void OnDisconnection(ext_DisconnectMode RemoveMode, ref Array custom) { } public void OnStartupComplete(ref Array custom) { } #endregion #region IDTCommandTarget Members public void QueryStatus(string CmdName, vsCommandStatusTextWanted NeededText, ref vsCommandStatus StatusOption, ref object CommandText) { } public void Exec(string CmdName, vsCommandExecOption ExecuteOption, ref object VariantIn, ref object VariantOut, ref bool Handled) { } #endregion } }Pokrótce omówię poszczególne metody interfejsu IDTExtensiibility2:
- OnAddInsUpdate() jest używana do zaprogramowania zależności od innych addinów. Przykładowo piszemy dwa addiny A1 i A2 i definiujemy sobie, że tylko jeden z nich może być aktywny. Implementujemy więc metodę OnAddInsUpdate i monitorujemy sobie zmiany zbioru aktywnych addinów;
- OnBeginShutdown() jest uruchamiana wraz z zamykaniem środowiska. Można ją wykorzystać, by zwolnić zasoby używane przez addin;
- OnConnection() jest uruchamiana gdy addin jest ładowany do VS. Na podstawie parametru ConnectMode można ustalić w jakim kontekście addin jest ładowany. Tej metodzie przyjrzymy się bliżej w dalszym ciągu artykułu;
- OnDisconnection() jest uruchamiana wraz z usunięciem addina ze środowiska. Podobnie do OnBeginShutdown() można ją wykorzystać do zwolnienia zasobów używanych przez addin;
- OnStartupComplete() jest uruchamiana wówczas, gdy uruchomione zostanie środowisko Visual Studio. Metoda ta zostanie oczywiście uruchomiona tylko w przypadku addinów, które ładują się razem ze środowiskiem.
Zazwyczaj podczas rejestracji addinu w systemie jest on powiązywany z poleceniem VS – istniejącym bądź nowym, definiowanym tylko i wyłącznie na potrzeby modułu. Obsługą tego polecenia zajmują się metody interfejsu IDTCommandTarget. QueryStatus() określa status polecenia w danym momencie. Exec() jest używane do implementacji samego polecenia – tą metodą też zajmiemy się za chwilę.
No to implementujemy...
W pierwszej kolejności implementujemy metodę OnConnection(). Jak już wspomniałem wyżej, jest ona czymś w rodzaju entry point addina. Poprzez jej parametry otrzymujemy referencje do obiektu reprezentującego instancję Visual Studio jak i obiektu reprezentującego instancję addinu:
private DTE2 _applicationObject; private AddIn _addInInstance; public void OnConnection(object Application, ext_ConnectMode ConnectMode, object AddInInst, ref Array custom) { _applicationObject = (DTE2)Application; _addInInstance = (AddIn)AddInInst;
Teraz należy właściwie zinterpretować wartość parametru ConnectMode. W tej chwili istotna dla nas będzie wartość ext_ConnectMode.ext_cm_UISetup. Metoda OnConnection() uruchamiana jest z tą wartością tylko wtedy, gdy addin jest konfugurowany w VS, np. przy pierwszym uruchomieniu po instalacji. To co w tym momencie należałoby wykonać, to utworzenie nowego polecenia uruchamiającego nasz kod oraz powiązanie go z menu lub paskiem narzędzi:
// Instalacja addina if (ConnectMode == ext_ConnectMode.ext_cm_UISetup) { object[] contextUIGUIDs = new object[] { }; Commands2 commands = (Commands2)_applicationObject.Commands; try { // Utworz polecenie Command command = commands.AddNamedCommand2( _addInInstance, "ZineTool", "", "Uruchamia ZineTool", true, 59, ref contextUIGUIDs, (int)vsCommandStatus.vsCommandStatusEnabled+ (int)vsCommandStatus.vsCommandStatusSupported, (int)vsCommandStyle.vsCommandStylePict, vsCommandControlType.vsCommandControlTypeButton); // Teraz znajdz odpowiedni pasek narzedzi CommandBar commandBar; CommandBars bars = (CommandBars)_applicationObject.CommandBars; try { commandBar = bars["ZineBar"] as CommandBar; } catch (ArgumentException) { // Pasek narzedzi nie istnieje. Wiec go utworzmy. commandBar = commandBars.Add("ZineBar", 1, false, false); } // Dodaj kontrolke do p.aska CommandBarButton button = command.AddControl(commandBar, 1) as CommandBarButton; // I zmien jej obrazek button.Picture = Support.ImageToIPictureDisp( Properties.Resources.BitmapVersionTool) as stdole.StdPicture; button.Mask = Support.ImageToIPictureDisp( Properties.Resources.BitmapVersionToolMask) as stdole.StdPicture; } catch (ArgumentException) { // Jesli jestesmy tutaj, to najpewniej komenda juz istnieje } }
Stworzyliśmy więc nowy pasek narzędzi, umieściliśmy na nim przycisk i powiązaliśmy go z poleceniem (uwaga!) ZineVersion.Connect.ZineTool. To co nam w tym momencie zostało, to implementacja kodu tego polecenia. Tę wykonujemy w funkcji Exec():
public void Exec(string CmdName, vsCommandExecOption ExecuteOption, ref object VariantIn, ref object VariantOut, ref bool Handled) { Handled = false; if (ExecuteOption == vsCommandExecOption.vsCommandExecOptionDoDefault) { if (CmdName == "ZineVersion.Connect.ZineTool") { Console.Beep(); Handled = true; } } }
Na koniec, w metodzie QueryStatus(), określamy status utworzonego przez nas polecenia. Bez implementacji tej metody wyżej zdefiniowane polecenie dałoby się uruchomić tylko jeden raz.
public void QueryStatus(string CmdName, vsCommandStatusTextWanted NeededText, ref vsCommandStatus StatusOption, ref object CommandText) { if (NeededText == vsCommandStatusTextWanted.vsCommandStatusTextWantedNone) if (CmdName == "ZineVersion.Connect.ZineTool") StatusOption = (vsCommandStatus)vsCommandStatus.vsCommandStatusSupported | vsCommandStatus.vsCommandStatusEnabled; }
I to prawie wszystko, tylko...
Jak to teraz uruchomić???Instalacja addinów już w środowisku Visual Studio 2005 stała się bardzo łatwa (w porównaniu do poprzedniej wersji) i nie zmieniło się to w wersji obecnej. Polega ona na spreparowaniu odowiedniego pliku o rozszerzeniu .AddIn, wprowadzeniu do niego informacji o addinie i umieszczeniu go w katalogu <Moje Dokumenty>\Visual Studio 2008\Addins. Jeśli katalog nie istnieje, to należy go utworzyć. Przykładowy plik dla naszego addinu przedstawiam poniżej: <?xml version="1.0" encoding="utf-8" standalone="no"?> <Extensibility xmlns="http://schemas.microsoft.com/AutomationExtensibility"> <HostApplication> <!-- Pozwol uruchomic addina w VS 2005 --> <Name>Microsoft Visual Studio</Name> <Version>8.0</Version> </HostApplication> <HostApplication> <!-- Pozwol uruchomic addina w VS 2008 --> <Name>Microsoft Visual Studio</Name> <Version>9.0</Version> </HostApplication> <Addin> <FriendlyName>ZineVersion</FriendlyName> <Description>ZineVersion - narzedzie do zarzadzania wersjami projektow </Description> <Assembly>e:\Private\Zine\ZineVersion\ZineVersion\bin\Debug\ZineVersion.dll </Assembly> <FullClassName>ZineVersion.Connect</FullClassName> <CommandPreload>1</CommandPreload> <CommandLineSafe>1</CommandLineSafe> <LoadBehavior>0</LoadBehavior> </Addin> </Extensibility>Poza tym docelowa assembly z addinem musi posiadać atrybut ComVisible. To wystarczy aby addin bezproblemowo ładował się wraz z uruchomieniem środowiska Visual Studio. Debugging addina jest też prosty. W tym celu w zakładce Debug ustawiamy Start Action na Start external program i wybieramy ścieżkę do pliku startowego Visual Studio. Następnie polecam zapisanie konfiguracji i skopiowanie jej jako Debug Reset. W tej skopiowanej dodajemy parametr startowy „/resetaddin ZineVersion.Connect”. Umożliwi nam to sterowanie kodem uruchamianym przy przyłączaniu addina – Visual Studio uruchomione z tym parametrem zachowa się tak jak przy pierwszym uruchomieniu addina i wywoła metodę OnConnection z parametrem ConnectMode = UISetup. I to wszystko. Po uruchomieniu nowej instancji Visual Studio konieczne może być dostosowanie wyświetlanych pasków narzędzi i aktywacja ZineBar. Po kliknięciu znajdującego się tam przycisku usłyszymy krótkie beep. W drugiej części (jeśli chcecie, żeby druga część powstała) opiszę jak do addinu dodać interfejs użytkownika – okienko narzędziowe, okienko opcji oraz jak porozumiewać się z użytkownikiem.
|
-
W mojej ostatniej notce pisałem o zachowywaniu historii danych za pomocą pól tabeli ValidFrom i ValidTo. Taki sposób zarządzania danymi wymaga oczywiście przedefiniowania operacji INSERT, UPDATE i DELETE. Tutaj chciałbym przedstawić pewien problem związany taką aktualizacją danych poprzez DataSet. Tym razem struktura danych jest prostsza, mianowicie mamy relację 1:n (oczywiście dla relacji m:n występuje ten sam problem): Nie wchodząc w szczegóły: klucze w tabelach są zdefiniowane jako wartości unikalne, generowane automatycznie, pola ValidFrom mają ustawioną wartość domyślną 01.01.1900, pola ValidTo analogicznie 31.12.9999. Dla tej struktury wygenerowałem sobie w Visual Studio 2005 dataset i zdefiniowałem dla tabel operacje SELECT, INSERT, UPDATE i DELETE. Typ relacji w datasecie ustawiłem na „FK & Relation” i włączyłem kaskadowego update’a. Kod dołączyłem do tekstu, tutaj pokaże tylko, najistotniejszą dla dalszej części, implementację operacji UPDATE dla tabeli ParentTable: ad.UpdateCommand = new SqlCommand( @"UPDATE ParentTable SET ValidTo=@versionTime WHERE Id=@id INSERT INTO ParentTable (Value, ValidFrom) VALUES (@value, @versionTime) SELECT ID, Value, ValidFrom, ValidTo FROM ParentTable WHERE ID=@@IDENTITY", cnn, tr); ad.UpdateCommand.Parameters.Add("@value", System.Data.SqlDbType.NVarChar, 50, "Value"); ad.UpdateCommand.Parameters.Add("@id", System.Data.SqlDbType.Int, 4, "ID"); ad.UpdateCommand.Parameters.AddWithValue("@versionTime", VersionTime);
Testy przeprowadziłem na tabeli Parent zawierającej jeden wiersz o wartości ID=1 powiązany z jednym wierszem w tabeli Child, także o ID=1. Test polegał na zmianie wartości Value wiersza tabeli Parent, dodaniu to tej tabeli nowego wiersza i aktualizacji danych. Po zmodyfikowaniu danych próba wykonania operacji UPDATE na pierwszym wierszu kończy się wyjątkiem ConstraintException i komunikatem, że wiersz o ID=2 już istnieje w tabeli. Skąd tez identyfikator? A stąd, że w ramach aktualizacji danych został wpisany do bazy nowy wiersz, ze zmienioną wartością Value i nowym identyfikatorem. Kończąca operację instrukcja SELECT pobiera właśnie ten nowy wiersz powodując wewnętrzny konflikt w datasecie. Mądry Exception Helper w Visual Studio proponuje w tym momencie wyłączenie sprawdzania integralności danych na czas ich aktualizacji. Jeśli jednak to zrobimy, to efekty będą conajmniej... zaskakujące: Czytam dane: 1 Parent1 01.01.1900 00:00:00 - 01.01.9999 00:00:00 1(1) Child1 01.01.1900 00:00:00 - 01.01.9999 00:00:00 Modyfikuje dataset 1 Parent1* 01.01.1900 00:00:00 - 01.01.9999 00:00:00 1(1) Child1 01.01.1900 00:00:00 - 01.01.9999 00:00:00 2 Parent2 01.01.1900 00:00:00 - 01.01.9999 00:00:00 Zapisuje dane do bazy 2 Parent1* 24.01.2008 14:23:29 - 01.01.9999 00:00:00 3 Parent2 01.01.1900 00:00:00 - 01.01.9999 00:00:00 2(3) Child1 24.01.2008 14:23:29 - 01.01.9999 00:00:00 Czytam dane: 2 Parent1* 24.01.2008 14:23:29 - 01.01.9999 00:00:00 3 Parent2 24.01.2008 14:23:29 - 01.01.9999 00:00:00 2(3) Child1 24.01.2008 14:23:29 - 01.01.9999 00:00:00 1 Parent1 01.01.1900 00:00:00 - 24.01.2008 14:23:29 1(1) Child1 01.01.1900 00:00:00 - 24.01.2008 14:23:29 Pierwszy blok to dane początkowe, drugi zmodyfikowane. Trzeci to stan po wykonaniu synchronizacji danych, czwarty to cała baza. Jak widać, wiersz w tabeli podrzędnej zmienił swojego „ojca”! Jak? Wiersze w tabeli Parent przetwarzene są sekwencyjnie, w takiej kolejności w jakiej są zapisane w tabeli. Kaskadowy update dla relacji oznacza, że każda zmiana wartości ID zostanie wprowadzona automatycznie w tabeli Child. Wiersze zostaną więc zmieniowe w następującej sekwencji: - W wyniku operacji UPDATE w pierwszym wierszu tabeli Parent ID=1 zostanie zmienione na ID=2
- W wyniku kaskadowego update w tabeli Child wszystkie rekordy z IDParent=1 zostaną zmienione na IDParent=2
- W wyniku operacji INSERT dla drugiego wiersza tabeli Parent jego identyfikator ID=2 zostanie zmieniony na ID=3
- W wyniku kaskadowego update w tabeli Child wszystkie rekordy z IDParent=2 zostaną zmienione na IDParent=3
I tu jest pies pogrzebany... Rozwiązanie problemu jest bajecznie proste i co ciekawe zostało zaimplementowane w DataSet Designerze w VS 2008. Otoż wystarczy zmienić wartości dwóch właściwości dla kolumny ID w datasecie. AutoIncrementSeed zmienić z 0 na -1 a AutoIncrementStep z 1 ma -1. Mamy w tym momencie gwarantowane, że ID datasetu i bazy nigdy się nie pokryją. A wniosek z tego wszystkiego taki: nie wyłączajmy sprawdzania integralności datasetu jeśli naprawdę tego nie potrzebujemy.
|
-
Czasami stosowany model danych wymaga, by nieaktualne rekordy nie zostawały usuwane z bazy danych, a zostały zachowane do późniejszej analizy. Zazwyczaj rozwiązuje się ten problem poprzez wersjonowanie rekordów. Są różne metody zachowywanie informacji o wersji, ja przedstawię tutaj metodę polegającą na rozszerzeniu tabeli o dwa pola określające okres ważności rekordu: ValidFrom i ValidTo. Uwaga: w całym tekście stosuję założenie, że wszystkie operacje na danych odbywają się po stronie klienta. Oznacza to, że nie mogę stosować ani procedur ani triggerów. Ot takie dodatkowe utrudnienie, ale nic na to nie poradzę... Wszystkie klucze są elementami IDENTITY i są generowane automatycznie po stronie serwera.   Dla tak zdefiniowanej tabeli opreacje SELECT, INSERT, UPDATE i DELETE są zdefiniowane nieco inaczej niż w przypadku standardowym, poniżej przedstawiam odpowiedni kod: private SqlDataAdapter GetAdapterTable1(DateTime version) { SqlDataAdapter ad = new SqlDataAdapter(); SqlCommand cmdSelect = new SqlCommand( @"SELECT Id, Name, ValidFrom, ValidTo FROM Table1 WHERE @versionTime BETWEEN ValidFrom AND ValidTo", cnn); cmdSelect.Parameters.AddWithValue("@versionTime", version); SqlCommand cmdInsert = new SqlCommand( @"INSERT INTO Table1(Name, ValidFrom, ValidTo) VALUES (@Name, @ValidFrom) SELECT @Id=@@IDENTITY", cnn); cmdInsert.Parameters.Add("@Id", SqlDbType.Int, 4, "Id"); cmdInsert.Parameters.Add("@Name", SqlDbType.VarChar, 50, "Name"); cmdInsert.Parameters.AddWithValue("@ValidFrom", version); SqlCommand cmdDelete = new SqlCommand( @"UPDATE Table1 SET ValidTo=@ValidTo WHERE Id=@Id", cnn); cmdDelete.Parameters.Add("@Id", SqlDbType.Int, 4, "Id"); cmdDelete.Parameters.AddWithValue("@ValidTo", version); SqlCommand cmdUpdate = new SqlCommand( @"UPDATE Table1 SET ValidTo=@ValidTo WHERE Id=@Id INSERT INTO Table1(Name, ValidFrom) VALUES (@Name, @ValidFrom) SELECT Id, Name, ValidFrom, ValidTo FROM Table1 WHERE Id=SCOPE_IDENTITY()", cnn); cmdUpdate.Parameters.Add("@Id", SqlDbType.Int, 4, "Id"); cmdUpdate.Parameters.Add("@Name", SqlDbType.VarChar, 50, "Name"); cmdUpdate.Parameters.AddWithValue("@ValidFrom", version); cmdUpdate.Parameters.AddWithValue("@ValidTo", version); ad.SelectCommand = cmdSelect; ad.InsertCommand = cmdInsert; ad.UpdateCommand = cmdUpdate; ad.DeleteCommand = cmdDelete; return ad; }SELECT i INSERT chyba nie wymagają komentarza, DELETE polega tylko na zmianie daty ważności rekordu, UPDATE to skombinowane DELETE/INSERT/SELECT. W kodzie korzystam z tego, że w tabeli ustawione są predefiniowane wartości dla pól określających ważność rekordu. Tak mimochodem wspomnę jeszcze (a’ propos tekstu Pawła), że użycie metody AddWithValue nie jest w tym miejscu „niebezpieczne”, jako że parametr version zostanie przez SQL Server przy kompilacji planu wykonania przedstawiony zawsze jako @version datetime. Ale miało być o relacjach, prawda? No więc co się dzieje, jeśli nasz schemat nieco skomplikujemy:  Pierwsza sprawa: właściwości relacji należy tak skonfigurować, by oprócz relacji definiowany był również klucz obcy, a to dlatego, że tylko dla klucza można wymusić kaskadową aktualizację danych – dla reguły Update. Kiedy skorzystamy z kaskadowej aktualizacji? Przy wstawianiu nowego wiersza z tabeli Table1 bądź Table2 – po wstawieniu zawartość kolumny ID odpowiedniego wiersza zostanie zaktualizowana i rozpropagowana do odpowiednich pozycji w tabeli Relation. Pozostaje przypadek najtrudniejszy do zrealizowania – mianowicie UPDATE. Zakładając, że aktualizowane są tylko dane w tabeli, zachowane pomiędzy wersjami muszą zostać informacje o strukturze relacji. Innymi słowy update Table1 lub Table2 wiąże się ze zduplikowaniem odpowiednich pozycji w tabeli Relation i wstawieniem tam odpowiednich wartości ID. Zadanie wydawałoby się nietrywialne ale wykonywane praktycznie automatycznie. Po operacji UPDATE zdefiniowanej jak wyżej stary rekord „znika” z tabeli a pojawia się nowy, z poprawionym ID. Co ciekawe, dla wiersza jest to traktowane jako operacja UPDATE, więc odpowiedni wiersz w tabeli relacji zostaje także automatycznie zaktualizowany. I tu kryje się cały trick aktualizacji danych: dla synchronizacji tabeli relacji definiujemy tylko dwie komendy (nie definiujemy DELETE bo z definicji nie chcemy tracić informacji), ale obie składniowo są identyczne, tj. zawierają komendę INSERT: SqlCommand cmdUpdate = new SqlCommand( @"INSERT INTO Relation([OldId], [NewId]) VALUES (@OldId, @NewId) SELECT [Id], [OldId], [NewId] FROM Relation WHERE Id=@@IDENTITY", cnn);W ten sposób stare wiersze określające strukturę relacji w bazie nie zostaną nadpisane i będzie do nich dostęp kiedy przyjdzie taka potrzeba. Nie opisuję tutaj jakiegoś konkretnego systemu, ale mam nadzieję, że powyższe rozważania mogą okazać się pomocne dla kogoś, kto będzie potrzebował stworzyć własny system wersjonowania danych w bazie.
|
-
Natknąłem się dzisiaj na dziwny problem w mechanizmie DataBinding. Problem ten dał się zredukować do prostego przykładu, który przedstawię poniżej.
Na początek definiuję sobie strukturę danych złożoną z dwóch tabel, jednej nadrzędnej i jednej podrzędnej: 
Te tabele w jakiś sposób wypełniam sobie danymi: 
Po czym tworzę aplikację do prezentacji i zmiany tych danych: 
Tutaj kilka słów o ustawieniach łącz dla kontrolek. I tak bindingSource1 jest podłączona do data1.Parent, parentChildBindingSource zostało utworzone automatycznie i reprezentuje relację z Rys. 1. ListBox po lewej pobiera dane z bindingSource1 i wyświetla wartość właściwości Name, TextBox analogicznie wartość właściwości Description. Kod aplikacji jest dołączony do postu do ściągnięcia i przetestowania. Wszystko działa (teoretycznie) tak jak powinno: 
Zgodnie z dokumentacją, jeśli spróbujemy zmienić jedną z wartości na tym formularzu, to odpowiedni wiersz zmieni swój status z Unchanged na Modified. Informacja o stanie może zostać później użyta na przykład przy zapisywaniu zawartości datasetu z powrotem do bazy. Stąd olbrzymie moje zaskoczenie, kiedy to okazało się, że nie ma problemu w zmianie wartości pól tabeli podrzędnej (w gridzie), ale zmiana wartości Description tabeli nadrzędnej w TextBoxie powoduje zmianę odpowiedniej wartości w wierszu, ale nie(!) powoduje zmiany stanu wiersza.

Jak widać, wiersze z DataGridView zmieniły swój stan na Modified, nawet bez zmiany aktualnego wiersza (drugi wiersz), ale choć Description w aktywnym wierszu tabeli głównej został zmieniony, to zmiana ta nie została uwzględniona w opisie stanu wiersza, co oznacza, że zmiany te nie zostaną później zapisane do bazy. Pomaga zmiana wiersza tabeli głównej i powrót do edytowane wiersza, ale co robić, gdy mamy tylko jeden wiersz w tabeli głównej?
W powyższym przykładzie pomogła implementacja procedury obsługi zdarzenia TextChanged: 
Bezpośrednie przypisanie wartości do właściwości Description powoduje zmianę stanu wiersza na Modified. Ale czy takie rzeczy nie powinny się dziać automatycznie?
|
-
Jedną z właściwości SQL Servera jest collation (nie pytajcie mnie o polskie określenie, bo takowego nie znam, a przecież nie spolszczę tego słówka na kolację). Żeby nieco przybliżyć sens używania i definiowania właściwej wartości collation, pozwolę sobie zacytować odpowiedni fragment Books Online:
| Collations specify the rules for how strings of character data are sorted and compared, based on the norms of particular languages and locales. For example, in an ORDER BY clause, an English speaker would expect the character string 'Chiapas' to come before 'Colima' in ascending order. But a Spanish speaker in Mexico might expect words beginning with 'Ch' to appear at the end of a list of words starting with 'C'. Collations dictate these kinds of sorting and comparison rules. The Latin_1 General collation will sort 'Chiapas' before 'Colima' in an ORDER BY ASC clause, while the Traditional_Spanish collation will sort 'Chiapas' after 'Colima'. |
Opis powyżej jak najbardziej prawidłowo oddaje charakter tej właściwości serwera i jest równocześnie tak mylący i niepełny jak tylko się to dało napisać. Skąd taka opinia? Zanim odpowiem na to pytanie, podzielę się z Wami ostatnimi błędami, które zdarzyły mi się podczas różnych prób instalacji SQL Serwera.
1) Instalujemy SQL Server 2005 na polskim systemie Windows 2000. Instalacja przeprowadzana jest w trybie silent, parametry instalacji wyspecyfikowane są w pliku *.ini, jednym z nich jest linia SQLCOLLATION="SQL_Latin1_General_Pref_CP1_CI_AS". Instalacja kończy się błędem, ponieważ instalator nie jest w stanie znaleźć użytkownika ZARZADZANIE NT\SYSTEM. Powód? Usługa SQL Servera jest zakładana nie przez instalatora a przez proces serwera. Ten pobiera sobie łańcuch z nazwą użytkownika, przepuszcza go przez swoje funkcje przetwarzania łańcuchów (korzystające oczywiście ze zdefiniowanego collation) i „gubi” po drodze Ą przekształcając je do A. Rozwiązanie polegało na zamianie linii w pliku konfiguracyjnym na SQLCOLLATION="SQL_Polish_Cp1250_CI_AS".
2) Collation definiuje nie tylko kolejność sortowania, ale także rozróżnianie małych i wielkich liter czy akcentów. I tak powyższe SQL_Polish_Cp1250_CI_AS oznacza sortowanie niezależne od wielkości liter (case-insentitive) i zależne od akcentu (accent-sensitive). Ciekawe efekty dało użycie SQL_Polish_Cp1250_CS_AS z sortowaniem zależnym od wielkości liter. Najpierw program konfigurujący nie był się w stanie połączyć z serwerem zwracając lakoniczny komunikat „Błąd logowania użytkownika SA”. Widzicie już powód? Tak, w bazie master nie ma loginu SA, jest sa. Po poprawieniu nazwy użytkownika w kodzie programu otrzymałem jeszcze kilka błędów, jak np. niemożność uruchomienia skryptu T-SQL, w którym do zadeklarowanej zmiennej @Cmd odwoływałem się poprzez @cmd.
Tak więc odpowiednie zdefiniowanie collation w SQL Serwerze to nie tylko określenie kolejności sortowania danych. Wartość ta ma wpływ na wszystkie teksty przetwarzane przez serwer – od nazw kolumn po zmienne w skryptach nie wspominając już o parametrach procedur. Na szczęście nie wpływa ona na słowa kluczowe T-SQL – bez względu na zdefiniowanie collation zarówno select jak SELECT i SeLeCt są napisane poprawnie.
|
-
Visual Studio 2005 umożliwia śledzenie procedur składowanych (po ludzku: Stored Procedures) w SQL Server 2005. Fakt. Mówi się o tym, pisze się o tym, ale nie wspomina się, że cecha ta jest domyślnie wyłączona, nawet w domyślnej instalacji VS z SQL Express. Wiedza o tym, jak owo śledzenie aktywować jest rozbita po kilku artykułach MSDN, więc postaram się ją skonsolidować w jednym miejscu.
Krok 1: Upewniamy się, że zainstalowany jest Remote Debugger dla VS 2005. Jeśli ktoś wykonywał standardową instalację VS, to nie ma się czym martwić w tym miejscu. W przeciwnym wypadku lepiej sprawdzić i doinstalować. Visual Studio Remote Debugger znajduje się domyślnie w Menu Start --> Microsoft Visual Studio 2005 --> Visual Studio Tools.
Krok 2: Na komputerze z instancją serwera instalujemy debugger dla serwera. Jest to plik <sql server install dir>\90\Shared\<LCID>\rdbgsetup.exe.
Krok 2a: Na komputerze klienta udostępniamy port TCP 135 w firewallu i dodajemy devenv.exe do listy aplikacji uprawnionych do dostępu do sieci. Analogicznie na komputerze serwera włączamy porty TCP 135, TCP 139, TCP 445, UDP 137, UDP 138 i dodajemy sqlserv.exe do listy aplikacji uprawnionych do dostępu do sieci. Cały ten krok można sobie darować, jeśli serwer zainstalowany jest na tym samym komputerze co VS.
Krok 3: Ustawiamy uprawnienia na serwerze. Po pierwsze – nigdzie się o tym nie wspomina, ale debugger działa tylko w trybie Windows Authentication. Najlepiej będzie więc utworzyć login w SQL Serwerze o takiej samej nazwie jak nasz użytkownik Windows i przydzielić mu rolę sysadmina. Następnie, w bazie, w której będziemy uruchamiać procedurę, należy utworzyć użytkownika i zmapować go z właśnie stworzonym loginem. Dla użytkownika tego przydzielany rolę db_owner.
I tyle. Niby proste, ale jednak trochę skomplikowane. Tekst powyższy tyczy się procedur T-SQL-owych, ale na tak ustawiony serwerze można też bezproblemowo śledzić kod CLR (po włączeniu śledzenia CLR).
|
-
Microsoft SQL Server obsługuje dwa tryby autoryzacji – autoryzację SQL (wbudowaną) i autoryzację Windows (w tak zwanym trybie mieszanym). W skrócie, różnią się one tym, że w pierwszym przypadku do nawiązania połączenia z serwerem potrzebne są dane autoryzacyjne (użytkownik i hasło), natomiast w drugim używane są uprawnienia bieżącego użytkownika. Nie chcę się tutaj wgłębiać w wyjaśnienia i analizy Microsoftu o tym jak to autoryzacja Windows jest super bezpieczna a SQL jest be, faktem jest natomiast, że niektóre firmy nie życzą sobie, aby jakikolwiek użytkownik systemu (w tym także administrator) miał dostęp do danych przechowywanych w bazie.
Procedura blokowania autoryzacji Windows w SQL Serwerze jest prosta – należy we właściwościach loginów BuildIn\Administrators i BuildIn\Users ustawić pozwolenie na łączenie się z serwerem (permission to connect to database engine) na deny. Poza tym należy usunąć uprawnienie sysadmin dla loginu BuildIn\Administrators – inaczej administratorzy dalej będą mieli możliwość połączenia się z serwerem, a to z tego powodu, że nie ma możliwości zablokowania dostępu do serwera administratorowi serwera. Po wykonaniu powyższego możemy cieszyć się instancją serwera, do której mogą logować się tylko użytkownicy posiadający odpowiedni login na serwerze. Ale czy na pewno?
SQL Server 2005, tak jak i jego poprzednik obsługuje tryb pojedynczego użytkownika (single user mode). Tryb ten jest aktywowany, gdy usługa serwera zostanie uruchomiona z dodatkowym parametrem –m. Co ciekawe, tak uruchomiony serwer, oprócz obsługi tylko jednego połączenia, ustawia rolę sysadmin dla administratorów systemu (niejawnie). Konsekwencją tego i tego co już wspomniałem wyżej jest to, że można się podłączyć do zabezpieczonego serwera korzystając z mechanizmu autoryzacji Windows. Błąd? Ależ skądże. Cały trik doczekał się już własnej pozycji w KB (KB937682) i nosi dumną nazwę failure recovery mechanism.
|
-
Korzystając z szybkiego łącza internetowego ściągnąłem sobie pierwszą betę najnowszego Visual Studio. Plik waży 5.7 GB i zawiera pliki instalacyjne VS wraz z dopasowaną wersją MSDN.
Samą aplikację zainstalowałem w maszynie wirtualnej VMWare 6.0 pod kontrolą systemu Windows Vista Home Premium – nie dla fajerwerków graficznych Visty (bo te są w maszynie wirtualnej niedostępne), ale przede wszystkim po to, aby nie przeoczyć nowości aktywnych tylko w tym systemie.
Zaraz na początku otrzymujemy ostrzeżenie – nie warto instalować tej wersji równolegle z VS 2005, bo mogą wystąpić problemy po usunięciu Bety.
A jaki komponenty mamy do wyboru? Zaskakująco wybór jest bardzo podobny do tego z poprzedniej wersji:
Z ciekawszych nowości warto zwrócić uwagę na Unit Testing Tools. Czyżby w wersji 9.0 narzędzia testów jednostkowych dostępne były już od wersji Professional (bo taką instaluję)? Rozsądny wybór, bo dzisiejsza polityka Microsoftu (MSTest dostępny od wersji Enterprise w górę) powoduje ucieczkę do konkurencyjnego (i według niektórych lepszego) NUnit. Standardowo instalowana jest też mobilna wersja SQL Servera – wygląda to wszystko dość ciekawie…
Co zwraca uwagę – nie ma jeszcze przygotowanego SDK dla Framework w wersji 3.5, więc instalowane są narzędzia z wersji 2.0. I last but not least - wymagania przestrzeniu dyskowej. Prawie 4GB to ponad dwukrotnie więcej niż w poprzedniej wersji. I nie wydaje mi się, żeby to miało ulec dużej zmianie w wersji finalnej. Zapewne część plików zostanie zoptymalizowana ze względu ma zajmowane miejsce, ale dojdzie SDK 3.5, a ten będzie z pewnością zajmował więcej miejsca niż jego poprzednik.
W międzyczasie, po 15 minutach instalacji zainstalowany został FW 3.5 i nastąpił restart komputera. Następne 50 minut i po instalacji. Co zaskakuje, nie zmienił się zewnętrzny wygląd aplikacji: dostajemy Visual Studio 2005 po lekkim liftingu.
 [c.d.n.]
|
-
Jakiś czas temu na forum śp. portalu developers.pl zadałem pytanie dotyczące obsługi schowka w aplikacjach Windows Forms. Konkretnie chodziło mi o aplikację MDI, gdzie okna s | |
|