2026-08-198 min czytania

Cofanie stanu listy SharePoint po tym, jak automatyzacja oszaleje

SharePointPowerShellAutomatyzacja

Problem

Złe dane trafiają na listę SharePoint — posypany skrypt, podwójne uruchomienie synchronizacji, zapętlony workflow. Trzeba cofnąć listę do stanu sprzed konkretnego znacznika czasu, bez pełnego przywracania z backupu (zła granulacja: cała witryna albo baza zawartości, nie jedna lista, a przy okazji kasuje wszystko inne, co zmieniło się od tego czasu).

Jeśli wersjonowanie jest włączone, backup w ogóle nie jest potrzebny — historia wersji już to ma. Dwie niezależne operacje:

  • Elementy utworzone po punkcie odcięcia: usunięcie.
  • Elementy zmodyfikowane po punkcie odcięcia: przywrócenie ostatniej wersji sprzed punktu odcięcia, usunięcie wersji powstałych po nim.

Restore-SPListState.ps1 robi obie te rzeczy, partiami, przez SPQuery po stronie serwera.

Mechanika

Konfiguracja przez $webUrl, $listName, $cutoff.

Część 1. SPQuery z paginacją na Created > $cutoff, ID zbierane do listy, usuwanie w drugim przebiegu. Usuwanie w trakcie iteracji po tej samej SPListItemCollection psuje enumerator — najpierw zbierz, potem usuwaj.

Część 2. SPQuery na Modified > $cutoff, potem dla każdego elementu: przejście po $item.Versions, podział na $versionsAfterCutoff i pojedynczą najnowszą wersję sprzed lub w punkcie odcięcia ($restoreVersion / $restoreIndex). Brak wersji sprzed punktu odcięcia → ostrzeżenie i pominięcie, bez zgadywania.

Dwa nieoczywiste ograniczenia API napędzają tę logikę:

  • Versions.Restore() przyjmuje indeks kolekcji, nie ID wersji. $version.ID było puste w tym środowisku, więc RestoreByID() odpadało. Zamiast tego śledzimy $i z pętli.
  • Restore() wstawia nową bieżącą wersję; nie nadpisuje. Po przywróceniu Versions[0] to nowa bieżąca wersja, a wszystko, co ma zostać usunięte, przesunęło się na indeks 1+. SharePoint rzuca wyjątkiem przy próbie usunięcia Versions[0] („Cannot delete the current version"). Obiekty wersji przechwycone przed Restore() są potem nieaktualne — przeładuj element i przejdź kolekcję ponownie przed usuwaniem.

$dryRun blokuje wszystkie zmiany i loguje dokładny plan. Uruchom raz przed przełączeniem na $false.

Czego Restore() nie odda: czystej ścieżki audytu. Przywrócona wersja to nowy wpis, nie oryginał — jej numer wersji nie będzie ciągły z tym, co było wcześniej (przywrócenie 3.0 na liście, która jest już przy 7.0, nie resetuje licznika), jej znacznik Modified to moment uruchomienia skryptu, nie oryginalny czas edycji, a Modified By to osoba, która uruchomiła skrypt, nie oryginalny edytor. Treść się zgadza, metadane już nie. Jeśli trzeba później udowodnić, kto i kiedy co napisał, uruchomienie tego skryptu samo staje się najnowszym wpisem w tej historii.

Luka w numeracji wersji to po prostu sposób, w jaki działa Restore() — tego się nie naprawi. Modified i Modified By to inna sprawa i dobry materiał na kolejny wpis — i pewnie kolejny skrypt.

Skrypt

Pobierz Restore-SPListState.ps1

Add-PSSnapin Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue

# =========================
# CONFIGURATION
# =========================

$webUrl   = "https://sharepoint/site"
$listName = "LIST_TITLE"

# Anything created/modified AFTER this date is targeted
$cutoff = Get-Date "2026-08-19 00:00:00"

# Number of items retrieved from SharePoint per batch
$batchSize = 500

# IMPORTANT:
# $true  = report only, DO NOT DELETE
# $false = actually delete
$dryRun = $false

# ============================================================
# CONNECT TO SHAREPOINT
# ============================================================

$web = Get-SPWeb $webUrl

try {
    $list = $web.Lists[$listName]

    Write-Host ""
    Write-Host "==================================================" -ForegroundColor Cyan
    Write-Host "SHAREPOINT ROLLBACK SCRIPT"
    Write-Host "==================================================" -ForegroundColor Cyan
    Write-Host "Web:        $webUrl"
    Write-Host "List:       $listName"
    Write-Host "Cutoff:     $cutoff"
    Write-Host "Batch size: $batchSize"
    Write-Host "Dry run:    $dryRun"
    Write-Host "==================================================" -ForegroundColor Cyan

    # ========================================================
    # PART 1 - FIND AND DELETE ITEMS CREATED AFTER CUTOFF
    # ========================================================

    Write-Host ""
    Write-Host "==================================================" -ForegroundColor Cyan
    Write-Host "PART 1 - ITEMS CREATED AFTER CUTOFF"
    Write-Host "==================================================" -ForegroundColor Cyan

    $itemsToDelete = New-Object System.Collections.Generic.List[int]
    $position = $null

    $cutoffUtc = $cutoff.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")

    do {
        $query = New-Object Microsoft.SharePoint.SPQuery
        $query.RowLimit = $batchSize
        $query.Query = @"
<Where>
    <Gt>
        <FieldRef Name='Created' />
        <Value Type='DateTime' IncludeTimeValue='TRUE'>$cutoffUtc</Value>
    </Gt>
</Where>
<OrderBy>
    <FieldRef Name='ID' Ascending='TRUE' />
</OrderBy>
"@
        $query.ListItemCollectionPosition = $position

        $items = $list.GetItems($query)
        $position = $items.ListItemCollectionPosition

        Write-Host ""
        Write-Host "Batch returned: $($items.Count) items" -ForegroundColor Yellow

        foreach ($item in $items) {
            $itemId = [int]$item.ID
            Write-Host ("FOUND ITEM: ID={0} | Created={1} | Title={2}" -f $itemId, $item["Created"], $item["Title"])

            # Only collect the ID here. Do NOT call $item.Delete() while
            # enumerating $items — mutating a collection you're iterating
            # over corrupts the enumeration.
            $itemsToDelete.Add($itemId)
        }
    }
    while ($null -ne $position)

    Write-Host ""
    Write-Host "Items found: $($itemsToDelete.Count)" -ForegroundColor Yellow

    if ($itemsToDelete.Count -gt 0) {
        Write-Host ""
        Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
        Write-Host "ITEM DELETION"
        Write-Host "--------------------------------------------------" -ForegroundColor DarkGray

        if ($dryRun) {
            Write-Host "DRY RUN - items will NOT be deleted." -ForegroundColor Yellow
            foreach ($id in $itemsToDelete) {
                Write-Host "WOULD DELETE ITEM: ID=$id"
            }
        }
        else {
            $itemsDeleted = 0

            foreach ($id in $itemsToDelete) {
                try {
                    # Re-fetch by ID instead of reusing the enumerated item —
                    # we're no longer inside that collection's enumeration.
                    $item = $list.GetItemById($id)

                    if ($null -ne $item) {
                        Write-Host "DELETE ITEM: ID=$id"
                        $item.Delete()
                        $itemsDeleted++
                    }
                }
                catch {
                    Write-Host ("ERROR deleting item ID={0}: {1}" -f $id, $_.Exception.Message) -ForegroundColor Red
                }
            }

            Write-Host ""
            Write-Host "Items deleted: $itemsDeleted" -ForegroundColor Green
        }
    }

    # ========================================================
    # PART 2 - ROLLBACK VERSIONS AFTER CUTOFF
    # ========================================================

    Write-Host ""
    Write-Host "==================================================" -ForegroundColor Cyan
    Write-Host "PART 2 - VERSION ROLLBACK"
    Write-Host "==================================================" -ForegroundColor Cyan

    if (-not $list.EnableVersioning) {
        Write-Host ""
        Write-Host "Versioning is disabled on this list." -ForegroundColor Yellow
    }
    else {
        $position = $null
        $itemsProcessed = 0
        $itemsToRollback = 0
        $versionsFound = 0
        $versionsDeleted = 0

        do {
            $query = New-Object Microsoft.SharePoint.SPQuery
            $query.RowLimit = $batchSize

            # We only retrieve items whose CURRENT Modified date is after the
            # cutoff, then inspect their complete version collection.
            $query.Query = @"
<Where>
    <Gt>
        <FieldRef Name='Modified' />
        <Value Type='DateTime' IncludeTimeValue='TRUE'>$cutoffUtc</Value>
    </Gt>
</Where>
<OrderBy>
    <FieldRef Name='ID' Ascending='TRUE' />
</OrderBy>
"@
            $query.ListItemCollectionPosition = $position

            $items = $list.GetItems($query)
            $position = $items.ListItemCollectionPosition

            Write-Host ""
            Write-Host "Version batch: $($items.Count) items" -ForegroundColor Yellow

            foreach ($item in $items) {
                $itemsProcessed++
                $itemId = $item.ID

                if ($item.Versions.Count -eq 0) {
                    continue
                }

                # Find every version created after cutoff, and the newest
                # version created at or before cutoff (the restore target).
                #
                # IMPORTANT: don't use $version.ID — in this environment it's
                # empty, so RestoreByID() can't be used. We track the actual
                # collection index instead.
                $versionsAfterCutoff = @()
                $restoreVersion = $null
                $restoreIndex = -1

                for ($i = 0; $i -lt $item.Versions.Count; $i++) {
                    $version = $item.Versions[$i]

                    Write-Host ("[{0}] Item={1} | Version={2} | Created={3}" -f $i, $itemId, $version.VersionLabel, $version.Created)

                    if ($version.Created -gt $cutoff) {
                        $versionsAfterCutoff += $version
                        $versionsFound++
                        Write-Host ("VERSION FOUND: Item ID={0} | Version={1} | Created={2}" -f $itemId, $version.VersionLabel, $version.Created) -ForegroundColor Magenta
                    }
                    elseif ($null -eq $restoreVersion -or $version.Created -gt $restoreVersion.Created) {
                        $restoreVersion = $version
                        $restoreIndex = $i
                    }
                }

                if ($versionsAfterCutoff.Count -eq 0) {
                    continue
                }

                $itemsToRollback++

                if ($restoreIndex -lt 0) {
                    Write-Host ("WARNING: Item ID={0} has no version before cutoff. SKIPPING." -f $itemId) -ForegroundColor Red
                    continue
                }

                Write-Host ""
                Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
                Write-Host "Item ID:             $itemId"
                Write-Host ("Current version:     {0}" -f $item.Versions[0].VersionLabel)
                Write-Host ("Restore version:     {0}" -f $restoreVersion.VersionLabel)
                Write-Host ("Restore index:       {0}" -f $restoreIndex)
                Write-Host ("Restore date:        {0}" -f $restoreVersion.Created)
                Write-Host ("Versions after:      {0}" -f $versionsAfterCutoff.Count)
                Write-Host "--------------------------------------------------" -ForegroundColor DarkGray

                if ($dryRun) {
                    Write-Host ("DRY RUN: would restore Versions.Restore({0})" -f $restoreIndex) -ForegroundColor Yellow
                    Write-Host ("DRY RUN: would then delete {0} post-cutoff versions." -f $versionsAfterCutoff.Count) -ForegroundColor Yellow
                    continue
                }

                try {
                    Write-Host ("RESTORING Version={0} using Restore({1})..." -f $restoreVersion.VersionLabel, $restoreIndex) -ForegroundColor Yellow

                    # Restore() belongs to the version collection and takes the
                    # collection INDEX, not $restoreVersion.ID and not
                    # $item.Versions.RestoreByID(...).
                    $item.Versions.Restore($restoreIndex)

                    Write-Host ("RESTORE SUCCESS: Item ID={0} -> Version {1}" -f $itemId, $restoreVersion.VersionLabel) -ForegroundColor Green
                }
                catch {
                    Write-Host ("ERROR restoring version {0} of item {1}: {2}" -f $restoreVersion.VersionLabel, $itemId, $_.Exception.Message) -ForegroundColor Red
                    continue
                }

                try {
                    $item = $list.GetItemById($itemId)
                }
                catch {
                    Write-Host ("ERROR reloading item {0}: {1}" -f $itemId, $_.Exception.Message) -ForegroundColor Red
                    continue
                }

                Write-Host ""
                Write-Host ("VERSION COLLECTION AFTER RESTORE - ITEM {0}" -f $itemId) -ForegroundColor Cyan
                for ($i = 0; $i -lt $item.Versions.Count; $i++) {
                    $v = $item.Versions[$i]
                    Write-Host ("[{0}] Version={1} | Created={2}" -f $i, $v.VersionLabel, $v.Created)
                }

                # Restore() creates a NEW current version, e.g.:
                #   [0] 3.0  <-- new current, restored content
                #   [1] 2.0  <-- bad version, delete
                #   [2] 1.0  <-- old good version, keep
                #
                # Versions[0] is always the current version — SharePoint
                # rejects deleting it ("Cannot delete the current version").
                # Re-scan from the reloaded item; the old $version objects
                # from before Restore() are stale.
                $versionsToDelete = @()

                for ($i = 0; $i -lt $item.Versions.Count; $i++) {
                    $version = $item.Versions[$i]

                    if ($i -eq 0) {
                        Write-Host ("KEEP CURRENT: Version={0}" -f $version.VersionLabel) -ForegroundColor Green
                        continue
                    }

                    if ($version.Created -gt $cutoff) {
                        $versionsToDelete += $version
                    }
                }

                # Delete newest-first.
                $versionsToDelete = $versionsToDelete | Sort-Object Created -Descending

                foreach ($version in $versionsToDelete) {
                    try {
                        Write-Host ("DELETE VERSION: Item ID={0} | Version={1} | Created={2}" -f $itemId, $version.VersionLabel, $version.Created) -ForegroundColor Yellow
                        $version.Delete()
                        $versionsDeleted++
                    }
                    catch {
                        Write-Host ("ERROR deleting version {0} of item {1}: {2}" -f $version.VersionLabel, $itemId, $_.Exception.Message) -ForegroundColor Red
                    }
                }
            }
        }
        while ($null -ne $position)

        Write-Host ""
        Write-Host "==================================================" -ForegroundColor Green
        Write-Host "VERSION ROLLBACK SUMMARY"
        Write-Host "==================================================" -ForegroundColor Green
        Write-Host "Items processed:       $itemsProcessed"
        Write-Host "Items to rollback:     $itemsToRollback"
        Write-Host "Versions found:        $versionsFound"

        if ($dryRun) {
            Write-Host ""
            Write-Host "DRY RUN - NOTHING WAS CHANGED." -ForegroundColor Yellow
        }
        else {
            Write-Host "Versions deleted:      $versionsDeleted" -ForegroundColor Green
        }
    }

    # ========================================================
    # FINAL SUMMARY
    # ========================================================

    Write-Host ""
    Write-Host "==================================================" -ForegroundColor Green
    Write-Host "SCRIPT COMPLETE"
    Write-Host "==================================================" -ForegroundColor Green
    Write-Host "Items found for deletion: $($itemsToDelete.Count)"

    if ($dryRun) {
        Write-Host ""
        Write-Host "***** DRY RUN *****" -ForegroundColor Yellow
        Write-Host "No items or versions were modified." -ForegroundColor Yellow
        Write-Host ""
        Write-Host "Set:" -ForegroundColor Yellow
        Write-Host '$dryRun = $false' -ForegroundColor Yellow
        Write-Host "to perform the changes."
    }
}
finally {
    if ($null -ne $web) {
        $web.Dispose()
    }

    Write-Host ""
    Write-Host "SharePoint web disposed." -ForegroundColor Gray
}

Destrukcyjny z założenia — najpierw $dryRun na wszystkim, czego nie da się łatwo odtworzyć.

Cofanie stanu listy SharePoint po tym, jak automatyzacja oszaleje | Kamil Wierzbowski