From 03d4673c001f3b02f7b67db4bfabd20a3985f8f0 Mon Sep 17 00:00:00 2001 From: Bartosz Gradzik Date: Wed, 8 Oct 2025 21:42:55 +0200 Subject: [PATCH] Fix data export formatting and implement robust modifier detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to data cleaning, modifier extraction, and role name handling: ## Data Cleaning & Normalization - Enhanced StripColorTags() to remove ALL HTML/Unity tags using universal regex - Added removal of modifier symbols (♥, #, ★, etc.) from player names - Fixed winningTeam containing HTML tags by applying StripColorTags() - Result: Clean player names without tags or symbols in exported JSON ## Modifier Detection System - Implemented three-tier modifier detection: 1. Symbol extraction from SYMBOL tags (primary) 2. Reflection-based GetModifiers() (secondary) 3. RoleString parsing fallback (tertiary) - Fixed false positive Underdog detection from friend codes (username#1234) - Symbols now extracted ONLY from size tags, not entire player name - Fixed Dictionary duplicate key error (removed \u2665 duplicate of ♥) - Supported symbols: ♥❤♡ (Lovers), # (Underdog), ★☆ (VIP), @ (Sleuth), etc. ## Role Name Handling - Added fallback for roles without GetRoleName() method - Extracts role names from type names: "GrenadierRole" → "Grenadier" - Removes "Tou" suffix: "EngineerTou" → "Engineer" - Skips generic "RoleBehaviour" type (not a real role) - Result: Full role history now populated correctly ## Code Quality - Added comprehensive logging for debugging modifier/role extraction - Enhanced error handling with per-player try-catch blocks - Improved code documentation with inline comments ## Documentation - Added CLAUDE.md with complete architecture guide - Documented modifier detection system - Added data cleaning & normalization section - Included troubleshooting guide and development workflows Fixes: #1 (HTML tags in export), #2 (missing role history), #3 (false modifier detection) 🎮 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 311 ++++++++++++++++++++++++++ Export/DataTransformer.cs | 130 +++++++++-- Reflection/TouMiraReflectionBridge.cs | 49 +++- 3 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9fb3a92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,311 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**TownOfUs Stats Exporter** is a standalone BepInEx plugin for Among Us that exports Town of Us Mira game statistics to a cloud API. The plugin operates completely independently of TOU Mira using reflection and Harmony patches - requiring zero modifications to the main mod. + +## Key Architecture Principles + +### Reflection-Based Approach +The plugin **never directly references TOU Mira types**. All access is via reflection through `TouMiraReflectionBridge.cs` which: +- Caches all Type and PropertyInfo objects at initialization for performance +- Handles IL2CPP interop for Unity collections +- Provides version compatibility checking +- Falls back gracefully if TOU Mira structures change + +### Harmony Patching Strategy +Uses `[HarmonyPriority(Priority.Low)]` on the `AmongUsClient.OnGameEnd` patch to ensure TOU Mira's `BuildEndGameData()` runs first and populates `EndGameData.PlayerRecords` before the export begins. + +### Async Export Pattern +Export runs in a fire-and-forget Task.Run() to avoid blocking the game UI. The entire export process (reflection, data transformation, API call) happens asynchronously. + +## Build Commands + +```bash +# Build the plugin +dotnet build -c Release + +# Build in debug mode +dotnet build -c Debug + +# Clean build +dotnet clean && dotnet build -c Release +``` + +The compiled DLL will be in `bin/Release/TownOfUsStatsExporter.dll` or `bin/Debug/TownOfUsStatsExporter.dll`. + +If the `AmongUs` environment variable is set, the DLL auto-copies to `$(AmongUs)/BepInEx/plugins/` after build. + +## Testing & Debugging + +### Local Testing +1. Build in Debug mode +2. Copy DLL to `Among Us/BepInEx/plugins/` +3. Launch Among Us with BepInEx console (F10) +4. Check logs for "TownOfUs Stats Exporter" messages +5. Complete a game and watch export logs + +### Log Locations +- **BepInEx Console**: Real-time logs (press F10 in game) +- **BepInEx Log File**: `Among Us/BepInEx/LogOutput.log` +- **Local Backups**: `Documents/TownOfUs/GameLogs/Game_YYYYMMDD_HHMMSS_.json` + +### Common Debug Scenarios +- **"Failed to initialize reflection bridge"**: TOU Mira assembly not found or incompatible version +- **"No player data available"**: Export triggered before TOU Mira populated EndGameData +- **Empty role history**: RoleString fallback parser activates - check RoleString format + +## Code Structure & Flow + +### Initialization Flow (Plugin Load) +1. `TownOfUsStatsPlugin.Load()` - BepInEx entry point +2. `TouMiraReflectionBridge.Initialize()` - Find TOU Mira assembly, cache reflection metadata +3. `VersionCompatibility.CheckVersion()` - Verify TOU Mira version compatibility +4. `Harmony.PatchAll()` - Apply patches (EndGameExportPatch) + +### Export Flow (Game End) +1. **AmongUsClient.OnGameEnd** fires (game ends) +2. **TOU Mira's patch** runs (High priority) - populates EndGameData.PlayerRecords +3. **EndGameExportPatch.Postfix** runs (Low priority) - starts export + - Checks for Hide & Seek mode (skip if true) + - Fires Task.Run() with `StatsExporter.ExportGameStatsAsync()` +4. **StatsExporter.ExportGameStatsAsync()** + - Reads `ApiSet.ini` config + - Calls reflection bridge methods to collect data + - Transforms data via `DataTransformer.TransformToExportFormat()` + - Saves local backup if enabled + - POSTs to API via `ApiClient.SendToApiAsync()` + +### Key Reflection Patterns + +**Reading TOU Mira Data:** +```csharp +// Get static property value +var playerRecords = cache.PlayerRecordsProperty!.GetValue(null); + +// Get instance property value +var value = obj.GetType().GetProperty("PropertyName")?.GetValue(obj); + +// Handle IL2CPP lists +var managedList = IL2CPPHelper.ConvertToManagedList(il2cppList); +``` + +**Adding New Reflected Data:** +1. Add to `ReflectionCache.cs` (Type, PropertyInfo, FieldInfo) +2. Cache in `TouMiraReflectionBridge.CacheReflectionMetadata()` +3. Add getter method in `TouMiraReflectionBridge` +4. Add model to `Models/ReflectedData.cs` +5. Use in `DataTransformer.TransformToExportFormat()` + +## Important Constraints + +### What You Can Access +- Public classes, properties, fields, methods in TOU Mira +- Static data in `EndGamePatches.EndGameData` +- Static data in `GameHistory` (PlayerStats, RoleHistory, KilledPlayers, WinningFaction) +- PlayerControl instances via `PlayerControl.AllPlayerControls` + +### What You Cannot Access +- Internal or private members (C# access modifiers enforced) +- Non-public nested types +- Direct type references (must use reflection strings) + +### Version Compatibility +- **Tested**: TOU Mira 1.2.0, 1.2.1 +- **Probably Compatible**: Same major version (1.x.x) +- **Incompatible**: Different major version (2.x.x) + +The plugin logs warnings for untested versions but attempts to run anyway. + +## Configuration System + +The plugin searches for `ApiSet.ini` in priority order: +1. Game directory (where DLL is located) +2. `Documents/TownOfUs/ApiSet.ini` + +If not found, creates a default template at the Documents location. + +**Config Structure (INI format):** +```ini +EnableApiExport=true +ApiToken=your_token_here +ApiEndpoint=https://api.example.com/endpoint +SaveLocalBackup=true +Secret=optional_secret +``` + +Config is read asynchronously each game - no restart required after editing. + +## Error Handling Philosophy + +### Fail-Safe Design +- Plugin failures never crash the game +- Export errors are logged but don't interrupt gameplay +- Individual player data errors skip that player, continue with others +- Missing optional data (modifiers, platform) defaults to empty/Unknown + +### Error Isolation Layers +1. **Top-level**: `Task.Run()` catches all exceptions +2. **Export-level**: `ExportGameStatsAsync()` catches all exceptions +3. **Component-level**: Each transformer/client method has try-catch +4. **Player-level**: Each player transform has try-catch + +## Data Models + +### Export Format (GameStatsData) +``` +GameStatsData +├── Token (string) - API auth token +├── Secret (string?) - Optional additional secret +├── GameInfo +│ ├── GameId (guid) +│ ├── Timestamp (UTC) +│ ├── LobbyCode (e.g., "ABCDEF") +│ ├── GameMode (Normal/HideNSeek) +│ ├── Duration (seconds) +│ └── Map (string) +├── Players[] (list) +│ ├── PlayerId (byte) +│ ├── PlayerName (string) +│ ├── PlayerTag (friend code) +│ ├── Platform (Steam/Epic/etc) +│ ├── Role (final role string) +│ ├── Roles[] (full role history, excludes ghost roles) +│ ├── Modifiers[] (e.g., ["Button Barry", "Flash"]) +│ ├── IsWinner (bool) +│ └── Stats +│ ├── TotalTasks +│ ├── TasksCompleted +│ ├── Kills +│ ├── CorrectKills +│ ├── IncorrectKills +│ ├── CorrectAssassinKills +│ └── IncorrectAssassinKills +└── GameResult + └── WinningTeam (Crewmates/Impostors/Neutrals) +``` + +### Internal Models (ReflectedData) +Intermediate models used between reflection bridge and transformer: +- `PlayerRecordData` - Raw data from EndGameData.PlayerRecords +- `PlayerStatsData` - Kill statistics from GameHistory.PlayerStats +- `KilledPlayerData` - Kill events from GameHistory.KilledPlayers + +## Dependencies + +- **BepInEx.Unity.IL2CPP** (6.0.0-be.735) - Plugin framework +- **AmongUs.GameLibs.Steam** (2025.9.9) - Game libraries +- **HarmonyX** (2.13.0) - Runtime patching +- **AllOfUs.MiraAPI** (0.3.0) - MiraAPI types (for PlayerControl, etc) +- **Reactor** (2.3.1) - Required by TOU Mira +- **Il2CppInterop.Runtime** (1.4.6-ci.426) - IL2CPP interop + +All dependencies are NuGet packages referenced in `TownOfUsStatsExporter.csproj`. + +## Performance Considerations + +- Reflection is ~100x slower than direct access, but total overhead is <100ms +- Reflection metadata is cached at plugin load for fast repeated access +- Export runs fully async - zero UI blocking time +- HTTP client is reused (static singleton) to avoid connection overhead +- JSON serialization uses System.Text.Json (fast, modern) + +## Modifier Detection System + +The plugin uses **three-tier modifier detection**: + +### 1. Symbol Extraction (Primary) +Player names can contain special symbols inside HTML tags that indicate modifiers. + +**Format**: `SYMBOL` + +Supported symbols: +- `♥` `❤` `♡` `\u2665` → **Lovers** modifier +- `#` → **Underdog** modifier +- `★` `☆` → **VIP** modifier +- `@` → **Sleuth** modifier +- `†` `‡` → **Bait** modifier +- `§` → **Torch** modifier +- `⚡` → **Flash** modifier +- `⚔` `🗡` → **Assassin** modifier + +**Important**: +- Symbols are extracted **only from `SYMBOL` tags**, not from entire player name +- This prevents false positives (e.g., `#` in friend codes like `username#1234`) +- Extraction happens **before** HTML tag stripping to preserve modifier information + +Example: +``` +Input: "boracik " +Step 1: ExtractModifiersFromPlayerName() finds "♥" in size tag → ["Lovers"] +Step 2: StripColorTags() removes HTML tags AND symbols → "boracik" +Output: playerName="boracik", modifiers=["Lovers"] +``` + +**Note**: The `StripColorTags()` function removes both HTML tags AND modifier symbols to ensure clean player names. + +### 2. Reflection (Secondary) +Attempts to get modifiers via reflection on `PlayerControl.GetModifiers()` + +### 3. RoleString Parsing (Fallback) +Parses modifier names from parentheses in RoleString if both above methods fail. + +### Processing Order in DataTransformer.TransformPlayerData() +``` +1. ExtractModifiersFromPlayerName(record.PlayerName) → Gets symbols +2. bridge.GetPlayerModifiers(playerId) → Gets via reflection +3. ParseModifiersFromRoleString(record.RoleString) → Parses string +4. StripColorTags(record.PlayerName) → Clean name for export +``` + +## Adding New Features + +### To Add New Modifier Symbol +1. Edit `ExtractModifiersFromPlayerName()` in `Export/DataTransformer.cs` +2. Add symbol to `symbolToModifier` dictionary +3. Rebuild and test + +### To Add New Exported Data +1. Check if TOU Mira exposes it publicly (use dnSpy or ILSpy) +2. Add reflection cache entry in `ReflectionCache.cs` +3. Add caching logic in `TouMiraReflectionBridge.CacheReflectionMetadata()` +4. Add getter method in `TouMiraReflectionBridge` +5. Add property to export model in `Models/GameStatsData.cs` +6. Add transformation in `DataTransformer.TransformToExportFormat()` + +### To Add New Config Options +1. Add property to `ApiConfig.cs` +2. Add parsing in `ApiConfigManager.ParseIniFile()` +3. Add to default template in `ApiConfigManager.CreateDefaultConfigAsync()` +4. Use in appropriate exporter class + +## Data Cleaning & Normalization + +All exported data is cleaned to ensure consistency: + +### Text Cleaning (StripColorTags) +Removes from all text fields (playerName, winningTeam, etc.): +- HTML/Unity tags: ``, ``, ``, etc. +- Modifier symbols: `♥`, `#`, `★`, `@`, etc. + +### Role Name Normalization +- Removes suffixes: `"GrenadierRole"` → `"Grenadier"` +- Removes TOU suffix: `"EngineerTou"` → `"Engineer"` +- Skips generic types: `"RoleBehaviour"` (not added to history) + +### Modifier Symbol Extraction +Symbols are extracted **before** text cleaning to preserve information: +1. Extract from `SYMBOL` tags +2. Map to modifier names (e.g., `♥` → `"Lovers"`) +3. Clean text (remove tags and symbols) + +## Known Limitations + +- Cannot access internal/private TOU Mira members +- Role/modifier parsing falls back to string parsing if reflection fails +- Platform detection may fail on some Among Us versions (defaults to "Unknown") +- No compile-time type safety (all reflection is string-based) +- Breaking changes in TOU Mira require plugin updates +- Symbol encoding may vary by console/log viewer (e.g., `♥` may display as `ÔÖą` in logs) diff --git a/Export/DataTransformer.cs b/Export/DataTransformer.cs index 254a20f..58ecf84 100644 --- a/Export/DataTransformer.cs +++ b/Export/DataTransformer.cs @@ -92,17 +92,27 @@ public static class DataTransformer var player = PlayerControl.AllPlayerControls.ToArray() .FirstOrDefault(p => p.PlayerId == record.PlayerId); + TownOfUsStatsPlugin.Logger.LogInfo($"Transforming player {record.PlayerId} ({record.PlayerName})"); + TownOfUsStatsPlugin.Logger.LogInfo($" RoleString: '{record.RoleString}'"); + + // STEP 1: Extract modifiers from player name BEFORE cleaning it + // Symbols like ♥ indicate Lovers modifier + var nameModifiers = ExtractModifiersFromPlayerName(record.PlayerName); + // Get role history for this player var roles = roleHistory.GetValueOrDefault(record.PlayerId, new List()); - + TownOfUsStatsPlugin.Logger.LogInfo($" RoleHistory entries: {roles.Count}"); + // If roleHistory is empty, try parsing from RoleString as fallback if (roles.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) { - TownOfUsStatsPlugin.Logger.LogInfo($"RoleHistory empty for player {record.PlayerId}, parsing from RoleString: {record.RoleString}"); + TownOfUsStatsPlugin.Logger.LogInfo($" Parsing roles from RoleString..."); roles = ParseRolesFromRoleString(record.RoleString); + TownOfUsStatsPlugin.Logger.LogInfo($" Parsed {roles.Count} role(s): {string.Join(", ", roles)}"); } - + var lastRole = roles.LastOrDefault() ?? "Unknown"; + TownOfUsStatsPlugin.Logger.LogInfo($" Final role: {lastRole}"); // Get stats var stats = playerStats.GetValueOrDefault(record.PlayerId, new PlayerStatsData()); @@ -110,20 +120,35 @@ public static class DataTransformer // Count kills var kills = killedPlayers.Count(k => k.KillerId == record.PlayerId && k.VictimId != record.PlayerId); - // Get modifiers + // STEP 2: Get modifiers from multiple sources var bridge = ReflectionBridgeProvider.GetBridge(); var modifiers = bridge.GetPlayerModifiers(record.PlayerId); - - // If no modifiers found via reflection, try parsing from RoleString - if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) + TownOfUsStatsPlugin.Logger.LogInfo($" Reflection modifiers: {modifiers.Count}"); + + // Add modifiers from player name symbols + if (nameModifiers.Count > 0) { - modifiers = ParseModifiersFromRoleString(record.RoleString); - if (modifiers.Count > 0) + TownOfUsStatsPlugin.Logger.LogInfo($" Name symbol modifiers: {nameModifiers.Count} ({string.Join(", ", nameModifiers)})"); + foreach (var mod in nameModifiers) { - TownOfUsStatsPlugin.Logger.LogInfo($"Parsed {modifiers.Count} modifier(s) from RoleString for player {record.PlayerId}"); + if (!modifiers.Contains(mod)) + { + modifiers.Add(mod); + } } } + // If still no modifiers, try parsing from RoleString + if (modifiers.Count == 0 && !string.IsNullOrEmpty(record.RoleString)) + { + TownOfUsStatsPlugin.Logger.LogInfo($" Parsing modifiers from RoleString..."); + var roleStringMods = ParseModifiersFromRoleString(record.RoleString); + TownOfUsStatsPlugin.Logger.LogInfo($" Parsed {roleStringMods.Count} modifier(s): {string.Join(", ", roleStringMods)}"); + modifiers.AddRange(roleStringMods); + } + + TownOfUsStatsPlugin.Logger.LogInfo($" Total modifiers: {modifiers.Count} ({string.Join(", ", modifiers)})"); + // Get task info int totalTasks = 0; int completedTasks = 0; @@ -173,7 +198,8 @@ public static class DataTransformer // Use WinningFaction from GameHistory if available if (!string.IsNullOrEmpty(winningFaction)) { - return winningFaction; + // Clean HTML tags from winning faction + return StripColorTags(winningFaction); } // Fallback: Check first winner's team @@ -198,6 +224,74 @@ public static class DataTransformer return MapNames.TryGetValue(mapId, out var name) ? name : $"Unknown Map ({mapId})"; } + /// + /// Extracts modifier information from special symbols in player names. + /// Symbols like ♥ (Lovers) appear inside HTML tags like: + /// + /// Raw player name with HTML tags + /// List of modifier names found in the name + private static List ExtractModifiersFromPlayerName(string playerName) + { + var modifiers = new List(); + + if (string.IsNullOrEmpty(playerName)) + { + return modifiers; + } + + // Map of symbols to modifier names + // Note: \u2665 IS the same as ♥, so we only include one + var symbolToModifier = new Dictionary + { + { "♥", "Lovers" }, // \u2665 in Unicode + { "❤", "Lovers" }, // \u2764 + { "♡", "Lovers" }, // \u2661 + { "#", "Underdog" }, + { "★", "VIP" }, // \u2605 + { "☆", "VIP" }, // \u2606 + { "@", "Sleuth" }, + { "†", "Bait" }, // \u2020 + { "‡", "Bait" }, // \u2021 + { "§", "Torch" }, + { "⚡", "Flash" }, + { "⚔", "Assassin" }, + { "🗡", "Assassin" }, + }; + + // Extract content from size tags: SYMBOL + // This is where modifier symbols are located + var sizeTagPattern = @"]*>([^<]+)"; + var matches = Regex.Matches(playerName, sizeTagPattern); + + foreach (Match match in matches) + { + if (match.Groups.Count > 1) + { + var symbolText = match.Groups[1].Value; + TownOfUsStatsPlugin.Logger.LogInfo($" Found symbol in size tag: '{symbolText}'"); + + // Check if this symbol matches any known modifier + foreach (var kvp in symbolToModifier) + { + if (symbolText.Contains(kvp.Key)) + { + if (!modifiers.Contains(kvp.Value)) + { + modifiers.Add(kvp.Value); + TownOfUsStatsPlugin.Logger.LogInfo($" Matched modifier '{kvp.Value}' from symbol '{kvp.Key}'"); + } + } + } + } + } + + return modifiers; + } + + /// + /// Strips all HTML/Unity tags AND modifier symbols from text to get clean player name. + /// Should be called AFTER extracting modifiers from symbols. + /// private static string StripColorTags(string text) { if (string.IsNullOrEmpty(text)) @@ -205,8 +299,18 @@ public static class DataTransformer return text; } - text = Regex.Replace(text, @"", string.Empty); - text = text.Replace("", string.Empty); + // Step 1: Remove all Unity/HTML tags + // Pattern matches: , , , + text = Regex.Replace(text, @"<[^>]+>", string.Empty); + + // Step 2: Remove all modifier symbols that were extracted + // These symbols should not appear in the final clean name + var symbolsToRemove = new[] { "♥", "❤", "♡", "#", "★", "☆", "@", "†", "‡", "§", "⚡", "⚔", "🗡" }; + foreach (var symbol in symbolsToRemove) + { + text = text.Replace(symbol, string.Empty); + } + return text.Trim(); } diff --git a/Reflection/TouMiraReflectionBridge.cs b/Reflection/TouMiraReflectionBridge.cs index f386a23..1790ba8 100644 --- a/Reflection/TouMiraReflectionBridge.cs +++ b/Reflection/TouMiraReflectionBridge.cs @@ -317,18 +317,48 @@ public class TouMiraReflectionBridge TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: RoleBehaviour type = {roleBehaviour.GetType().Name}"); - // Get role name from RoleBehaviour.GetRoleName() + // Get role name from RoleBehaviour.GetRoleName() or fallback to type name + string? roleName = null; + var getRoleNameMethod = roleBehaviour.GetType().GetMethod("GetRoleName"); - if (getRoleNameMethod == null) + if (getRoleNameMethod != null) { - TownOfUsStatsPlugin.Logger.LogWarning($"GetRoleName method not found on {roleBehaviour.GetType().Name}"); + roleName = getRoleNameMethod.Invoke(roleBehaviour, null) as string; + } + + // Fallback: Extract role name from type name (e.g., "GrenadierRole" -> "Grenadier") + if (string.IsNullOrEmpty(roleName)) + { + var typeName = roleBehaviour.GetType().Name; + + // Remove "Role" suffix if present + if (typeName.EndsWith("Role")) + { + roleName = typeName.Substring(0, typeName.Length - 4); + } + // Remove "Tou" suffix (e.g., "EngineerTou" -> "Engineer") + else if (typeName.EndsWith("Tou")) + { + roleName = typeName.Substring(0, typeName.Length - 3); + } + else + { + roleName = typeName; + } + + TownOfUsStatsPlugin.Logger.LogInfo($"Player {playerId}: Using type name as role: {roleName}"); + } + + if (string.IsNullOrEmpty(roleName)) + { + TownOfUsStatsPlugin.Logger.LogWarning($"Could not determine role name for player {playerId}"); continue; } - var roleName = getRoleNameMethod.Invoke(roleBehaviour, null) as string; - if (string.IsNullOrEmpty(roleName)) + // Skip generic RoleBehaviour (not a real role) + if (roleName == "RoleBehaviour") { - TownOfUsStatsPlugin.Logger.LogWarning($"GetRoleName returned null/empty for player {playerId}"); + TownOfUsStatsPlugin.Logger.LogInfo($"Skipping generic RoleBehaviour for player {playerId}"); continue; } @@ -549,10 +579,9 @@ public class TouMiraReflectionBridge return text; } - text = Regex.Replace(text, @"", string.Empty); - text = text.Replace("", string.Empty); - text = text.Replace("", string.Empty).Replace("", string.Empty); - text = text.Replace("", string.Empty).Replace("", string.Empty); + // Remove all Unity/HTML tags + // Pattern matches: , , , + text = Regex.Replace(text, @"<[^>]+>", string.Empty); return text.Trim(); }