]> BookStack Code Mirror - bookstack/commitdiff
Started work on the recycle bin interface
authorDan Brown <redacted>
Sat, 3 Oct 2020 17:44:12 +0000 (18:44 +0100)
committerDan Brown <redacted>
Sat, 3 Oct 2020 17:44:12 +0000 (18:44 +0100)
15 files changed:
app/Entities/Deletion.php [moved from app/Entities/DeleteRecord.php with 80% similarity]
app/Entities/Entity.php
app/Entities/EntityProvider.php
app/Entities/Managers/TrashCan.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/MaintenanceController.php
app/Http/Controllers/RecycleBinController.php [new file with mode: 0644]
database/migrations/2020_09_27_210528_create_deletions_table.php [moved from database/migrations/2020_09_27_210528_create_delete_records_table.php with 80% similarity]
resources/lang/en/settings.php
resources/sass/styles.scss
resources/views/partials/table-user.blade.php [new file with mode: 0644]
resources/views/settings/audit.blade.php
resources/views/settings/maintenance.blade.php
resources/views/settings/recycle-bin.blade.php [new file with mode: 0644]
routes/web.php

similarity index 80%
rename from app/Entities/DeleteRecord.php
rename to app/Entities/Deletion.php
index 84b37f5a3e44cb3ff50e191cbb19e302c6b99ef2..576862caa7b7ea8d8124fb724b1c75a49c0d3a5e 100644 (file)
@@ -5,7 +5,7 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
-class DeleteRecord extends Model
+class Deletion extends Model
 {
 
     /**
@@ -13,21 +13,21 @@ class DeleteRecord extends Model
      */
     public function deletable(): MorphTo
     {
-        return $this->morphTo();
+        return $this->morphTo('deletable')->withTrashed();
     }
 
     /**
      * The the user that performed the deletion.
      */
-    public function deletedBy(): BelongsTo
+    public function deleter(): BelongsTo
     {
-        return $this->belongsTo(User::class);
+        return $this->belongsTo(User::class, 'deleted_by');
     }
 
     /**
      * Create a new deletion record for the provided entity.
      */
-    public static function createForEntity(Entity $entity): DeleteRecord
+    public static function createForEntity(Entity $entity): Deletion
     {
         $record = (new self())->forceFill([
             'deleted_by' => user()->id,
index d1a8664e472d1fc5e0fa1d86ef3990c8efd2a7ce..14328386ccc7b4e02577cb549bab9818d0c2bb70 100644 (file)
@@ -204,9 +204,9 @@ class Entity extends Ownable
     /**
      * Get the related delete records for this entity.
      */
-    public function deleteRecords(): MorphMany
+    public function deletions(): MorphMany
     {
-        return $this->morphMany(DeleteRecord::class, 'deletable');
+        return $this->morphMany(Deletion::class, 'deletable');
     }
 
     /**
index 6bf923b3112aa8e7387fd6eedeb601a601511dad..d28afe6f2c13701e87e898726e29af1e7745bdd8 100644 (file)
@@ -57,6 +57,7 @@ class EntityProvider
     /**
      * Fetch all core entity types as an associated array
      * with their basic names as the keys.
+     * @return [string => Entity]
      */
     public function all(): array
     {
index 9a21f5e2c13d9abd15ab8b2d9fbc1c410010d1c1..280694906f10952b7c296281edd45e74c6fd5e2e 100644 (file)
@@ -3,8 +3,9 @@
 use BookStack\Entities\Book;
 use BookStack\Entities\Bookshelf;
 use BookStack\Entities\Chapter;
-use BookStack\Entities\DeleteRecord;
+use BookStack\Entities\Deletion;
 use BookStack\Entities\Entity;
+use BookStack\Entities\EntityProvider;
 use BookStack\Entities\HasCoverImage;
 use BookStack\Entities\Page;
 use BookStack\Exceptions\NotifyException;
@@ -21,7 +22,7 @@ class TrashCan
      */
     public function softDestroyShelf(Bookshelf $shelf)
     {
-        DeleteRecord::createForEntity($shelf);
+        Deletion::createForEntity($shelf);
         $shelf->delete();
     }
 
@@ -31,7 +32,7 @@ class TrashCan
      */
     public function softDestroyBook(Book $book)
     {
-        DeleteRecord::createForEntity($book);
+        Deletion::createForEntity($book);
 
         foreach ($book->pages as $page) {
             $this->softDestroyPage($page, false);
@@ -51,7 +52,7 @@ class TrashCan
     public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
     {
         if ($recordDelete) {
-            DeleteRecord::createForEntity($chapter);
+            Deletion::createForEntity($chapter);
         }
 
         if (count($chapter->pages) > 0) {
@@ -70,7 +71,7 @@ class TrashCan
     public function softDestroyPage(Page $page, bool $recordDelete = true)
     {
         if ($recordDelete) {
-            DeleteRecord::createForEntity($page);
+            Deletion::createForEntity($page);
         }
 
         // Check if set as custom homepage & remove setting if not used or throw error if active
@@ -151,6 +152,59 @@ class TrashCan
         $page->forceDelete();
     }
 
+    /**
+     * Get the total counts of those that have been trashed
+     * but not yet fully deleted (In recycle bin).
+     */
+    public function getTrashedCounts(): array
+    {
+        $provider = app(EntityProvider::class);
+        $counts = [];
+
+        /** @var Entity $instance */
+        foreach ($provider->all() as $key => $instance) {
+            $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
+        }
+
+        return $counts;
+    }
+
+    /**
+     * Destroy all items that have pending deletions.
+     */
+    public function destroyFromAllDeletions()
+    {
+        $deletions = Deletion::all();
+        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) {
+                $this->destroyEntity($deletion->deletable);
+            }
+            $deletion->delete();
+        }
+    }
+
+    /**
+     * Destroy the given entity.
+     */
+    protected function destroyEntity(Entity $entity)
+    {
+        if ($entity->isA('page')) {
+            return $this->destroyPage($entity);
+        }
+        if ($entity->isA('chapter')) {
+            return $this->destroyChapter($entity);
+        }
+        if ($entity->isA('book')) {
+            return $this->destroyBook($entity);
+        }
+        if ($entity->isA('shelf')) {
+            return $this->destroyShelf($entity);
+        }
+    }
+
     /**
      * Update entity relations to remove or update outstanding connections.
      */
@@ -163,7 +217,7 @@ class TrashCan
         $entity->comments()->delete();
         $entity->jointPermissions()->delete();
         $entity->searchTerms()->delete();
-        $entity->deleteRecords()->delete();
+        $entity->deletions()->delete();
 
         if ($entity instanceof HasCoverImage && $entity->cover) {
             $imageService = app()->make(ImageService::class);
index 3d68b8bcdb35410e96309305460a6d0f67149149..3b8b7c6e2e06dc6d6ccd711e8a3a57d368965852 100644 (file)
@@ -14,7 +14,6 @@ class HomeController extends Controller
 
     /**
      * Display the homepage.
-     * @return Response
      */
     public function index()
     {
@@ -22,9 +21,12 @@ class HomeController extends Controller
         $draftPages = [];
 
         if ($this->isSignedIn()) {
-            $draftPages = Page::visible()->where('draft', '=', true)
+            $draftPages = Page::visible()
+                ->where('draft', '=', true)
                 ->where('created_by', '=', user()->id)
-                ->orderBy('updated_at', 'desc')->take(6)->get();
+                ->orderBy('updated_at', 'desc')
+                ->take(6)
+                ->get();
         }
 
         $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
index 664a896b25e714ca36ab80c45ee724d0374dbf82..0d6265f903092583fcb5219ecc0f76b14e8f1abd 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers;
 
+use BookStack\Entities\Managers\TrashCan;
 use BookStack\Notifications\TestEmail;
 use BookStack\Uploads\ImageService;
 use Illuminate\Http\Request;
@@ -19,7 +20,13 @@ class MaintenanceController extends Controller
         // Get application version
         $version = trim(file_get_contents(base_path('version')));
 
-        return view('settings.maintenance', ['version' => $version]);
+        // Recycle bin details
+        $recycleStats = (new TrashCan())->getTrashedCounts();
+
+        return view('settings.maintenance', [
+            'version' => $version,
+            'recycleStats' => $recycleStats,
+        ]);
     }
 
     /**
diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php
new file mode 100644 (file)
index 0000000..b30eddf
--- /dev/null
@@ -0,0 +1,35 @@
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Deletion;
+use BookStack\Entities\Managers\TrashCan;
+use Illuminate\Http\Request;
+
+class RecycleBinController extends Controller
+{
+    /**
+     * 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', [
+            'deletions' => $deletions,
+        ]);
+    }
+
+    /**
+     * Empty out the recycle bin.
+     */
+    public function empty()
+    {
+        $this->checkPermission('settings-manage');
+        $this->checkPermission('restrictions-manage-all');
+
+        (new TrashCan())->destroyFromAllDeletions();
+        return redirect('/settings/recycle-bin');
+    }
+}
similarity index 80%
rename from database/migrations/2020_09_27_210528_create_delete_records_table.php
rename to database/migrations/2020_09_27_210528_create_deletions_table.php
index cdb18ced6ae5d6c7d9251af2e94fa94585446d30..c38a9357f85fb5e2f649816846ed84ac6cbf449e 100644 (file)
@@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Support\Facades\Schema;
 
-class CreateDeleteRecordsTable extends Migration
+class CreateDeletionsTable extends Migration
 {
     /**
      * Run the migrations.
@@ -13,7 +13,7 @@ class CreateDeleteRecordsTable extends Migration
      */
     public function up()
     {
-        Schema::create('delete_records', function (Blueprint $table) {
+        Schema::create('deletions', function (Blueprint $table) {
             $table->increments('id');
             $table->integer('deleted_by');
             $table->string('deletable_type', 100);
@@ -33,6 +33,6 @@ class CreateDeleteRecordsTable extends Migration
      */
     public function down()
     {
-        Schema::dropIfExists('delete_records');
+        Schema::dropIfExists('deletions');
     }
 }
index e280396a25fb136154aabf2552a364e59fa99476..66a1fe30c73934b9a89f6f71d27e8cd744a9bfc7 100755 (executable)
@@ -80,6 +80,18 @@ return [
     'maint_send_test_email_mail_subject' => 'Test Email',
     'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
     'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
+    'maint_recycle_bin_desc' => 'Items deleted remain in the recycle bin until it is emptied. Open the recycle bin to restore or permanently remove items.',
+    'maint_recycle_bin_open' => 'Open Recycle Bin',
+
+    // Recycle Bin
+    'recycle_bin' => 'Recycle Bin',
+    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
+    'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_deleted_by' => 'Deleted By',
+    'recycle_bin_deleted_at' => 'Deletion Time',
+    '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?',
 
     // Audit Log
     'audit' => 'Audit Log',
index 376541b5dcba5d67b476f269b1c60ab8aab61131..78d94f977f8d0043679557148fc00af97a8cd276 100644 (file)
@@ -290,12 +290,12 @@ $btt-size: 40px;
   }
 }
 
-table a.audit-log-user {
+table.table .table-user-item {
   display: grid;
   grid-template-columns: 42px 1fr;
   align-items: center;
 }
-table a.icon-list-item {
+table.table .table-entity-item {
   display: grid;
   grid-template-columns: 36px 1fr;
   align-items: center;
diff --git a/resources/views/partials/table-user.blade.php b/resources/views/partials/table-user.blade.php
new file mode 100644 (file)
index 0000000..a8f2777
--- /dev/null
@@ -0,0 +1,12 @@
+{{--
+$user - User mode to display, Can be null.
+$user_id - Id of user to show. Must be provided.
+--}}
+@if($user)
+    <a href="{{ $user->getEditUrl() }}" class="table-user-item">
+        <div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
+        <div>{{ $user->name }}</div>
+    </a>
+@else
+    [ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
+@endif
\ No newline at end of file
index 9b97f060da7ca9ce70b32c10819156cff5c6e823..47a2355d1eb13d5d15185353ac2e2f43954c4d32 100644 (file)
             @foreach($activities as $activity)
                 <tr>
                     <td>
-                        @if($activity->user)
-                            <a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
-                                <div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
-                                <div>{{ $activity->user->name }}</div>
-                            </a>
-                        @else
-                            [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
-                        @endif
+                        @include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
                     </td>
                     <td>{{ $activity->key }}</td>
                     <td>
                         @if($activity->entity)
-                            <a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
+                            <a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
                                 <span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
                                 <div class="text-{{ $activity->entity->getType() }}">
                                     {{ $activity->entity->name }}
index 35686ca3307e0f27a268987cc2fbad708d411071..804112c91cb43f5ff65ccff6b2a12cb1892e0c9f 100644 (file)
         </div>
     </div>
 
+    <div class="card content-wrap auto-height pb-xl">
+        <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+        <div class="grid half gap-xl">
+            <div>
+                <p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
+                <div class="grid half no-gap">
+                    <p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
+                    <p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
+                    <p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
+                    <p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
+                </div>
+            </div>
+            <div>
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
+            </div>
+        </div>
+    </div>
+
 </div>
 @stop
diff --git a/resources/views/settings/recycle-bin.blade.php b/resources/views/settings/recycle-bin.blade.php
new file mode 100644 (file)
index 0000000..145eb5d
--- /dev/null
@@ -0,0 +1,90 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container">
+
+        <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') }}</h2>
+
+            <div class="grid half left-focus">
+                <div>
+                    <p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
+                </div>
+                <div class="text-right">
+                    <div component="dropdown" class="dropdown-container">
+                        <button refs="dropdown@toggle"
+                                type="button"
+                                class="button outline">{{ trans('settings.recycle_bin_empty') }} </button>
+                        <div refs="dropdown@menu" class="dropdown-menu">
+                            <p class="text-neg small px-m mb-xs">{{ trans('settings.recycle_bin_empty_confirm') }}</p>
+
+                            <form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
+                                {!! csrf_field() !!}
+                                <button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                            </form>
+                        </div>
+                    </div>
+
+                </div>
+            </div>
+
+
+            <hr class="mt-l mb-s">
+
+            {!! $deletions->links() !!}
+
+            <table class="table">
+                <tr>
+                    <th>{{ trans('settings.recycle_bin_deleted_item') }}</th>
+                    <th>{{ trans('settings.recycle_bin_deleted_by') }}</th>
+                    <th>{{ trans('settings.recycle_bin_deleted_at') }}</th>
+                </tr>
+                @if(count($deletions) === 0)
+                    <tr>
+                        <td colspan="3">
+                            <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
+                        </td>
+                    </tr>
+                @endif
+                @foreach($deletions as $deletion)
+                <tr>
+                    <td>
+                        <div class="table-entity-item mb-m">
+                            <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)
+                            <div class="pl-xl block inline">
+                                <div class="text-chapter">
+                                    @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
+                                </div>
+                            </div>
+                        @endif
+                        @if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter)
+                        <div class="pl-xl block inline">
+                            <div class="text-page">
+                                @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
+                            </div>
+                        </div>
+                        @endif
+                    </td>
+                    <td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
+                    <td>{{ $deletion->created_at }}</td>
+                </tr>
+                @endforeach
+            </table>
+
+            {!! $deletions->links() !!}
+
+        </div>
+
+    </div>
+@stop
index acbcb4e8fd4eb8f8e78002adc51584ec79306f02..20f6639a57ab3bde7ea0d69a3ba243c1fc5dd92e 100644 (file)
@@ -166,6 +166,10 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
         Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
 
+        // Recycle Bin
+        Route::get('/recycle-bin', 'RecycleBinController@index');
+        Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
+
         // Audit Log
         Route::get('/audit', 'AuditLogController@index');