]> BookStack Code Mirror - bookstack/commitdiff
ZIP Export: Expanded page & added base attachment handling
authorDan Brown <redacted>
Sat, 19 Oct 2024 14:41:07 +0000 (15:41 +0100)
committerDan Brown <redacted>
Sat, 19 Oct 2024 14:41:07 +0000 (15:41 +0100)
app/Exports/Controllers/PageExportController.php
app/Exports/ZipExportBuilder.php
app/Exports/ZipExportFiles.php [new file with mode: 0644]
app/Uploads/AttachmentService.php
lang/en/entities.php
resources/views/entities/export-menu.blade.php
routes/web.php
tests/Exports/ZipExportTest.php [new file with mode: 0644]

index a4e7aae879dcc6a7e8f894267e64712a1c632591..01611fd2121db42c533b4b7058db88de2e0efead 100644 (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));
+    }
 }
index d1a7b6bd4611db2b2514db4e5915d8058cc4c7b7..2b8b45d0d2a01d3259fc9a6f707df621699eeae0 100644 (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,
         ];
+    }
 
-        return $this->build();
+    protected function convertAttachment(Attachment $attachment): array
+    {
+        $data = [
+            'name'  => $attachment->name,
+            'order' => $attachment->order,
+        ];
+
+        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;
     }
 }
diff --git a/app/Exports/ZipExportFiles.php b/app/Exports/ZipExportFiles.php
new file mode 100644 (file)
index 0000000..d3ee70e
--- /dev/null
@@ -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);
+        }
+    }
+}
index bd319fbd795af717c4c026d63b9b8bc58ea7fabd..227649d8f000b53230edaffdde433d9d553556e7 100644 (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
+    ) {
     }
 
     /**
index 35e6f050bb885cc28cb83e5064fed910778c1ae1..7e5a708ef6260c939dccc49999be37207c8246c5 100644 (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',
index a55ab56d199cf174b6144f26bf0b7306bfaaa72d..e58c842ba421a324981c8849c989ab5d36b5fdb2 100644 (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>
index 5220684c0b03b5233f07a0cd09620ce4f01402ba..6ae70983d52267dd97811cdc9c7cc42a9a4006b8 100644 (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']);
diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php
new file mode 100644 (file)
index 0000000..d8ce00b
--- /dev/null
@@ -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
+    }
+}
Morty Proxy This is a proxified and sanitized view of the page, visit original site.