Get-TranslationDrift
This PnP PowerShell script scans the Site Pages library of a SharePoint Online site, matches English source pages to their French (fr-fr) translations using the SharePoint multilingual _SPTranslationSourceItemId field, and calculates the drift in days between the English and French modified dates. Results are written to the TranslationDrift list for dashboard reporting. Run Create-TranslationDriftList first to provision the target list.
Purpose
Multilingual sites drift out of sync silently as English content is updated without a corresponding update to its translations. This script automates detection by:
- Retrieving every item in the Site Pages library along with its translation metadata fields
- Separating English source pages from their French translations using
_SPIsTranslationand_SPTranslationLanguage - Matching each French page back to its English source via
_SPTranslationSourceItemId - Calculating
DaysDriftas the difference between the English and FrenchModifieddates - Classifying each page as In Sync, Stale (beyond the configured threshold), or Missing (no translation exists)
- Retrieving the site owner to populate the translator name and email fields
- Upserting into the TranslationDrift list — existing rows are updated in place rather than duplicated on re-run
Scope
- Only approved pages are processed — items with
_ModerationStatusother than Approved are skipped - Folder items are excluded — only
.aspxpages are considered - Matching is keyed on page GUID plus translation language, so multiple target languages can be tracked from the same list without collisions
Prerequisites
- PnP.PowerShell module installed
- The TranslationDrift list already created on the target site
- Site Collection Administrator permissions
- An Azure AD app registration with the required permissions, or use interactive login
PowerShell Script
# ============================================================
# Get-TranslationDrift.ps1
# Detects translation drift between English source pages and
# their French (fr-FR) translations in the Site Pages library.
# Populates the TranslationDrift list for dashboard reporting.
# ============================================================
$SiteUrl = "https://tenantName.sharepoint.com/sites/siteName"
$ClientId = ""
$ListName = "TranslationDrift"
$TargetLocale = "fr-fr"
$StaleDays = 7 # Days drift before a page is considered Stale rather than In Sync
# ── Connect ──────────────────────────────────────────────────
try {
Write-Host "Connecting to SharePoint..." -ForegroundColor Cyan
Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId -ErrorAction Stop
Write-Host "Connected successfully" -ForegroundColor Green
}
catch {
Write-Host "Failed to connect: $($_.Exception.Message)" -ForegroundColor Red
exit
}
# ── Get site owner for translator field ───────────────────────
try {
$owners = Get-PnPGroupMember -Group "devintranet Owners" -ErrorAction Stop
$firstOwner = $owners | Where-Object { $_.LoginName -notlike "*sharepoint*" } | Select-Object -First 1
$translatorName = $firstOwner.Title
$translatorEmail = $firstOwner.Email
Write-Host "Site owner (translator): $translatorName <$translatorEmail>" -ForegroundColor Cyan
}
catch {
Write-Host "Could not retrieve site owner — translator fields will be blank: $($_.Exception.Message)" -ForegroundColor Yellow
$translatorName = ""
$translatorEmail = ""
}
# ── Get all Site Pages ────────────────────────────────────────
try {
Write-Host "Retrieving Site Pages..." -ForegroundColor Cyan
$allPages = Get-PnPListItem -List "Site Pages" -PageSize 500 -Fields "Title","FileRef","FileLeafRef","Modified","_SPTranslationSourceItemId","_SPIsTranslation","_SPTranslationLanguage","_SPTranslatedLanguages","UniqueId","_ModerationStatus" -ErrorAction Stop
Write-Host "Retrieved $($allPages.Count) pages" -ForegroundColor Green
}
catch {
Write-Host "Failed to retrieve Site Pages: $($_.Exception.Message)" -ForegroundColor Red
Disconnect-PnPOnline
exit
}
# ── Separate English source pages and French translations ─────
$sourcePages = $allPages | Where-Object {
($_.FieldValues["_SPIsTranslation"] -ne $true) -and
$_.FieldValues["FileRef"] -notmatch "/fr/" -and
$_.FieldValues["FileRef"] -match "\.aspx$" -and
$_.FieldValues["Title"] -ne $null -and
$_.FieldValues["Title"] -ne "" -and
$_.FieldValues["_ModerationStatus"] -eq 0 # 0 = Approved
}
$frenchPages = $allPages | Where-Object {
$_.FieldValues["_SPIsTranslation"] -eq $true -and
$_.FieldValues["_SPTranslationLanguage"] -eq $TargetLocale
}
Write-Host "English source pages: $($sourcePages.Count)" -ForegroundColor Cyan
Write-Host "French translated pages: $($frenchPages.Count)" -ForegroundColor Cyan
# ── Build French pages lookup by source GUID ─────────────────
$frenchLookup = @{}
foreach ($frPage in $frenchPages) {
$sourceId = $frPage.FieldValues["_SPTranslationSourceItemId"]
if ($sourceId) {
$frenchLookup[$sourceId.ToString().ToLower()] = $frPage
}
}
# ── Get existing list items for upsert comparison ────────────
Write-Host "Loading existing drift list items..." -ForegroundColor Cyan
$existingItems = Get-PnPListItem -List $ListName -PageSize 500 -Fields "PageGuid","TranslationLanguage" -ErrorAction SilentlyContinue
$existingLookup = @{}
foreach ($ei in $existingItems) {
$key = "$($ei.FieldValues['PageGuid'].ToString().ToLower())|$($ei.FieldValues['TranslationLanguage'].ToString().ToLower())"
$existingLookup[$key] = $ei
}
Write-Host "Existing items loaded: $($existingItems.Count)" -ForegroundColor Cyan
# ── Process each English source page ─────────────────────────
$now = Get-Date
$processed = 0
$errors = 0
foreach ($sourcePage in $sourcePages) {
$pageGuid = $sourcePage.FieldValues["UniqueId"].ToString().ToLower()
$pageTitle = $sourcePage.FieldValues["Title"]
$pageUrl = $sourcePage.FieldValues["FileRef"]
$pageModified = $sourcePage.FieldValues["Modified"]
Write-Host "Processing: $pageTitle" -ForegroundColor Cyan
# Determine drift status
$frPage = $frenchLookup[$pageGuid.ToLower()]
$translatedLangs = $sourcePage.FieldValues["_SPTranslatedLanguages"]
if (-not $frPage) {
# No French translation exists
$driftStatus = "Missing"
$translationPageUrl = ""
$translationModified = $null
$daysDrift = $null
}
else {
$translationPageUrl = $frPage.FieldValues["FileRef"]
$translationModified = $frPage.FieldValues["Modified"]
# Positive drift = English newer than French (stale). Negative = French newer (unexpected).
$daysDrift = [math]::Round(($pageModified - $translationModified).TotalDays, 0)
if ($daysDrift -lt 0) { $daysDrift = 0 } # French newer than English — treat as In Sync
if ($daysDrift -le 0) {
$driftStatus = "In Sync"
}
elseif ($daysDrift -le $StaleDays) {
$driftStatus = "In Sync"
}
else {
$driftStatus = "Stale"
}
}
# ── Upsert into TranslationDrift list ────────────────────
try {
# Check if a row already exists for this page + language
$lookupKey = "$($pageGuid.ToLower())|$($TargetLocale.ToLower())"
$existingItem = $existingLookup[$lookupKey]
$itemValues = @{
"Title" = $pageTitle
"DefaultPageTitle" = $pageTitle
"DefaultPageUrl" = $pageUrl
"DefaultPageModified" = $pageModified.ToString("yyyy-MM-ddTHH:mm:ssZ")
"TranslationLanguage" = $TargetLocale
"TranslationPageUrl" = $translationPageUrl
"DriftStatus" = $driftStatus
"TranslatorName" = $translatorName
"TranslatorEmail" = $translatorEmail
"SiteUrl" = $SiteUrl
"LastChecked" = $now.ToString("yyyy-MM-ddTHH:mm:ssZ")
"PageGuid" = $pageGuid
}
# Only set date fields if they have values
if ($translationModified) {
$itemValues["TranslationModified"] = $translationModified.ToString("yyyy-MM-ddTHH:mm:ssZ")
}
if ($null -ne $daysDrift) {
$itemValues["DaysDrift"] = $daysDrift
}
if ($existingItem) {
# Update existing row — preserve NudgeSent/NudgeDate
try {
Set-PnPListItem -List $ListName -Identity $existingItem.Id -Values $itemValues -ErrorAction Stop | Out-Null
Write-Host " Updated: $driftStatus ($daysDrift days)" -ForegroundColor Green
}
catch {
# Item may have been deleted — fall back to create
Write-Host " Item not found, creating instead..." -ForegroundColor Yellow
$itemValues["NudgeSent"] = $false
Add-PnPListItem -List $ListName -Values $itemValues -ErrorAction Stop | Out-Null
Write-Host " Created: $driftStatus" -ForegroundColor Green
}
}
else {
# Create new row
$itemValues["NudgeSent"] = $false
Add-PnPListItem -List $ListName -Values $itemValues -ErrorAction Stop | Out-Null
Write-Host " Created: $driftStatus" -ForegroundColor Green
}
$processed++
}
catch {
Write-Host " Failed to write list item for '$pageTitle': $($_.Exception.Message)" -ForegroundColor Red
$errors++
}
Start-Sleep -Milliseconds 200
}
# ── Summary ───────────────────────────────────────────────────
Write-Host "`n── Run complete ────────────────────────────────" -ForegroundColor Cyan
Write-Host "Pages processed : $processed" -ForegroundColor Green
Write-Host "Errors : $errors" -ForegroundColor $(if ($errors -gt 0) { "Red" } else { "Green" })
Write-Host "View the list : $SiteUrl/Lists/$ListName" -ForegroundColor Cyan
# ── NOTE: Scheduling ─────────────────────────────────────────
# To run this script on a schedule, consider Azure Automation Runbooks.
# Replace -Interactive with certificate-based authentication:
# Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant "yourtenant.onmicrosoft.com" -CertificatePath "cert.pfx" -CertificatePassword $securePassword
# Then configure a monthly schedule in Azure Automation to trigger the Runbook.
try {
Disconnect-PnPOnline
Write-Host "Disconnected." -ForegroundColor Cyan
}
catch {
Write-Host "Disconnect error: $($_.Exception.Message)" -ForegroundColor Yellow
}
Usage Notes
- Update
$SiteUrlto your target SharePoint site URL - Update
$ClientIdwith your Azure AD app registration client ID, or leave blank to use interactive login without a registered app - Replace
"devintranet Owners"with the actual owners group name on your site — this is used purely to populate the translator name/email fields $TargetLocaledefaults tofr-fr— change it to match the translation language you want to audit (the field value must match_SPTranslationLanguageexactly, e.g.es-es,de-de)$StaleDayscontrols the threshold in days before a page flips from In Sync to Stale — tune this to match your translation SLA- Upsert logic matches on page GUID plus translation language, so re-running the script updates existing rows instead of creating duplicates
- Only approved pages are scanned — unapproved drafts and folder items are skipped automatically
- For scheduled runs, switch from
-Interactiveto certificate-based authentication and trigger via an Azure Automation Runbook — see the note at the bottom of the script
Related
- Create-TranslationDriftList — provisions the TranslationDrift list and its columns; run this first
- Translation Drift Dashboard — SPFx web part that renders the populated list as a sortable, colour-coded dashboard