ZIP Imports: Added full contents view to import display

Reduced import data will now be stored on the import itself, instead of
storing a set of totals.
This commit is contained in:
Dan Brown 2024-11-05 13:17:31 +00:00
parent 14578c2257
commit 92cfde495e
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
20 changed files with 303 additions and 105 deletions

View File

@ -65,10 +65,13 @@ class ImportController extends Controller
{
$import = $this->imports->findVisible($id);
// dd($import->decodeMetadata());
$this->setPageTitle(trans('entities.import_continue'));
return view('exports.import-show', [
'import' => $import,
'data' => $import->decodeMetadata(),
]);
}
@ -89,7 +92,7 @@ class ImportController extends Controller
// TODO - Validate again before
// TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments])
// TODO - Redirect to result
// TOOD - Or redirect back with errors
// TODO - Or redirect back with errors
}
/**

View File

@ -3,6 +3,9 @@
namespace BookStack\Exports;
use BookStack\Activity\Models\Loggable;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -14,9 +17,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $path
* @property string $name
* @property int $size - ZIP size in bytes
* @property int $book_count
* @property int $chapter_count
* @property int $page_count
* @property string $type
* @property string $metadata
* @property int $created_by
* @property Carbon $created_at
* @property Carbon $updated_at
@ -26,24 +28,6 @@ class Import extends Model implements Loggable
{
use HasFactory;
public const TYPE_BOOK = 'book';
public const TYPE_CHAPTER = 'chapter';
public const TYPE_PAGE = 'page';
/**
* Get the type (model) that this import is intended to be.
*/
public function getType(): string
{
if ($this->book_count === 1) {
return self::TYPE_BOOK;
} elseif ($this->chapter_count === 1) {
return self::TYPE_CHAPTER;
}
return self::TYPE_PAGE;
}
public function getSizeString(): string
{
$mb = round($this->size / 1000000, 2);
@ -68,4 +52,15 @@ class Import extends Model implements Loggable
{
return $this->belongsTo(User::class, 'created_by');
}
public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null
{
$metadataArray = json_decode($this->metadata, true);
return match ($this->type) {
'book' => ZipExportBook::fromArray($metadataArray),
'chapter' => ZipExportChapter::fromArray($metadataArray),
'page' => ZipExportPage::fromArray($metadataArray),
default => null,
};
}
}

View File

@ -2,7 +2,12 @@
namespace BookStack\Exports;
use BookStack\Exceptions\FileUploadException;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exceptions\ZipValidationException;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Exports\ZipExports\ZipExportReader;
use BookStack\Exports\ZipExports\ZipExportValidator;
use BookStack\Uploads\FileStorage;
@ -41,6 +46,11 @@ class ImportRepo
return $query->findOrFail($id);
}
/**
* @throws FileUploadException
* @throws ZipValidationException
* @throws ZipExportException
*/
public function storeFromUpload(UploadedFile $file): Import
{
$zipPath = $file->getRealPath();
@ -50,15 +60,23 @@ class ImportRepo
throw new ZipValidationException($errors);
}
$zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo();
$reader = new ZipExportReader($zipPath);
$exportModel = $reader->decodeDataToExportModel();
$import = new Import();
$import->name = $zipEntityInfo['name'];
$import->book_count = $zipEntityInfo['book_count'];
$import->chapter_count = $zipEntityInfo['chapter_count'];
$import->page_count = $zipEntityInfo['page_count'];
$import->type = match (get_class($exportModel)) {
ZipExportPage::class => 'page',
ZipExportChapter::class => 'chapter',
ZipExportBook::class => 'book',
};
$import->name = $exportModel->name;
$import->created_by = user()->id;
$import->size = filesize($zipPath);
$exportModel->metadataOnly();
$import->metadata = json_encode($exportModel);
$path = $this->storage->uploadFile(
$file,
'uploads/files/imports/',
@ -72,6 +90,13 @@ class ImportRepo
return $import;
}
public function runImport(Import $import, ?string $parent = null)
{
// TODO - Download import zip (if needed)
// TODO - Validate zip file again
// TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments])
}
public function deleteImport(Import $import): void
{
$this->storage->delete($import->path);

View File

@ -14,6 +14,11 @@ class ZipExportAttachment extends ZipExportModel
public ?string $link = null;
public ?string $file = null;
public function metadataOnly(): void
{
$this->order = $this->link = $this->file = null;
}
public static function fromModel(Attachment $model, ZipExportFiles $files): self
{
$instance = new self();
@ -49,4 +54,17 @@ class ZipExportAttachment extends ZipExportModel
return $context->validateData($data, $rules);
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->order = isset($data['order']) ? intval($data['order']) : null;
$model->link = $data['link'] ?? null;
$model->file = $data['file'] ?? null;
return $model;
}
}

View File

@ -21,6 +21,21 @@ class ZipExportBook extends ZipExportModel
/** @var ZipExportTag[] */
public array $tags = [];
public function metadataOnly(): void
{
$this->description_html = $this->cover = null;
foreach ($this->chapters as $chapter) {
$chapter->metadataOnly();
}
foreach ($this->pages as $page) {
$page->metadataOnly();
}
foreach ($this->tags as $tag) {
$tag->metadataOnly();
}
}
public static function fromModel(Book $model, ZipExportFiles $files): self
{
$instance = new self();
@ -71,4 +86,19 @@ class ZipExportBook extends ZipExportModel
return $errors;
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->description_html = $data['description_html'] ?? null;
$model->cover = $data['cover'] ?? null;
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
$model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []);
return $model;
}
}

View File

@ -18,6 +18,18 @@ class ZipExportChapter extends ZipExportModel
/** @var ZipExportTag[] */
public array $tags = [];
public function metadataOnly(): void
{
$this->description_html = $this->priority = null;
foreach ($this->pages as $page) {
$page->metadataOnly();
}
foreach ($this->tags as $tag) {
$tag->metadataOnly();
}
}
public static function fromModel(Chapter $model, ZipExportFiles $files): self
{
$instance = new self();
@ -61,4 +73,18 @@ class ZipExportChapter extends ZipExportModel
return $errors;
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->description_html = $data['description_html'] ?? null;
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
return $model;
}
}

View File

@ -25,6 +25,11 @@ class ZipExportImage extends ZipExportModel
return $instance;
}
public function metadataOnly(): void
{
//
}
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
@ -36,4 +41,16 @@ class ZipExportImage extends ZipExportModel
return $context->validateData($data, $rules);
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->file = $data['file'];
$model->type = $data['type'];
return $model;
}
}

View File

@ -26,4 +26,32 @@ abstract class ZipExportModel implements JsonSerializable
* item in the array for its own validation messages.
*/
abstract public static function validate(ZipValidationHelper $context, array $data): array;
/**
* Decode the array of data into this export model.
*/
abstract public static function fromArray(array $data): self;
/**
* Decode an array of array data into an array of export models.
* @param array[] $data
* @return self[]
*/
public static function fromManyArray(array $data): array
{
$results = [];
foreach ($data as $item) {
$results[] = static::fromArray($item);
}
return $results;
}
/**
* Remove additional content in this model to reduce it down
* to just essential id/name values for identification.
*
* The result of this may be something that does not pass validation, but is
* simple for the purpose of creating a contents.
*/
abstract public function metadataOnly(): void;
}

View File

@ -21,6 +21,21 @@ class ZipExportPage extends ZipExportModel
/** @var ZipExportTag[] */
public array $tags = [];
public function metadataOnly(): void
{
$this->html = $this->markdown = $this->priority = null;
foreach ($this->attachments as $attachment) {
$attachment->metadataOnly();
}
foreach ($this->images as $image) {
$image->metadataOnly();
}
foreach ($this->tags as $tag) {
$tag->metadataOnly();
}
}
public static function fromModel(Page $model, ZipExportFiles $files): self
{
$instance = new self();
@ -70,4 +85,20 @@ class ZipExportPage extends ZipExportModel
return $errors;
}
public static function fromArray(array $data): self
{
$model = new self();
$model->id = $data['id'] ?? null;
$model->name = $data['name'];
$model->html = $data['html'] ?? null;
$model->markdown = $data['markdown'] ?? null;
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
$model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []);
$model->images = ZipExportImage::fromManyArray($data['images'] ?? []);
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
return $model;
}
}

View File

@ -11,6 +11,11 @@ class ZipExportTag extends ZipExportModel
public ?string $value = null;
public ?int $order = null;
public function metadataOnly(): void
{
$this->value = $this->order = null;
}
public static function fromModel(Tag $model): self
{
$instance = new self();
@ -36,4 +41,15 @@ class ZipExportTag extends ZipExportModel
return $context->validateData($data, $rules);
}
public static function fromArray(array $data): self
{
$model = new self();
$model->name = $data['name'];
$model->value = $data['value'] ?? null;
$model->order = isset($data['order']) ? intval($data['order']) : null;
return $model;
}
}

View File

@ -3,6 +3,10 @@
namespace BookStack\Exports\ZipExports;
use BookStack\Exceptions\ZipExportException;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportModel;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use ZipArchive;
class ZipExportReader
@ -71,32 +75,18 @@ class ZipExportReader
/**
* @throws ZipExportException
* @returns array{name: string, book_count: int, chapter_count: int, page_count: int}
*/
public function getEntityInfo(): array
public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage
{
$data = $this->readData();
$info = ['name' => '', 'book_count' => 0, 'chapter_count' => 0, 'page_count' => 0];
if (isset($data['book'])) {
$info['name'] = $data['book']['name'] ?? '';
$info['book_count']++;
$chapters = $data['book']['chapters'] ?? [];
$pages = $data['book']['pages'] ?? [];
$info['chapter_count'] += count($chapters);
$info['page_count'] += count($pages);
foreach ($chapters as $chapter) {
$info['page_count'] += count($chapter['pages'] ?? []);
}
} elseif (isset($data['chapter'])) {
$info['name'] = $data['chapter']['name'] ?? '';
$info['chapter_count']++;
$info['page_count'] += count($data['chapter']['pages'] ?? []);
} elseif (isset($data['page'])) {
$info['name'] = $data['page']['name'] ?? '';
$info['page_count']++;
return ZipExportBook::fromArray($data['book']);
} else if (isset($data['chapter'])) {
return ZipExportChapter::fromArray($data['chapter']);
} else if (isset($data['page'])) {
return ZipExportPage::fromArray($data['page']);
}
return $info;
throw new ZipExportException("Could not identify content in ZIP file data.");
}
}

View File

@ -38,7 +38,6 @@ class ZipExportValidator
return ['format' => trans('errors.import_zip_no_data')];
}
return $this->flattenModelErrors($modelErrors, $keyPrefix);
}

View File

@ -23,9 +23,8 @@ class ImportFactory extends Factory
return [
'path' => 'uploads/imports/' . Str::random(10) . '.zip',
'name' => $this->faker->words(3, true),
'book_count' => 1,
'chapter_count' => 5,
'page_count' => 15,
'type' => 'book',
'metadata' => '{"name": "My book"}',
'created_at' => User::factory(),
];
}

View File

@ -16,10 +16,9 @@ return new class extends Migration
$table->string('name');
$table->string('path');
$table->integer('size');
$table->integer('book_count');
$table->integer('chapter_count');
$table->integer('page_count');
$table->integer('created_by');
$table->string('type');
$table->longText('metadata');
$table->integer('created_by')->index();
$table->timestamps();
});
}

View File

@ -45,7 +45,7 @@ return [
'default_template_select' => 'Select a template page',
'import' => 'Import',
'import_validate' => 'Validate Import',
'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
'import_zip_select' => 'Select ZIP file to upload',
'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
'import_pending' => 'Pending Imports',
@ -53,9 +53,9 @@ return [
'import_continue' => 'Continue Import',
'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
'import_run' => 'Run Import',
'import_size' => 'Import ZIP Size:',
'import_uploaded_at' => 'Uploaded:',
'import_uploaded_by' => 'Uploaded by:',
'import_size' => ':size Import ZIP Size',
'import_uploaded_at' => 'Uploaded :relativeTime',
'import_uploaded_by' => 'Uploaded by',
'import_location' => 'Import Location',
'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
'import_delete_confirm' => 'Are you sure you want to delete this import?',

View File

@ -248,4 +248,9 @@ $loadingSize: 10px;
transform: rotate(180deg);
}
}
}
.import-item {
border-inline-start: 2px solid currentColor;
padding-inline-start: $-xs;
}

View File

@ -1,11 +1,6 @@
@extends('layouts.simple')
@section('body')
@php
$type = $import->getType();
@endphp
<div class="container small">
<main class="card content-wrap auto-height mt-xxl">
@ -13,29 +8,17 @@
<p class="text-muted">{{ trans('entities.import_continue_desc') }}</p>
<div class="mb-m">
<label class="setting-list-label">Import Details</label>
<div class="flex-container-row items-center justify-space-between wrap">
<label class="setting-list-label mb-m">Import Details</label>
<div class="flex-container-row justify-space-between wrap">
<div>
<p class="text-{{ $type }} mb-xs bold">@icon($type) {{ $import->name }}</p>
@if($type === 'book')
<p class="text-chapter mb-xs ml-l">@icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}</p>
@endif
@if($type === 'book' || $type === 'chapter')
<p class="text-page mb-xs ml-l">@icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}</p>
@endif
@include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
</div>
<div>
<div class="opacity-80">
<strong>{{ trans('entities.import_size') }}</strong>
<span>{{ $import->getSizeString() }}</span>
</div>
<div class="opacity-80">
<strong>{{ trans('entities.import_uploaded_at') }}</strong>
<span title="{{ $import->created_at->toISOString() }}">{{ $import->created_at->diffForHumans() }}</span>
</div>
<div class="text-right text-muted">
<div>{{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}</div>
<div><span title="{{ $import->created_at->toISOString() }}">{{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}</span></div>
@if($import->createdBy)
<div class="opacity-80">
<strong>{{ trans('entities.import_uploaded_by') }}</strong>
<div>
{{ trans('entities.import_uploaded_by') }}
<a href="{{ $import->createdBy->getProfileUrl() }}">{{ $import->createdBy->name }}</a>
</div>
@endif
@ -48,14 +31,14 @@
method="POST">
{{ csrf_field() }}
@if($type === 'page' || $type === 'chapter')
@if($import->type === 'page' || $import->type === 'chapter')
<hr>
<label class="setting-list-label">{{ trans('entities.import_location') }}</label>
<p class="small mb-m">{{ trans('entities.import_location_desc') }}</p>
@include('entities.selector', [
'name' => 'parent',
'entityTypes' => $type === 'page' ? 'chapter,book' : 'book',
'entityPermission' => "{$type}-create",
'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book',
'entityPermission' => "{$import->type}-create",
'selectorSize' => 'compact small',
])
@include('form.errors', ['name' => 'parent'])

View File

@ -0,0 +1,26 @@
{{--
$type - string
$model - object
--}}
<div class="import-item text-{{ $type }} mb-xs">
<p class="mb-none">@icon($type){{ $model->name }}</p>
<div class="ml-s">
<div class="text-muted">
@if($model->attachments ?? [])
<span>@icon('attach'){{ count($model->attachments) }}</span>
@endif
@if($model->images ?? [])
<span>@icon('image'){{ count($model->images) }}</span>
@endif
@if($model->tags ?? [])
<span>@icon('tag'){{ count($model->tags) }}</span>
@endif
</div>
@foreach($model->chapters ?? [] as $chapter)
@include('exports.parts.import-item', ['type' => 'chapter', 'model' => $chapter])
@endforeach
@foreach($model->pages ?? [] as $page)
@include('exports.parts.import-item', ['type' => 'page', 'model' => $page])
@endforeach
</div>
</div>

View File

@ -1,18 +1,9 @@
@php
$type = $import->getType();
@endphp
<div class="item-list-row flex-container-row items-center justify-space-between wrap">
<div class="px-m py-s">
<a href="{{ $import->getUrl() }}"
class="text-{{ $type }}">@icon($type) {{ $import->name }}</a>
class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}</a>
</div>
<div class="px-m py-s flex-container-row gap-m items-center">
@if($type === 'book')
<div class="text-chapter opacity-80 bold">@icon('chapter') {{ $import->chapter_count }}</div>
@endif
@if($type === 'book' || $type === 'chapter')
<div class="text-page opacity-80 bold">@icon('page') {{ $import->page_count }}</div>
@endif
<div class="bold opacity-80">{{ $import->getSizeString() }}</div>
<div class="bold opacity-80 text-muted" title="{{ $import->created_at->toISOString() }}">@icon('time'){{ $import->created_at->diffForHumans() }}</div>
</div>

View File

@ -4,6 +4,9 @@ namespace Tests\Exports;
use BookStack\Activity\ActivityType;
use BookStack\Exports\Import;
use BookStack\Exports\ZipExports\Models\ZipExportBook;
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportPage;
use Illuminate\Http\UploadedFile;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
@ -130,7 +133,7 @@ class ZipImportTest extends TestCase
{
$admin = $this->users->admin();
$this->actingAs($admin);
$resp = $this->runImportFromFile($this->zipUploadFromData([
$data = [
'book' => [
'name' => 'My great book name',
'chapters' => [
@ -149,13 +152,13 @@ class ZipImportTest extends TestCase
]
],
],
]));
];
$resp = $this->runImportFromFile($this->zipUploadFromData($data));
$this->assertDatabaseHas('imports', [
'name' => 'My great book name',
'book_count' => 1,
'chapter_count' => 1,
'page_count' => 2,
'type' => 'book',
'created_by' => $admin->id,
]);
@ -168,11 +171,25 @@ class ZipImportTest extends TestCase
public function test_import_show_page()
{
$import = Import::factory()->create(['name' => 'MySuperAdminImport']);
$exportBook = new ZipExportBook();
$exportBook->name = 'My exported book';
$exportChapter = new ZipExportChapter();
$exportChapter->name = 'My exported chapter';
$exportPage = new ZipExportPage();
$exportPage->name = 'My exported page';
$exportBook->chapters = [$exportChapter];
$exportChapter->pages = [$exportPage];
$import = Import::factory()->create([
'name' => 'MySuperAdminImport',
'metadata' => json_encode($exportBook)
]);
$resp = $this->asAdmin()->get("/import/{$import->id}");
$resp->assertOk();
$resp->assertSee('MySuperAdminImport');
$resp->assertSeeText('My exported book');
$resp->assertSeeText('My exported chapter');
$resp->assertSeeText('My exported page');
}
public function test_import_show_page_access_limited()