Cofanie stanu listy SharePoint po tym, jak automatyzacja oszaleje
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.IDbyło puste w tym środowisku, więcRestoreByID()odpadało. Zamiast tego śledzimy$iz pętli.Restore()wstawia nową bieżącą wersję; nie nadpisuje. Po przywróceniuVersions[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ęciaVersions[0](„Cannot delete the current version"). Obiekty wersji przechwycone przedRestore()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ć.