Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: PHPUnit

on:
pull_request:
push:
branches:
- main

jobs:
phpunit:
strategy:
fail-fast: true
matrix:
os: [ ubuntu-latest ]
php: [ 8.3, 8.2 ]
stability: [ prefer-stable ]
name: PHPUnit - PHP ${{ matrix.php }}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Composer install
run: composer install --no-interaction --no-ansi --no-progress
- name: Run PHPUnit
run: php ./vendor/bin/phpunit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
vendor
.idea
.phpunit.result.cache
.phpunit.cache
composer.lock
10 changes: 7 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@
"license": "MIT",
"type": "library",
"scripts": {
"phpstan": "php ./vendor/bin/phpstan analyse --memory-limit=4G"
"phpstan": "php ./vendor/bin/phpstan analyse --memory-limit=4G",
"test": "php ./vendor/bin/phpunit"
},
"require": {
"php": ">=8.2",
"guzzlehttp/guzzle": "~7.4",
"ext-json": "*",
"ext-mbstring": "*"
"ext-mbstring": "*",
"psr/log": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"phpstan/phpstan": "^2.1.36",
"phpstan/extension-installer": "^1.3"
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan-phpunit": "^2.0.12"
},
"autoload": {
"psr-4": {
Expand Down
20 changes: 20 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true"
failOnRisky="true"
beStrictAboutOutputDuringTests="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
13 changes: 10 additions & 3 deletions src/ConfluencePageContentDownloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Artemeon\Confluence\MacroReplacer\MacroReplacerInterface;
use DOMDocument;
use Exception;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

class ConfluencePageContentDownloader
{
Expand All @@ -19,15 +21,17 @@ class ConfluencePageContentDownloader
private array $macroReplacers;
private Content $contentEndpoint;
private Download $downloadEndpoint;
private LoggerInterface $logger;

/**
* @param MacroReplacerInterface[] $macroReplacers
*/
public function __construct(Content $contentEndpoint, Download $downloadEndpoint, array $macroReplacers = [])
public function __construct(Content $contentEndpoint, Download $downloadEndpoint, array $macroReplacers = [], ?LoggerInterface $logger = null)
{
$this->macroReplacers = $macroReplacers;
$this->contentEndpoint = $contentEndpoint;
$this->downloadEndpoint = $downloadEndpoint;
$this->logger = $logger ?? new NullLogger();
}

public function downloadPageContent(ConfluencePage $page, bool $withAttachments = true): void
Expand All @@ -54,10 +58,13 @@ public function downloadPageContent(ConfluencePage $page, bool $withAttachments

$attachments = $this->contentEndpoint->findChildAttachments($pageId);
foreach ($attachments as $attachment) {
$this->downloadEndpoint->downloadAttachment($attachment);
$this->downloadEndpoint->downloadAttachment($attachment, $pageId);
}
} catch (Exception $e) {
echo 'An error has occurred: ' . $e->getMessage();
$this->logger->error(
sprintf('Failed to download Confluence page content for page "%s": %s', $page->getId() ?? 'unknown', $e->getMessage()),
['exception' => $e],
);
}
}

Expand Down
21 changes: 17 additions & 4 deletions src/Endpoint/Content.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function __construct(Client $client, Auth $auth)
* @param int|null $offset
* @return ConfluencePage[]
* @throws GuzzleException
* @throws Exception
*/
public function findPagesInSpace(string $spaceKey, int $limit = 2000, ?int $offset = null): array
{
Expand Down Expand Up @@ -65,7 +66,13 @@ public function findPagesInSpace(string $spaceKey, int $limit = 2000, ?int $offs
}

/**
* Use the Confluence Content API to retrieve page content
* Fetches a single page by its ID from the Confluence Content API, expanding its
* stored body, version, space and labels into a {@see ConfluencePage}.
*
* @param string $pageId the Confluence content ID of the page to load
*
* @throws GuzzleException if the HTTP request fails (network error, timeout, …)
* @throws Exception if Confluence responds with a non-200 status code
*/
public function findPageContent(string $pageId): ConfluencePage
{
Expand All @@ -88,9 +95,15 @@ public function findPageContent(string $pageId): ConfluencePage
}

/**
* Use descendants.attachment in the Content API to get attachments
* Lists the attachments of a page via the Content API's child/attachment endpoint,
* expanding each attachment's history so its last-updated timestamp is available.
*
* @param string $pageId the Confluence content ID of the parent page
*
* @return list<ConfluenceAttachment> the page's attachments, empty if it has none
*
* @return list<ConfluenceAttachment>
* @throws GuzzleException if the HTTP request fails (network error, timeout, …)
* @throws Exception if Confluence responds with a non-200 status code
*/
public function findChildAttachments(string $pageId): array
{
Expand All @@ -104,7 +117,7 @@ public function findChildAttachments(string $pageId): array
);

if ($response->getStatusCode() !== 200) {
throw new Exception('Fehler beim Abrufen der Attachments. HTTP-Statuscode: ' . $response->getStatusCode());
throw new Exception('Error retrieving attachments. HTTP status code: ' . $response->getStatusCode());
}

$attachmentsData = json_decode($response->getBody()->getContents(), true);
Expand Down
71 changes: 50 additions & 21 deletions src/Endpoint/Download.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Artemeon\Confluence\Endpoint\Dto\ConfluencePage;
use DateTime;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use RuntimeException;

class Download
{
Expand All @@ -22,39 +24,55 @@ public function __construct(Client $client, Auth $auth, string $downloadFolder)
$this->downloadFolder = $downloadFolder;
}

private function checkDownloadFolder(): bool
/**
* Makes sure the configured download folder exists, creating it recursively if needed.
*
* @throws RuntimeException if the folder is missing and cannot be created
*/
private function ensureDownloadFolder(): void
{
if (!is_dir($this->downloadFolder)) {
return mkdir($this->downloadFolder, 0755, true);
if (!is_dir($this->downloadFolder) && !mkdir($this->downloadFolder, 0755, true) && !is_dir($this->downloadFolder)) {
throw new RuntimeException(sprintf('The download folder "%s" does not exist and could not be created.', $this->downloadFolder));
}

return true;
}

/**
* Writes a page's (already prepared) HTML content to a file in the download folder.
*
* @param ConfluencePage $confluencePage the page whose content is written
* @param string $fileName the target file name, relative to the download folder (e.g. "content.html")
*
* @throws RuntimeException if the download folder cannot be ensured
*/
public function downloadPageContent(ConfluencePage $confluencePage, string $fileName): void
{
if (!$this->checkDownloadFolder()) {
echo 'Error: The download folder does not exist or could not be created.';

return;
}
$this->ensureDownloadFolder();

$htmlFile = $this->downloadFolder . '/' . $fileName;
file_put_contents($htmlFile, $confluencePage->getContent());
}

public function downloadAttachment(ConfluenceAttachment $attachment): void
/**
* Downloads a single page attachment into the download folder, but only if it is
* new or has been updated since the locally stored copy (see {@see shouldAttachmentBeUpdated()}).
*
* @param ConfluenceAttachment $attachment the attachment to download; its title is used as the file name
* @param string $pageId the Confluence content ID of the page the attachment belongs to
*
* @throws RuntimeException if the download folder cannot be ensured
* @throws GuzzleException if the HTTP request to the REST endpoint fails
*/
public function downloadAttachment(ConfluenceAttachment $attachment, string $pageId): void
{
if (!$this->checkDownloadFolder()) {
echo 'Error: The download folder does not exist or could not be created.';

return;
}
$this->ensureDownloadFolder();

if ($this->shouldAttachmentBeUpdated($attachment)) {
// Verwende den relativen Pfad aus der API, um das Attachment herunterzuladen
// Download via the supported REST endpoint. Same Basic-auth credentials
// (email + API token) as every other request; only the endpoint changed:
// the legacy /wiki/download servlet rejects API-token auth (HTTP 401),
// while the REST API accepts it.
$attachmentContent = $this->client->get(
'/wiki/' . $attachment->findDownloadPath(),
'wiki/rest/api/content/' . $pageId . '/child/attachment/' . $attachment->getId() . '/download',
array_merge([], $this->auth->getAuthenticationArray())
)->getBody()->getContents();

Expand All @@ -67,6 +85,17 @@ private function getAttachmentFilePath(ConfluenceAttachment $attachment): string
return $this->downloadFolder . '/' . $attachment->getTitle();
}

/**
* Decides whether an attachment needs to be (re-)downloaded.
*
* Returns true when no local copy exists yet, when the attachment has no known
* last-updated date, or when the local file is older than the attachment's
* last-updated date; otherwise the local copy is considered up to date.
*
* @param ConfluenceAttachment $attachment the attachment to check against its local copy
*
* @return bool true if the attachment should be downloaded, false if the local copy is current
*/
private function shouldAttachmentBeUpdated(ConfluenceAttachment $attachment): bool
{
$filepath = $this->getAttachmentFilePath($attachment);
Expand All @@ -77,9 +106,9 @@ private function shouldAttachmentBeUpdated(ConfluenceAttachment $attachment): bo
}

if (file_exists($filepath)) {
$filemtime = filemtime($filepath);
if (is_int($filemtime)) {
return $filemtime < $lastUpdated->getTimestamp();
$fileModificationTime = filemtime($filepath);
if (is_int($fileModificationTime)) {
return $fileModificationTime < $lastUpdated->getTimestamp();
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/Endpoint/Dto/ConfluenceAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ConfluenceAttachment

/**
* @param array{
* id: string,
* title: string,
* history?: array{
* lastUpdated?: array{
Expand All @@ -28,9 +29,9 @@ public function __construct(private array $rawData)
$this->lastUpdated = isset($rawData['history']['lastUpdated']['when']) ? new DateTime($rawData['history']['lastUpdated']['when']) : null;
}

public function findDownloadPath(): ?string
public function getId(): string
{
return $this->rawData['_links']['download'] ?? null;
return $this->rawData['id'];
}

public function getTitle(): string
Expand Down
Empty file removed tests/.gitkeep
Empty file.
Loading
Loading