use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
-class DeleteRecord extends Model
+class Deletion 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,
/**
* 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');
}
/**
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
+ * @return [string => Entity]
*/
public function all(): array
{
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;
*/
public function softDestroyShelf(Bookshelf $shelf)
{
- DeleteRecord::createForEntity($shelf);
+ Deletion::createForEntity($shelf);
$shelf->delete();
}
*/
public function softDestroyBook(Book $book)
{
- DeleteRecord::createForEntity($book);
+ Deletion::createForEntity($book);
foreach ($book->pages as $page) {
$this->softDestroyPage($page, false);
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
{
if ($recordDelete) {
- DeleteRecord::createForEntity($chapter);
+ Deletion::createForEntity($chapter);
}
if (count($chapter->pages) > 0) {
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
$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.
*/
$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);
/**
* Display the homepage.
- * @return Response
*/
public function index()
{
$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;
namespace BookStack\Http\Controllers;
+use BookStack\Entities\Managers\TrashCan;
use BookStack\Notifications\TestEmail;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
// 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,
+ ]);
}
/**
--- /dev/null
+<?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');
+ }
+}
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-class CreateDeleteRecordsTable extends Migration
+class CreateDeletionsTable extends Migration
{
/**
* Run the migrations.
*/
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);
*/
public function down()
{
- Schema::dropIfExists('delete_records');
+ Schema::dropIfExists('deletions');
}
}
'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',
}
}
-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;
--- /dev/null
+{{--
+$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
@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 }}
</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
--- /dev/null
+@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
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');