mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-23 12:20:21 +00:00
Added per-item recycle-bin delete and restore
This commit is contained in:
parent
ff7cbd14fc
commit
9e033709a7
14 changed files with 291 additions and 38 deletions
app
Entities
Http/Controllers
resources
lang/en
sass
views
partials
settings/recycle-bin
routes
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -2,36 +2,103 @@
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
7
resources/views/partials/entity-display-item.blade.php
Normal file
7
resources/views/partials/entity-display-item.blade.php
Normal file
|
@ -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>
|
|
@ -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
|
31
resources/views/settings/recycle-bin/destroy.blade.php
Normal file
31
resources/views/settings/recycle-bin/destroy.blade.php
Normal file
|
@ -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
|
|
@ -44,10 +44,11 @@
|
|||
<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>
|
||||
|
@ -55,12 +56,15 @@
|
|||
@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">
|
||||
|
@ -77,7 +81,16 @@
|
|||
@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>
|
33
resources/views/settings/recycle-bin/restore.blade.php
Normal file
33
resources/views/settings/recycle-bin/restore.blade.php
Normal file
|
@ -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
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Reference in a new issue