1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2024-10-29 23:22:34 +01:00

ZIP Export: Expanded page & added base attachment handling

This commit is contained in:
Dan Brown 2024-10-19 15:41:07 +01:00
parent bf0262d7d1
commit 21ccfa97dd
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 154 additions and 12 deletions

View File

@ -6,6 +6,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExportBuilder;
use BookStack\Http\Controller;
use Throwable;
@ -74,4 +75,16 @@ class PageExportController extends Controller
return $this->download()->directly($pageText, $pageSlug . '.md');
}
/**
* Export a page to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$zip = $builder->buildForPage($page);
return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
}
}

View File

@ -2,24 +2,70 @@
namespace BookStack\Exports;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ZipExportException;
use BookStack\Uploads\Attachment;
use ZipArchive;
class ZipExportBuilder
{
protected array $data = [];
public function __construct(
protected ZipExportFiles $files
) {
}
/**
* @throws ZipExportException
*/
public function buildForPage(Page $page): string
{
$this->data['page'] = [
'id' => $page->id,
$this->data['page'] = $this->convertPage($page);
return $this->build();
}
protected function convertPage(Page $page): array
{
$tags = array_map($this->convertTag(...), $page->tags()->get()->all());
$attachments = array_map($this->convertAttachment(...), $page->attachments()->get()->all());
return [
'id' => $page->id,
'name' => $page->name,
'html' => '', // TODO
'markdown' => '', // TODO
'priority' => $page->priority,
'attachments' => $attachments,
'images' => [], // TODO
'tags' => $tags,
];
}
protected function convertAttachment(Attachment $attachment): array
{
$data = [
'name' => $attachment->name,
'order' => $attachment->order,
];
return $this->build();
if ($attachment->external) {
$data['link'] = $attachment->path;
} else {
$data['file'] = $this->files->referenceForAttachment($attachment);
}
return $data;
}
protected function convertTag(Tag $tag): array
{
return [
'name' => $tag->name,
'value' => $tag->value,
'order' => $tag->order,
];
}
/**
@ -29,7 +75,7 @@ class ZipExportBuilder
{
$this->data['exported_at'] = date(DATE_ATOM);
$this->data['instance'] = [
'version' => trim(file_get_contents(base_path('version'))),
'version' => trim(file_get_contents(base_path('version'))),
'id_ciphertext' => encrypt('bookstack'),
];
@ -43,6 +89,18 @@ class ZipExportBuilder
$zip->addFromString('data.json', json_encode($this->data));
$zip->addEmptyDir('files');
$toRemove = [];
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
$zip->addFile($filePath, "files/$fileRef");
$toRemove[] = $filePath;
});
$zip->close();
foreach ($toRemove as $file) {
unlink($file);
}
return $zipFile;
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace BookStack\Exports;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService;
use Illuminate\Support\Str;
class ZipExportFiles
{
/**
* References for attachments by attachment ID.
* @var array<int, string>
*/
protected array $attachmentRefsById = [];
public function __construct(
protected AttachmentService $attachmentService,
) {
}
/**
* Gain a reference to the given attachment instance.
* This is expected to be a file-based attachment that the user
* has visibility of, no permission/access checks are performed here.
*/
public function referenceForAttachment(Attachment $attachment): string
{
if (isset($this->attachmentRefsById[$attachment->id])) {
return $this->attachmentRefsById[$attachment->id];
}
do {
$fileName = Str::random(20) . '.' . $attachment->extension;
} while (in_array($fileName, $this->attachmentRefsById));
$this->attachmentRefsById[$attachment->id] = $fileName;
return $fileName;
}
/**
* Extract each of the ZIP export tracked files.
* Calls the given callback for each tracked file, passing a temporary
* file reference of the file contents, and the zip-local tracked reference.
*/
public function extractEach(callable $callback): void
{
foreach ($this->attachmentRefsById as $attachmentId => $ref) {
$attachment = Attachment::query()->find($attachmentId);
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-');
$tmpFileStream = fopen($tmpFile, 'w');
stream_copy_to_stream($stream, $tmpFileStream);
$callback($tmpFile, $ref);
}
}
}

View File

@ -13,14 +13,9 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService
{
protected FilesystemManager $fileSystem;
/**
* AttachmentService constructor.
*/
public function __construct(FilesystemManager $fileSystem)
{
$this->fileSystem = $fileSystem;
public function __construct(
protected FilesystemManager $fileSystem
) {
}
/**

View File

@ -39,6 +39,7 @@ return [
'export_pdf' => 'PDF File',
'export_text' => 'Plain Text File',
'export_md' => 'Markdown File',
'export_zip' => 'Portable ZIP',
'default_template' => 'Default Page Template',
'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
'default_template_select' => 'Select a template page',

View File

@ -18,6 +18,7 @@
<li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
<li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
<li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
<li><a href="{{ $entity->getUrl('/export/zip') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_zip') }}</span><span>.zip</span></a></li>
</ul>
</div>

View File

@ -91,6 +91,7 @@ Route::middleware('auth')->group(function () {
Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']);
Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']);
Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']);
Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']);
Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']);
Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']);
Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']);

View File

@ -0,0 +1,15 @@
<?php
namespace Tests\Exports;
use BookStack\Entities\Models\Book;
use Tests\TestCase;
class ZipExportTest extends TestCase
{
public function test_page_export()
{
$page = $this->entities->page();
// TODO
}
}