ZIP Exports: Added entity cross refs, Started export tests
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled

This commit is contained in:
Dan Brown 2024-10-23 15:59:58 +01:00
parent 42ada66fdd
commit 484342f26a
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
6 changed files with 136 additions and 4 deletions

View File

@ -3,7 +3,9 @@
namespace BookStack\Exports\Controllers; namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Throwable; use Throwable;
@ -63,4 +65,16 @@ class BookExportController extends Controller
return $this->download()->directly($textContent, $bookSlug . '.md'); return $this->download()->directly($textContent, $bookSlug . '.md');
} }
/**
* Export a book to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, ZipExportBuilder $builder)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$zip = $builder->buildForBook($book);
return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
}
} }

View File

@ -5,6 +5,7 @@ namespace BookStack\Exports\Controllers;
use BookStack\Entities\Queries\ChapterQueries; use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Throwable; use Throwable;
@ -70,4 +71,16 @@ class ChapterExportController extends Controller
return $this->download()->directly($chapterText, $chapterSlug . '.md'); return $this->download()->directly($chapterText, $chapterSlug . '.md');
} }
/**
* Export a book to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$zip = $builder->buildForChapter($chapter);
return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
}
} }

View File

@ -3,6 +3,9 @@
namespace BookStack\Exports\ZipExports; namespace BookStack\Exports\ZipExports;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\Models\ZipExportAttachment; use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportChapter;
@ -107,8 +110,6 @@ class ZipExportReferences
protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string
{ {
// TODO - References to other entities
// Handle attachment references // Handle attachment references
// No permission check needed here since they would only already exist in this // No permission check needed here since they would only already exist in this
// reference context if already allowed via their entity access. // reference context if already allowed via their entity access.
@ -143,6 +144,15 @@ class ZipExportReferences
return null; return null;
} }
// Handle entity references
if ($model instanceof Book && isset($this->books[$model->id])) {
return "[[bsexport:book:{$model->id}]]";
} else if ($model instanceof Chapter && isset($this->chapters[$model->id])) {
return "[[bsexport:chapter:{$model->id}]]";
} else if ($model instanceof Page && isset($this->pages[$model->id])) {
return "[[bsexport:page:{$model->id}]]";
}
return null; return null;
} }
} }

View File

@ -132,6 +132,7 @@ Route::middleware('auth')->group(function () {
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']);
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']);
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']);
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']);
Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']);
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']);
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']);

View File

@ -2,14 +2,95 @@
namespace Tests\Exports; namespace Tests\Exports;
use BookStack\Entities\Models\Book; use Illuminate\Support\Carbon;
use Illuminate\Testing\TestResponse;
use Tests\TestCase; use Tests\TestCase;
use ZipArchive;
class ZipExportTest extends TestCase class ZipExportTest extends TestCase
{ {
public function test_page_export() public function test_export_results_in_zip_format()
{ {
$page = $this->entities->page(); $page = $this->entities->page();
$response = $this->asEditor()->get($page->getUrl("/export/zip"));
$zipData = $response->streamedContent();
$zipFile = tempnam(sys_get_temp_dir(), 'bstesta-');
file_put_contents($zipFile, $zipData);
$zip = new ZipArchive();
$zip->open($zipFile, ZipArchive::RDONLY);
$this->assertNotFalse($zip->locateName('data.json'));
$this->assertNotFalse($zip->locateName('files/'));
$data = json_decode($zip->getFromName('data.json'), true);
$this->assertIsArray($data);
$this->assertGreaterThan(0, count($data));
$zip->close();
unlink($zipFile);
}
public function test_export_metadata()
{
$page = $this->entities->page();
$zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
$zip = $this->extractZipResponse($zipResp);
$this->assertEquals($page->id, $zip->data['page']['id'] ?? null);
$this->assertArrayNotHasKey('book', $zip->data);
$this->assertArrayNotHasKey('chapter', $zip->data);
$now = time();
$date = Carbon::parse($zip->data['exported_at'])->unix();
$this->assertLessThan($now + 2, $date);
$this->assertGreaterThan($now - 2, $date);
$version = trim(file_get_contents(base_path('version')));
$this->assertEquals($version, $zip->data['instance']['version']);
$instanceId = decrypt($zip->data['instance']['id_ciphertext']);
$this->assertEquals('bookstack', $instanceId);
}
public function test_page_export()
{
// TODO // TODO
} }
public function test_book_export()
{
// TODO
}
public function test_chapter_export()
{
// TODO
}
protected function extractZipResponse(TestResponse $response): ZipResultData
{
$zipData = $response->streamedContent();
$zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
file_put_contents($zipFile, $zipData);
$extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-');
if (file_exists($extractDir)) {
unlink($extractDir);
}
mkdir($extractDir);
$zip = new ZipArchive();
$zip->open($zipFile, ZipArchive::RDONLY);
$zip->extractTo($extractDir);
$dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json");
$data = json_decode($dataJson, true);
return new ZipResultData(
$zipFile,
$extractDir,
$data,
);
}
} }

View File

@ -0,0 +1,13 @@
<?php
namespace Tests\Exports;
class ZipResultData
{
public function __construct(
public string $zipPath,
public string $extractedDirPath,
public array $data,
) {
}
}