commit 17266ecb99ec281d48ed8ea80aee6b1adbd29b30 Author: Rodweil, Theodor Date: Sun Jul 30 16:16:55 2023 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..657afab --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/_* +/._* +.DS_Store diff --git a/.nuspec b/.nuspec new file mode 100755 index 0000000..6e7a6a3 --- /dev/null +++ b/.nuspec @@ -0,0 +1,14 @@ + + + + ConfluencePublisher + 1.1.0 + Übergangslösung für die Integration von sphinx-confluencebuilder generierter Confluence Dokumentation + tiara.rodney@adesso.de + irina.ternovykh@vkb.de + UNLICENSED + README.md + VKBit Betrieb GmbH + Powershell Confluence Publisher + + diff --git a/PSConfluencePublisher/._.DS_Store b/PSConfluencePublisher/._.DS_Store new file mode 100755 index 0000000..9ad849c Binary files /dev/null and b/PSConfluencePublisher/._.DS_Store differ diff --git a/PSConfluencePublisher/._Connection.Tests.ps1 b/PSConfluencePublisher/._Connection.Tests.ps1 new file mode 100755 index 0000000..41649b9 Binary files /dev/null and b/PSConfluencePublisher/._Connection.Tests.ps1 differ diff --git a/PSConfluencePublisher/._Connection.psm1 b/PSConfluencePublisher/._Connection.psm1 new file mode 100755 index 0000000..c8dd778 Binary files /dev/null and b/PSConfluencePublisher/._Connection.psm1 differ diff --git a/PSConfluencePublisher/._Manifest.Tests.ps1 b/PSConfluencePublisher/._Manifest.Tests.ps1 new file mode 100755 index 0000000..41649b9 Binary files /dev/null and b/PSConfluencePublisher/._Manifest.Tests.ps1 differ diff --git a/PSConfluencePublisher/._Manifest.psm1 b/PSConfluencePublisher/._Manifest.psm1 new file mode 100755 index 0000000..35de140 Binary files /dev/null and b/PSConfluencePublisher/._Manifest.psm1 differ diff --git a/PSConfluencePublisher/._PSConfluencePublisher.psd1 b/PSConfluencePublisher/._PSConfluencePublisher.psd1 new file mode 100755 index 0000000..af4eebf Binary files /dev/null and b/PSConfluencePublisher/._PSConfluencePublisher.psd1 differ diff --git a/PSConfluencePublisher/._PSConfluencePublisher.psm1 b/PSConfluencePublisher/._PSConfluencePublisher.psm1 new file mode 100755 index 0000000..c70d1cd Binary files /dev/null and b/PSConfluencePublisher/._PSConfluencePublisher.psm1 differ diff --git a/PSConfluencePublisher/._Page.psm1 b/PSConfluencePublisher/._Page.psm1 new file mode 100755 index 0000000..59f0c23 Binary files /dev/null and b/PSConfluencePublisher/._Page.psm1 differ diff --git a/PSConfluencePublisher/._PersonalAccessToken.Tests.ps1 b/PSConfluencePublisher/._PersonalAccessToken.Tests.ps1 new file mode 100755 index 0000000..bc21001 Binary files /dev/null and b/PSConfluencePublisher/._PersonalAccessToken.Tests.ps1 differ diff --git a/PSConfluencePublisher/._PersonalAccessToken.psm1 b/PSConfluencePublisher/._PersonalAccessToken.psm1 new file mode 100755 index 0000000..b3bbd6c Binary files /dev/null and b/PSConfluencePublisher/._PersonalAccessToken.psm1 differ diff --git a/PSConfluencePublisher/._manifest.schema.json b/PSConfluencePublisher/._manifest.schema.json new file mode 100755 index 0000000..b755fd4 Binary files /dev/null and b/PSConfluencePublisher/._manifest.schema.json differ diff --git a/PSConfluencePublisher/._scripts.deps.json b/PSConfluencePublisher/._scripts.deps.json new file mode 100755 index 0000000..809588f Binary files /dev/null and b/PSConfluencePublisher/._scripts.deps.json differ diff --git a/PSConfluencePublisher/Connection.Tests.ps1 b/PSConfluencePublisher/Connection.Tests.ps1 new file mode 100755 index 0000000..3ad6c80 --- /dev/null +++ b/PSConfluencePublisher/Connection.Tests.ps1 @@ -0,0 +1,65 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot 'PSConfluencePublisher.psd1') -Force +} + + +Describe 'Test-Connection' ` +{ + + Context 'Parameterized' { + + It 'throws no exception' { + + InModuleScope Connection { + + Mock Get-PersonalAccessToken {'01234567890123456789'} + + Mock Invoke-WebRequest { + return @{ + 'Content' = "{'type': 'known'}" + 'StatusCode' = 200 + } + } + + Test-Connection -Host 'confluence.contoso.com' + } + } + + It 'detects anonymous authentication' { + + InModuleScope Connection { + + Mock Get-PersonalAccessToken {'01234567890123456789'} + + Mock Invoke-WebRequest { + return @{ + 'Content' = "{'type': 'anonymous'}" + 'StatusCode' = 200 + } + } + + {Test-Connection -Host 'confluence.contoso.com'} | Should -Throw + } + } + + It 'detects non 200 status codes' { + + InModuleScope Connection { + + Mock Get-PersonalAccessToken {'01234567890123456789'} + + Mock Invoke-WebRequest { + return @{ + 'Content' = "{'type': 'anonymous'}" + 'StatusCode' = 500 + } + } + + {Test-Connection -Host 'confluence.contoso.com'} | Should -Throw + } + } + } +} diff --git a/PSConfluencePublisher/Connection.psm1 b/PSConfluencePublisher/Connection.psm1 new file mode 100755 index 0000000..efb0b01 --- /dev/null +++ b/PSConfluencePublisher/Connection.psm1 @@ -0,0 +1,54 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + + +function Test-Connection +{ + <# + .SYNOPSIS + Test the connectivity to a Confluence instance. + + .DESCRIPTION + Just making an arbitrary authenticated HTTP request and making sure + that we're getting a 2xx status code back. This way we make sure + that network connectivity is fine, and that the PAT is valid. + + It is required to register a PAT through + ``Register-PersonalAccessToken`` beforehand. + + .EXAMPLE + Test-Connection confluence.contoso.com + #> + Param( + [Parameter(Mandatory, Position = 0)] [string] $Host + ) + + Process + { + # Screw Invoke-RestMethod, how am i supposed to get a non 4xx status + # code? Catch a non-existent exception 🤷‍♀️???? + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/user/current" ` + -Method 'Get' ` + -Headers @{ + 'Authorization' = "Bearer $(Get-PersonalAccessToken $Host)" + } ` + -OutVariable response + + if(($response.Content | ConvertFrom-JSON).type -ne "known") + { + throw "personal access token for host '$Host' does not " + + "authenticate." + } + + if ($response.StatusCode -eq 200) + { + Write-Host "Verified connectivity ($Host)." + } + else + { + throw "received status code other than 200 " + + "($($response.StatusCode))" + } + } +} diff --git a/PSConfluencePublisher/Manifest.Tests.ps1 b/PSConfluencePublisher/Manifest.Tests.ps1 new file mode 100755 index 0000000..2674a9f --- /dev/null +++ b/PSConfluencePublisher/Manifest.Tests.ps1 @@ -0,0 +1,70 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot 'PSConfluencePublisher.psd1') -Force + +} + +AfterAll { + +} + + +Describe 'Get-Manifest' ` +{ + + Context 'Parameterized' { + + It 'throws no exception' { + + InModuleScope Connection { + + Mock Get-PersonalAccessToken {'01234567890123456789'} + + Mock Invoke-WebRequest { + return @{ + 'Content' = "{'type': 'known'}" + 'StatusCode' = 200 + } + } + + Test-Connection -Host 'confluence.contoso.com' + } + } + + It 'detects anonymous authentication' { + + InModuleScope Connection { + + Mock Get-PersonalAccessToken {'01234567890123456789'} + + Mock Invoke-WebRequest { + return @{ + 'Content' = "{'type': 'anonymous'}" + 'StatusCode' = 200 + } + } + + {Test-Connection -Host 'confluence.contoso.com'} | Should -Throw + } + } + + It 'detects non 200 status codes' { + + InModuleScope Connection { + + Mock Get-PersonalAccessToken {'01234567890123456789'} + + Mock Invoke-WebRequest { + return @{ + 'Content' = "{'type': 'anonymous'}" + 'StatusCode' = 500 + } + } + + {Test-Connection -Host 'confluence.contoso.com'} | Should -Throw + } + } + } +} diff --git a/PSConfluencePublisher/Manifest.psm1 b/PSConfluencePublisher/Manifest.psm1 new file mode 100755 index 0000000..dd52e27 --- /dev/null +++ b/PSConfluencePublisher/Manifest.psm1 @@ -0,0 +1,74 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + + +$script:schema = Get-Content ( + Join-Path $PSScriptRoot 'manifest.schema.json' +) | Out-String + + +function Get-Manifest +{ + <# + .SYNOPSIS + Load the archive manifest + + .EXAMPLE + Get-Manifest 'manifest.json' + #> + Param( + # filesystem location of manifest + [Parameter(Mandatory)] [string] $File + ) + + Process + { + try + { + $raw = Get-Content $File + } + + catch + { + $raw = '{"pages":{}, "attachments": {}}' + } + + $raw | Test-JSON -Schema $script:schema | Out-Null + + $data = $raw | ConvertFrom-JSON + } +} + + +function Set-Manifest +{ + <# + .SYNOPSIS + Dump the archive manifest + + .EXAMPLE + Set-Manifest 'manifest.json' + #> + Param( + # manifest object + [Parameter(Mandatory)] [PSObject] $Manifest, + # filesystem location of manifest + [Parameter(Mandatory)] [string] $File, + # create a backup first + [Parameter()] [bool] $Backup = $false + ) + + Process + { + $raw = $Manifest | ConvertTo-JSON + + $raw | Test-JSON -Schema $script:schema + + if ($Backup) + { + Copy-Item -Path $File -Destination "$(Split-Path -Leaf $File).bck" + } + + Set-Content -Path $File -Value $raw + } +} diff --git a/PSConfluencePublisher/PSConfluencePublisher.psd1 b/PSConfluencePublisher/PSConfluencePublisher.psd1 new file mode 100755 index 0000000..35859d5 --- /dev/null +++ b/PSConfluencePublisher/PSConfluencePublisher.psd1 @@ -0,0 +1,131 @@ +@{ + +# Script module or binary module file associated with this manifest. +#RootModule = 'ConfluencePublisher.psm1' + +ModuleVersion = '1.1.0' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = 'b51d47f9-19b9-4c34-9a88-36eb8cf4c9bd' + +# Author of this module +Author = 'Theodor Rodweil' + +# Company or vendor of this module +CompanyName = 'Victory Karma IT' + +# Copyright statement for this module +Copyright = '(c) victory-k.it. All rights reserved.' + +RootModule = 'PSConfluencePublisher.psm1' + +# Description of the functionality provided by this module +# Description = '' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '6.0' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @('sdf') + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess + NestedModules = @( + 'PersonalAccessToken.psm1', + 'Connection.psm1', + 'Manifest.psm1' +) + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = '*' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @() + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +FileList = @( + "./manifest.schema.json" +) + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/PSConfluencePublisher/PSConfluencePublisher.psm1 b/PSConfluencePublisher/PSConfluencePublisher.psm1 new file mode 100755 index 0000000..f8c5e53 --- /dev/null +++ b/PSConfluencePublisher/PSConfluencePublisher.psm1 @@ -0,0 +1,508 @@ +#!/usr/bin/env pwsh +<# + .SYNOPSIS + PowerShell Publisher for sphinxcontrib.confluencebuilder + + .DESCRIPTION + + - support for ancestral pages and containered attachments + - creates new pages if they don't exist + - updates existing pages and attachments if checksum mismatches + + .EXAMPLE + + Import-Module (Join-Path 'vendor' 'tiara.rodney' + 'PSConfluencePublisher' + 'PSConfluencePublisher' + 'PSConfluencePublisher.psd1') + + Register-PersonalAccessToken ` + -Host 'confluence.contoso.com' ` + -Token '123456789123456789' + + Test-Connection confluence.contoso.com + + Publish-All ` + -Url 'https://confluence.contoso.com/display/TIARA/Testitest' ` + -DumpIndex build/docs/confluence.out/data.json + + .NOTES + - tested with PowerShell Core (PSVersion 7.3.6) + - tested with PowerShell Desktop (PSVersion 5.1.19041.3031) +#> +$ErrorActionPreference = "Stop" + + +function New-ConfluencePage +{ + <# + .SYNOPSIS + Add a confluence page + + .DESCRIPTION + + .EXAMPLE + Add-ConfluencePage + -Host 'confluence.contoso.com' ` + -Space 'TIARA' ` + -Title 'Testitest' ` + -Content @{} + #> + Param( + [Parameter(Mandatory)] [string] $Host, + # The name of the Confluence space to publish to + [Parameter(Mandatory)] [string] $Space, + # title of page to be published + [Parameter(Mandatory)] [string] $Title, + # content of page + [Parameter(Mandatory)] [string] $Content, + # parent page id + [Parameter()] [string] $Ancestor + ) + + Process + { + Assert-PersonalAccessToken $Host + + $transportBody = @{ + 'type' = 'page' + 'title' = $Title + 'space' = @{ + 'key' = $Space + } + 'body' = @{ + 'storage' = @{ + 'value' = $Content + 'representation' = 'storage' + } + } + } | ConvertTo-JSON + + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/content" ` + -Method 'Post' ` + -Headers @{ + 'Authorization' = "Bearer $([System.Net.NetworkCredential]::new('', $script:PATS[$Host_]).Password)" + } ` + -ContentType "application/json" ` + -Body $transportBody ` + -OutVariable rawResponse | Out-Null + } + + End + { + $response = ($rawResponse.Content | ConvertFrom-JSON) + + @{ + 'PageId' = $response.Id + 'Version' = $response.version | Select -ExpandProperty 'number' + } + } +} + + +function Update-Page +{ + <# + .SYNOPSIS + Add a confluence page + + .DESCRIPTION + + .EXAMPLE + Add-ConfluencePage + -Host 'confluence.contoso.com' ` + -Space 'TIARA' ` + -Title 'Testitest' ` + -Content @{} + #> + Param( + [Parameter(Mandatory)] [string] $Host, + # The page id of an existing page + [Parameter(Mandatory)] [string] $PageId, + # The name of the Confluence space to publish to + [Parameter(Mandatory)] [string] $Space, + # title of page to be published + [Parameter(Mandatory)] [string] $Title, + # version of content + [Parameter(Mandatory)] [int] $Version, + # content of page + [Parameter(Mandatory)] [string] $Content, + # parent page id + [Parameter()] [string] $Ancestor + ) + + Process + { + Assert-PersonalAccessToken $Host + + $transportBody = @{ + 'id' = $PageId + 'type' = 'page' + 'title' = $Title + 'space' = @{ + 'key' = $Space + } + 'body' = @{ + 'storage' = @{ + 'value' = $Content + 'representation' = 'storage' + } + } + 'version' = @{ + 'number' = $Version + } + } | ConvertTo-JSON + + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/content/$PageId" ` + -Method 'Put' ` + -Headers @{ + 'Authorization' = "Bearer $([System.Net.NetworkCredential]::new('', $script:PATS[$Host_]).Password)" + } ` + -ContentType "application/json" ` + -Body $transportBody ` + -OutVariable rawResponse | Out-Null + } + + End + { + $response = ($rawResponse.Content | ConvertFrom-JSON) + } +} + + +function Get-PageMeta +{ + <# + .SYNOPSIS + Get a Confluence page id + + .DESCRIPTION + First, tries to retrieve from local page id index (cache) through + the local alias. If no cache hit, then polls the Confluence + instance host for the id by providing a space key and page title. + + .EXAMPLE + Get-PageMeta + -Host 'confluence.contoso.com' ` + -Title 'Testitest' ` + -Space 'TIARA' ` + -CacheIndexFile 'confluence-page-cache.json' + #> + Param( + [Parameter(Mandatory)] [string] $Host, + [Parameter(Mandatory)] [string] $Title, + [Parameter(Mandatory)] [string] $Space, + [Parameter(Mandatory)] [string] $CacheIndexFile + ) + + Process + { + if ($Title) + { + $cachedPageMeta = Get-CachedPageMeta ` + -Title $Title ` + -CacheIndexFile $CacheIndexFile + } + + if ($cachedPageMeta) + { + return $cachedPageMeta + } + + $escapedTitle = [uri]::EscapeDataString($Title) + + $query = "title=${escapedTitle}&spaceKey=${Space}&expand=history" + + Assert-PersonalAccessToken $Host + + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/content?$query" ` + -Method 'Get' ` + -Headers @{ + 'Authorization' = "Bearer $([System.Net.NetworkCredential]::new('', $script:PATS[$Host_]).Password)" + } ` + -OutVariable response + + $results = ($response.Content | ConvertFrom-JSON).results + + if ($results.Count -gt 1) + { + throw "more than one result for query: $query" + } + elseif ($results.Count -eq 1) + { + Register-PageMeta ` + -PageId $results[0].id ` + -Version ($results[0]._expandable | Select -ExpandProperty 'version') ` + -Title $Title ` + -CacheIndexFile $CacheIndexFile + } + } +} + + +function Get-CachedPageMeta +{ + <# + .SYNOPSIS + Get a locally indexed/cached Confluence page id + + .EXAMPLE + Get-CachedPageMeta ` + -Title 'd231cc3422bfdf96.xml' ` + -CacheIndexFile 'confluence-page-cache.json' + #> + Param( + [Parameter(Mandatory)] [string] $Title, + [Parameter(Mandatory)] [string] $CacheIndexFile + ) + + Process + { + try + { + $raw = Get-Content $CacheIndexFile + } + + catch + { + $raw = "{}" + } + + $data = $raw | ConvertFrom-JSON + + try + { + $pageMeta = $data | Select -ExpandProperty $Title + + $pageMeta + + Write-Debug "page id cache hit: $Title -> $($pageMeta.PageId)" + } + + catch + { + $null + + Write-Debug "page id cache miss: $Title" + } + } +} + + +function Register-PageMeta +{ + <# + .SYNOPSIS + Register a Confluence page's metadata in the local cache + + .DESCRIPTION + + .EXAMPLE + Add-ConfluencePage + -Host 'confluence.contoso.com' ` + -Space 'TIARA' ` + -Title 'Testitest' ` + -Content @{} + #> + Param( + [Parameter(Mandatory)] [string] $PageId, + [Parameter()] [int] $Version = 0, + [Parameter(Mandatory)] [string] $Title, + [Parameter()] [string] $ContentHash = '', + [Parameter(Mandatory)] [string] $CacheIndexFile + ) + + Process + { + try + { + $raw = Get-Content $CacheIndexFile + } + + catch + { + $raw = "{}" + } + + $data = $raw | ConvertFrom-JSON + + $data | Add-Member -Name $Title ` + -Value @{ + 'PageId' = $PageId + 'Version' = $Version + 'ContentHash' = $ContentHash + } ` + -MemberType NoteProperty ` + -Force + + Set-Content -Path $CacheIndexFile -Value ($data | ConvertTo-JSON) + + Write-Debug "indexed page id: $Title -> $PageId" + } +} + + +function Publish-Page +{ + Param( + # title of the page (used for manifest lookup) + [Parameter(Mandatory)] [string] $Title, + # hostname of Confluence instance + [Parameter(Mandatory)] [string] $Host, + # name of Confluence space + [Parameter(Mandatory)] [string] $Space, + # manifest object + [Parameter(Mandatory)] [PSObject] $Manifest + ) + + Begin + { + $pageMeta = Get-PageMeta ` + -Host $hostname ` + -Space $spaceName ` + -Title $Title ` + -Manifest $Manifest + } + + Process + { + if ($pageMeta.ContentHash -eq $_) + { + Write-Host "skipping (no changes): $Title" + + return + } + + $pageId = $pageMeta.PageId + + $path = Join-Path $basepath 'content' "$_" + + $pageContent = Get-Content $path | Out-String + + $prettyName = $Title + + if ($data.pages[$_].ancestor_id) + { + $ancestorTitle = $data.pages[$data.pages[$_].ancestor_id].title + + $ancestorPageMeta = Get-PageMeta ` + -Host $hostname ` + -Space $spaceName ` + -Title $ancestorTitle ` + -CacheIndexFile $cacheIndexFile + + if ($ancestorPageMeta) + { + $ancestorPageId = $ancestorPageMeta.PageId + } + + $prettyName += " [$ancestorPageId]" + } + + if (-Not $pageId) + { + Write-Host ("create ${_}: $prettyName") + + try { + $pageMeta = New-ConfluencePage ` + -Host $hostname ` + -Space $spaceName ` + -Title $pageTitle ` + -Content $pageContent ` + -Ancestor $ancestorPageId + } + + catch + { + Write-Host "error (skipping): $prettyName" + + return + } + + + Register-PageMeta ` + -PageId $pageMeta.PageId ` + -Version $pageMeta.Version ` + -Title $pageTitle ` + -ContentHash $_ ` + -CacheIndexFile $cacheIndexFile + } + else + { + Write-Host ("update ${_} (${pageId}): $prettyName") + + $version = $pageMeta.Version + 1 + + try + { + Update-Page ` + -Host $hostname ` + -PageId $pageId ` + -Space $spaceName ` + -Title $pageTitle ` + -Version $version ` + -Content $pageContent ` + -Ancestor $ancestorPageId + } + + catch + { + Write-Host "error (skipping): $prettyName" + + return + } + + Register-PageMeta ` + -PageId $pageMeta.PageId ` + -Version $version ` + -Title $pageTitle ` + -ContentHash $_ ` + -CacheIndexFile $cacheIndexFile + } + } +} + + +function Publish-All +{ + <# + .SYNOPSIS + 1. cast index hash table to array + 2. (quick) sort the array + + .EXAMPLE + Get-Help -Name Test-Help + + This shows the help for the example function. + #> + Param( + [Parameter(Mandatory, Position = 0)] [string] $Url, + [Parameter(Mandatory, Position = 1)] [string] $Manifest + ) + + Begin + { + $hostname = ([uri]$url).Host + + $spaceName = (Split-Path -Leaf (Split-Path $Url)) + + $ancestorName = Split-Path -Leaf $url + + $data = Get-Content -Raw $Manifest | ConvertFrom-JSON -AsHashtable + + $basepath = Split-Path $Manifest + + $cacheIndexFile = 'confluence-page-cache.json' + } + + Process + { + $data.pages.keys | ForEach-Object ` + { + + } + } +} diff --git a/PSConfluencePublisher/Page.psm1 b/PSConfluencePublisher/Page.psm1 new file mode 100755 index 0000000..66da66a --- /dev/null +++ b/PSConfluencePublisher/Page.psm1 @@ -0,0 +1,435 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + + +function Get-CachedPageMeta +{ + <# + .SYNOPSIS + Get a locally indexed/cached Confluence page id + + .EXAMPLE + Get-CachedPageMeta ` + -Title 'd231cc3422bfdf96.xml' ` + -CacheIndexFile 'confluence-page-cache.json' + #> + Param( + [Parameter(Mandatory)] [string] $Title, + [Parameter(Mandatory)] [string] $CacheIndexFile + ) + + Process + { + try + { + $raw = Get-Content $CacheIndexFile + } + + catch + { + $raw = "{}" + } + + $data = $raw | ConvertFrom-JSON + + try + { + $pageMeta = $data | Select -ExpandProperty $Title + + $pageMeta + + Write-Debug "page id cache hit: $Title -> $($pageMeta.PageId)" + } + + catch + { + $null + + Write-Debug "page id cache miss: $Title" + } + } +} + + +function Get-PageMeta +{ + <# + .SYNOPSIS + Get a Confluence page id + + .DESCRIPTION + First, tries to retrieve from local page id index (cache) through + the local alias. If no cache hit, then polls the Confluence + instance host for the id by providing a space key and page title. + + .EXAMPLE + Get-PageMeta + -Host 'confluence.contoso.com' ` + -Title 'Testitest' ` + -Space 'TIARA' ` + -CacheIndexFile 'confluence-page-cache.json' + #> + Param( + [Parameter(Mandatory)] [string] $Host, + [Parameter(Mandatory)] [string] $Title, + [Parameter(Mandatory)] [string] $Space, + [Parameter(Mandatory)] [string] $CacheIndexFile + ) + + Process + { + if ($Title) + { + $cachedPageMeta = Get-CachedPageMeta ` + -Title $Title ` + -CacheIndexFile $CacheIndexFile + } + + if ($cachedPageMeta) + { + return $cachedPageMeta + } + + $escapedTitle = [uri]::EscapeDataString($Title) + + $query = "title=${escapedTitle}&spaceKey=${Space}&expand=history" + + Assert-PersonalAccessToken $Host + + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/content?$query" ` + -Method 'Get' ` + -Headers @{ + 'Authorization' = "Bearer $([System.Net.NetworkCredential]::new('', $script:PATS[$Host_]).Password)" + } ` + -OutVariable response + + $results = ($response.Content | ConvertFrom-JSON).results + + if ($results.Count -gt 1) + { + throw "more than one result for query: $query" + } + elseif ($results.Count -eq 1) + { + Register-PageMeta ` + -PageId $results[0].id ` + -Version ($results[0]._expandable | Select -ExpandProperty 'version') ` + -Title $Title ` + -CacheIndexFile $CacheIndexFile + } + } +} + + +function Register-PageMeta +{ + <# + .SYNOPSIS + Register a Confluence page's metadata in the local cache + + .DESCRIPTION + + .EXAMPLE + Add-ConfluencePage + -Host 'confluence.contoso.com' ` + -Space 'TIARA' ` + -Title 'Testitest' ` + -Content @{} + #> + Param( + [Parameter(Mandatory)] [string] $PageId, + [Parameter()] [int] $Version = 0, + [Parameter(Mandatory)] [string] $Title, + [Parameter()] [string] $ContentHash = '', + [Parameter(Mandatory)] [string] $CacheIndexFile + ) + + Process + { + try + { + $raw = Get-Content $CacheIndexFile + } + + catch + { + $raw = "{}" + } + + $data = $raw | ConvertFrom-JSON + + $data | Add-Member -Name $Title ` + -Value @{ + 'PageId' = $PageId + 'Version' = $Version + 'ContentHash' = $ContentHash + } ` + -MemberType NoteProperty ` + -Force + + Set-Content -Path $CacheIndexFile -Value ($data | ConvertTo-JSON) + + Write-Debug "indexed page id: $Title -> $PageId" + } +} + + +function New-Page +{ + <# + .SYNOPSIS + Add a confluence page + + .DESCRIPTION + + .EXAMPLE + Add-ConfluencePage + -Host 'confluence.contoso.com' ` + -Space 'TIARA' ` + -Title 'Testitest' ` + -Content @{} + #> + Param( + [Parameter(Mandatory)] [string] $Host, + # The name of the Confluence space to publish to + [Parameter(Mandatory)] [string] $Space, + # title of page to be published + [Parameter(Mandatory)] [string] $Title, + # content of page + [Parameter(Mandatory)] [string] $Content, + # parent page id + [Parameter()] [string] $Ancestor + ) + + Process + { + Assert-PersonalAccessToken $Host + + $transportBody = @{ + 'type' = 'page' + 'title' = $Title + 'space' = @{ + 'key' = $Space + } + 'body' = @{ + 'storage' = @{ + 'value' = $Content + 'representation' = 'storage' + } + } + } | ConvertTo-JSON + + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/content" ` + -Method 'Post' ` + -Headers @{ + 'Authorization' = "Bearer $([System.Net.NetworkCredential]::new('', $script:PATS[$Host_]).Password)" + } ` + -ContentType "application/json" ` + -Body $transportBody ` + -OutVariable rawResponse | Out-Null + } + + End + { + $response = ($rawResponse.Content | ConvertFrom-JSON) + + @{ + 'PageId' = $response.Id + 'Version' = $response.version | Select -ExpandProperty 'number' + } + } +} + + +function Update-Page +{ + <# + .SYNOPSIS + Add a confluence page + + .DESCRIPTION + + .EXAMPLE + Add-ConfluencePage + -Host 'confluence.contoso.com' ` + -Space 'TIARA' ` + -Title 'Testitest' ` + -Content @{} + #> + Param( + [Parameter(Mandatory)] [string] $Host, + # The page id of an existing page + [Parameter(Mandatory)] [string] $PageId, + # The name of the Confluence space to publish to + [Parameter(Mandatory)] [string] $Space, + # title of page to be published + [Parameter(Mandatory)] [string] $Title, + # version of content + [Parameter(Mandatory)] [int] $Version, + # content of page + [Parameter(Mandatory)] [string] $Content, + # parent page id + [Parameter()] [string] $Ancestor + ) + + Process + { + Assert-PersonalAccessToken $Host + + $transportBody = @{ + 'id' = $PageId + 'type' = 'page' + 'title' = $Title + 'space' = @{ + 'key' = $Space + } + 'body' = @{ + 'storage' = @{ + 'value' = $Content + 'representation' = 'storage' + } + } + 'version' = @{ + 'number' = $Version + } + } | ConvertTo-JSON + + Invoke-WebRequest ` + -Uri "https://${Host}/rest/api/content/$PageId" ` + -Method 'Put' ` + -Headers @{ + 'Authorization' = "Bearer $([System.Net.NetworkCredential]::new('', $script:PATS[$Host_]).Password)" + } ` + -ContentType "application/json" ` + -Body $transportBody ` + -OutVariable rawResponse | Out-Null + } + + End + { + $response = ($rawResponse.Content | ConvertFrom-JSON) + } +} + + +function Publish-Page +{ + Param( + # title of the page (used for manifest lookup) + [Parameter(Mandatory)] [string] $Title, + # hostname of Confluence instance + [Parameter(Mandatory)] [string] $Host, + # name of Confluence space + [Parameter(Mandatory)] [string] $Space, + # manifest object + [Parameter(Mandatory)] [PSObject] $Manifest + ) + + Begin + { + $pageMeta = Get-PageMeta ` + -Host $hostname ` + -Space $spaceName ` + -Title $Title ` + -Manifest $Manifest + } + + Process + { + if ($pageMeta.ContentHash -eq $_) + { + Write-Host "skipping (no changes): $Title" + + return + } + + $pageId = $pageMeta.PageId + + $path = Join-Path $basepath 'content' "$_" + + $pageContent = Get-Content $path | Out-String + + $prettyName = $Title + + if ($data.pages[$_].ancestor_id) + { + $ancestorTitle = $data.pages[$data.pages[$_].ancestor_id].title + + $ancestorPageMeta = Get-PageMeta ` + -Host $hostname ` + -Space $spaceName ` + -Title $ancestorTitle ` + -CacheIndexFile $cacheIndexFile + + if ($ancestorPageMeta) + { + $ancestorPageId = $ancestorPageMeta.PageId + } + + $prettyName += " [$ancestorPageId]" + } + + if (-Not $pageId) + { + Write-Host ("create ${_}: $prettyName") + + try { + $pageMeta = New-Page ` + -Host $hostname ` + -Space $spaceName ` + -Title $pageTitle ` + -Content $pageContent ` + -Ancestor $ancestorPageId + } + + catch + { + Write-Host "error (skipping): $prettyName" + + return + } + + + Register-PageMeta ` + -PageId $pageMeta.PageId ` + -Version $pageMeta.Version ` + -Title $pageTitle ` + -ContentHash $_ ` + -CacheIndexFile $cacheIndexFile + } + else + { + Write-Host ("update ${_} (${pageId}): $prettyName") + + $version = $pageMeta.Version + 1 + + try + { + Update-Page ` + -Host $hostname ` + -PageId $pageId ` + -Space $spaceName ` + -Title $pageTitle ` + -Version $version ` + -Content $pageContent ` + -Ancestor $ancestorPageId + } + + catch + { + Write-Host "error (skipping): $prettyName" + + return + } + + Register-PageMeta ` + -PageId $pageMeta.PageId ` + -Version $version ` + -Title $pageTitle ` + -ContentHash $_ ` + -CacheIndexFile $cacheIndexFile + } + } +} \ No newline at end of file diff --git a/PSConfluencePublisher/PersonalAccessToken.Tests.ps1 b/PSConfluencePublisher/PersonalAccessToken.Tests.ps1 new file mode 100755 index 0000000..ea62a03 --- /dev/null +++ b/PSConfluencePublisher/PersonalAccessToken.Tests.ps1 @@ -0,0 +1,65 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = "Stop" + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot 'PSConfluencePublisher.psd1') -Force + + $mockHost = 'confluence.contoso.com' + + $mockPat = '01234567890123456789' +} + + +Describe 'Register-PersonalAccessToken' ` +{ + BeforeEach { + Initialize-PersonalAccessTokenStore + } + + Context 'Parameterized' { + + It 'throws no exception' { + Register-PersonalAccessToken -Host $mockHost -Token $mockPat + } + } + + Context 'Shorthand' { + + It 'throws no exception' { + Register-PersonalAccessToken $mockHost $mockPat + } + } +} + + +Describe 'Get-PersonalAccessToken' ` +{ + BeforeEach { + Initialize-PersonalAccessTokenStore + } + + Context 'Parameterized' { + + It 'gets an existing PAT' { + + Register-PersonalAccessToken -Host $mockHost -Token $mockPat + + Get-PersonalAccessToken -Host $mockHost | Should -Be $mockPat + } + + It 'requires PAT to exist' { + + {Get-PersonalAccessToken -Host $mockHost} | Should -Throw + } + } + + Context 'Shorthand' { + + It 'throws no exception' { + + Register-PersonalAccessToken -Host $mockHost -Token $mockPat + + Get-PersonalAccessToken $mockHost | Should -Be $mockPat + } + } +} \ No newline at end of file diff --git a/PSConfluencePublisher/PersonalAccessToken.psm1 b/PSConfluencePublisher/PersonalAccessToken.psm1 new file mode 100755 index 0000000..1f3a45c --- /dev/null +++ b/PSConfluencePublisher/PersonalAccessToken.psm1 @@ -0,0 +1,99 @@ +#!/usr/bin/env pwsh +<# + .SYNOPSIS + Utilities for working with Confluence Personal Access Tokens + + .DESCRIPTION + + + + .EXAMPLE + + Register-PersonalAccessToken ` + -Host 'confluence.contoso.com' ` + -Token '123456789123456789' + + Get-PersonalAccessToken -Host 'confluence.contoso.com' +#> +$ErrorActionPreference = "Stop" + + +#session storage of Confluence personal access tokens +$script:PATS = @{} + + +function Initialize-PersonalAccessTokenStore +{ + <# + .SYNOPSIS + Initialize the store within this script's scope. + + .EXAMPLE + Initialize-PersonalAccessTokenStore + #> + Process + { + $script:PATS = @{} + } +} + + +function Register-PersonalAccessToken +{ + <# + .SYNOPSIS + Register a Confluence Personal Access Token (PAT) + + .DESCRIPTION + The PAT is stored in the pseudo-local ``script`` scope as a + SecureString. Implementors of functions accessing PATs MUST stall + conversion to plain text string until the string is actually needed + + .EXAMPLE + Register-PersonalAccessToken confluence.contoso.com 0123456789 + #> + [CmdletBinding()] + + Param( + [Parameter(Mandatory, Position = 0)] [string] $Host, + [Parameter(Mandatory, Position = 1)] [string] $Token + ) + + Process + { + if ($script:PATS[$Host]) + { + Write-Debug "PAT for '$Host' already registered, overwriting." + } + + $script:PATS[$Host] = ConvertTo-SecureString $Token -AsPlainText -Force + } +} + + +function Get-PersonalAccessToken +{ + <# + .SYNOPSIS + Get a Confluence Personal Access Token (PAT) registered in this + script scope. + + .EXAMPLE + Get-PersonalAccessToken confluence.contoso.com + #> + Param( + # Confluence instance hostname + [Parameter(Mandatory, Position = 0)] [string] $Host + ) + + Process + { + if (-Not $PATS[$Host]) + { + throw "No personal access token for host '$Host' registered. " + + "Hint: Call ``Register-PersonalAccessToken``" + } + + $([Net.NetworkCredential]::new('', $script:PATS[$Host]).Password) + } +} \ No newline at end of file diff --git a/PSConfluencePublisher/_mock/._test-manifest1.json b/PSConfluencePublisher/_mock/._test-manifest1.json new file mode 100755 index 0000000..5897760 Binary files /dev/null and b/PSConfluencePublisher/_mock/._test-manifest1.json differ diff --git a/PSConfluencePublisher/_mock/._test-manifest2.json b/PSConfluencePublisher/_mock/._test-manifest2.json new file mode 100755 index 0000000..d26f896 Binary files /dev/null and b/PSConfluencePublisher/_mock/._test-manifest2.json differ diff --git a/PSConfluencePublisher/_mock/test-manifest1.json b/PSConfluencePublisher/_mock/test-manifest1.json new file mode 100755 index 0000000..8c3a0ae --- /dev/null +++ b/PSConfluencePublisher/_mock/test-manifest1.json @@ -0,0 +1,4 @@ +{ + "pages": {}, + "attachments": {} +} \ No newline at end of file diff --git a/PSConfluencePublisher/_mock/test-manifest2.json b/PSConfluencePublisher/_mock/test-manifest2.json new file mode 100755 index 0000000..5d2aa91 --- /dev/null +++ b/PSConfluencePublisher/_mock/test-manifest2.json @@ -0,0 +1,12 @@ +{ + "pages": { + "": { + "Ref": "" + } + }, + "attachments": { + "": { + + } + } +} \ No newline at end of file diff --git a/PSConfluencePublisher/manifest.schema.json b/PSConfluencePublisher/manifest.schema.json new file mode 100755 index 0000000..62be76f --- /dev/null +++ b/PSConfluencePublisher/manifest.schema.json @@ -0,0 +1,93 @@ +{ + "$id": "https://spec.victory-k.it/psconfluencepublisher.json", + "x-authors": [ + "theodor.rodweil@victory-k.it" + ], + "type": "object", + "properties": { + "pages": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/definitions/page" + } + } + }, + "attachments": { + "type": "object", + "patternProperties": { + ".*": { + "$ref": "#/definitions/attachment" + } + } + } + }, + "required": [ + "pages", + "attachments" + ], + "definitions": { + "page": { + "type": "object", + "description": "Local Confluence page/container attachment metadata", + "properties": { + "PageId": { + "type": "string", + "description": "Id of attachment defined by Confluence instance. The id is generated after the publishing of a page." + }, + "Version": { + "type": "string" + }, + "Hash": { + "type": "string", + "description": "SHA512 hexadecimal content hash value" + }, + "Ref": { + "type": "string", + "description": "Local filesystem reference/path" + }, + "AncestorTitle": { + "type": "string", + "description": "Title of Confluence page this page is a child of. The title must be a property key of the pages object." + } + }, + "required": [ + "Hash", + "Ref" + ] + }, + "attachment": { + "type": "object", + "description": "Local Confluence page/container attachment metadata", + "properties": { + "AttachmentId": { + "type": "string", + "description": "Id of attachment defined by Confluence instance. The id is generated after the publishing of an attachment." + }, + "Hash": { + "type": "string", + "description": "SHA512 hexadecimal attachment content hash value" + }, + "MimeType": { + "type": "string", + "description": "MIME type of attachment", + "default": "binary/octet-stream" + }, + "ContainerPageTitle": { + "type": "string", + "description": "Title of Confluence page this attachment is contained in. The title must be a property key of the pages object." + }, + "Ref": { + "type": "string", + "description": "Local filesystem reference/path" + } + }, + "required": [ + "Hash", + "MimeType", + "ContainerPageTitle", + "Ref" + ] + } + } +} \ No newline at end of file diff --git a/PSConfluencePublisher/scripts.deps.json b/PSConfluencePublisher/scripts.deps.json new file mode 100755 index 0000000..adc9bd7 --- /dev/null +++ b/PSConfluencePublisher/scripts.deps.json @@ -0,0 +1,19 @@ +{ + "runtimeTarget": { + "name": ".NETStandard,Version=v2.0/", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETStandard,Version=v2.0": {}, + ".NETStandard,Version=v2.0/": { + "PSConfluencePublisher/1.0.0": { + "dependencies": { + "NETStandard.Library": "2.0.3" + }, + "runtime": {} + } + } + }, + "libraries": {} +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..f2d30b0 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# PSConfluencePublisher + +This program is a standalone publisher component for the +`sphinxcontrib.confluencebuilder` Sphinx extension. + +It consumes, a JSON-formatted manifest of a *Sphinx build* dump generated by +the ``sphinxcontrib.xconfluencebuilder`` and unidirectionally synchronizes +pages, page ancestry, and attachments. + +Publishing is supported via the Confluence Server REST API through +[Personal Access Token (PAT) authorization](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html). + +## Usage + +You can install the module via [nuget](https://www.nuget.org). + +``` +Install-Module victorykit.PSConfluencePublisher +``` + +Alternatively, you can import the module from source. In order to do that, +clone the +[Git repository](https://bitbucket.org/victorykit/psconfluencepublisher/src) +, change into the directory and import it. + +``` +PS> git clone git@bitbucket.org:victorykit/psconfluencepublisher.git +``` + +``` +PS> # universal import statement compatible with PowerShell Core & Desktop +PS> Import-Module (Join-Path 'PSConfluencePublisher' + 'PSConfluencePublisher.psd1') +``` + +Next, register your personal access token for your Confluence server instance. +The token is stored as a *SecureString* within the *Script* scope. + +``` +Register-PersonalAccessToken ` + -Host 'confluence.contoso.com' ` + -Token '123456789123456789' +``` + +Optionally, you may test the connectivity to your Confluence instance. The test +will try to retrieve your user profile, in order to determine whether the PAT +authenticates, since an invalid PAT simply results in anonymous authentication +for some REST API functions. + +``` +Test-Connection confluence.contoso.com +``` + +Now you may publish by supplying the URL of the root Confluence page +you want to publish to, in addition to the location of the local dump manifest. +Make sure to use the full URL, with the same hostname as the one you used to +register your personal access token. + +``` +Publish-Dump ` + -Url 'https://confluence.contoso.com/display/TIARA/Testitest' ` + -DumpIndex build/docs/confluence.out/data.json +``` + +The manifest may be writable, where it is then used to cache the publishing +status of each page and attachment. + +You may publish a single page, which however requires it's direct ancestor page +to exist. + +``` +Publish-Page +``` + +## Debugging + +To display debug messages, set +[$DebugPreference](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_preference_variables?view=powershell-7.3#debugpreference) +to `Continue`, or `Inquire` in your shell's *Global* scope. + +## Testing + +This program requires [Pester](https://pester.dev/) to execute it's test suite. + +``PS> Invoke-Pester PSConfluencePublisher/*.Tests.ps1 -Show 'All'`` \ No newline at end of file