]> BookStack Code Mirror - bookstack/commitdiff
Added per-item recycle-bin delete and restore
authorDan Brown <redacted>
Mon, 2 Nov 2020 22:47:48 +0000 (22:47 +0000)
committerDan Brown <redacted>
Mon, 2 Nov 2020 22:47:48 +0000 (22:47 +0000)
14 files changed:
app/Entities/Entity.php
app/Entities/Managers/TrashCan.php
app/Entities/Page.php
app/Entities/Repos/PageRepo.php
app/Http/Controllers/PageController.php
app/Http/Controllers/RecycleBinController.php
resources/lang/en/settings.php
resources/sass/_layout.scss
resources/views/partials/entity-display-item.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/deletable-entity-list.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/destroy.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/index.blade.php [moved from resources/views/settings/recycle-bin.blade.php with 76% similarity]
resources/views/settings/recycle-bin/restore.blade.php [new file with mode: 0644]
routes/web.php

index 14328386ccc7b4e02577cb549bab9818d0c2bb70..ed304092919c880a093854bc00e55f23edfb7067 100644 (file)
@@ -287,6 +287,22 @@ class Entity extends Ownable
         return $path;
     }
 
+    /**
+     * Get the parent entity if existing.
+     * This is the "static" parent and does not include dynamic
+     * relations such as shelves to books.
+     */
+    public function getParent(): ?Entity
+    {
+        if ($this->isA('page')) {
+            return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book->withTrashed()->first();
+        }
+        if ($this->isA('chapter')) {
+            return $this->book->withTrashed()->first();
+        }
+        return null;
+    }
+
     /**
      * Rebuild the permissions for this entity.
      */
index aedf4d7afc3c32b487adae6264d3c292f81e4687..f99c62801cab68a2066079205c5234b44a541449 100644 (file)
@@ -180,24 +180,91 @@ class TrashCan
 
     /**
      * Destroy all items that have pending deletions.
+     * @throws Exception
      */
     public function destroyFromAllDeletions(): int
     {
         $deletions = Deletion::all();
         $deleteCount = 0;
         foreach ($deletions as $deletion) {
-            // For each one we load in the relation since it may have already
-            // been deleted as part of another deletion in this loop.
-            $entity = $deletion->deletable()->first();
-            if ($entity) {
-                $count = $this->destroyEntity($deletion->deletable);
-                $deleteCount += $count;
-            }
-            $deletion->delete();
+            $deleteCount += $this->destroyFromDeletion($deletion);
         }
         return $deleteCount;
     }
 
+    /**
+     * Destroy an element from the given deletion model.
+     * @throws Exception
+     */
+    public function destroyFromDeletion(Deletion $deletion): int
+    {
+        // We directly load the deletable element here just to ensure it still
+        // exists in the event it has already been destroyed during this request.
+        $entity = $deletion->deletable()->first();
+        $count = 0;
+        if ($entity) {
+            $count = $this->destroyEntity($deletion->deletable);
+        }
+        $deletion->delete();
+        return $count;
+    }
+
+    /**
+     * Restore the content within the given deletion.
+     * @throws Exception
+     */
+    public function restoreFromDeletion(Deletion $deletion): int
+    {
+        $shouldRestore = true;
+        $restoreCount = 0;
+        $parent = $deletion->deletable->getParent();
+
+        if ($parent && $parent->trashed()) {
+            $shouldRestore = false;
+        }
+
+        if ($shouldRestore) {
+            $restoreCount = $this->restoreEntity($deletion->deletable);
+        }
+
+        $deletion->delete();
+        return $restoreCount;
+    }
+
+    /**
+     * Restore an entity so it is essentially un-deleted.
+     * Deletions on restored child elements will be removed during this restoration.
+     */
+    protected function restoreEntity(Entity $entity): int
+    {
+        $count = 1;
+        $entity->restore();
+
+        if ($entity->isA('chapter') || $entity->isA('book')) {
+            foreach ($entity->pages()->withTrashed()->withCount('deletions')->get() as $page) {
+                if ($page->deletions_count > 0) {
+                    $page->deletions()->delete();
+                }
+
+                $page->restore();
+                $count++;
+            }
+        }
+
+        if ($entity->isA('book')) {
+            foreach ($entity->chapters()->withTrashed()->withCount('deletions')->get() as $chapter) {
+                if ($chapter->deletions_count === 0) {
+                    $chapter->deletions()->delete();
+                }
+
+                $chapter->restore();
+                $count++;
+            }
+        }
+
+        return $count;
+    }
+
     /**
      * Destroy the given entity.
      */
index 32ba2981d807e7a2b2b7a35c7984e523a82448ca..8ad05e7aa7393ee1fcb3dafee9f12bc71a437ffa 100644 (file)
@@ -49,14 +49,6 @@ class Page extends BookChild
         return $array;
     }
 
-    /**
-     * Get the parent item
-     */
-    public function parent(): Entity
-    {
-        return $this->chapter_id ? $this->chapter : $this->book;
-    }
-
     /**
      * Get the chapter that this page is in, If applicable.
      * @return BelongsTo
index 87839192b0623183a8109e03c9d775b46560ac2b..3b9b1f34c9fb50dcfaeaa3699cb9ba7e22600eaa 100644 (file)
@@ -321,7 +321,7 @@ class PageRepo
      */
     public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
     {
-        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
+        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
         if ($parent === null) {
             throw new MoveOperationException('Book or chapter to move page into not found');
         }
@@ -440,8 +440,9 @@ class PageRepo
      */
     protected function getNewPriority(Page $page): int
     {
-        if ($page->parent() instanceof Chapter) {
-            $lastPage = $page->parent()->pages('desc')->first();
+        $parent = $page->getParent();
+        if ($parent instanceof Chapter) {
+            $lastPage = $parent->pages('desc')->first();
             return $lastPage ? $lastPage->priority + 1 : 0;
         }
 
index 57d70fb3247f8177b4879e9f25eecf54dbbfd76d..ee998996f6540933470adfab2d83241f87f28cfd 100644 (file)
@@ -78,7 +78,7 @@ class PageController extends Controller
     public function editDraft(string $bookSlug, int $pageId)
     {
         $draft = $this->pageRepo->getById($pageId);
-        $this->checkOwnablePermission('page-create', $draft->parent());
+        $this->checkOwnablePermission('page-create', $draft->getParent());
         $this->setPageTitle(trans('entities.pages_edit_draft'));
 
         $draftsEnabled = $this->isSignedIn();
@@ -104,7 +104,7 @@ class PageController extends Controller
             'name' => 'required|string|max:255'
         ]);
         $draftPage = $this->pageRepo->getById($pageId);
-        $this->checkOwnablePermission('page-create', $draftPage->parent());
+        $this->checkOwnablePermission('page-create', $draftPage->getParent());
 
         $page = $this->pageRepo->publishDraft($draftPage, $request->all());
         Activity::add($page, 'page_create', $draftPage->book->id);
index 3cbc99df3e3be533be8a4b73ef3be4d4d596a5bb..64459da23d2307c88988a7964bbd764164cb0b4b 100644 (file)
 
 use BookStack\Entities\Deletion;
 use BookStack\Entities\Managers\TrashCan;
-use Illuminate\Http\Request;
 
 class RecycleBinController extends Controller
 {
+
+    protected $recycleBinBaseUrl = '/settings/recycle-bin';
+
+    /**
+     * On each request to a method of this controller check permissions
+     * using a middleware closure.
+     */
+    public function __construct()
+    {
+        // TODO - Check this is enforced.
+        $this->middleware(function ($request, $next) {
+            $this->checkPermission('settings-manage');
+            $this->checkPermission('restrictions-manage-all');
+            return $next($request);
+        });
+        parent::__construct();
+    }
+
+
     /**
      * Show the top-level listing for the recycle bin.
      */
     public function index()
     {
-        $this->checkPermission('settings-manage');
-        $this->checkPermission('restrictions-manage-all');
-
         $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
 
-        return view('settings.recycle-bin', [
+        return view('settings.recycle-bin.index', [
             'deletions' => $deletions,
         ]);
     }
 
+    /**
+     * Show the page to confirm a restore of the deletion of the given id.
+     */
+    public function showRestore(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+
+        return view('settings.recycle-bin.restore', [
+            'deletion' => $deletion,
+        ]);
+    }
+
+    /**
+     * Restore the element attached to the given deletion.
+     * @throws \Exception
+     */
+    public function restore(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        $restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
+
+        $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
+        return redirect($this->recycleBinBaseUrl);
+    }
+
+    /**
+     * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id.
+     */
+    public function showDestroy(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+
+        return view('settings.recycle-bin.destroy', [
+            'deletion' => $deletion,
+        ]);
+    }
+
+    /**
+     * Permanently delete the content associated with the given deletion.
+     * @throws \Exception
+     */
+    public function destroy(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        $deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
+
+        $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+        return redirect($this->recycleBinBaseUrl);
+    }
+
     /**
      * Empty out the recycle bin.
+     * @throws \Exception
      */
     public function empty()
     {
-        $this->checkPermission('settings-manage');
-        $this->checkPermission('restrictions-manage-all');
-
         $deleteCount = (new TrashCan())->destroyFromAllDeletions();
 
-        $this->showSuccessNotification(trans('settings.recycle_bin_empty_notification', ['count' => $deleteCount]));
-        return redirect('/settings/recycle-bin');
+        $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+        return redirect($this->recycleBinBaseUrl);
     }
 }
index 6de6c2f1a9e8bac39ef012412861a5963943ae4c..b9d91e18c1ae49e429c27404526404fd0dc00f36 100755 (executable)
@@ -89,10 +89,18 @@ return [
     'recycle_bin_deleted_item' => 'Deleted Item',
     'recycle_bin_deleted_by' => 'Deleted By',
     'recycle_bin_deleted_at' => 'Deletion Time',
+    'recycle_bin_permanently_delete' => 'Permanently Delete',
+    'recycle_bin_restore' => 'Restore',
     'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
     'recycle_bin_empty' => 'Empty Recycle Bin',
     'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_empty_notification' => 'Deleted :count total items from the recycle bin.',
+    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
+    'recycle_bin_destroy_list' => 'Items to be Destroyed',
+    'recycle_bin_restore_list' => 'Items to be Restored',
+    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
+    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
+    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
     // Audit Log
     'audit' => 'Audit Log',
index 519cb27ad7787f31199276fbe821d87334a76603..c4e412f0e67a982492b61363c05dab4bf27f0471 100644 (file)
@@ -150,22 +150,25 @@ body.flexbox {
 .justify-flex-end {
   justify-content: flex-end;
 }
+.justify-center {
+  justify-content: center;
+}
 
 
 /**
  * Display and float utilities
  */
 .block {
-  display: block;
+  display: block !important;
   position: relative;
 }
 
 .inline {
-  display: inline;
+  display: inline !important;
 }
 
 .block.inline {
-  display: inline-block;
+  display: inline-block !important;
 }
 
 .hidden {
diff --git a/resources/views/partials/entity-display-item.blade.php b/resources/views/partials/entity-display-item.blade.php
new file mode 100644 (file)
index 0000000..d6633ed
--- /dev/null
@@ -0,0 +1,7 @@
+<?php $type = $entity->getType(); ?>
+<div class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item no-hover">
+    <span role="presentation" class="icon text-{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}}">@icon($type)</span>
+    <div class="content">
+        <div class="entity-list-item-name break-text">{{ $entity->name }}</div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/settings/recycle-bin/deletable-entity-list.blade.php b/resources/views/settings/recycle-bin/deletable-entity-list.blade.php
new file mode 100644 (file)
index 0000000..07ad94f
--- /dev/null
@@ -0,0 +1,11 @@
+@include('partials.entity-display-item', ['entity' => $entity])
+@if($entity->isA('book'))
+    @foreach($entity->chapters()->withTrashed()->get() as $chapter)
+        @include('partials.entity-display-item', ['entity' => $chapter])
+    @endforeach
+@endif
+@if($entity->isA('book') || $entity->isA('chapter'))
+    @foreach($entity->pages()->withTrashed()->get() as $page)
+        @include('partials.entity-display-item', ['entity' => $page])
+    @endforeach
+@endif
\ No newline at end of file
diff --git a/resources/views/settings/recycle-bin/destroy.blade.php b/resources/views/settings/recycle-bin/destroy.blade.php
new file mode 100644 (file)
index 0000000..2cc11da
--- /dev/null
@@ -0,0 +1,31 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container small">
+
+        <div class="grid left-focus v-center no-row-gap">
+            <div class="py-m">
+                @include('settings.navbar', ['selected' => 'maintenance'])
+            </div>
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin_permanently_delete') }}</h2>
+            <p class="text-muted">{{ trans('settings.recycle_bin_destroy_confirm') }}</p>
+            <form action="{{ url('/settings/recycle-bin/' . $deletion->id) }}" method="post">
+                {!! method_field('DELETE') !!}
+                {!! csrf_field() !!}
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                <button type="submit" class="button">{{ trans('common.delete_confirm') }}</button>
+            </form>
+
+            @if($deletion->deletable instanceof \BookStack\Entities\Entity)
+                <hr class="mt-m">
+                <h5>{{ trans('settings.recycle_bin_destroy_list') }}</h5>
+                @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+            @endif
+
+        </div>
+
+    </div>
+@stop
similarity index 76%
rename from resources/views/settings/recycle-bin.blade.php
rename to resources/views/settings/recycle-bin/index.blade.php
index 145eb5d3c3b5f0fa8d8943c9c29a631f1ebc384e..6a61ff9fa61100a0cb77d7c0d57f64e0607b796d 100644 (file)
                     <th>{{ trans('settings.recycle_bin_deleted_item') }}</th>
                     <th>{{ trans('settings.recycle_bin_deleted_by') }}</th>
                     <th>{{ trans('settings.recycle_bin_deleted_at') }}</th>
+                    <th></th>
                 </tr>
                 @if(count($deletions) === 0)
                     <tr>
-                        <td colspan="3">
+                        <td colspan="4">
                             <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
                         </td>
                     </tr>
                 @foreach($deletions as $deletion)
                 <tr>
                     <td>
-                        <div class="table-entity-item mb-m">
+                        <div class="table-entity-item">
                             <span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
                             <div class="text-{{ $deletion->deletable->getType() }}">
                                 {{ $deletion->deletable->name }}
                             </div>
                         </div>
+                        @if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter)
+                            <div class="mb-m"></div>
+                        @endif
                         @if($deletion->deletable instanceof \BookStack\Entities\Book)
                             <div class="pl-xl block inline">
                                 <div class="text-chapter">
                         @endif
                     </td>
                     <td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
-                    <td>{{ $deletion->created_at }}</td>
+                    <td width="200">{{ $deletion->created_at }}</td>
+                    <td width="150" class="text-right">
+                        <div component="dropdown" class="dropdown-container">
+                            <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
+                            <ul refs="dropdown@menu" class="dropdown-menu">
+                                <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
+                                <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+                            </ul>
+                        </div>
+                    </td>
                 </tr>
                 @endforeach
             </table>
diff --git a/resources/views/settings/recycle-bin/restore.blade.php b/resources/views/settings/recycle-bin/restore.blade.php
new file mode 100644 (file)
index 0000000..79ccf1b
--- /dev/null
@@ -0,0 +1,33 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container small">
+
+        <div class="grid left-focus v-center no-row-gap">
+            <div class="py-m">
+                @include('settings.navbar', ['selected' => 'maintenance'])
+            </div>
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin_restore') }}</h2>
+            <p class="text-muted">{{ trans('settings.recycle_bin_restore_confirm') }}</p>
+            <form action="{{ url('/settings/recycle-bin/' . $deletion->id . '/restore') }}" method="post">
+                {!! csrf_field() !!}
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                <button type="submit" class="button">{{ trans('settings.recycle_bin_restore') }}</button>
+            </form>
+
+            @if($deletion->deletable instanceof \BookStack\Entities\Entity)
+                <hr class="mt-m">
+                <h5>{{ trans('settings.recycle_bin_restore_list') }}</h5>
+                @if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed())
+                    <p class="text-neg">{{ trans('settings.recycle_bin_restore_deleted_parent') }}</p>
+                @endif
+                @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+            @endif
+
+        </div>
+
+    </div>
+@stop
index 20f6639a57ab3bde7ea0d69a3ba243c1fc5dd92e..b873551050e52e8d93f7a2a7587d593cd85dbc62 100644 (file)
@@ -169,6 +169,10 @@ Route::group(['middleware' => 'auth'], function () {
         // Recycle Bin
         Route::get('/recycle-bin', 'RecycleBinController@index');
         Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
+        Route::get('/recycle-bin/{id}/destroy', 'RecycleBinController@showDestroy');
+        Route::delete('/recycle-bin/{id}', 'RecycleBinController@destroy');
+        Route::get('/recycle-bin/{id}/restore', 'RecycleBinController@showRestore');
+        Route::post('/recycle-bin/{id}/restore', 'RecycleBinController@restore');
 
         // Audit Log
         Route::get('/audit', 'AuditLogController@index');