diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
 index eb168f4..f1c5907 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
@@ -1,46 +1,70 @@
 @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters
+@using MatrixUtils.Web.Shared.FilterComponents
 <div style="margin-left: 8px; margin-bottom: 8px;">
     <u style="display: block;">String contains</u>
-    <span class="tile tile280">Room ID: <FancyTextBox @bind-Value="@Filter.RoomIdContains"></FancyTextBox></span>
-    <span class="tile tile280">Room name: <FancyTextBox @bind-Value="@Filter.NameContains"></FancyTextBox></span>
-    <span class="tile tile280">Canonical alias: <FancyTextBox @bind-Value="@Filter.CanonicalAliasContains"></FancyTextBox></span>
-    <span class="tile tile280">Creator: <FancyTextBox @bind-Value="@Filter.CreatorContains"></FancyTextBox></span>
-    <span class="tile tile280">Room version: <FancyTextBox @bind-Value="@Filter.VersionContains"></FancyTextBox></span>
-    <span class="tile tile280">Encryption algorithm: <FancyTextBox @bind-Value="@Filter.EncryptionContains"></FancyTextBox></span>
-    <span class="tile tile280">Join rules: <FancyTextBox @bind-Value="@Filter.JoinRulesContains"></FancyTextBox></span>
-    <span class="tile tile280">Guest access: <FancyTextBox @bind-Value="@Filter.GuestAccessContains"></FancyTextBox></span>
-    <span class="tile tile280">History visibility: <FancyTextBox @bind-Value="@Filter.HistoryVisibilityContains"></FancyTextBox></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.RoomId" Label="Room ID"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Name" Label="Room name"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.CanonicalAlias" Label="Canonical alias"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Creator" Label="Creator"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Version" Label="Room version"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Encryption" Label="Encryption algorithm"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.JoinRules" Label="Join rules"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.GuestAccess" Label="Guest access"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.HistoryVisibility" Label="History visibility"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Topic" Label="Topic"/></span>
 
     <u style="display: block;">Optional checks</u>
-    <span class="tile tile150">
-        <InputCheckbox @bind-Value="@Filter.CheckFederation"></InputCheckbox> Is federated:
-        @if (Filter.CheckFederation) {
-            <InputCheckbox @bind-Value="@Filter.Federatable"></InputCheckbox>
-        }
-    </span>
-    <span class="tile tile150">
-        <InputCheckbox @bind-Value="@Filter.CheckPublic"></InputCheckbox> Is public:
-        @if (Filter.CheckPublic) {
-            <InputCheckbox @bind-Value="@Filter.Public"></InputCheckbox>
-        }
-    </span>
+    <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Federation" Label="Is federated"/></span>
+    <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Public" Label="Is public"/></span>
+    <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Tombstone" Label="Is tombstoned"/></span>
 
     <u style="display: block;">Ranges</u>
     <span class="tile center-children">
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsGreaterThan"/>
-        <span class="range-sep">state events</span>
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsLessThan"/>
+        <InputCheckbox @bind-Value="@Filter.StateEvents.Enabled"/>
+        @if (!Filter.StateEvents.Enabled) {
+            <span>State events</span>
+        }
+        else {
+            <InputCheckbox @bind-Value="@Filter.StateEvents.CheckGreaterThan"/>
+            <span> </span>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEvents.GreaterThan"/>
+            <span class="range-sep">state events</span>
+            <InputCheckbox @bind-Value="@Filter.StateEvents.CheckLessThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEvents.LessThan"/>
+        }
     </span>
     <span class="tile center-children">
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembersGreaterThan"></InputNumber><span class="range-sep">members</span><InputNumber
-            max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembersLessThan"></InputNumber>
+        <InputCheckbox @bind-Value="@Filter.JoinedMembers.Enabled"/>
+        @if (!Filter.JoinedMembers.Enabled) {
+            <span>Joined members</span>
+        }
+        else {
+            <InputCheckbox @bind-Value="@Filter.JoinedMembers.CheckGreaterThan"/>
+            <span> </span>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembers.GreaterThan"/>
+            <span class="range-sep">members</span>
+            <InputCheckbox @bind-Value="@Filter.JoinedMembers.CheckLessThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembers.LessThan"/>
+        }
     </span>
     <span class="tile center-children">
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembersGreaterThan"></InputNumber><span
-            class="range-sep">local members</span><InputNumber max="@int.MaxValue" class="int-input" TValue="int"
-                                                               @bind-Value="@Filter.JoinedLocalMembersLessThan"></InputNumber>
+        <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.Enabled"/>
+        <span> </span>
+        @if (!Filter.JoinedLocalMembers.Enabled) {
+            <span>Joined local members</span>
+        }
+        else {
+            <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.CheckGreaterThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembers.GreaterThan"/>
+            <span class="range-sep">local members</span>
+            <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.CheckLessThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembers.LessThan"/>
+        }
     </span>
 </div>
+@* @{ *@
+@*     Console.WriteLine($"Rendered SynapseRoomQueryFilter with filter: {Filter.ToJson()}"); *@
+@* } *@
 
 @code {
 
diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
 index 48aea86..fc9f8e8 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
@@ -1,8 +1,12 @@
+@using System.Text.Json.Serialization
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Homeservers.Extensions.NamedCaches
 @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests
 @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses
 
-@if (string.IsNullOrWhiteSpace(Context.DeleteId)) {
+@if (string.IsNullOrWhiteSpace(Context.DeleteId) || EditorOnly) {
     <span>Block room: </span>
     <InputCheckbox @bind-Value="@Context.DeleteRequest.Block"/>
     <br/>
@@ -34,6 +38,12 @@
         <br/>
         <span>Delete <b>ALL</b> local user media: </span>
         <InputCheckbox @bind-Value="@Context.ExtraOptions.DeleteLocalUserMedia"></InputCheckbox>
+        <br/>
+        <span>Follow tombstone (if any): </span>
+        <InputCheckbox @bind-Value="@Context.ExtraOptions.FollowTombstone"/>
+        @if (!EditorOnly) {
+            <LinkButton InlineText="true" OnClickAsync="@FollowTombstoneAsync">Exec</LinkButton>
+        }
     </details>
 
     <details>
@@ -50,7 +60,19 @@
         <br/>
     </details>
 
-    <LinkButton OnClickAsync="@DeleteRoom">Execute</LinkButton>
+    @if (!EditorOnly) {
+        <LinkButton OnClickAsync="@DeleteRoom">Execute</LinkButton>
+    }
+}
+else {
+    <pre>
+        @(_status?.ToJson() ?? "Loading status...")
+    </pre>
+    <br/>
+    <LinkButton InlineText="true" OnClickAsync="@OnComplete">[Stop tracking]</LinkButton>
+    if (_status?.Status == SynapseAdminRoomDeleteStatus.Failed) {
+        <LinkButton InlineText="true" OnClickAsync="@ForceDelete">[Force delete]</LinkButton>
+    }
 }
 
 @code {
@@ -61,14 +83,52 @@
     [Parameter]
     public required AuthenticatedHomeserverSynapse Homeserver { get; set; }
 
+    [Parameter]
+    public bool EditorOnly { get; set; }
+
     private NamedCache<RoomShutdownContext> TaskMap { get; set; } = null!;
+    private SynapseAdminRoomDeleteStatus? _status = null;
+    private bool _isTracking = false;
 
     protected override async Task OnInitializedAsync() {
+        if (EditorOnly) return;
         TaskMap = new NamedCache<RoomShutdownContext>(Homeserver, "gay.rory.matrixutils.synapse_room_shutdown_tasks");
+        var existing = await TaskMap.GetValueAsync(Context.RoomId);
+        if (existing is not null) {
+            Context = existing;
+        }
+
+        if (Context.ExecuteImmediately)
+            await DeleteRoom();
+    }
+
+    protected override async Task OnAfterRenderAsync(bool firstRender) {
+        if (EditorOnly) return;
+        if (!_isTracking) {
+            if (!string.IsNullOrWhiteSpace(Context.DeleteId)) {
+                _isTracking = true;
+                _ = Task.Run(async () => {
+                    do {
+                        _status = await Homeserver.Admin.GetRoomDeleteStatus(Context.DeleteId);
+                        StateHasChanged();
+                        if (_status.Status == SynapseAdminRoomDeleteStatus.Complete) {
+                            await OnComplete();
+                            break;
+                        }
+
+                        await Task.Delay(1000);
+                    } while (_status.Status != SynapseAdminRoomDeleteStatus.Failed && _status.Status != SynapseAdminRoomDeleteStatus.Complete);
+                });
+            }
+        }
     }
 
     public class RoomShutdownContext {
         public required string RoomId { get; set; }
+
+        [JsonIgnore] // do NOT persist - this triggers immediate purging
+        public bool ExecuteImmediately { get; set; }
+
         public string? DeleteId { get; set; }
         public ExtraDeleteOptions ExtraOptions { get; set; } = new();
 
@@ -81,10 +141,11 @@
         public SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom? RoomDetails { get; set; }
 
         public class ExtraDeleteOptions {
-            // room options
+            public bool FollowTombstone { get; set; }
+
+            // media options
             public bool QuarantineLocalMedia { get; set; }
             public bool QuarantineRemoteMedia { get; set; }
-
             public bool DeleteRemoteMedia { get; set; }
 
             // user options
@@ -95,9 +156,14 @@
     }
 
     public async Task OnComplete() {
+        if (EditorOnly) return;
+        Console.WriteLine($"Room shutdown task for {Context.RoomId} completed, removing from map.");
         await OnCompleteLock.WaitAsync();
         try {
-            await TaskMap.RemoveValueAsync(Context.DeleteId!);
+            await TaskMap.RemoveValueAsync(Context.RoomId!);
+        }
+        catch (Exception e) {
+            Console.WriteLine("Failed to remove completed room shutdown task from map: " + e);
         }
         finally {
             OnCompleteLock.Release();
@@ -105,6 +171,11 @@
     }
 
     public async Task DeleteRoom() {
+        if (EditorOnly) return;
+        if (Context.ExtraOptions.FollowTombstone) await FollowTombstoneAsync();
+
+        Console.WriteLine($"Deleting room {Context.RoomId} with options: " + Context.DeleteRequest.ToJson());
+
         var resp = await Homeserver.Admin.DeleteRoom(Context.RoomId, Context.DeleteRequest, false);
         Context.DeleteId = resp.DeleteId;
         await TaskMap.SetValueAsync(Context.RoomId, Context);
@@ -112,4 +183,84 @@
 
     private static readonly SemaphoreSlim OnCompleteLock = new(1, 1);
 
+    private async Task FollowTombstoneAsync() {
+        if (EditorOnly) return;
+        var tomb = await TryGetTombstoneAsync();
+        var content = tomb?.ContentAs<RoomTombstoneEventContent>();
+        if (content != null && !string.IsNullOrWhiteSpace(content.ReplacementRoom)) {
+            Console.WriteLine("Tombstone: " + tomb.ToJson());
+            if (!content.ReplacementRoom.StartsWith('!')) {
+                Console.WriteLine($"Invalid replacement room ID in tombstone: {content.ReplacementRoom}, ignoring!");
+            }
+            else {
+                var oldMembers = await Homeserver.Admin.GetRoomMembersAsync(Context.RoomId, localOnly: true);
+                var isKnownRoom = await Homeserver.Admin.CheckRoomKnownAsync(content.ReplacementRoom);
+                var targetMembers = isKnownRoom
+                    ? await Homeserver.Admin.GetRoomMembersAsync(Context.RoomId, localOnly: true)
+                    : new() { Members = [] };
+
+                var members = oldMembers.Members.Except(targetMembers.Members).ToList();
+                Console.WriteLine("To migrate: " + members.ToJson());
+                foreach (var member in members) {
+                    var success = false;
+                    do {
+                        var sess = member == Homeserver.WhoAmI.UserId ? Homeserver : await Homeserver.Admin.GetHomeserverForUserAsync(member, TimeSpan.FromSeconds(15));
+                        var oldRoom = sess.GetRoom(Context.RoomId);
+                        var room = sess.GetRoom(content.ReplacementRoom);
+                        try {
+                            var servers = (await oldRoom.GetMembersByHomeserverAsync(joinedOnly: true))
+                                .Select(x => new KeyValuePair<string, int>(x.Key, x.Value.Count))
+                                .OrderByDescending(x => x.Key == "matrix.org" ? 0 : x.Value); // try anything else first, to reduce load on matrix.org
+
+                            await room.JoinAsync(servers.Take(10).Select(x => x.Key).ToArray(), reason: "Automatically following tombstone as old room is being purged.", checkIfAlreadyMember: isKnownRoom);
+                            Console.WriteLine($"Migrated {member} from {Context.RoomId} to {content.ReplacementRoom}");
+                            success = true;
+                        }
+                        catch (Exception e) {
+                            if (e is MatrixException { ErrorCode: "M_FORBIDDEN" }) {
+                                Console.WriteLine($"Cannot migrate {member} to {content.ReplacementRoom}: {(e as MatrixException)!.GetAsJson()}");
+                                success = true; // give up
+                                continue;
+                            }
+
+                            Console.WriteLine($"Failed to invite {member} to {content.ReplacementRoom}: {e}");
+                            success = false;
+                            await Task.Delay(1000);
+                        }
+                    } while (!success);
+                }
+            }
+        }
+    }
+
+    private async Task<StateEventResponse?> TryGetTombstoneAsync() {
+        if (EditorOnly) return null;
+        try {
+            return (await Homeserver.Admin.GetRoomStateAsync(Context.RoomId, RoomTombstoneEventContent.EventId)).Events.FirstOrDefault(x => x.StateKey == "");
+        }
+        catch {
+            return null;
+        }
+    }
+
+    private async Task ForceDelete() {
+        if (EditorOnly) return;
+        Console.WriteLine($"Forcing purge for {Context.RoomId}!");
+        await OnCompleteLock.WaitAsync();
+        try {
+            var resp = await Homeserver.Admin.DeleteRoom(Context.RoomId, new() {
+                ForcePurge = true
+            }, waitForCompletion: false);
+            Context.DeleteId = resp.DeleteId;
+            await TaskMap.SetValueAsync(Context.RoomId, Context);
+            StateHasChanged();
+        }
+        catch (Exception e) {
+            Console.WriteLine("Failed to remove completed room shutdown task from map: " + e);
+        }
+        finally {
+            OnCompleteLock.Release();
+        }
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
 index 07a3dd2..05899c8 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
@@ -1,5 +1,7 @@
 @page "/HSAdmin/Synapse/RoomQuery"
 @using System.Diagnostics.CodeAnalysis
+@using System.Text.Json
+@using ArcaneLibs.Blazor.Components.Services
 @using Microsoft.AspNetCore.WebUtilities
 @using ArcaneLibs.Extensions
 @using LibMatrix
@@ -10,6 +12,7 @@
 @using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components
 @using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components.RoomQuery
 @inject ILogger<RoomQuery> Logger
+@inject BlazorSaveFileService BlazorSaveFileService
 
 <h3>Homeserver Administration - Room Query</h3>
 
@@ -21,15 +24,42 @@
         <option value="@item.Key">@item.Value</option>
     }
 </select><br/>
-<label>Ascending: </label>
-<InputCheckbox @bind-Value="Ascending"/><br/>
+<InputCheckbox @bind-Value="Ascending"/>
+<label> Ascending</label><br/>
+<InputCheckbox @bind-Value="FetchV12PlusCreatorServer"/>
+<label> Fetch v12+ room creation homeserver</label>
+<LinkButton InlineText="true" OnClickAsync="FetchV12PlusCreatorServersAsync"> (Execute manually)</LinkButton><br/>
+<InputCheckbox @bind-Value="FetchTombstones"/>
+<label> Check for tombstone events</label>
+<LinkButton InlineText="true" OnClickAsync="FetchTombstoneEventsAsync"> (Execute manually)</LinkButton><br/>
+<InputCheckbox @bind-Value="SummarizeLocalMembers"/>
+<label> Fetch local member list for small rooms</label>
+<LinkButton InlineText="true" OnClickAsync="FetchLocalMemberEventsAsync"> (Execute manually)</LinkButton><br/>
+<InputCheckbox @bind-Value="ShowFullResultData"/>
+<label> Show full result data (JSON)</label><br/>
+<InputCheckbox @bind-Value="EnableMultiPurge"/>
+<label> Enable multi-purge mode</label>
+@if (EnableMultiPurge) {
+    <span> </span>
+    <LinkButton InlineText="true" OnClick="@MultiPurgeInvertSelection">[Invert selection]</LinkButton>
+    <span> </span>
+    <details style="display: inline-block;">
+        <summary>Edit purge options</summary>
+        <SynapseRoomShutdownWindowContent Context="@DefaultShutdownContext" Homeserver="Homeserver" EditorOnly="true"/>
+    </details>
+}
+else {
+    <br/>
+}
 <details>
-    <summary>
-        <span>Local filtering (slow)</span>
-    </summary>
+    <summary>Local filtering (slow)</summary>
     <SynapseRoomQueryFilter Filter="@Filter"/>
 </details>
-<button class="btn btn-primary" @onclick="Search">Search</button>
+<LinkButton OnClickAsync="@Search">Search</LinkButton>
+
+@if (EnableMultiPurge) {
+    <LinkButton Color="#FF8800" OnClick="@PurgeSelection">Purge selected rooms</LinkButton>
+}
 <br/>
 
 @if (Results.Count > 0) {
@@ -40,6 +70,10 @@
     <div class="room-list-item">
         @* <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem> *@
         <p>
+            @if (EnableMultiPurge) {
+                <InputCheckbox @bind-Value="@room.MultiPurgeSelected"/>
+                <span> </span>
+            }
             @if (!string.IsNullOrWhiteSpace(room.CanonicalAlias)) {
                 <span>@room.CanonicalAlias - </span>
             }
@@ -57,6 +91,8 @@
         <p>
             <LinkButton OnClickAsync="@(() => DeleteRoom(room))">Delete room</LinkButton>
             <LinkButton target="_blank" href="@($"/HSAdmin/Synapse/ResyncState?roomId={room.RoomId}&via={room.OriginHomeserver}")">Resync state</LinkButton>
+            <LinkButton OnClickAsync="@(() => ExportState(room))">@(room.JoinedLocalMembers == 0 ? "Try to export state" : "Export state")</LinkButton>
+            <LinkButton OnClickAsync="@(() => ForceJoin(room))">Force Join</LinkButton>
         </p>
 
         @{
@@ -119,11 +155,23 @@
                 memberSummary += $": {string.Join(", ", room.LocalMembers)}";
             }
         }
-        <span>@memberSummary</span>
-        <details>
-            <summary>Full result data</summary>
-            <pre>@room.ToJson(ignoreNull: true)</pre>
-        </details>
+        <span>@memberSummary</span><br/>
+        @if (!string.IsNullOrWhiteSpace(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic)) {
+            <details>
+                <summary>Room topic</summary>
+                <pre>@(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic)</pre>
+            </details>
+        }
+        @foreach (var ex in room.Exceptions) {
+            <span style="color: red;">@ex</span>
+            <br/>
+        }
+        @if (ShowFullResultData) {
+            <details>
+                <summary>Full result data</summary>
+                <pre>@room.ToJson(ignoreNull: true)</pre>
+            </details>
+        }
     </div>
 }
 @* *@
@@ -148,10 +196,6 @@
     </ModalWindow>
 }
 
-<style>
-
-</style>
-
 @code {
 
     [Parameter]
@@ -166,6 +210,18 @@
     [SupplyParameterFromQuery(Name = "ascending")]
     public bool Ascending { get; set; } = true;
 
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "FetchV12PlusCreatorServer")]
+    public bool FetchV12PlusCreatorServer { get; set; } = true;
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "SummarizeLocalMembers")]
+    public bool SummarizeLocalMembers { get; set; } = true;
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "FetchTombstones")]
+    public bool FetchTombstones { get; set; } = true;
+
     private List<RoomInfo> Results { get; set; } = new();
 
     private AuthenticatedHomeserverSynapse Homeserver { get; set; } = null!;
@@ -178,6 +234,21 @@
 
     private NamedCache<SynapseRoomShutdownWindowContent.RoomShutdownContext> TaskMap { get; set; } = null!;
 
+    private SynapseRoomShutdownWindowContent.RoomShutdownContext DefaultShutdownContext { get; set; } = new() {
+        RoomId = "",
+        DeleteRequest = new() { Block = true, Purge = true, ForcePurge = false }
+    };
+
+    public bool ShowFullResultData {
+        get;
+        set {
+            field = value;
+            StateHasChanged();
+        }
+    }
+
+    public bool EnableMultiPurge { get; set; }
+
     protected override async Task OnInitializedAsync() {
         var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true);
         if (hs is not AuthenticatedHomeserverSynapse synapse) {
@@ -199,67 +270,95 @@
         foreach (var (key, value) in QueryHelpers.ParseQuery(new Uri(NavigationManager.Uri).Query)) {
             switch (key) {
                 case "RoomIdContains":
-                    Filter.RoomIdContains = value[0]!;
+                    Filter.RoomId.Enabled = Filter.RoomId.CheckValueContains = true;
+                    Filter.RoomId.ValueContains = value[0]!;
                     break;
                 case "NameContains":
-                    Filter.NameContains = value[0]!;
+                    Filter.Name.Enabled = Filter.Name.CheckValueContains = true;
+                    Filter.Name.ValueContains = value[0]!;
                     break;
                 case "CanonicalAliasContains":
-                    Filter.CanonicalAliasContains = value[0]!;
+                    Filter.CanonicalAlias.Enabled = Filter.CanonicalAlias.CheckValueContains = true;
+                    Filter.CanonicalAlias.ValueContains = value[0]!;
                     break;
                 case "VersionContains":
-                    Filter.VersionContains = value[0]!;
+                    Filter.Version.Enabled = Filter.Version.CheckValueContains = true;
+                    Filter.Version.ValueContains = value[0]!;
                     break;
                 case "CreatorContains":
-                    Filter.CreatorContains = value[0]!;
+                    Filter.Creator.Enabled = Filter.Creator.CheckValueContains = true;
+                    Filter.Creator.ValueContains = value[0]!;
                     break;
                 case "EncryptionContains":
-                    Filter.EncryptionContains = value[0]!;
+                    Filter.Encryption.Enabled = Filter.Encryption.CheckValueContains = true;
+                    Filter.Encryption.ValueContains = value[0]!;
                     break;
                 case "JoinRulesContains":
-                    Filter.JoinRulesContains = value[0]!;
+                    Filter.JoinRules.Enabled = Filter.JoinRules.CheckValueContains = true;
+                    Filter.JoinRules.ValueContains = value[0]!;
                     break;
                 case "GuestAccessContains":
-                    Filter.GuestAccessContains = value[0]!;
+                    Filter.GuestAccess.Enabled = Filter.GuestAccess.CheckValueContains = true;
+                    Filter.GuestAccess.ValueContains = value[0]!;
                     break;
                 case "HistoryVisibilityContains":
-                    Filter.HistoryVisibilityContains = value[0]!;
+                    Filter.HistoryVisibility.Enabled = Filter.HistoryVisibility.CheckValueContains = true;
+                    Filter.HistoryVisibility.ValueContains = value[0]!;
                     break;
                 case "Federatable":
-                    Filter.Federatable = bool.Parse(value[0]!);
-                    Filter.CheckFederation = true;
+                    Filter.Federation = new() {
+                        Enabled = true,
+                        Value = bool.Parse(value[0]!)
+                    };
                     break;
                 case "Public":
-                    Filter.Public = value[0] == "true";
-                    Filter.CheckPublic = true;
+                    Filter.Public = new() {
+                        Enabled = true,
+                        Value = bool.Parse(value[0]!)
+                    };
                     break;
                 case "JoinedMembersGreaterThan":
-                    Filter.JoinedMembersGreaterThan = int.Parse(value[0]!);
+                    Filter.JoinedMembers.Enabled = Filter.JoinedLocalMembers.CheckGreaterThan = true;
+                    Filter.JoinedMembers.GreaterThan = int.Parse(value[0]!);
                     break;
                 case "JoinedMembersLessThan":
-                    Filter.JoinedMembersLessThan = int.Parse(value[0]!);
+                    Filter.JoinedMembers.Enabled = Filter.JoinedLocalMembers.CheckLessThan = true;
+                    Filter.JoinedMembers.LessThan = int.Parse(value[0]!);
                     break;
                 case "JoinedLocalMembersGreaterThan":
-                    Filter.JoinedLocalMembersGreaterThan = int.Parse(value[0]!);
+                    Filter.JoinedLocalMembers.Enabled = Filter.JoinedLocalMembers.CheckGreaterThan = true;
+                    Filter.JoinedLocalMembers.GreaterThan = int.Parse(value[0]!);
                     break;
                 case "JoinedLocalMembersLessThan":
-                    Filter.JoinedLocalMembersLessThan = int.Parse(value[0]!);
+                    Filter.JoinedLocalMembers.Enabled = Filter.JoinedLocalMembers.CheckLessThan = true;
+                    Filter.JoinedLocalMembers.LessThan = int.Parse(value[0]!);
                     break;
                 case "StateEventsGreaterThan":
-                    Filter.StateEventsGreaterThan = int.Parse(value[0]!);
+                    Filter.StateEvents.Enabled = Filter.StateEvents.CheckGreaterThan = true;
+                    Filter.StateEvents.GreaterThan = int.Parse(value[0]!);
                     break;
                 case "StateEventsLessThan":
-                    Filter.StateEventsLessThan = int.Parse(value[0]!);
+                    Filter.StateEvents.Enabled = Filter.StateEvents.CheckLessThan = true;
+                    Filter.StateEvents.LessThan = int.Parse(value[0]!);
                     break;
                 case "Execute":
                     execute = true;
                     break;
+                case "order_by":
+                case "name_search":
+                case "ascending":
+                case "FetchV12PlusCreatorServer":
+                case "SummarizeLocalMembers":
+                case "FetchTombstones":
+                    break;
                 default:
                     Console.WriteLine($"Unknown query parameter: {key}");
                     break;
             }
         }
 
+        StateHasChanged();
+
         if (execute)
             _ = Search();
 
@@ -268,7 +367,26 @@
 
     private async Task Search() {
         Results.Clear();
-        var searchRooms = Homeserver.Admin.SearchRoomsAsync(orderBy: OrderBy!, dir: Ascending ? "f" : "b", searchTerm: SearchTerm, localFilter: Filter).GetAsyncEnumerator();
+        Console.WriteLine("Starting search... Parameters: " + new {
+            orderBy = OrderBy!,
+            dir = Ascending ? "f" : "b",
+            searchTerm = SearchTerm,
+            localFilter = Filter,
+            chunkLimit = 1000,
+            fetchTombstones = FetchTombstones,
+            fetchTopics = true,
+            fetchCreateEvents = true
+        }.ToJson());
+        var searchRooms = Homeserver.Admin.SearchRoomsAsync(
+            orderBy: OrderBy!,
+            dir: Ascending ? "f" : "b",
+            searchTerm: SearchTerm,
+            localFilter: Filter,
+            chunkLimit: 1000,
+            fetchTombstones: FetchTombstones,
+            fetchTopics: true,
+            fetchCreateEvents: true
+        ).GetAsyncEnumerator();
         var joinedRooms = await Homeserver.GetJoinedRooms();
         while (await searchRooms.MoveNextAsync()) {
             var room = searchRooms.Current;
@@ -288,15 +406,26 @@
                 StateEvents = room.StateEvents,
                 JoinedMembers = room.JoinedMembers,
                 JoinedLocalMembers = room.JoinedLocalMembers,
-                OriginHomeserver = joinedRooms.Any(x => x.RoomId == room.RoomId)
-                    ? await Homeserver.GetRoom(room.RoomId).GetOriginHomeserverAsync()
-                    : (await Homeserver.Admin.GetRoomStateAsync(room.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1] 
-                      ?? string.Empty
+                OriginHomeserver =
+                    Homeserver.GetRoom(room.RoomId).IsV12PlusRoomId
+                        ? room.RoomId.Split(':', 2).Skip(1).FirstOrDefault(string.Empty)
+                        : string.Empty
             };
 
+            if (string.IsNullOrWhiteSpace(roomInfo.OriginHomeserver) && FetchV12PlusCreatorServer) {
+                try {
+                    if (joinedRooms.Any(x => x.RoomId == room.RoomId))
+                        roomInfo.OriginHomeserver = await Homeserver.GetRoom(room.RoomId).GetOriginHomeserverAsync();
+                    else roomInfo.OriginHomeserver = (await Homeserver.Admin.GetRoomStateAsync(room.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1];
+                }
+                catch (MatrixException e) {
+                    roomInfo.Exceptions.Add($"While getting origin homeserver: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}");
+                }
+            }
+
             Results.Add(roomInfo);
 
-            if ((Results.Count <= 200 && Results.Count % 10 == 0) || Results.Count % 1000 == 0) {
+            if ((Results.Count <= 200 && Results.Count % 10 == 0 && FetchV12PlusCreatorServer) || Results.Count % 1000 == 0) {
                 StateHasChanged();
                 await Task.Yield();
                 await Task.Delay(1);
@@ -305,97 +434,29 @@
 
         StateHasChanged();
 
-        var getLocalMembersTasks = Results
-            .Where(x => x.JoinedLocalMembers is > 0 and < 100)
-            .Select(async r => {
-                    var members = (await Homeserver.Admin.GetRoomMembersAsync(r.RoomId)).Members.Where(x => x.EndsWith(":" + Homeserver.ServerName)).ToList();
-                    r.LocalMembers = members;
-                }
-            );
-        await Task.WhenAll(getLocalMembersTasks);
-
-        var getTombstoneTasks = Results
-            .Select(async r => {
-                var state = await Homeserver.Admin.GetRoomStateAsync(r.RoomId, type: "m.room.tombstone");
-                var tombstone = state.Events.FirstOrDefault(x => x is { StateKey: "", Type: "m.room.tombstone" });
-                if (tombstone is { } tombstoneEvent) {
-                    r.TombstoneEvent = tombstoneEvent;
-                }
-            });
-        await Task.WhenAll(getTombstoneTasks);
+        if (FetchV12PlusCreatorServer) await FetchV12PlusCreatorServersAsync(false);
+        if (SummarizeLocalMembers) await FetchLocalMemberEventsAsync(false);
+        // if (CheckTombstone) await FetchTombstoneEventsAsync(false);
 
         StateHasChanged();
     }
 
-    Task DeleteRoom(RoomInfo room) {
-        DeleteRequests.TryAdd(room.RoomId, new() { RoomId = room.RoomId, RoomDetails = room, DeleteRequest = new() { Block = true, Purge = true, ForcePurge = false } });
+    private Task DeleteRoom(RoomInfo room, bool executeWithoutConfirmation = false) {
+        var dc = JsonSerializer.Deserialize<SynapseRoomShutdownWindowContent.RoomShutdownContext>(DefaultShutdownContext.ToJson())!;
+        dc.RoomId = room.RoomId;
+        dc.RoomDetails = room;
+        dc.ExecuteImmediately = executeWithoutConfirmation;
+        DeleteRequests.TryAdd(room.RoomId, dc);
         StateHasChanged();
 
         return Task.CompletedTask;
     }
 
-    //
-    // private async Task DeleteRoom() {
-    //     if (DeleteRequest is { } deleteRequest) {
-    //         var media = await Homeserver.Admin.GetRoomMediaAsync(deleteRequest.RoomId);
-    //         if (deleteRequest.DeleteRequest.QuarantineRemoteMedia) {
-    //             foreach (var remoteMedia in media.Remote) {
-    //                 await Homeserver.Admin.QuarantineMediaById(remoteMedia);
-    //             }
-    //         }
-    //
-    //         if (deleteRequest.DeleteRequest.DeleteRemoteMedia) {
-    //             foreach (var remoteMedia in media.Remote) {
-    //                 await Homeserver.Admin.DeleteMediaById(remoteMedia);
-    //             }
-    //         }
-    //         else if (deleteRequest.DeleteRequest.QuarantineLocalMedia) {
-    //             foreach (var localMedia in media.Local) {
-    //                 await Homeserver.Admin.QuarantineMediaById(localMedia);
-    //             }
-    //         }
-    //
-    //         var deleteId = await Homeserver.Admin.DeleteRoom(deleteRequest.RoomId, deleteRequest.DeleteRequest, waitForCompletion: false);
-    //         DeleteRequest = null;
-    //         List<string> alreadyCleanedUsers = [];
-    //         while (true) {
-    //             var status = await Homeserver.Admin.GetRoomDeleteStatus(deleteId.DeleteId);
-    //             DeleteStatuses[deleteRequest.RoomId] = status;
-    //             StateHasChanged();
-    //             await Task.Delay(5000);
-    //             if (status.Status == "complete") {
-    //                 DeleteStatuses.Remove(deleteRequest.RoomId);
-    //                 StateHasChanged();
-    //                 break;
-    //             }
-    //
-    //             if (status.Status == "failed") {
-    //                 deleteId = await Homeserver.Admin.DeleteRoom(deleteRequest.RoomId, deleteRequest.DeleteRequest, waitForCompletion: false);
-    //             }
-    //
-    //             var newCleanedUsers = status.ShutdownRoom?.KickedUsers?.Except(alreadyCleanedUsers).ToList();
-    //             if (newCleanedUsers is not null) {
-    //                 alreadyCleanedUsers.AddRange(newCleanedUsers);
-    //                 foreach (var user in newCleanedUsers) {
-    //                     if (deleteRequest.DeleteRequest.SuspendLocalUsers) {
-    //                         // await Homeserver.Admin.(user);
-    //                     }
-    //
-    //                     if (deleteRequest.DeleteRequest.QuarantineLocalUserMedia) {
-    //                         await Homeserver.Admin.QuarantineMediaByUserId(user);
-    //                     }
-    //
-    //                     if (deleteRequest.DeleteRequest.DeleteLocalUserMedia) {
-    //                         var userMedia = Homeserver.Admin.GetUserMediaEnumerableAsync(user);
-    //                         await foreach (var mediaEntry in userMedia) {
-    //                             await Homeserver.Admin.DeleteMediaById(mediaEntry.MediaId);
-    //                         }
-    //                     }
-    //                 }
-    //             }
-    //         }
-    //     }
-    // }
+    private void PurgeSelection() {
+        foreach (var room in Results.Where(x => x.MultiPurgeSelected)) {
+            DeleteRoom(room, true);
+        }
+    }
 
     private readonly Dictionary<string, string> validOrderBy = new() {
         { "name", "Room name" },
@@ -415,11 +476,143 @@
 
     private class RoomInfo : SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom {
         public List<string>? LocalMembers { get; set; }
-        public StateEventResponse? TombstoneEvent { get; set; }
         public required string OriginHomeserver { get; set; }
 
         [field: AllowNull, MaybeNull]
         public string MemberSummary => field ??= $"{JoinedMembers} members, of which {JoinedLocalMembers} are on this server";
+
+        public List<string> Exceptions { get; set; } = [];
+        public bool MultiPurgeSelected { get; set; }
+    }
+
+    private async Task ExportState(RoomInfo room) {
+        try {
+            var state = await Homeserver.Admin.GetRoomStateAsync(room.RoomId);
+            var json = state.ToJson();
+            await BlazorSaveFileService.SaveFileAsync($"{room.RoomId.Replace(":", "_")}_state.json", System.Text.Encoding.UTF8.GetBytes(json), "application/json");
+        }
+        catch (Exception e) {
+            Logger.LogError(e, "Failed to export room state for {RoomId}", room.RoomId);
+        }
+    }
+
+    private async Task ForceJoin(RoomInfo room) {
+        try {
+            await Homeserver.GetRoom(room.RoomId).JoinAsync([Homeserver.ServerName]);
+        }
+        catch (Exception e) {
+            Logger.LogError(e, "Failed to force-join room {RoomId}", room.RoomId);
+            // await Homeserver.Admin.room
+        }
+    }
+
+    private SemaphoreSlim _concurrencyLimiter = new SemaphoreSlim(16, 16);
+
+    private async Task FetchV12PlusCreatorServersAsync() => await FetchV12PlusCreatorServersAsync(true);
+
+    private async Task FetchV12PlusCreatorServersAsync(bool rerender) {
+        var joinedRooms = await Homeserver.GetJoinedRooms();
+        var tasks = Results
+            .Where(x => string.IsNullOrWhiteSpace(x.OriginHomeserver))
+            .Select(async r => {
+                if (!string.IsNullOrWhiteSpace(r.Creator) && r.Creator.Contains(':')) {
+                    r.OriginHomeserver = r.Creator.Split(':', 2)[1];
+                    return;
+                }
+
+                if (r.CreateEvent != null && !string.IsNullOrWhiteSpace(r.CreateEvent.Sender) && r.CreateEvent.Sender.Contains(':')) {
+                    r.OriginHomeserver = r.CreateEvent.Sender.Split(':', 2)[1];
+                    return;
+                }
+
+                await _concurrencyLimiter.WaitAsync();
+                try {
+                    if (joinedRooms.Any(x => x.RoomId == r.RoomId))
+                        r.OriginHomeserver = await Homeserver.GetRoom(r.RoomId).GetOriginHomeserverAsync();
+                    else r.OriginHomeserver = (await Homeserver.Admin.GetRoomStateAsync(r.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1];
+                }
+                catch (MatrixException e) {
+                    r.Exceptions.Add($"While getting origin homeserver: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}");
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to get origin homeserver for {r.RoomId}, unhandled exception: " + e);
+                }
+                finally {
+                    _concurrencyLimiter.Release();
+                }
+            });
+
+        await Task.WhenAll(tasks);
+
+        if (rerender)
+            StateHasChanged();
+    }
+
+    private async Task FetchTombstoneEventsAsync() => await FetchTombstoneEventsAsync(true);
+
+    private async Task FetchTombstoneEventsAsync(bool rerender) {
+        var getTombstoneTasks = Results
+            .Where(x => x.TombstoneEvent is null)
+            .Select(async r => {
+                await _concurrencyLimiter.WaitAsync();
+                try {
+                    var state = await Homeserver.Admin.GetRoomStateAsync(r.RoomId, type: "m.room.tombstone");
+                    var tombstone = state.Events.FirstOrDefault(x => x is { StateKey: "", Type: "m.room.tombstone" });
+                    if (tombstone is { } tombstoneEvent) {
+                        r.TombstoneEvent = tombstoneEvent;
+                    }
+                }
+                catch (MatrixException e) {
+                    r.Exceptions.Add($"While checking for tombstone: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}");
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to check tombstone for {r.RoomId}, unhandled exception: " + e);
+                }
+                finally {
+                    _concurrencyLimiter.Release();
+                }
+            });
+
+        await Task.WhenAll(getTombstoneTasks);
+
+        if (rerender)
+            StateHasChanged();
+    }
+
+    private async Task FetchLocalMemberEventsAsync() => await FetchLocalMemberEventsAsync(true);
+
+    private async Task FetchLocalMemberEventsAsync(bool rerender) {
+        var getLocalMembersTasks = Results
+            .Where(x => x.LocalMembers is null && x.JoinedLocalMembers is > 0 and < 100)
+            .Select(async r => {
+                await _concurrencyLimiter.WaitAsync();
+                try {
+                    var members = (await Homeserver.Admin.GetRoomMembersAsync(r.RoomId)).Members.Where(x => x.EndsWith(":" + Homeserver.ServerName)).ToList();
+                    r.LocalMembers = members;
+                }
+                catch (MatrixException e) {
+                    r.Exceptions.Add($"While fetching local members: {e.GetAsObject().ToJson(ignoreNull: true, indent: false)}");
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to fetch local members for {r.RoomId}, unhandled exception: " + e);
+                }
+                finally {
+                    _concurrencyLimiter.Release();
+                }
+            });
+
+        await Task.WhenAll(getLocalMembersTasks);
+
+        if (rerender)
+            StateHasChanged();
+    }
+
+    private void MultiPurgeInvertSelection() {
+        foreach (var room in Results) {
+            room.MultiPurgeSelected ^= true;
+        }
+
+        StateHasChanged();
     }
 
 }
  |