diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 93c1ddd..36aa723 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,20 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ce64918acc96dda73eb78f827036b794bfa6fa1a # v5.5.7 - secrets: inherit + # Pinned to the TestData feature branch of Process-PSModule (PR #365). + # Repin to the released tag once that change ships. + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ea019351821a74ae1528a4472198c84c91dd3059 # feat/52-test-secrets + secrets: + # PowerShell Gallery API key used to publish the module. + APIKey: ${{ secrets.APIKey }} + # All data the module's tests need, in one object: a "secrets" map (masked) and a "variables" + # map (not masked), each entry exposed as an environment variable. Only what the tests require + # is passed - secrets: inherit is intentionally not used. Integration tests skip when + # CONFLUENCE_API_TOKEN is unavailable (forks / PRs without access). Secrets use the direct + # "${{ secrets.X }}" form (CodeQL-clean) and the whole blob is one folded line so GitHub + # registers a single mask. + TestData: >- + { "secrets": { "CONFLUENCE_API_TOKEN": "${{ secrets.CONFLUENCE_API_TOKEN }}" }, + "variables": { "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, + "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, + "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } } diff --git a/README.md b/README.md index df91bec..50b5b8e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,51 @@ # Confluence -Confluence is intended to be a PowerShell module for interacting with Atlassian Confluence. +Confluence is a PowerShell module for interacting with the Atlassian Confluence Cloud REST API, both interactively and in automation. -## Status +It exposes pages, blog posts, folders, spaces, comments, labels, content properties, attachments, restrictions and more as PowerShell commands. +Every command is a thin wrapper over a single generic REST entry point (`Invoke-ConfluenceRestMethod`). Credentials and module configuration +are stored with the [Context](https://github.com/PSModule/Context) module, so you connect once and reuse the profile across sessions. -This repository is currently a placeholder. The module source still contains scaffold code and examples, so there are no supported Confluence-specific commands or usage examples to document yet. +## Installation + +Install the module from the PowerShell Gallery: + +```powershell +Install-PSResource -Name Confluence +Import-Module -Name Confluence +``` + +## Usage + +Connect with a scoped Atlassian API token, then call the resource commands. The token is kept as a `SecureString` in the Context vault. + +```powershell +# Connect and store a reusable, named credential profile +$token = Read-Host -AsSecureString # a scoped Atlassian API token +Connect-Confluence -Site 'yoursite' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' + +# Work with content +$space = Get-ConfluenceSpace -Key 'DOCS' +$page = New-ConfluencePage -SpaceId $space.id -Title 'Release notes' -Body '
Hello
' +Set-ConfluencePage -PageId $page.id -Body 'Updated
' +Get-ConfluencePageChild -PageId $page.id +``` + +See the [examples](examples) folder for more, including managing contexts and pages. + +The service-account token must be granted the module's required Confluence scopes — see [SCOPES.md](SCOPES.md). ## Documentation -When this module is implemented, command details should live in PowerShell help and generated documentation rather than being duplicated in this README. +Documentation is published at [psmodule.io/Confluence](https://psmodule.io/Confluence/). + +Use PowerShell help and command discovery for module details: + +```powershell +Get-Command -Module Confluence +Get-Help New-ConfluencePage -Examples +``` ## Contributing -Issues and pull requests are welcome when implementation work begins. +Issues and pull requests are welcome. Please use the repository issue tracker to report bugs, request features, or discuss improvements. diff --git a/SCOPES.md b/SCOPES.md new file mode 100644 index 0000000..08be391 --- /dev/null +++ b/SCOPES.md @@ -0,0 +1,158 @@ +# Required Atlassian scopes + +`Confluence` authenticates with a **scoped** Atlassian API token (the `ATSTT…` +prefix) used as HTTP Basic authentication (`First draft
' + +# Read it back (storage format by default) +Get-ConfluencePage -PageId $page.id + +# Update the body (the version number is incremented automatically) +Set-ConfluencePage -PageId $page.id -Body 'Updated content
' + +# Add a child page, then list children and descendants +New-ConfluencePage -SpaceId $space.id -Title 'Details' -ParentId $page.id -Body 'Nested
' +Get-ConfluencePageChild -PageId $page.id +Get-ConfluenceDescendant -PageId $page.id + +# Labels and comments +Add-ConfluenceLabel -PageId $page.id -Label 'release', 'published' +Add-ConfluenceComment -PageId $page.id -Body 'Looks good.
' + +# Remove the whole subtree (moves the pages to trash) +Remove-ConfluencePage -PageId $page.id -Recurse diff --git a/src/finally.ps1 b/src/finally.ps1 deleted file mode 100644 index d8fc207..0000000 --- a/src/finally.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '------------------------------' -Write-Verbose '--- THIS IS A LAST LOADER ---' -Write-Verbose '------------------------------' diff --git a/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 new file mode 100644 index 0000000..5f65563 --- /dev/null +++ b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 @@ -0,0 +1,39 @@ +function Resolve-ConfluenceContext { + <# + .SYNOPSIS + Resolve a context argument into a usable credential-context object. + + .DESCRIPTION + Accepts a context object/hashtable (returned as-is), a context name + (looked up in the vault), or nothing (falls back to the default context + recorded in the module configuration). + #> + [CmdletBinding()] + param( + # A context object, a context name, or $null for the default context. + [object]$Context + ) + + if ($null -ne $Context -and $Context -isnot [string]) { + return $Context + } + + Initialize-ConfluenceConfig + $id = if ([string]::IsNullOrEmpty([string]$Context)) { $script:Confluence.Config['DefaultContext'] } else { [string]$Context } + + if ([string]::IsNullOrEmpty($id)) { + throw 'No Confluence context was specified and no default context is set. Run Connect-Confluence first.' + } + + $resolved = $null + try { + $resolved = Get-Context -ID $id -Vault $script:Confluence.ContextVault -ErrorAction Stop + } catch { + $resolved = $null + } + + if (-not $resolved) { + throw "Confluence context '$id' was not found in vault '$($script:Confluence.ContextVault)'." + } + return $resolved +} diff --git a/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 new file mode 100644 index 0000000..5736dca --- /dev/null +++ b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 @@ -0,0 +1,22 @@ +function Resolve-ConfluenceToken { + <# + .SYNOPSIS + Return the plain-text API token from a SecureString or string. + + .DESCRIPTION + The token is stored as a SecureString in the credential context; this + converts it to the plain text needed to build the Basic auth header. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The token as a SecureString or a plain string. + [Parameter(Mandatory)] + [object]$Token + ) + + if ($Token -is [System.Security.SecureString]) { + return [System.Net.NetworkCredential]::new('', $Token).Password + } + return [string]$Token +} diff --git a/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 new file mode 100644 index 0000000..f90f38e --- /dev/null +++ b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 @@ -0,0 +1,29 @@ +function ConvertTo-ConfluenceHashtable { + <# + .SYNOPSIS + Convert a PSCustomObject (or hashtable) into a plain hashtable. + + .DESCRIPTION + Used to turn the stored module-configuration context returned by + Get-Context into a mutable hashtable. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + # The object to convert. + [Parameter(Mandatory)] + [object]$InputObject + ) + + if ($InputObject -is [hashtable]) { + # Return a shallow clone so callers can mutate the result without altering + # the source hashtable (e.g. the built-in $script:Confluence.DefaultConfig). + return $InputObject.Clone() + } + + $result = @{} + foreach ($property in $InputObject.PSObject.Properties) { + $result[$property.Name] = $property.Value + } + return $result +} diff --git a/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 new file mode 100644 index 0000000..d9b993c --- /dev/null +++ b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 @@ -0,0 +1,57 @@ +function Initialize-ConfluenceConfig { + <# + .SYNOPSIS + Load the module configuration from the context vault into memory. + + .DESCRIPTION + Loads the stored 'Module' configuration context, creating it from the + built-in defaults on first use. Missing default keys are backfilled. + #> + [CmdletBinding()] + param( + # Recreate the configuration from the built-in defaults. + [switch]$Force + ) + + if (-not $Force -and $null -ne $script:Confluence.Config) { + return + } + + $vault = $script:Confluence.ContextVault + $id = $script:Confluence.DefaultConfig.ID + + $stored = $null + if (-not $Force) { + try { + $stored = Get-Context -ID $id -Vault $vault -ErrorAction Stop + } catch { + $stored = $null + } + } + + $dirty = $false + if ($Force -or -not $stored) { + $config = ConvertTo-ConfluenceHashtable -InputObject $script:Confluence.DefaultConfig + $dirty = $true + } else { + $config = ConvertTo-ConfluenceHashtable -InputObject $stored + foreach ($key in $script:Confluence.DefaultConfig.Keys) { + if (-not $config.ContainsKey($key)) { + $config[$key] = $script:Confluence.DefaultConfig[$key] + $dirty = $true + } + } + } + + if ($config['ID'] -ne $id) { + $config['ID'] = $id + $dirty = $true + } + + # Only persist when we actually created or changed the stored config, to avoid + # unnecessary vault writes on every cmdlet call (this runs from most cmdlets). + if ($dirty) { + $null = Set-Context -ID $id -Context $config -Vault $vault + } + $script:Confluence.Config = $config +} diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 new file mode 100644 index 0000000..add9d9b --- /dev/null +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -0,0 +1,194 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6'; MaximumVersion = '8.999.999' } + +function Invoke-ConfluenceRestMethod { + <# + .SYNOPSIS + Call the Confluence REST API using a stored (or supplied) context. + + .DESCRIPTION + The single generic entry point that every other Confluence function is + built on. It resolves the credential context, builds the HTTP Basic auth + header, sends the request, and surfaces API errors as terminating errors. + With -All it transparently follows v2 cursor pagination and returns the + aggregated 'results'. Error messages include Atlassian's + 'X-Failure-Category' response header when present (for example + FAILURE_CLIENT_SCOPE_CHECK), which distinguishes a missing-scope + rejection from other failures. Pass -Debug to emit the full request and + response (status, headers, and body); the Authorization header is + redacted. + + .EXAMPLE + ```powershell + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } + ``` + + Calls the spaces endpoint directly and returns the raw response. + + .LINK + https://psmodule.io/Confluence/Functions/API/Invoke-ConfluenceRestMethod/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ + + .LINK + https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ + #> + [CmdletBinding()] + [OutputType([object])] + param( + # The API path beginning with '/wiki/', e.g. '/wiki/api/v2/pages'. + # A full absolute URL is also accepted (used when following pagination links). + # When -ApiVersion is supplied this is treated as a path relative to that + # API family's root (e.g. -ApiVersion v2 -ApiEndpoint 'spaces'). + [Parameter(Mandatory)] + [string]$ApiEndpoint, + + # Optional API family. Uses the internal map to prepend the version prefix + # (v1 -> /wiki/rest/api, v2 -> /wiki/api/v2) to a relative -ApiEndpoint. + [ValidateSet('v1', 'v2')] + [string]$ApiVersion, + + # The HTTP method. Defaults to GET. + [ValidateSet('GET', 'POST', 'PUT', 'DELETE')] + [string]$Method = 'GET', + + # The request body as a hashtable/object (serialized to JSON) or a pre-serialized JSON string. + [object]$Body, + + # A multipart/form-data payload (used for attachment uploads). + [hashtable]$Form, + + # Query-string parameters as a hashtable. + [hashtable]$Query, + + # Follow pagination and return every item from the 'results' collections. + [switch]$All, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($All -and $Method -ne 'GET') { + throw "The -All switch follows pagination and is only valid for GET requests, not $Method." + } + + # Resolve a relative endpoint against the internal v1/v2 map when a version is given. + if ($ApiVersion -and $ApiEndpoint -notmatch '^https?://') { + $ApiEndpoint = '{0}/{1}' -f $script:Confluence.ApiPaths[$ApiVersion], $ApiEndpoint.TrimStart('/') + } + + $resolved = Resolve-ConfluenceContext -Context $Context + $token = Resolve-ConfluenceToken -Token $resolved.Token + $pair = '{0}:{1}' -f $resolved.Username, $token + $authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair)) + + # For v2 collection endpoints, default the page size from the PerPage config so + # -All pages in configured-sized batches instead of Confluence's small server + # default (~25), unless the caller passed an explicit limit. Absolute _links.next + # URLs and v1 endpoints are left untouched. + $effectiveQuery = if ($Query) { @{} + $Query } else { @{} } + if ($All -and $ApiEndpoint -like '/wiki/api/v2/*' -and -not ($effectiveQuery.Keys | Where-Object { $_ -ieq 'limit' })) { + Initialize-ConfluenceConfig + $perPage = $script:Confluence.Config['PerPage'] + if ($perPage) { + $effectiveQuery['limit'] = $perPage + } + } + + $endpoint = $ApiEndpoint + if ($effectiveQuery.Count -gt 0) { + $pairs = foreach ($entry in $effectiveQuery.GetEnumerator()) { + '{0}={1}' -f [uri]::EscapeDataString([string]$entry.Key), [uri]::EscapeDataString([string]$entry.Value) + } + $separator = if ($endpoint.Contains('?')) { '&' } else { '?' } + $endpoint = '{0}{1}{2}' -f $endpoint, $separator, ($pairs -join '&') + } + + $paginate = $All.IsPresent + $debug = $DebugPreference -eq 'Continue' + + do { + $uri = if ($endpoint -match '^https?://') { $endpoint } else { '{0}{1}' -f $resolved.ApiBaseUri, $endpoint } + + $headers = @{ + Authorization = $authorization + Accept = 'application/json' + } + + $request = @{ + Uri = $uri + Method = $Method + Headers = $headers + SkipHttpErrorCheck = $true + StatusCodeVariable = 'statusCode' + ResponseHeadersVariable = 'responseHeaders' + } + + if ($Form) { + $headers['X-Atlassian-Token'] = 'no-check' + $request['Form'] = $Form + } elseif ($null -ne $Body) { + $request['ContentType'] = 'application/json' + # Serialize with -InputObject rather than the pipeline: piping a + # single-element array to ConvertTo-Json emits a bare object and loses + # the array, whereas -InputObject serializes arrays and objects faithfully. + $request['Body'] = if ($Body -is [string]) { $Body } else { ConvertTo-Json -InputObject $Body -Depth 20 -Compress } + } + + if ($debug) { + Write-Debug "Request: $Method $uri" + foreach ($headerName in ($headers.Keys | Sort-Object)) { + $headerValue = if ($headerName -eq 'Authorization') { 'BasicNice page.
' + ``` + + Adds a footer comment to page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Comments/Add-ConfluenceComment/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id to comment on. + [Parameter(Mandatory)] + [string]$PageId, + + # The comment body. + [Parameter(Mandatory)] + [string]$Body, + + # The body representation. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'wiki')] + [string]$Representation = 'storage', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = @{ + pageId = $PageId + body = @{ + representation = $Representation + value = $Body + } + } + + if ($PSCmdlet.ShouldProcess($PageId, 'Add footer comment')) { + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/footer-comments' -Method 'POST' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Comments/Get-ConfluenceComment.ps1 b/src/functions/public/Comments/Get-ConfluenceComment.ps1 new file mode 100644 index 0000000..a1ddf80 --- /dev/null +++ b/src/functions/public/Comments/Get-ConfluenceComment.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluenceComment { + <# + .SYNOPSIS + List the footer comments on a page (read:comment). + + .DESCRIPTION + Returns every footer comment on the page, following pagination. + + .EXAMPLE + ```powershell + Get-ConfluenceComment -PageId '12345' + ``` + + Lists the footer comments on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Comments/Get-ConfluenceComment/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/footer-comments" -All -Context $Context +} diff --git a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 new file mode 100644 index 0000000..3fbf246 --- /dev/null +++ b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 @@ -0,0 +1,35 @@ +function Remove-ConfluenceComment { + <# + .SYNOPSIS + Delete a footer comment (delete:comment). + + .DESCRIPTION + Permanently deletes a footer comment by id. + + .EXAMPLE + ```powershell + Remove-ConfluenceComment -CommentId '55555' + ``` + + Deletes the footer comment with ID 55555. + + .LINK + https://psmodule.io/Confluence/Functions/Comments/Remove-ConfluenceComment/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The footer comment id. + [Parameter(Mandatory)] + [string]$CommentId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($CommentId, 'Delete footer comment')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/footer-comments/$CommentId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/Config/Get-ConfluenceConfig.ps1 b/src/functions/public/Config/Get-ConfluenceConfig.ps1 new file mode 100644 index 0000000..00790b6 --- /dev/null +++ b/src/functions/public/Config/Get-ConfluenceConfig.ps1 @@ -0,0 +1,34 @@ +function Get-ConfluenceConfig { + <# + .SYNOPSIS + Get the Confluence module configuration. + + .DESCRIPTION + Returns the whole configuration hashtable, or the value of a single + named configuration item. + + .EXAMPLE + ```powershell + Get-ConfluenceConfig -Name 'DefaultContext' + ``` + + Gets the DefaultContext value from the module configuration. + + .LINK + https://psmodule.io/Confluence/Functions/Config/Get-ConfluenceConfig/ + #> + [CmdletBinding()] + param( + # The configuration item to return. If omitted, the whole config is returned. + [string]$Name + ) + + Initialize-ConfluenceConfig + if ([string]::IsNullOrEmpty($Name)) { + # Return a shallow copy so callers cannot mutate the in-memory configuration cache by + # accident; changes must go through Set-ConfluenceConfig to be persisted. Cloning a small + # hashtable of scalars is negligible next to the vault I/O the module already performs. + return $script:Confluence.Config.Clone() + } + $script:Confluence.Config[$Name] +} diff --git a/src/functions/public/Config/Set-ConfluenceConfig.ps1 b/src/functions/public/Config/Set-ConfluenceConfig.ps1 new file mode 100644 index 0000000..9114da0 --- /dev/null +++ b/src/functions/public/Config/Set-ConfluenceConfig.ps1 @@ -0,0 +1,37 @@ +function Set-ConfluenceConfig { + <# + .SYNOPSIS + Set a Confluence module configuration value. + + .DESCRIPTION + Updates a single configuration item and persists the configuration to + the context vault. + + .EXAMPLE + ```powershell + Set-ConfluenceConfig -Name 'PerPage' -Value 50 + ``` + + Sets the PerPage configuration value to 50. + + .LINK + https://psmodule.io/Confluence/Functions/Config/Set-ConfluenceConfig/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The configuration item name. + [Parameter(Mandatory)] + [string]$Name, + + # The value to store. + [Parameter(Mandatory)] + [AllowEmptyString()] + [object]$Value + ) + + Initialize-ConfluenceConfig + if ($PSCmdlet.ShouldProcess("Confluence config '$Name'", 'Set')) { + $script:Confluence.Config[$Name] = $Value + $null = Set-Context -ID $script:Confluence.DefaultConfig.ID -Context $script:Confluence.Config -Vault $script:Confluence.ContextVault + } +} diff --git a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 new file mode 100644 index 0000000..694456b --- /dev/null +++ b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 @@ -0,0 +1,43 @@ +function Get-ConfluenceContentProperty { + <# + .SYNOPSIS + Get content properties of a page (read:page). + + .DESCRIPTION + Returns all content properties on a page, or the single property with the + given key. In v2, a page's content properties are governed by the page's + own scope (read:page); the read:content.property scope applies to the v1 + property API. + + .EXAMPLE + ```powershell + Get-ConfluenceContentProperty -PageId '12345' -Key 'my-prop' + ``` + + Gets the 'my-prop' content property from page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/ContentProperties/Get-ConfluenceContentProperty/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The property key to return. If omitted, all properties are returned. + [string]$Key, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $all = Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/properties" -All -Context $Context + if ([string]::IsNullOrEmpty($Key)) { + return $all + } + $all | Where-Object { $_.key -eq $Key } | Select-Object -First 1 +} diff --git a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 new file mode 100644 index 0000000..847605e --- /dev/null +++ b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 @@ -0,0 +1,40 @@ +function Remove-ConfluenceContentProperty { + <# + .SYNOPSIS + Delete a content property from a page (read:page and write:page). + + .DESCRIPTION + Deletes a content property by its id. In v2, page content properties are + governed by the page's own scope (read:page and write:page). + + .EXAMPLE + ```powershell + Remove-ConfluenceContentProperty -PageId '12345' -PropertyId '98765' + ``` + + Deletes the content property with ID 98765 from page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/ContentProperties/Remove-ConfluenceContentProperty/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The content-property id. + [Parameter(Mandatory)] + [string]$PropertyId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess("$PageId/$PropertyId", 'Delete content property')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/properties/$PropertyId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 new file mode 100644 index 0000000..d77585a --- /dev/null +++ b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 @@ -0,0 +1,66 @@ +function Set-ConfluenceContentProperty { + <# + .SYNOPSIS + Create or update a content property on a page (read:page and write:page). + + .DESCRIPTION + Creates the property if it does not exist, or updates it (incrementing + its version) if it does. In v2, page content properties are governed by + the page's own scope: reading requires read:page and writing requires + write:page. + + .EXAMPLE + ```powershell + Set-ConfluenceContentProperty -PageId '12345' -Key 'owner' -Value @{ team = 'ai' } + ``` + + Creates or updates the 'owner' content property on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/ContentProperties/Set-ConfluenceContentProperty/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The property key. + [Parameter(Mandatory)] + [string]$Key, + + # The property value (any JSON-serializable object). + [Parameter(Mandatory)] + [object]$Value, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $existing = Get-ConfluenceContentProperty -PageId $PageId -Key $Key -Context $Context + + if ($existing) { + $payload = @{ + key = $Key + value = $Value + version = @{ + number = [int]$existing.version.number + 1 + } + } + $endpoint = "/wiki/api/v2/pages/$PageId/properties/$($existing.id)" + if ($PSCmdlet.ShouldProcess("$PageId/$Key", 'Update content property')) { + Invoke-ConfluenceRestMethod -ApiEndpoint $endpoint -Method 'PUT' -Body $payload -Context $Context + } + } else { + $payload = @{ + key = $Key + value = $Value + } + if ($PSCmdlet.ShouldProcess("$PageId/$Key", 'Create content property')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/properties" -Method 'POST' -Body $payload -Context $Context + } + } +} diff --git a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 new file mode 100644 index 0000000..55ff2f3 --- /dev/null +++ b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluenceFolder { + <# + .SYNOPSIS + Get a Confluence folder by id (read:folder). + + .DESCRIPTION + Returns a single folder. + + .EXAMPLE + ```powershell + Get-ConfluenceFolder -FolderId '67890' + ``` + + Gets the folder with ID 67890. + + .LINK + https://psmodule.io/Confluence/Functions/Folders/Get-ConfluenceFolder/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ + #> + [CmdletBinding()] + param( + # The folder id. + [Parameter(Mandatory)] + [string]$FolderId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/folders/$FolderId" -Context $Context +} diff --git a/src/functions/public/Folders/New-ConfluenceFolder.ps1 b/src/functions/public/Folders/New-ConfluenceFolder.ps1 new file mode 100644 index 0000000..9b0270e --- /dev/null +++ b/src/functions/public/Folders/New-ConfluenceFolder.ps1 @@ -0,0 +1,50 @@ +function New-ConfluenceFolder { + <# + .SYNOPSIS + Create a Confluence folder (write:folder). + + .DESCRIPTION + Creates a folder in a space, optionally beneath a parent page or folder. + + .EXAMPLE + ```powershell + New-ConfluenceFolder -SpaceId $spaceId -Title 'Archive' -ParentId $pageId + ``` + + Creates a folder named 'Archive' beneath the given parent. + + .LINK + https://psmodule.io/Confluence/Functions/Folders/New-ConfluenceFolder/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The id of the space to create the folder in. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The folder title. + [Parameter(Mandatory)] + [string]$Title, + + # The id of the parent page or folder. Omit to create at the space root. + [string]$ParentId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = @{ + spaceId = $SpaceId + title = $Title + } + if (-not [string]::IsNullOrEmpty($ParentId)) { + $payload['parentId'] = $ParentId + } + + if ($PSCmdlet.ShouldProcess($Title, 'Create Confluence folder')) { + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/folders' -Method 'POST' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 new file mode 100644 index 0000000..83584eb --- /dev/null +++ b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 @@ -0,0 +1,35 @@ +function Remove-ConfluenceFolder { + <# + .SYNOPSIS + Delete a Confluence folder (delete:folder). + + .DESCRIPTION + Moves a folder to the trash. + + .EXAMPLE + ```powershell + Remove-ConfluenceFolder -FolderId '67890' + ``` + + Moves the folder with ID 67890 to the trash. + + .LINK + https://psmodule.io/Confluence/Functions/Folders/Remove-ConfluenceFolder/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The folder id to delete. + [Parameter(Mandatory)] + [string]$FolderId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($FolderId, 'Delete Confluence folder')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/folders/$FolderId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 deleted file mode 100644 index ffe3483..0000000 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -#Requires -Modules Utilities - -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 new file mode 100644 index 0000000..b3a7612 --- /dev/null +++ b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 @@ -0,0 +1,54 @@ +function Add-ConfluenceLabel { + <# + .SYNOPSIS + Add one or more labels to a page (read:label and write:label). + + .DESCRIPTION + Adds labels to a page. Confluence exposes label creation only on the v1 + content endpoint (/wiki/rest/api/content/{id}/label), which the granular + read:label and write:label scopes authorise. + + .EXAMPLE + ```powershell + Add-ConfluenceLabel -PageId '12345' -Label 'docs', 'published' + ``` + + Adds the 'docs' and 'published' labels to page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Labels/Add-ConfluenceLabel/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page (content) id to label. + [Parameter(Mandatory)] + [string]$PageId, + + # One or more label names to add. + [Parameter(Mandatory)] + [string[]]$Label, + + # The label prefix. Defaults to 'global'. + [string]$Prefix = 'global', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = foreach ($name in $Label) { + @{ + prefix = $Prefix + name = $name + } + } + # @(...) forces an array (a single label is otherwise a scalar) and -InputObject + # avoids the pipeline, so the v1 label endpoint always receives a JSON array. + $json = ConvertTo-Json -InputObject @($payload) -Depth 5 -Compress + + if ($PSCmdlet.ShouldProcess($PageId, "Add label(s): $($Label -join ', ')")) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/rest/api/content/$PageId/label" -Method 'POST' -Body $json -Context $Context + } +} diff --git a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 new file mode 100644 index 0000000..fba6234 --- /dev/null +++ b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 @@ -0,0 +1,35 @@ +function Get-ConfluenceLabel { + <# + .SYNOPSIS + List the labels on a page (read:page). + + .DESCRIPTION + Returns every label on the page, following pagination. In v2 a page's + labels are read under the page's own scope (read:page); the read:label + scope covers only the site-wide GET /labels endpoint. + + .EXAMPLE + ```powershell + Get-ConfluenceLabel -PageId '12345' + ``` + + Lists the labels on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Labels/Get-ConfluenceLabel/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-label/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/labels" -All -Context $Context +} diff --git a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 new file mode 100644 index 0000000..beecdb5 --- /dev/null +++ b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 @@ -0,0 +1,39 @@ +function Remove-ConfluenceLabel { + <# + .SYNOPSIS + Remove a label from a page (write:label). + + .DESCRIPTION + Removes a label from a page via the v1 content endpoint. + + .EXAMPLE + ```powershell + Remove-ConfluenceLabel -PageId '12345' -Label 'docs' + ``` + + Removes the 'docs' label from page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Labels/Remove-ConfluenceLabel/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page (content) id. + [Parameter(Mandatory)] + [string]$PageId, + + # The label name to remove. + [Parameter(Mandatory)] + [string]$Label, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($PageId, "Remove label: $Label")) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/rest/api/content/$PageId/label" -Method 'DELETE' -Query @{ name = $Label } -Context $Context + } +} diff --git a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 new file mode 100644 index 0000000..91aefdd --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 @@ -0,0 +1,34 @@ +function Get-ConfluenceDescendant { + <# + .SYNOPSIS + List all descendants of a page (read:hierarchical-content). + + .DESCRIPTION + Returns every descendant (child, grandchild, ...) of a page, following + pagination automatically. + + .EXAMPLE + ```powershell + Get-ConfluenceDescendant -PageId '12345' + ``` + + Lists all descendants of page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluenceDescendant/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-descendants/ + #> + [CmdletBinding()] + param( + # The ancestor page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/descendants" -All -Context $Context +} diff --git a/src/functions/public/Pages/Get-ConfluencePage.ps1 b/src/functions/public/Pages/Get-ConfluencePage.ps1 new file mode 100644 index 0000000..70c0a9e --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePage.ps1 @@ -0,0 +1,37 @@ +function Get-ConfluencePage { + <# + .SYNOPSIS + Get a Confluence page by id (read:page). + + .DESCRIPTION + Returns a single page, including its body in the requested format. + + .EXAMPLE + ```powershell + Get-ConfluencePage -PageId '12345' + ``` + + Gets page 12345, including its body in storage format. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluencePage/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The body format to return. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'view', 'export_view', 'anonymous_export_view', 'styled_view', 'editor')] + [string]$BodyFormat = 'storage', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId" -Query @{ 'body-format' = $BodyFormat } -Context $Context +} diff --git a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 new file mode 100644 index 0000000..33d7fb9 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluencePageChild { + <# + .SYNOPSIS + List the direct child pages of a page (read:page). + + .DESCRIPTION + Returns every direct child page, following pagination automatically. + + .EXAMPLE + ```powershell + Get-ConfluencePageChild -PageId '12345' + ``` + + Lists the direct child pages of page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluencePageChild/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-children/ + #> + [CmdletBinding()] + param( + # The parent page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/children" -All -Context $Context +} diff --git a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 new file mode 100644 index 0000000..af47530 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluencePageVersion { + <# + .SYNOPSIS + List the version history of a page (read:page). + + .DESCRIPTION + Returns every version entry for a page, newest first, following pagination. + + .EXAMPLE + ```powershell + Get-ConfluencePageVersion -PageId '12345' + ``` + + Lists the version history of page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluencePageVersion/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-version/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/versions" -All -Context $Context +} diff --git a/src/functions/public/Pages/New-ConfluencePage.ps1 b/src/functions/public/Pages/New-ConfluencePage.ps1 new file mode 100644 index 0000000..4ba5cc7 --- /dev/null +++ b/src/functions/public/Pages/New-ConfluencePage.ps1 @@ -0,0 +1,63 @@ +function New-ConfluencePage { + <# + .SYNOPSIS + Create a Confluence page (write:page). + + .DESCRIPTION + Creates a page in a space, optionally beneath a parent page. A page with + no parent is created under the space home page. + + .EXAMPLE + ```powershell + New-ConfluencePage -SpaceId $spaceId -Title 'Docs' -Body 'Hello
' + ``` + + Creates a page titled 'Docs' in the given space. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/New-ConfluencePage/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The id of the space to create the page in. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The page title (must be unique within the space). + [Parameter(Mandatory)] + [string]$Title, + + # The id of the parent page. Omit to create a top-level page. + [string]$ParentId, + + # The page body in the chosen representation. Defaults to empty. + [string]$Body = '', + + # The body representation. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'wiki')] + [string]$Representation = 'storage', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = @{ + spaceId = $SpaceId + status = 'current' + title = $Title + body = @{ + representation = $Representation + value = $Body + } + } + if (-not [string]::IsNullOrEmpty($ParentId)) { + $payload['parentId'] = $ParentId + } + + if ($PSCmdlet.ShouldProcess($Title, 'Create Confluence page')) { + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/pages' -Method 'POST' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Pages/Remove-ConfluencePage.ps1 b/src/functions/public/Pages/Remove-ConfluencePage.ps1 new file mode 100644 index 0000000..d2df8bb --- /dev/null +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -0,0 +1,67 @@ +function Remove-ConfluencePage { + <# + .SYNOPSIS + Delete a Confluence page (delete:page). + + .DESCRIPTION + Moves a page to the trash. With -Recurse, direct and nested child pages + are removed first so that a whole subtree can be deleted. With -Purge the + page is permanently deleted: because Confluence requires a page to be in + the trash before it can be purged, the page is trashed first and then + purged in a second call. + + .EXAMPLE + ```powershell + Remove-ConfluencePage -PageId '12345' -Recurse + ``` + + Moves page 12345 and its child pages to the trash. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Remove-ConfluencePage/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id to delete. + [Parameter(Mandatory)] + [string]$PageId, + + # Remove child pages before removing the page itself. + [switch]$Recurse, + + # Permanently delete the page. It is moved to the trash first (as the API + # requires) and then purged. + [switch]$Purge, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($Recurse) { + $children = Get-ConfluencePageChild -PageId $PageId -Context $Context + foreach ($child in $children) { + Remove-ConfluencePage -PageId $child.id -Recurse -Purge:$Purge -Context $Context + } + } + + $base = "/wiki/api/v2/pages/$PageId" + $action = if ($Purge) { 'Permanently delete Confluence page' } else { 'Delete Confluence page' } + + if ($PSCmdlet.ShouldProcess($PageId, $action)) { + if ($Purge) { + # A page must be in the trash before it can be purged. Trash first + # (ignoring failures, e.g. when it is already trashed), then purge. + try { + Invoke-ConfluenceRestMethod -ApiEndpoint $base -Method 'DELETE' -Context $Context + } catch { + Write-Verbose "Trash step before purge failed (the page may already be trashed): $($_.Exception.Message)" + } + Invoke-ConfluenceRestMethod -ApiEndpoint ('{0}?purge=true' -f $base) -Method 'DELETE' -Context $Context + } else { + Invoke-ConfluenceRestMethod -ApiEndpoint $base -Method 'DELETE' -Context $Context + } + } +} diff --git a/src/functions/public/Pages/Set-ConfluencePage.ps1 b/src/functions/public/Pages/Set-ConfluencePage.ps1 new file mode 100644 index 0000000..cb74bba --- /dev/null +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -0,0 +1,70 @@ +function Set-ConfluencePage { + <# + .SYNOPSIS + Update a Confluence page (read:page and write:page). + + .DESCRIPTION + Updates the title and/or body of a page, automatically incrementing the + version number. Unspecified fields keep their current values. The current + page is read first (read:page) to preserve unspecified fields and to + obtain the version number, then written back (write:page). + + .EXAMPLE + ```powershell + Set-ConfluencePage -PageId '12345' -Body 'Updated
' + ``` + + Updates the body of page 12345, incrementing its version. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Set-ConfluencePage/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id to update. + [Parameter(Mandatory)] + [string]$PageId, + + # The new title. Defaults to the existing title. + [string]$Title, + + # The new body. Defaults to the existing body. + [string]$Body, + + # The body representation. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'wiki')] + [string]$Representation = 'storage', + + # The page status. Defaults to the page's current status. + [string]$Status, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $current = Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId" -Query @{ 'body-format' = $Representation } -Context $Context + + $newTitle = if ($PSBoundParameters.ContainsKey('Title')) { $Title } else { $current.title } + $newBody = if ($PSBoundParameters.ContainsKey('Body')) { $Body } else { $current.body.$Representation.value } + $newStatus = if ($PSBoundParameters.ContainsKey('Status')) { $Status } else { $current.status } + + $payload = @{ + id = $PageId + status = $newStatus + title = $newTitle + version = @{ + number = [int]$current.version.number + 1 + } + body = @{ + representation = $Representation + value = $newBody + } + } + + if ($PSCmdlet.ShouldProcess($PageId, 'Update Confluence page')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId" -Method 'PUT' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 new file mode 100644 index 0000000..b45a3a7 --- /dev/null +++ b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 @@ -0,0 +1,39 @@ +function Get-ConfluenceRestriction { + <# + .SYNOPSIS + Get the content restrictions on a page (read:content-details). + + .DESCRIPTION + Returns the read/update restrictions applied to a page. Reading + restrictions requires read:content-details; the read:content.restriction + scope only covers the narrow byOperation/{op}/user|group status checks. + Content restrictions are part of the v1 content API (see the link). + + .EXAMPLE + ```powershell + Get-ConfluenceRestriction -PageId '12345' + ``` + + Gets the read/update restrictions on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Restrictions/Get-ConfluenceRestriction/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + # Content restrictions live on the v1 content API; there is no equivalent v2 + # endpoint (a v2 '/pages/{id}/restrictions' path is rejected with a scope + # check). The v1 endpoint returns the read/update operations in 'results'. + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/rest/api/content/$PageId/restriction" -All -Context $Context +} diff --git a/src/functions/public/Site/Get-ConfluenceCloudId.ps1 b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 new file mode 100644 index 0000000..3a56291 --- /dev/null +++ b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 @@ -0,0 +1,91 @@ +function Get-ConfluenceCloudId { + <# + .SYNOPSIS + Get the cloud ID for a Confluence Cloud site. + + .DESCRIPTION + Looks up the cloud ID for an Atlassian Confluence Cloud site from the + public `/_edge/tenant_info` endpoint (a GET request), so a caller can supply + a site name or URL instead of the cloud ID. Accepts a bare subdomain + (`myorg`), a host (`myorg.atlassian.net`), or any URL on the site + (`https://myorg.atlassian.net/wiki/...`). No authentication is required. + + .EXAMPLE + ```powershell + Get-ConfluenceCloudId -Site 'msxorg' + ``` + + Returns the cloud ID for `https://msxorg.atlassian.net`. + + .EXAMPLE + ```powershell + 'https://msxorg.atlassian.net/wiki/spaces/DOCS' | Get-ConfluenceCloudId + ``` + + Resolves the cloud ID from a full site URL supplied through the pipeline. + + .EXAMPLE + ```powershell + Connect-Confluence -CloudId (Get-ConfluenceCloudId -Site 'msxorg') -Username $user -Token $token + ``` + + Uses the resolved cloud ID to connect, so the caller only needs the site name. + + .OUTPUTS + System.String + + .LINK + https://psmodule.io/Confluence/Functions/Site/Get-ConfluenceCloudId/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/intro/#about + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The site to resolve: a subdomain (`myorg`), a host (`myorg.atlassian.net`), + # or any URL on the site (`https://myorg.atlassian.net/...`). + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('Url', 'Uri', 'Name')] + [string]$Site + ) + + process { + $trimmed = $Site.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + throw 'The -Site value cannot be empty.' + } + + # Derive the host whether the caller passed a bare subdomain, a host, or a URL. + try { + $siteHost = if ($trimmed -match '^[A-Za-z][A-Za-z0-9+.-]*://') { + ([uri]$trimmed).Host + } elseif ($trimmed -match '[/.]') { + ([uri]"https://$trimmed").Host + } else { + "$trimmed.atlassian.net" + } + } catch { + throw "Could not determine the site host from '$Site': $($_.Exception.Message)" + } + + if ([string]::IsNullOrWhiteSpace($siteHost)) { + throw "Could not determine the site host from '$Site'." + } + + $tenantInfoUri = "https://$siteHost/_edge/tenant_info" + Write-Verbose "Resolving cloud ID from [$tenantInfoUri]." + + try { + $tenantInfo = Invoke-RestMethod -Uri $tenantInfoUri -Method Get -ErrorAction Stop + } catch { + throw "Failed to resolve the cloud ID for '$siteHost' from [$tenantInfoUri]: $($_.Exception.Message)" + } + + if ([string]::IsNullOrWhiteSpace($tenantInfo.cloudId)) { + throw "The endpoint [$tenantInfoUri] did not return a cloud ID for '$siteHost'." + } + + $tenantInfo.cloudId + } +} diff --git a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 new file mode 100644 index 0000000..12f7ed3 --- /dev/null +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -0,0 +1,60 @@ +function Get-ConfluenceSiteInfo { + <# + .SYNOPSIS + Get basic information about the connected Confluence site (read:space or read:page). + + .DESCRIPTION + Returns the site's cloud id (parsed from the API-gateway base URI) and + the browsable site (wiki) base URL (read from the API's _links.base). A + scoped v2 token cannot reach the dedicated site/settings endpoints (site + name, edition, build number) — those are v1-only and are rejected. + + .EXAMPLE + ```powershell + Get-ConfluenceSiteInfo + ``` + + Returns the cloud ID and browsable site URL for the connected site. + + .LINK + https://psmodule.io/Confluence/Functions/Site/Get-ConfluenceSiteInfo/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $resolved = Resolve-ConfluenceContext -Context $Context + $cloudId = if ($resolved.ApiBaseUri -match '/ex/confluence/([0-9a-fA-F-]+)') { $Matches[1] } else { $null } + + # _links.base (the browsable site URL) appears on any v2 collection response; + # try a couple so this works whether the token has read:space or only read:page. + $siteUrl = $null + $lastError = $null + foreach ($probe in '/wiki/api/v2/spaces', '/wiki/api/v2/pages') { + try { + $response = Invoke-ConfluenceRestMethod -ApiEndpoint $probe -Query @{ limit = 1 } -Context $Context + if ($response._links.base) { + $siteUrl = $response._links.base + break + } + } catch { + $lastError = $_ + continue + } + } + if (-not $siteUrl -and $lastError) { + Write-Verbose "Could not resolve the browsable site URL from the probe endpoints: $($lastError.Exception.Message)" + } + + [pscustomobject]@{ + CloudId = $cloudId + ApiBaseUri = $resolved.ApiBaseUri + SiteUrl = $siteUrl + } +} diff --git a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 new file mode 100644 index 0000000..da91b8c --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 @@ -0,0 +1,51 @@ +function Get-ConfluenceSpace { + <# + .SYNOPSIS + Get a Confluence space (read:space). + + .DESCRIPTION + Returns a space by key or by id. When neither is supplied the default + space key from the connected context is used. + + .EXAMPLE + ```powershell + Get-ConfluenceSpace -Key 'DOCS' + ``` + + Gets the space with key 'DOCS'. + + .LINK + https://psmodule.io/Confluence/Functions/Spaces/Get-ConfluenceSpace/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ + #> + [CmdletBinding(DefaultParameterSetName = 'ByKey')] + param( + # The space key, e.g. 'DOCS'. + [Parameter(ParameterSetName = 'ByKey')] + [string]$Key, + + # The space id. + [Parameter(Mandatory, ParameterSetName = 'ById')] + [string]$Id, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ParameterSetName -eq 'ById') { + return Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$Id" -Context $Context + } + + if ([string]::IsNullOrEmpty($Key)) { + $resolved = Resolve-ConfluenceContext -Context $Context + $Key = $resolved.SpaceKey + } + if ([string]::IsNullOrEmpty($Key)) { + throw 'Specify -Key or -Id, or connect with a default SpaceKey.' + } + + $response = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ keys = $Key } -Context $Context + $response.results | Where-Object { $_.key -eq $Key } | Select-Object -First 1 +} diff --git a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 new file mode 100644 index 0000000..885ae91 --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 @@ -0,0 +1,38 @@ +function Get-ConfluenceSpacePermission { + <# + .SYNOPSIS + List the permission assignments on a space (read:space). + + .DESCRIPTION + Returns every permission assignment in the space — each entry pairs a + principal (user or group) with an operation (for example read/space, + create/page, delete/page, restrict_content/space). This is the space's + whole permission table, not a filtered "effective permissions for the + current user" view (Confluence exposes that only on the v1 operations + expansion, which a granular v2 token cannot call). + + .EXAMPLE + ```powershell + Get-ConfluenceSpacePermission -SpaceId '123456' + ``` + + Lists the permission assignments in the space with ID 123456. + + .LINK + https://psmodule.io/Confluence/Functions/Spaces/Get-ConfluenceSpacePermission/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-permissions/ + #> + [CmdletBinding()] + param( + # The space id whose permission assignments are returned. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$SpaceId/permissions" -All -Context $Context +} diff --git a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 new file mode 100644 index 0000000..38ae6d7 --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 @@ -0,0 +1,40 @@ +function Get-ConfluenceSpaceProperty { + <# + .SYNOPSIS + Get space properties (read:space). + + .DESCRIPTION + Returns all properties on a space, or the single property with the given key. + + .EXAMPLE + ```powershell + Get-ConfluenceSpaceProperty -SpaceId '123456' + ``` + + Gets all properties on the space with ID 123456. + + .LINK + https://psmodule.io/Confluence/Functions/Spaces/Get-ConfluenceSpaceProperty/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-properties/ + #> + [CmdletBinding()] + param( + # The space id. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The property key to return. If omitted, all properties are returned. + [string]$Key, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $all = Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$SpaceId/properties" -All -Context $Context + if ([string]::IsNullOrEmpty($Key)) { + return $all + } + $all | Where-Object { $_.key -eq $Key } | Select-Object -First 1 +} diff --git a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 new file mode 100644 index 0000000..3d66a5f --- /dev/null +++ b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluenceCurrentUser { + <# + .SYNOPSIS + Get the current (authenticated) user — Confluence's "me" endpoint (read:content-details). + + .DESCRIPTION + Returns the account behind the connected token via the Confluence + current-user endpoint (v1 /wiki/rest/api/user/current), which requires + read:content-details. The read:user scope only covers the anonymous-user + and group-membership endpoints, so a token without read:content-details is + rejected with a scope-check 401. + + .EXAMPLE + ```powershell + Get-ConfluenceCurrentUser + ``` + + Returns the account behind the connected token. + + .LINK + https://psmodule.io/Confluence/Functions/Users/Get-ConfluenceCurrentUser/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/ + #> + [CmdletBinding()] + param( + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/rest/api/user/current' -Context $Context +} diff --git a/src/header.ps1 b/src/header.ps1 deleted file mode 100644 index cc1fde9..0000000 --- a/src/header.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] -[CmdletBinding()] -param() diff --git a/src/init/initializer.ps1 b/src/init/initializer.ps1 deleted file mode 100644 index 28396fb..0000000 --- a/src/init/initializer.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------------' -Write-Verbose '--- THIS IS AN INITIALIZER ---' -Write-Verbose '-------------------------------' diff --git a/src/scripts/loader.ps1 b/src/scripts/loader.ps1 deleted file mode 100644 index 973735a..0000000 --- a/src/scripts/loader.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------' -Write-Verbose '--- THIS IS A LOADER ---' -Write-Verbose '-------------------------' diff --git a/src/variables/private/Confluence.ps1 b/src/variables/private/Confluence.ps1 new file mode 100644 index 0000000..fc10b32 --- /dev/null +++ b/src/variables/private/Confluence.ps1 @@ -0,0 +1,17 @@ +$script:Confluence = [pscustomobject]@{ + ContextVault = 'PSModule.Confluence' + # The API-gateway base is https://api.atlassian.com/ex/confluence/Created by integration tests.
' -Context 'ci' + LogGroup "New page [$title]" { + Write-Host ($page | Format-List | Out-String) + } + $page | Should -Not -BeNullOrEmpty + $page.id | Should -Not -BeNullOrEmpty + $page.title | Should -Be $title + $page.spaceId | Should -Be $script:spaceId + $page.version.number | Should -Be 1 + $script:pagesRootId = $page.id + } + + It 'Get-ConfluencePage returns the created page with its body' { + $page = Get-ConfluencePage -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Get page' { + Write-Host ($page | Format-List | Out-String) + Write-Host ($page.body | Format-List | Out-String) + } + $page.id | Should -Be $script:pagesRootId + $page.body.storage.value | Should -Match 'integration tests' + } + + It 'Set-ConfluencePage updates the body and increments the version' { + $newTitle = "$($script:prefix) Pages Root (updated)" + $updated = Set-ConfluencePage -PageId $script:pagesRootId -Title $newTitle -Body 'Updated body.
' -Context 'ci' + LogGroup 'Updated page' { + Write-Host ($updated | Format-List | Out-String) + } + $updated.version.number | Should -Be 2 + $updated.title | Should -Be $newTitle + (Get-ConfluencePage -PageId $script:pagesRootId -Context 'ci').body.storage.value | Should -Match 'Updated body' + } + + It 'Get-ConfluencePageVersion lists the version history' { + $versions = Get-ConfluencePageVersion -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Page versions' { + Write-Host ($versions | Format-Table -AutoSize | Out-String) + } + @($versions).Count | Should -BeGreaterOrEqual 2 + } + + It 'New-ConfluencePage creates a child page under a parent' { + $title = "$($script:prefix) Pages Child" + $child = New-ConfluencePage -SpaceId $script:spaceId -Title $title -ParentId $script:pagesRootId -Body 'Child.
' -Context 'ci' + LogGroup "Child page [$title]" { + Write-Host ($child | Format-List | Out-String) + } + $child.id | Should -Not -BeNullOrEmpty + $script:pagesChildId = $child.id + } + + It 'Get-ConfluencePageChild lists the child pages' { + $children = Get-ConfluencePageChild -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Child pages' { + Write-Host ($children | Format-Table -AutoSize | Out-String) + } + $children.id | Should -Contain $script:pagesChildId + } + + It 'Get-ConfluenceDescendant lists the descendants' { + $descendants = Get-ConfluenceDescendant -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Descendants' { + Write-Host ($descendants | Format-Table -AutoSize | Out-String) + } + $descendants.id | Should -Contain $script:pagesChildId + } + + It 'Remove-ConfluencePage removes the page tree' { + { Remove-ConfluencePage -PageId $script:pagesRootId -Recurse -Purge -Context 'ci' } | Should -Not -Throw + LogGroup 'Removed page tree' { + Write-Host "Purged page $($script:pagesRootId) and its descendants." + } + { Get-ConfluencePage -PageId $script:pagesRootId -Context 'ci' } | Should -Throw + $script:pagesRootId = $null + } + } + + Context 'Folders - CRUD lifecycle' { + AfterAll { + if ($script:folderId) { + Remove-ConfluenceFolder -FolderId $script:folderId -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'New-ConfluenceFolder creates a folder' { + $title = "$($script:prefix) Folder" + $folder = New-ConfluenceFolder -SpaceId $script:spaceId -Title $title -Context 'ci' + LogGroup "New folder [$title]" { + Write-Host ($folder | Format-List | Out-String) + } + $folder.id | Should -Not -BeNullOrEmpty + $folder.title | Should -Be $title + $script:folderId = $folder.id + } + + It 'Get-ConfluenceFolder returns the folder' { + $folder = Get-ConfluenceFolder -FolderId $script:folderId -Context 'ci' + LogGroup 'Get folder' { + Write-Host ($folder | Format-List | Out-String) + } + $folder.id | Should -Be $script:folderId + } + + It 'Remove-ConfluenceFolder deletes the folder' { + { Remove-ConfluenceFolder -FolderId $script:folderId -Context 'ci' } | Should -Not -Throw + LogGroup 'Removed folder' { + Write-Host "Deleted folder $($script:folderId)." + } + $script:folderId = $null + } + } + + Context 'Comments - CRUD lifecycle' { + BeforeAll { + $script:commentPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Comments" -Body 'Comments fixture.
' -Context 'ci' + } + AfterAll { + if ($script:commentPage) { + Remove-ConfluencePage -PageId $script:commentPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Add-ConfluenceComment adds a footer comment' { + $comment = Add-ConfluenceComment -PageId $script:commentPage.id -Body 'First comment.
' -Context 'ci' + LogGroup 'Added comment' { + Write-Host ($comment | Format-List | Out-String) + } + $comment.id | Should -Not -BeNullOrEmpty + $script:commentId = $comment.id + } + + It 'Get-ConfluenceComment lists the footer comments' { + $comments = Get-ConfluenceComment -PageId $script:commentPage.id -Context 'ci' + LogGroup 'Comments' { + Write-Host ($comments | Format-List | Out-String) + } + $comments.id | Should -Contain $script:commentId + } + + It 'Remove-ConfluenceComment deletes the comment' { + { Remove-ConfluenceComment -CommentId $script:commentId -Context 'ci' } | Should -Not -Throw + LogGroup 'Remaining comments' { + $remaining = Get-ConfluenceComment -PageId $script:commentPage.id -Context 'ci' + Write-Host ($remaining | Format-List | Out-String) + } + } + } + + Context 'Labels - CRUD lifecycle' { + BeforeAll { + $script:labelPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Labels" -Body 'Labels fixture.
' -Context 'ci' + $digits = ($id -replace '\D', '') + $script:labelName = if ($digits) { "itest$digits" } else { 'itestlocal' } + } + AfterAll { + if ($script:labelPage) { + Remove-ConfluencePage -PageId $script:labelPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Add-ConfluenceLabel adds labels to a page' { + $result = Add-ConfluenceLabel -PageId $script:labelPage.id -Label $script:labelName, "$($script:labelName)2" -Context 'ci' + LogGroup 'Added labels' { + Write-Host ($result | Format-List | Out-String) + } + $result | Should -Not -BeNullOrEmpty + } + + It 'Get-ConfluenceLabel lists the labels on the page' { + $labels = Get-ConfluenceLabel -PageId $script:labelPage.id -Context 'ci' + LogGroup 'Labels' { + Write-Host ($labels | Format-Table -AutoSize | Out-String) + } + $labels.name | Should -Contain $script:labelName + } + + It 'Remove-ConfluenceLabel removes a label' { + { Remove-ConfluenceLabel -PageId $script:labelPage.id -Label $script:labelName -Context 'ci' } | Should -Not -Throw + $labels = Get-ConfluenceLabel -PageId $script:labelPage.id -Context 'ci' + LogGroup 'Labels after removal' { + Write-Host ($labels | Format-Table -AutoSize | Out-String) + } + $labels.name | Should -Not -Contain $script:labelName + } + } + + Context 'Content properties - CRUD lifecycle' { + BeforeAll { + $script:propPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Properties" -Body 'Properties fixture.
' -Context 'ci' + } + AfterAll { + if ($script:propPage) { + Remove-ConfluencePage -PageId $script:propPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Set-ConfluenceContentProperty creates a property' { + $prop = Set-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Value @{ team = 'psmodule'; run = "$id" } -Context 'ci' + LogGroup 'Created property' { + Write-Host ($prop | Format-List | Out-String) + Write-Host ($prop.value | ConvertTo-Json -Depth 5) + } + $prop.key | Should -Be 'itest-owner' + $prop.version.number | Should -Be 1 + $script:propId = $prop.id + } + + It 'Get-ConfluenceContentProperty returns the property by key' { + $prop = Get-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Context 'ci' + LogGroup 'Fetched property' { + Write-Host ($prop | Format-List | Out-String) + } + $prop.value.team | Should -Be 'psmodule' + } + + It 'Set-ConfluenceContentProperty updates and versions the property' { + $prop = Set-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Value @{ team = 'ai-platform' } -Context 'ci' + LogGroup 'Updated property' { + Write-Host ($prop | Format-List | Out-String) + } + $prop.version.number | Should -Be 2 + (Get-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Context 'ci').value.team | Should -Be 'ai-platform' + } + + It 'Get-ConfluenceContentProperty lists all properties' { + $all = Get-ConfluenceContentProperty -PageId $script:propPage.id -Context 'ci' + LogGroup 'All properties' { + Write-Host ($all | Format-Table -AutoSize | Out-String) + } + $all.key | Should -Contain 'itest-owner' + } + + It 'Remove-ConfluenceContentProperty deletes the property' { + { Remove-ConfluenceContentProperty -PageId $script:propPage.id -PropertyId $script:propId -Context 'ci' } | Should -Not -Throw + Get-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Context 'ci' | Should -BeNullOrEmpty + } + } + + Context 'Attachments - CRUD lifecycle' { + BeforeAll { + $script:attachPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Attachments" -Body 'Attachments fixture.
' -Context 'ci' + $script:attachFile = Join-Path ([System.IO.Path]::GetTempPath()) "confluence-itest-$id-$os.txt" + Set-Content -Path $script:attachFile -Value "Integration test attachment $id" -Encoding utf8 + } + AfterAll { + if ($script:attachPage) { + Remove-ConfluencePage -PageId $script:attachPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + if ($script:attachFile -and (Test-Path $script:attachFile)) { + Remove-Item $script:attachFile -ErrorAction SilentlyContinue + } + } + + It 'Add-ConfluenceAttachment uploads a file' { + $upload = Add-ConfluenceAttachment -PageId $script:attachPage.id -Path $script:attachFile -Context 'ci' + LogGroup 'Uploaded attachment' { + Write-Host ($upload | ConvertTo-Json -Depth 6) + } + $upload | Should -Not -BeNullOrEmpty + } + + It 'Get-ConfluenceAttachment lists the attachments on the page' { + $attachments = Get-ConfluenceAttachment -PageId $script:attachPage.id -Context 'ci' + LogGroup 'Attachments on page' { + Write-Host ($attachments | Format-List | Out-String) + } + @($attachments).Count | Should -BeGreaterThan 0 + $script:attachmentId = @($attachments).id | Select-Object -First 1 + } + + It 'Get-ConfluenceAttachment returns a single attachment by id' { + $attachment = Get-ConfluenceAttachment -AttachmentId $script:attachmentId -Context 'ci' + LogGroup 'Single attachment' { + Write-Host ($attachment | Format-List | Out-String) + } + $attachment.id | Should -Be $script:attachmentId + } + + It 'Remove-ConfluenceAttachment deletes the attachment' { + # Confluence can briefly return HTTP 409 when an attachment is deleted immediately + # after upload (it is still being processed server-side). Retry the delete on a + # conflict so the test reflects the eventual-consistency behaviour rather than flaking. + $deleted = $false + $lastError = $null + foreach ($attempt in 1..5) { + try { + Remove-ConfluenceAttachment -AttachmentId $script:attachmentId -Context 'ci' + $deleted = $true + break + } catch { + $lastError = $_ + if ($_.Exception.Message -notmatch '409|[Cc]onflict') { throw } + Start-Sleep -Seconds ($attempt * 2) + } + } + LogGroup 'Attachments after removal' { + if ($deleted) { Write-Host "Deleted attachment $($script:attachmentId)." } + if ($lastError) { Write-Host "Transient conflict(s) handled: $($lastError.Exception.Message)" } + $remaining = Get-ConfluenceAttachment -PageId $script:attachPage.id -Context 'ci' + Write-Host ($remaining | Format-List | Out-String) + } + $deleted | Should -BeTrue + } + } + + Context 'Restrictions' { + BeforeAll { + $script:restrictPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Restrictions" -Body 'Restrictions fixture.
' -Context 'ci' + } + AfterAll { + if ($script:restrictPage) { + Remove-ConfluencePage -PageId $script:restrictPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Get-ConfluenceRestriction returns the read/update operations' { + $restrictions = Get-ConfluenceRestriction -PageId $script:restrictPage.id -Context 'ci' + LogGroup 'Restrictions' { + Write-Host ($restrictions | Format-List | Out-String) + } + $restrictions.operation | Should -Contain 'read' + } + } + + Context 'Blog posts' { + It 'Get-ConfluenceBlogPost is callable (documents the read:blogpost scope gap)' { + # The test token intentionally does not carry read:blogpost:confluence (see SCOPES.md), + # so the /blogposts endpoint returns a scope-check failure. This confirms the error is + # surfaced clearly rather than swallowed; if the scope is later granted the call succeeds. + $posts = $null + $blogError = $null + try { + $posts = Get-ConfluenceBlogPost -SpaceId $script:spaceId -Context 'ci' + } catch { + $blogError = $_ + } + LogGroup 'Blog posts' { + if ($null -ne $posts) { Write-Host ($posts | Select-Object -First 5 | Format-List | Out-String) } + if ($blogError) { Write-Host "Scope-gated as expected: $($blogError.Exception.Message)" } + } + if ($blogError) { + $blogError.Exception.Message | Should -Match 'scope|403|blogpost' + } else { + $true | Should -BeTrue + } + } + } + + Context 'REST API' { + It 'Invoke-ConfluenceRestMethod calls the API directly' { + $response = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } -Context 'ci' + LogGroup 'Raw spaces response' { + Write-Host ($response | ConvertTo-Json -Depth 6) + } + $response.results | Should -Not -BeNullOrEmpty + } + + It 'Invoke-ConfluenceRestMethod -All aggregates paginated results' { + $spaces = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -All -Context 'ci' + LogGroup 'All spaces' { + Write-Host ($spaces | Select-Object id, key, name | Format-Table -AutoSize | Out-String) + Write-Host "Total spaces: $(@($spaces).Count)" + } + @($spaces).Count | Should -BeGreaterThan 0 + $spaces.key | Should -Contain $env:CONFLUENCE_SPACE_KEY + } + + It 'Invoke-ConfluenceRestMethod resolves a relative endpoint with -ApiVersion' { + $response = Invoke-ConfluenceRestMethod -ApiVersion v2 -ApiEndpoint 'spaces' -Query @{ limit = 1 } -Context 'ci' + $response.results | Should -Not -BeNullOrEmpty + } + } + } +} diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index 85524cd..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -}