From 194d8ba3176c018fa821aa3fede2eb1802d310bf Mon Sep 17 00:00:00 2001 From: Kataanee Date: Fri, 26 Jan 2024 18:46:49 +0700 Subject: [PATCH 1/4] Revert "Russian translation added" This reverts commit d833b1e0889b5c56f152222a5e38bcf0b13b1fc3. --- TelegramBotBase/Localizations/Russian.cs | 36 ------------------------ 1 file changed, 36 deletions(-) delete mode 100644 TelegramBotBase/Localizations/Russian.cs diff --git a/TelegramBotBase/Localizations/Russian.cs b/TelegramBotBase/Localizations/Russian.cs deleted file mode 100644 index 9045d44..0000000 --- a/TelegramBotBase/Localizations/Russian.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace TelegramBotBase.Localizations; - -public sealed class Russian : Localization -{ - public Russian() - { - Values["Language"] = "Русский (Russian)"; - Values["ButtonGrid_Title"] = "Меню"; - Values["ButtonGrid_NoItems"] = "Нет доступных элементов."; - Values["ButtonGrid_PreviousPage"] = "◀️"; - Values["ButtonGrid_NextPage"] = "▶️"; - Values["ButtonGrid_CurrentPage"] = "Страница {0} из {1}"; - Values["ButtonGrid_SearchFeature"] = "💡 Отправьте сообщение, чтобы отфильтровать список. Нажмите на 🔍, чтобы сбросить фильтр."; - Values["ButtonGrid_Back"] = "Назада"; - Values["ButtonGrid_CheckAll"] = "Выделить все"; - Values["ButtonGrid_UncheckAll"] = "Отменить выбор"; - Values["CalendarPicker_Title"] = "Календарь / Выберите дату"; - Values["CalendarPicker_PreviousPage"] = "◀️"; - Values["CalendarPicker_NextPage"] = "▶️"; - Values["TreeView_Title"] = "Выберите пункт"; - Values["TreeView_LevelUp"] = "🔼 Обратно"; - Values["ToggleButton_On"] = "Вкл"; - Values["ToggleButton_Off"] = "Выкл"; - Values["ToggleButton_OnIcon"] = "⚫"; - Values["ToggleButton_OffIcon"] = "⚪"; - Values["ToggleButton_Title"] = "Переключить"; - Values["ToggleButton_Changed"] = "Выбрано"; - Values["MultiToggleButton_SelectedIcon"] = "✅"; - Values["MultiToggleButton_Title"] = "Множественный выбор"; - Values["MultiToggleButton_Changed"] = "Выбрано"; - Values["PromptDialog_Back"] = "Назад"; - Values["ToggleButton_Changed"] = "Настройки изменены"; - Values["ButtonGrid_SearchIcon"] = "🔍"; - Values["ButtonGrid_TagIcon"] = "📁"; - } -} \ No newline at end of file From a632440efdcb6dd82b049cce356e2301d757cda3 Mon Sep 17 00:00:00 2001 From: Kataanee Date: Fri, 26 Jan 2024 18:48:43 +0700 Subject: [PATCH 2/4] Add PostgreSQL serializer --- .../BotBaseBuilderExtensions.cs | 92 +++++++ .../PostgreSqlSerializer.cs | 252 ++++++++++++++++++ .../README.md | 27 ++ ...ions.Serializer.Database.PostgreSql.csproj | 29 ++ .../create_tables.sql | 25 ++ 5 files changed, 425 insertions(+) create mode 100644 TelegramBotBase.Extensions.Serializer.Database.PostgreSql/BotBaseBuilderExtensions.cs create mode 100644 TelegramBotBase.Extensions.Serializer.Database.PostgreSql/PostgreSqlSerializer.cs create mode 100644 TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md create mode 100644 TelegramBotBase.Extensions.Serializer.Database.PostgreSql/TelegramBotBase.Extensions.Serializer.Database.PostgreSql.csproj create mode 100644 TelegramBotBase.Extensions.Serializer.Database.PostgreSql/create_tables.sql diff --git a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/BotBaseBuilderExtensions.cs b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/BotBaseBuilderExtensions.cs new file mode 100644 index 0000000..ad0556b --- /dev/null +++ b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/BotBaseBuilderExtensions.cs @@ -0,0 +1,92 @@ +using System; +using TelegramBotBase.Builder; +using TelegramBotBase.Builder.Interfaces; + +namespace TelegramBotBase.Extensions.Serializer.Database.PostgreSql +{ + /// + /// Provides extension methods for configuring the use of PostgreSQL Server Database for session serialization. + /// + public static class BotBaseBuilderExtensions + { + /// + /// Uses an PostgreSQL Server Database to save and restore sessions. + /// + /// The session serialization stage builder. + /// The connection string to the PostgreSQL database. + /// The fallback form type. + /// The prefix for database table names (default is "tgb_"). + /// The language selection stage builder. + public static ILanguageSelectionStage UsePostgreSqlDatabase( + this ISessionSerializationStage builder, + string connectionString, Type fallbackForm = null, + string tablePrefix = "tgb_") + { + var serializer = new PostgreSqlSerializer(connectionString, tablePrefix, fallbackForm); + + builder.UseSerialization(serializer); + + return builder as BotBaseBuilder; + } + + + /// + /// Uses an PostgreSQL Server Database to save and restore sessions. + /// + /// The session serialization stage builder. + /// The host or IP address of the PostgreSQL server. + /// The port number for the PostgreSQL server. + /// The name of the PostgreSQL database. + /// The user ID for connecting to the PostgreSQL server. + /// The password for connecting to the PostgreSQL server. + /// The fallback form type. + /// The prefix for database table names (default is "tgb_"). + /// The language selection stage builder. + public static ILanguageSelectionStage UsePostgreSqlDatabase( + this ISessionSerializationStage builder, + string hostOrIp, string port, + string databaseName, string userId, + string password, Type fallbackForm = null, + string tablePrefix = "tgb_") + { + var connectionString = $"Host={hostOrIp};Port={port};Database={databaseName};Username={userId};Password={password}"; + + var serializer = new PostgreSqlSerializer(connectionString, tablePrefix, fallbackForm); + + builder.UseSerialization(serializer); + + return builder as BotBaseBuilder; + } + + /// + /// Uses an PostgreSQL Server Database with Windows Authentication to save and restore sessions. + /// + /// The session serialization stage builder. + /// The host or IP address of the PostgreSQL server. + /// The port number for the PostgreSQL server. + /// The name of the PostgreSQL database. + /// A flag indicating whether to use Windows Authentication (true) or not (false). + /// The fallback form type. + /// The prefix for database table names (default is "tgb_"). + /// The language selection stage builder. + public static ILanguageSelectionStage UsePostgreSqlDatabase( + this ISessionSerializationStage builder, + string hostOrIp, string port, + string databaseName, bool integratedSecurity = true, + Type fallbackForm = null, string tablePrefix = "tgb_") + { + if (!integratedSecurity) + { + throw new ArgumentOutOfRangeException(); + } + + var connectionString = $"Host={hostOrIp};Port={port};Database={databaseName};Integrated Security=true;"; + + var serializer = new PostgreSqlSerializer(connectionString, tablePrefix, fallbackForm); + + builder.UseSerialization(serializer); + + return builder as BotBaseBuilder; + } + } +} diff --git a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/PostgreSqlSerializer.cs b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/PostgreSqlSerializer.cs new file mode 100644 index 0000000..7875b70 --- /dev/null +++ b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/PostgreSqlSerializer.cs @@ -0,0 +1,252 @@ +using Npgsql; +using System; +using System.Data; +using NpgsqlTypes; +using TelegramBotBase.Args; +using TelegramBotBase.Base; +using TelegramBotBase.Form; +using TelegramBotBase.Interfaces; + +namespace TelegramBotBase.Extensions.Serializer.Database.PostgreSql +{ + /// + /// Represents a PostgreSQL implementation of the for saving and loading form states. + /// + public class PostgreSqlSerializer : IStateMachine + { + private readonly string insertIntoSessionSql; + private readonly string insertIntoSessionsDataSql; + private readonly string selectAllDevicesSessionsSql; + private readonly string selectAllDevicesSessionsDataSql; + + /// + /// Initializes a new instance of the class. + /// + /// The connection string to the PostgreSQL database. + /// The prefix for database table names (default is "tgb_"). + /// The fallback state form type. + /// Thrown when is null. + /// Thrown when is not a subclass of . + public PostgreSqlSerializer(string connectionString, string tablePrefix = "tgb_", Type fallbackStateForm = null) + { + ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + + TablePrefix = tablePrefix; + + FallbackStateForm = fallbackStateForm; + + if (FallbackStateForm != null && !FallbackStateForm.IsSubclassOf(typeof(FormBase))) + { + throw new ArgumentException($"{nameof(FallbackStateForm)} is not a subclass of {nameof(FormBase)}"); + } + + insertIntoSessionSql = "INSERT INTO " + TablePrefix + + "devices_sessions (deviceId, deviceTitle, \"FormUri\", \"QualifiedName\") VALUES (@deviceId, @deviceTitle, @FormUri, @QualifiedName)"; + insertIntoSessionsDataSql = "INSERT INTO " + TablePrefix + "devices_sessions_data (deviceId, key, value, type) VALUES (@deviceId, @key, @value, @type)"; + + selectAllDevicesSessionsSql = "SELECT * FROM " + TablePrefix + "devices_sessions"; + selectAllDevicesSessionsDataSql = "SELECT * FROM " + TablePrefix + "devices_sessions_data WHERE deviceId = @deviceId"; + } + + /// + /// Gets the connection string to the PostgreSQL database. + /// + public string ConnectionString { get; } + + /// + /// Gets or sets the table name prefix for database tables. + /// + public string TablePrefix { get; set; } + + /// + /// Gets or sets the fallback state form type. + /// + public Type FallbackStateForm { get; set; } + + /// + /// + /// Saves form states to the PostgreSQL database. + /// + /// The containing the states to be saved. + public void SaveFormStates(SaveStatesEventArgs e) + { + var container = e.States; + + //Cleanup old Session data + Cleanup(); + + using (var connection = new NpgsqlConnection(ConnectionString)) + { + connection.Open(); + + //Store session data in database + foreach (var state in container.States) + { + using (var sessionCommand = connection.CreateCommand()) + { + sessionCommand.CommandText = insertIntoSessionSql; + + sessionCommand.Parameters.Add(new NpgsqlParameter("@deviceId", NpgsqlDbType.Bigint){Value = state.DeviceId }); + sessionCommand.Parameters.Add(new NpgsqlParameter("@deviceTitle", DbType.StringFixedLength){Value = state.ChatTitle ?? string.Empty}); + sessionCommand.Parameters.Add(new NpgsqlParameter("@FormUri", DbType.StringFixedLength) {Value = state.FormUri}); + sessionCommand.Parameters.Add(new NpgsqlParameter("@QualifiedName", DbType.StringFixedLength){Value = state.QualifiedName }); + + sessionCommand.ExecuteNonQuery(); + } + } + } + + using (var connection = new NpgsqlConnection(ConnectionString)) + { + connection.Open(); + + foreach (var state in container.States) + { + SaveSessionsData(state, connection); + } + } + } + + /// + /// + /// Loads form states from the PostgreSQL database. + /// + /// A containing the loaded form states. + public StateContainer LoadFormStates() + { + var stateContainer = new StateContainer(); + + using (var connection = new NpgsqlConnection(ConnectionString)) + { + connection.Open(); + + using (var sessionCommand = connection.CreateCommand()) + { + sessionCommand.CommandText = selectAllDevicesSessionsSql; + + var sessionTable = new DataTable(); + using (var dataAdapter = new NpgsqlDataAdapter(sessionCommand)) + { + dataAdapter.Fill(sessionTable); + + foreach (DataRow row in sessionTable.Rows) + { + var stateEntry = new StateEntry + { + DeviceId = (long)row["deviceId"], + ChatTitle = row["deviceTitle"].ToString(), + FormUri = row["FormUri"].ToString(), + QualifiedName = row["QualifiedName"].ToString() + }; + + stateContainer.States.Add(stateEntry); + + if (stateEntry.DeviceId > 0) + { + stateContainer.ChatIds.Add(stateEntry.DeviceId); + } + else + { + stateContainer.GroupIds.Add(stateEntry.DeviceId); + } + + LoadDataTable(connection, row, stateEntry); + } + } + } + } + + return stateContainer; + } + + /// + /// Cleans up old session data in the PostgreSQL database. + /// + private void Cleanup() + { + using (var connection = new NpgsqlConnection(ConnectionString)) + { + connection.Open(); + + using (var clearCommand = connection.CreateCommand()) + { + clearCommand.CommandText = $"DELETE FROM {TablePrefix}devices_sessions_data"; + clearCommand.ExecuteNonQuery(); + } + + using (var clearCommand = connection.CreateCommand()) + { + clearCommand.CommandText = $"DELETE FROM {TablePrefix}devices_sessions"; + clearCommand.ExecuteNonQuery(); + } + } + } + + /// + /// Saves session data to the PostgreSQL database. + /// + /// The state entry containing session data to be saved. + /// The NpgsqlConnection used for the database interaction. + private void SaveSessionsData(StateEntry state, NpgsqlConnection connection) + { + foreach (var data in state.Values) + { + using (var dataCommand = connection.CreateCommand()) + { + dataCommand.CommandText = insertIntoSessionsDataSql; + + dataCommand.Parameters.Add(new NpgsqlParameter("@deviceId", NpgsqlDbType.Bigint) { Value = state.DeviceId }); + dataCommand.Parameters.Add(new NpgsqlParameter("@key", DbType.StringFixedLength) { Value = data.Key }); + + var type = data.Value.GetType(); + + if (type.IsPrimitive || type == typeof(string)) + { + dataCommand.Parameters.Add(new NpgsqlParameter("@value", NpgsqlDbType.Text) { Value = data.Value }); + } + else + { + var json = System.Text.Json.JsonSerializer.Serialize(data.Value); + dataCommand.Parameters.Add(new NpgsqlParameter("@value", NpgsqlDbType.Text) { Value = json }); + } + + dataCommand.Parameters.Add(new NpgsqlParameter("@type", DbType.StringFixedLength) { Value = type.AssemblyQualifiedName }); + + dataCommand.ExecuteNonQuery(); + } + } + } + + /// + /// Loads session data from the PostgreSQL database. + /// + /// The NpgsqlConnection used for the database interaction. + /// The DataRow representing a session entry in the main sessions table. + /// The StateEntry object to which session data will be loaded. + private void LoadDataTable(NpgsqlConnection connection, DataRow row, StateEntry stateEntry) + { + using (var sessionCommand = connection.CreateCommand()) + { + sessionCommand.CommandText = selectAllDevicesSessionsDataSql; + sessionCommand.Parameters.Add(new NpgsqlParameter("@deviceId", row["deviceId"])); + + var dataCommandTable = new DataTable(); + using (var npgSqlDataAdapter = new NpgsqlDataAdapter(sessionCommand)) + { + npgSqlDataAdapter.Fill(dataCommandTable); + + foreach (DataRow dataRow in dataCommandTable.Rows) + { + var key = dataRow["key"].ToString(); + var type = Type.GetType(dataRow["type"].ToString()); + + var value = System.Text.Json.JsonSerializer.Deserialize(dataRow["value"].ToString(), type); + + stateEntry.Values.Add(key, value); + } + } + + } + } + } +} \ No newline at end of file diff --git a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md new file mode 100644 index 0000000..1f1377f --- /dev/null +++ b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md @@ -0,0 +1,27 @@ +# TelegramBotBase.Extensions.Serializer.Database.PostgreSQL + +[![NuGet version (TelegramBotBase)](https://img.shields.io/nuget/v/TelegramBotBase.Extensions.Serializer.Database.PostgreSQL.svg?style=flat-square)](https://www.nuget.org/packages/TelegramBotBase.Extensions.Serializer.Database.PostgreSQL/) +[![Telegram chat](https://img.shields.io/badge/Support_Chat-Telegram-blue.svg?style=flat-square)](https://www.t.me/tgbotbase) + +[![License](https://img.shields.io/github/license/MajMcCloud/telegrambotframework.svg?style=flat-square&maxAge=2592000&label=License)](https://raw.githubusercontent.com/MajMcCloud/TelegramBotFramework/master/LICENCE.md) +[![Package Downloads](https://img.shields.io/nuget/dt/TelegramBotBase.Extensions.Serializer.Database.PostgreSQL.svg?style=flat-square&label=Package%20Downloads)](https://www.nuget.org/packages/TelegramBotBase.Extensions.Serializer.Database.PostgreSQL) + +## How to use + +```csharp +using TelegramBotBase.Extensions.Serializer.Database.PostgreSQL; + + +var bot = BotBaseBuilder + .Create() + .WithAPIKey(APIKey) + .DefaultMessageLoop() + .WithStartForm() + .NoProxy() + .OnlyStart() + .UseSQLDatabase("localhost", "telegram_bot") + .UseEnglish() + .Build(); + +bot.Start(); +``` diff --git a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/TelegramBotBase.Extensions.Serializer.Database.PostgreSql.csproj b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/TelegramBotBase.Extensions.Serializer.Database.PostgreSql.csproj new file mode 100644 index 0000000..7103081 --- /dev/null +++ b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/TelegramBotBase.Extensions.Serializer.Database.PostgreSql.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0;netcoreapp3.1;net8 + True + https://github.com/MajMcCloud/TelegramBotFramework + https://github.com/MajMcCloud/TelegramBotFramework + MIT + true + snupkg + 1.0.1 + 1.0.1 + 1.0.1 + + A session serializer for PostgreSQL Server. + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/create_tables.sql b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/create_tables.sql new file mode 100644 index 0000000..d83bd38 --- /dev/null +++ b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/create_tables.sql @@ -0,0 +1,25 @@ +-- Enable uuid-ossp extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create table tgb_devices_sessions +CREATE TABLE tgb_devices_sessions ( + deviceId bigint NOT NULL, + deviceTitle character varying(512) NOT NULL, + "FormUri" character varying(512) NOT NULL, + "QualifiedName" character varying(512) NOT NULL, + CONSTRAINT PK_tgb_devices_sessions_1 PRIMARY KEY (deviceId) +); + +-- Create table tgb_devices_sessions_data +CREATE TABLE tgb_devices_sessions_data ( + Id uuid DEFAULT uuid_generate_v4() NOT NULL, + deviceId bigint NOT NULL, + key character varying(512) NOT NULL, + "value" text NOT NULL, + "type" character varying(512) NOT NULL, + CONSTRAINT PK_tgb_devices_session_data PRIMARY KEY (Id) +); + +-- Add default constraint for Id column in tgb_devices_sessions_data +ALTER TABLE tgb_devices_sessions_data + ALTER COLUMN Id SET DEFAULT uuid_generate_v4(); From b14913362e9b78f1a8c2f774a859089ed7ef2727 Mon Sep 17 00:00:00 2001 From: Kataane <43317275+Kataane@users.noreply.github.com> Date: Sat, 27 Jan 2024 13:21:46 +0700 Subject: [PATCH 3/4] Update README.md --- .../README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md index 1f1377f..fb985d8 100644 --- a/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md +++ b/TelegramBotBase.Extensions.Serializer.Database.PostgreSql/README.md @@ -19,7 +19,7 @@ var bot = BotBaseBuilder .WithStartForm() .NoProxy() .OnlyStart() - .UseSQLDatabase("localhost", "telegram_bot") + .UsePostgreSqlDatabase("localhost", "8181", "telegram_bot") .UseEnglish() .Build(); From d4af8797fbbc564c91b98ae718605db3b3930d06 Mon Sep 17 00:00:00 2001 From: Florian Zevedei Date: Sun, 28 Jan 2024 02:16:55 +0100 Subject: [PATCH 4/4] Add new PostgreSQL extension to solution file --- TelegramBotFramework.sln | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/TelegramBotFramework.sln b/TelegramBotFramework.sln index 8fd0e98..4d81707 100644 --- a/TelegramBotFramework.sln +++ b/TelegramBotFramework.sln @@ -32,7 +32,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BotAndWebApplication", "Exa EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InlineAndReplyCombination", "Examples\InlineAndReplyCombination\InlineAndReplyCombination.csproj", "{067E8EBE-F90A-4AFF-A0FF-20578216486E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyInjection", "Examples\DependencyInjection\DependencyInjection.csproj", "{689B16BC-200E-4C68-BB2E-8B209070849B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DependencyInjection", "Examples\DependencyInjection\DependencyInjection.csproj", "{689B16BC-200E-4C68-BB2E-8B209070849B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TelegramBotBase.Extensions.Serializer.Database.PostgreSql", "TelegramBotBase.Extensions.Serializer.Database.PostgreSql\TelegramBotBase.Extensions.Serializer.Database.PostgreSql.csproj", "{7C55D9FF-7DC1-41D0-809C-469EBFA20992}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -84,6 +86,10 @@ Global {689B16BC-200E-4C68-BB2E-8B209070849B}.Debug|Any CPU.Build.0 = Debug|Any CPU {689B16BC-200E-4C68-BB2E-8B209070849B}.Release|Any CPU.ActiveCfg = Release|Any CPU {689B16BC-200E-4C68-BB2E-8B209070849B}.Release|Any CPU.Build.0 = Release|Any CPU + {7C55D9FF-7DC1-41D0-809C-469EBFA20992}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C55D9FF-7DC1-41D0-809C-469EBFA20992}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C55D9FF-7DC1-41D0-809C-469EBFA20992}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C55D9FF-7DC1-41D0-809C-469EBFA20992}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -98,6 +104,7 @@ Global {52EA3201-02E8-46F5-87C4-B4752C8A815C} = {BFA71E3F-31C0-4FC1-A320-4DCF704768C5} {067E8EBE-F90A-4AFF-A0FF-20578216486E} = {BFA71E3F-31C0-4FC1-A320-4DCF704768C5} {689B16BC-200E-4C68-BB2E-8B209070849B} = {BFA71E3F-31C0-4FC1-A320-4DCF704768C5} + {7C55D9FF-7DC1-41D0-809C-469EBFA20992} = {E3193182-6FDA-4FA3-AD26-A487291E7681} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {59CB40E1-9FA7-4867-A56F-4F418286F057}