This commit is contained in:
Rodweil, Theodor 2023-07-30 16:16:55 +02:00
commit 17266ecb99
No known key found for this signature in database
GPG key ID: F8BC1B0EB1F9CCF5
30 changed files with 1731 additions and 0 deletions

3
.gitignore vendored Executable file
View file

@ -0,0 +1,3 @@
/_*
/._*
.DS_Store

14
.nuspec Executable file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>ConfluencePublisher</id>
<version>1.1.0</version>
<description>Übergangslösung für die Integration von sphinx-confluencebuilder generierter Confluence Dokumentation</description>
<authors>tiara.rodney@adesso.de</authors>
<owners>irina.ternovykh@vkb.de</owners>
<license type="expression">UNLICENSED</license>
<readme>README.md</readme>
<copyright>VKBit Betrieb GmbH</copyright>
<title>Powershell Confluence Publisher</title>
</metadata>
</package>

BIN
PSConfluencePublisher/._.DS_Store Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
PSConfluencePublisher/._Page.psm1 Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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
}
}
}
}

View file

@ -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))"
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}

View file

@ -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 = ''
}

View file

@ -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 `
{
}
}
}

435
PSConfluencePublisher/Page.psm1 Executable file
View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,4 @@
{
"pages": {},
"attachments": {}
}

View file

@ -0,0 +1,12 @@
{
"pages": {
"": {
"Ref": ""
}
},
"attachments": {
"": {
}
}
}

View file

@ -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"
]
}
}
}

View file

@ -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": {}
}

85
README.md Executable file
View file

@ -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'``