]> BookStack Code Mirror - bookstack/commitdiff
ZIP Import: Finished base import process & error handling
authorDan Brown <redacted>
Thu, 14 Nov 2024 15:59:15 +0000 (15:59 +0000)
committerDan Brown <redacted>
Thu, 14 Nov 2024 15:59:15 +0000 (15:59 +0000)
Added file creation reverting and DB rollback on error.
Added error display on failed import.
Extracted likely shown import form/error text to translation files.

app/Exports/Controllers/ImportController.php
app/Exports/ImportRepo.php
app/Exports/ZipExports/ZipImportReferences.php
app/Exports/ZipExports/ZipImportRunner.php
app/Uploads/AttachmentService.php
app/Uploads/ImageService.php
lang/en/entities.php
lang/en/errors.php
resources/views/exports/import-show.blade.php
resources/views/exports/parts/import.blade.php

index 4d2c83090a3af1cdad41fff01cf532c6a8338f1d..d8dceed2f8e83aea554edc55ea926e56ad5af331 100644 (file)
@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 namespace BookStack\Exports\Controllers;
 
 
 namespace BookStack\Exports\Controllers;
 
-use BookStack\Activity\ActivityType;
+use BookStack\Exceptions\ZipImportException;
 use BookStack\Exceptions\ZipValidationException;
 use BookStack\Exports\ImportRepo;
 use BookStack\Http\Controller;
 use BookStack\Exceptions\ZipValidationException;
 use BookStack\Exports\ImportRepo;
 use BookStack\Http\Controller;
@@ -48,12 +48,9 @@ class ImportController extends Controller
         try {
             $import = $this->imports->storeFromUpload($file);
         } catch (ZipValidationException $exception) {
         try {
             $import = $this->imports->storeFromUpload($file);
         } catch (ZipValidationException $exception) {
-            session()->flash('validation_errors', $exception->errors);
-            return redirect('/import');
+            return redirect('/import')->with('validation_errors', $exception->errors);
         }
 
         }
 
-        $this->logActivity(ActivityType::IMPORT_CREATE, $import);
-
         return redirect($import->getUrl());
     }
 
         return redirect($import->getUrl());
     }
 
@@ -80,20 +77,20 @@ class ImportController extends Controller
         $parent = null;
 
         if ($import->type === 'page' || $import->type === 'chapter') {
         $parent = null;
 
         if ($import->type === 'page' || $import->type === 'chapter') {
+            session()->setPreviousUrl($import->getUrl());
             $data = $this->validate($request, [
             $data = $this->validate($request, [
-                'parent' => ['required', 'string']
+                'parent' => ['required', 'string'],
             ]);
             $parent = $data['parent'];
         }
 
             ]);
             $parent = $data['parent'];
         }
 
-        $entity = $this->imports->runImport($import, $parent);
-        if ($entity) {
-            $this->logActivity(ActivityType::IMPORT_RUN, $import);
-            return redirect($entity->getUrl());
+        try {
+            $entity = $this->imports->runImport($import, $parent);
+        } catch (ZipImportException $exception) {
+            return redirect($import->getUrl())->with('import_errors', $exception->errors);
         }
         }
-        // TODO - Redirect to result
-        // TODO - Or redirect back with errors
-        return 'failed';
+
+        return redirect($entity->getUrl());
     }
 
     /**
     }
 
     /**
@@ -104,8 +101,6 @@ class ImportController extends Controller
         $import = $this->imports->findVisible($id);
         $this->imports->deleteImport($import);
 
         $import = $this->imports->findVisible($id);
         $this->imports->deleteImport($import);
 
-        $this->logActivity(ActivityType::IMPORT_DELETE, $import);
-
         return redirect('/import');
     }
 }
         return redirect('/import');
     }
 }
index d169d4845ab5f02fc364e5c24762e7e875821a7e..f72386c47bc949848e6895922fc8c1a91e0d9b10 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Exports;
 
 
 namespace BookStack\Exports;
 
+use BookStack\Activity\ActivityType;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Exceptions\FileUploadException;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Exceptions\FileUploadException;
@@ -14,8 +15,10 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
 use BookStack\Exports\ZipExports\ZipExportReader;
 use BookStack\Exports\ZipExports\ZipExportValidator;
 use BookStack\Exports\ZipExports\ZipImportRunner;
 use BookStack\Exports\ZipExports\ZipExportReader;
 use BookStack\Exports\ZipExports\ZipExportValidator;
 use BookStack\Exports\ZipExports\ZipImportRunner;
+use BookStack\Facades\Activity;
 use BookStack\Uploads\FileStorage;
 use Illuminate\Database\Eloquent\Collection;
 use BookStack\Uploads\FileStorage;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\DB;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImportRepo
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImportRepo
@@ -93,25 +96,42 @@ class ImportRepo
         $import->path = $path;
         $import->save();
 
         $import->path = $path;
         $import->save();
 
+        Activity::add(ActivityType::IMPORT_CREATE, $import);
+
         return $import;
     }
 
     /**
         return $import;
     }
 
     /**
-     * @throws ZipValidationException|ZipImportException
+     * @throws ZipImportException
      */
      */
-    public function runImport(Import $import, ?string $parent = null): ?Entity
+    public function runImport(Import $import, ?string $parent = null): Entity
     {
         $parentModel = null;
         if ($import->type === 'page' || $import->type === 'chapter') {
             $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
         }
 
     {
         $parentModel = null;
         if ($import->type === 'page' || $import->type === 'chapter') {
             $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
         }
 
-        return $this->importer->run($import, $parentModel);
+        DB::beginTransaction();
+        try {
+            $model = $this->importer->run($import, $parentModel);
+        } catch (ZipImportException $e) {
+            DB::rollBack();
+            $this->importer->revertStoredFiles();
+            throw $e;
+        }
+
+        DB::commit();
+        $this->deleteImport($import);
+        Activity::add(ActivityType::IMPORT_RUN, $import);
+
+        return $model;
     }
 
     public function deleteImport(Import $import): void
     {
         $this->storage->delete($import->path);
         $import->delete();
     }
 
     public function deleteImport(Import $import): void
     {
         $this->storage->delete($import->path);
         $import->delete();
+
+        Activity::add(ActivityType::IMPORT_DELETE, $import);
     }
 }
     }
 }
index 3bce16bbb13be8f7710f7a037e73488eb968e0a5..b23d5e72b159a5684dc318de77b7d1069f84bf81 100644 (file)
@@ -139,4 +139,21 @@ class ZipImportReferences
             ]);
         }
     }
             ]);
         }
     }
+
+
+    /**
+     * @return Image[]
+     */
+    public function images(): array
+    {
+        return $this->images;
+    }
+
+    /**
+     * @return Attachment[]
+     */
+    public function attachments(): array
+    {
+        return $this->attachments;
+    }
 }
 }
index 9f19f03e2e54bc5c9bbd8e79775e2773da3f4cac..c5b9da319096d7a03cf4d80a11d50b7f9829f417 100644 (file)
@@ -47,14 +47,17 @@ class ZipImportRunner
      * Returns the top-level entity item which was imported.
      * @throws ZipImportException
      */
      * Returns the top-level entity item which was imported.
      * @throws ZipImportException
      */
-    public function run(Import $import, ?Entity $parent = null): ?Entity
+    public function run(Import $import, ?Entity $parent = null): Entity
     {
         $zipPath = $this->getZipPath($import);
         $reader = new ZipExportReader($zipPath);
 
         $errors = (new ZipExportValidator($reader))->validate();
         if ($errors) {
     {
         $zipPath = $this->getZipPath($import);
         $reader = new ZipExportReader($zipPath);
 
         $errors = (new ZipExportValidator($reader))->validate();
         if ($errors) {
-            throw new ZipImportException(["ZIP failed to validate"]);
+            throw new ZipImportException([
+                trans('errors.import_validation_failed'),
+                ...$errors,
+            ]);
         }
 
         try {
         }
 
         try {
@@ -65,15 +68,14 @@ class ZipImportRunner
 
         // Validate parent type
         if ($exportModel instanceof ZipExportBook && ($parent !== null)) {
 
         // Validate parent type
         if ($exportModel instanceof ZipExportBook && ($parent !== null)) {
-            throw new ZipImportException(["Must not have a parent set for a Book import"]);
-        } else if ($exportModel instanceof ZipExportChapter && (!$parent instanceof Book)) {
-            throw new ZipImportException(["Parent book required for chapter import"]);
+            throw new ZipImportException(["Must not have a parent set for a Book import."]);
+        } else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) {
+            throw new ZipImportException(["Parent book required for chapter import."]);
         } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) {
         } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) {
-            throw new ZipImportException(["Parent book or chapter required for page import"]);
+            throw new ZipImportException(["Parent book or chapter required for page import."]);
         }
 
         }
 
-        $this->ensurePermissionsPermitImport($exportModel);
-        $entity = null;
+        $this->ensurePermissionsPermitImport($exportModel, $parent);
 
         if ($exportModel instanceof ZipExportBook) {
             $entity = $this->importBook($exportModel, $reader);
 
         if ($exportModel instanceof ZipExportBook) {
             $entity = $this->importBook($exportModel, $reader);
@@ -81,32 +83,46 @@ class ZipImportRunner
             $entity = $this->importChapter($exportModel, $parent, $reader);
         } else if ($exportModel instanceof ZipExportPage) {
             $entity = $this->importPage($exportModel, $parent, $reader);
             $entity = $this->importChapter($exportModel, $parent, $reader);
         } else if ($exportModel instanceof ZipExportPage) {
             $entity = $this->importPage($exportModel, $parent, $reader);
+        } else {
+            throw new ZipImportException(['No importable data found in import data.']);
         }
 
         }
 
-          // TODO - In transaction?
-            // TODO - Revert uploaded files if goes wrong
-              // TODO - Attachments
-              // TODO - Images
-              // (Both listed/stored in references)
-
         $this->references->replaceReferences();
 
         $reader->close();
         $this->cleanup();
 
         $this->references->replaceReferences();
 
         $reader->close();
         $this->cleanup();
 
-        dd('stop');
+        return $entity;
+    }
 
 
-        // TODO - Delete import/zip after import?
-          // Do this in parent repo?
+    /**
+     * Revert any files which have been stored during this import process.
+     * Considers files only, and avoids the database under the
+     * assumption that the database may already have been
+     * reverted as part of a transaction rollback.
+     */
+    public function revertStoredFiles(): void
+    {
+        foreach ($this->references->images() as $image) {
+            $this->imageService->destroyFileAtPath($image->type, $image->path);
+        }
 
 
-        return $entity;
+        foreach ($this->references->attachments() as $attachment) {
+            if (!$attachment->external) {
+                $this->attachmentService->deleteFileInStorage($attachment);
+            }
+        }
+
+        $this->cleanup();
     }
 
     }
 
-    protected function cleanup()
+    protected function cleanup(): void
     {
         foreach ($this->tempFilesToCleanup as $file) {
             unlink($file);
         }
     {
         foreach ($this->tempFilesToCleanup as $file) {
             unlink($file);
         }
+
+        $this->tempFilesToCleanup = [];
     }
 
     protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book
     }
 
     protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book
@@ -256,9 +272,6 @@ class ZipImportRunner
     {
         $errors = [];
 
     {
         $errors = [];
 
-        // TODO - Extract messages to language files
-        // TODO - Ensure these are shown to users on failure
-
         $chapters = [];
         $pages = [];
         $images = [];
         $chapters = [];
         $pages = [];
         $images = [];
@@ -266,7 +279,7 @@ class ZipImportRunner
 
         if ($exportModel instanceof ZipExportBook) {
             if (!userCan('book-create-all')) {
 
         if ($exportModel instanceof ZipExportBook) {
             if (!userCan('book-create-all')) {
-                $errors[] = 'You are lacking the required permission to create books.';
+                $errors[] = trans('errors.import_perms_books');
             }
             array_push($pages, ...$exportModel->pages);
             array_push($chapters, ...$exportModel->chapters);
             }
             array_push($pages, ...$exportModel->pages);
             array_push($chapters, ...$exportModel->chapters);
@@ -283,7 +296,7 @@ class ZipImportRunner
         if (count($chapters) > 0) {
             $permission = 'chapter-create' . ($parent ? '' : '-all');
             if (!userCan($permission, $parent)) {
         if (count($chapters) > 0) {
             $permission = 'chapter-create' . ($parent ? '' : '-all');
             if (!userCan($permission, $parent)) {
-                $errors[] = 'You are lacking the required permission to create chapters.';
+                $errors[] = trans('errors.import_perms_chapters');
             }
         }
 
             }
         }
 
@@ -295,25 +308,25 @@ class ZipImportRunner
         if (count($pages) > 0) {
             if ($parent) {
                 if (!userCan('page-create', $parent)) {
         if (count($pages) > 0) {
             if ($parent) {
                 if (!userCan('page-create', $parent)) {
-                    $errors[] = 'You are lacking the required permission to create pages.';
+                    $errors[] = trans('errors.import_perms_pages');
                 }
             } else {
                 $hasPermission = userCan('page-create-all') || userCan('page-create-own');
                 if (!$hasPermission) {
                 }
             } else {
                 $hasPermission = userCan('page-create-all') || userCan('page-create-own');
                 if (!$hasPermission) {
-                    $errors[] = 'You are lacking the required permission to create pages.';
+                    $errors[] = trans('errors.import_perms_pages');
                 }
             }
         }
 
         if (count($images) > 0) {
             if (!userCan('image-create-all')) {
                 }
             }
         }
 
         if (count($images) > 0) {
             if (!userCan('image-create-all')) {
-                $errors[] = 'You are lacking the required permissions to create images.';
+                $errors[] = trans('errors.import_perms_images');
             }
         }
 
         if (count($attachments) > 0) {
             if (!userCan('attachment-create-all')) {
             }
         }
 
         if (count($attachments) > 0) {
             if (!userCan('attachment-create-all')) {
-                $errors[] = 'You are lacking the required permissions to create attachments.';
+                $errors[] = trans('errors.import_perms_attachments');
             }
         }
 
             }
         }
 
index fa53c4ae4992b697ab801cf870035856f62e24b8..033f2334104b44f008d0ce29ecdc84bc68a33117 100644 (file)
@@ -151,7 +151,7 @@ class AttachmentService
      * Delete a file from the filesystem it sits on.
      * Cleans any empty leftover folders.
      */
      * Delete a file from the filesystem it sits on.
      * Cleans any empty leftover folders.
      */
-    protected function deleteFileInStorage(Attachment $attachment): void
+    public function deleteFileInStorage(Attachment $attachment): void
     {
         $this->storage->delete($attachment->path);
     }
     {
         $this->storage->delete($attachment->path);
     }
index e501cc7b12ded686776c7ba4d80b3d25c9fc8389..5c455cf86336b346b7e53e9647b7f7ee52e23e2d 100644 (file)
@@ -153,11 +153,19 @@ class ImageService
      */
     public function destroy(Image $image): void
     {
      */
     public function destroy(Image $image): void
     {
-        $disk = $this->storage->getDisk($image->type);
-        $disk->destroyAllMatchingNameFromPath($image->path);
+        $this->destroyFileAtPath($image->type, $image->path);
         $image->delete();
     }
 
         $image->delete();
     }
 
+    /**
+     * Destroy the underlying image file at the given path.
+     */
+    public function destroyFileAtPath(string $type, string $path): void
+    {
+        $disk = $this->storage->getDisk($type);
+        $disk->destroyAllMatchingNameFromPath($path);
+    }
+
     /**
      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
      * Checks based off of only the image name.
     /**
      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
      * Checks based off of only the image name.
index ae1c1e8d4cc773d2a84dbe48868b851cb7ca1e51..26a563a7eb534388afd1e89fce92ea91e1a8a5c7 100644 (file)
@@ -52,6 +52,7 @@ return [
     'import_pending_none' => 'No imports have been started.',
     '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_pending_none' => 'No imports have been started.',
     '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_details' => 'Import Details',
     'import_run' => 'Run Import',
     'import_size' => ':size Import ZIP Size',
     'import_uploaded_at' => 'Uploaded :relativeTime',
     'import_run' => 'Run Import',
     'import_size' => ':size Import ZIP Size',
     'import_uploaded_at' => 'Uploaded :relativeTime',
@@ -60,6 +61,8 @@ return [
     '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?',
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     '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?',
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
+    'import_errors' => 'Import Errors',
+    'import_errors_desc' => 'The follow errors occurred during the import attempt:',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
index 3f2f303311e36015ecc9d5eb48a1c576d8cb13fe..ced80a32c1f53f0f842aa6d53febe3c62376da47 100644 (file)
@@ -109,6 +109,12 @@ return [
     'import_zip_cant_read' => 'Could not read ZIP file.',
     'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
     'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
     'import_zip_cant_read' => 'Could not read ZIP file.',
     'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
     'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
+    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
+    'import_perms_books' => 'You are lacking the required permissions to create books.',
+    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
+    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
+    'import_perms_images' => 'You are lacking the required permissions to create images.',
+    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
 
     // API errors
     'api_no_authorization_found' => 'No authorization token found on the request',
 
     // API errors
     'api_no_authorization_found' => 'No authorization token found on the request',
index 40867377fb08d6cdb91302e227e010fcfce7e6ff..e4f199aa20c3193a34a1d5e5085b9b03f52d0948 100644 (file)
@@ -7,8 +7,19 @@
             <h1 class="list-heading">{{ trans('entities.import_continue') }}</h1>
             <p class="text-muted">{{ trans('entities.import_continue_desc') }}</p>
 
             <h1 class="list-heading">{{ trans('entities.import_continue') }}</h1>
             <p class="text-muted">{{ trans('entities.import_continue_desc') }}</p>
 
+            @if(session()->has('import_errors'))
+                <div class="mb-m">
+                    <label class="setting-list-label mb-xs text-neg">@icon('warning') {{ trans('entities.import_errors') }}</label>
+                    <p class="mb-xs small">{{ trans('entities.import_errors_desc') }}</p>
+                    @foreach(session()->get('import_errors') ?? [] as $error)
+                        <p class="mb-none text-neg">{{ $error }}</p>
+                    @endforeach
+                    <hr class="mt-m">
+                </div>
+            @endif
+
             <div class="mb-m">
             <div class="mb-m">
-                <label class="setting-list-label mb-m">Import Details</label>
+                <label class="setting-list-label mb-m">{{ trans('entities.import_details') }}</label>
                 <div class="flex-container-row justify-space-between wrap">
                     <div>
                         @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
                 <div class="flex-container-row justify-space-between wrap">
                     <div>
                         @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
                 @if($import->type === 'page' || $import->type === 'chapter')
                     <hr>
                     <label class="setting-list-label">{{ trans('entities.import_location') }}</label>
                 @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>
+                    <p class="small mb-s">{{ trans('entities.import_location_desc') }}</p>
+                    @if($errors->has('parent'))
+                        <div class="mb-s">
+                            @include('form.errors', ['name' => 'parent'])
+                        </div>
+                    @endif
                     @include('entities.selector', [
                         'name' => 'parent',
                         'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book',
                         'entityPermission' => "{$import->type}-create",
                         'selectorSize' => 'compact small',
                     ])
                     @include('entities.selector', [
                         'name' => 'parent',
                         'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book',
                         'entityPermission' => "{$import->type}-create",
                         'selectorSize' => 'compact small',
                     ])
-                    @include('form.errors', ['name' => 'parent'])
                 @endif
                 @endif
-            </form>
 
 
-            <div class="text-right">
-                <a href="{{ url('/import') }}" class="button outline">{{ trans('common.cancel') }}</a>
-                <div component="dropdown" class="inline block mx-s">
-                    <button refs="dropdown@toggle"
-                            type="button"
-                            title="{{ trans('common.delete') }}"
-                            class="button outline">{{ trans('common.delete') }}</button>
-                    <div refs="dropdown@menu" class="dropdown-menu">
-                        <p class="text-neg bold small px-m mb-xs">{{ trans('entities.import_delete_confirm') }}</p>
-                        <p class="small px-m mb-xs">{{ trans('entities.import_delete_desc') }}</p>
-                        <button type="submit" form="import-delete-form" class="text-link small text-item">{{ trans('common.confirm') }}</button>
+                <div class="text-right">
+                    <a href="{{ url('/import') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <div component="dropdown" class="inline block mx-s">
+                        <button refs="dropdown@toggle"
+                                type="button"
+                                title="{{ trans('common.delete') }}"
+                                class="button outline">{{ trans('common.delete') }}</button>
+                        <div refs="dropdown@menu" class="dropdown-menu">
+                            <p class="text-neg bold small px-m mb-xs">{{ trans('entities.import_delete_confirm') }}</p>
+                            <p class="small px-m mb-xs">{{ trans('entities.import_delete_desc') }}</p>
+                            <button type="submit" form="import-delete-form" class="text-link small text-item">{{ trans('common.confirm') }}</button>
+                        </div>
                     </div>
                     </div>
+                    <button type="submit" class="button">{{ trans('entities.import_run') }}</button>
                 </div>
                 </div>
-                <button type="submit" form="import-run-form" class="button">{{ trans('entities.import_run') }}</button>
-            </div>
+            </form>
         </main>
     </div>
 
         </main>
     </div>
 
index fd53095a4221d08c5af46dff847ae83d68be853f..2f7659c469e39a4ea197ca11893b5bb8ee2cd021 100644 (file)
@@ -4,7 +4,7 @@
            class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}</a>
     </div>
     <div class="px-m py-s flex-container-row gap-m items-center">
            class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}</a>
     </div>
     <div class="px-m py-s flex-container-row gap-m items-center">
-        <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 class="bold opacity-80 text-muted">{{ $import->getSizeString() }}</div>
+        <div class="bold opacity-80 text-muted min-width-xs text-right" title="{{ $import->created_at->toISOString() }}">@icon('time'){{ $import->created_at->diffForHumans() }}</div>
     </div>
 </div>
\ No newline at end of file
     </div>
 </div>
\ No newline at end of file