mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-10-22 07:52:19 +03:00
DB: Aligned entity structure to a common table
As per PR #5800 * DB: Planned out new entity table format via migrations * DB: Created entity migration logic Made some other tweaks/fixes while testing. * DB: Added change of entity relation columns to suit new entities table * DB: Got most view queries working for new structure * Entities: Started logic change to new structure Updated base entity class, and worked through BaseRepo. Need to go through other repos next. Removed a couple of redundant interfaces as part of this since we can move the logic onto the shared ContainerData model as needed. * Entities: Been through repos to update for new format * Entities: Updated repos to act on refreshed clones Changes to core entity models are now done on clones to ensure clean state before save, and those clones are returned back if changes are needed after that action. * Entities: Updated model classes & relations for changes * Entities: Changed from *Data to a common "contents" system Added smart loading from builder instances which should hydrate with "contents()" loaded via join, while keeping the core model original. * Entities: Moved entity description/covers to own non-model classes Added back some interfaces. * Entities: Removed use of contents system for data access * Entities: Got most queries back to working order * Entities: Reverted back to data from contents, fixed various issues * Entities: Started addressing issues from tests * Entities: Addressed further tests/issues * Entities: Been through tests to get all passing in dev Fixed issues and needed test changes along the way. * Entities: Addressed phpstan errors * Entities: Reviewed TODO notes * Entities: Ensured book/shelf relation data removed on destroy * Entities: Been through API responses & adjusted field visibility * Entities: Added type index to massively improve query speed
This commit is contained in:
@@ -11,7 +11,6 @@ class MfaSession
|
||||
*/
|
||||
public function isRequiredForUser(User $user): bool
|
||||
{
|
||||
// TODO - Test both these cases
|
||||
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
||||
}
|
||||
|
||||
|
@@ -45,10 +45,8 @@ class UpdateUrlCommand extends Command
|
||||
|
||||
$columnsToUpdateByTable = [
|
||||
'attachments' => ['path'],
|
||||
'pages' => ['html', 'text', 'markdown'],
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'entity_page_data' => ['html', 'text', 'markdown'],
|
||||
'entity_container_data' => ['description_html'],
|
||||
'page_revisions' => ['html', 'text', 'markdown'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
|
@@ -122,9 +122,10 @@ class BookApiController extends ApiController
|
||||
$book = clone $book;
|
||||
$book->unsetRelations()->refresh();
|
||||
|
||||
$book->load(['tags', 'cover']);
|
||||
$book->makeVisible('description_html')
|
||||
->setAttribute('description_html', $book->descriptionHtml());
|
||||
$book->load(['tags']);
|
||||
$book->makeVisible(['cover', 'description_html'])
|
||||
->setAttribute('description_html', $book->descriptionInfo()->getHtml())
|
||||
->setAttribute('cover', $book->coverInfo()->getImage());
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
@@ -116,9 +116,10 @@ class BookshelfApiController extends ApiController
|
||||
$shelf = clone $shelf;
|
||||
$shelf->unsetRelations()->refresh();
|
||||
|
||||
$shelf->load(['tags', 'cover']);
|
||||
$shelf->makeVisible('description_html')
|
||||
->setAttribute('description_html', $shelf->descriptionHtml());
|
||||
$shelf->load(['tags']);
|
||||
$shelf->makeVisible(['cover', 'description_html'])
|
||||
->setAttribute('description_html', $shelf->descriptionInfo()->getHtml())
|
||||
->setAttribute('cover', $shelf->coverInfo()->getImage());
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
@@ -116,6 +116,7 @@ class BookshelfController extends Controller
|
||||
]);
|
||||
|
||||
$sort = $listOptions->getSort();
|
||||
|
||||
$sortedVisibleShelfBooks = $shelf->visibleBooks()
|
||||
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
|
||||
->get()
|
||||
|
@@ -104,7 +104,7 @@ class ChapterApiController extends ApiController
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
|
||||
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
|
||||
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
|
||||
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
|
||||
|
||||
try {
|
||||
@@ -144,7 +144,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$chapter->load(['tags']);
|
||||
$chapter->makeVisible('description_html');
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
|
||||
|
||||
/** @var Book $book */
|
||||
$book = $chapter->book()->first();
|
||||
|
@@ -130,7 +130,7 @@ class ChapterController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
|
||||
$this->chapterRepo->update($chapter, $validated);
|
||||
$chapter = $this->chapterRepo->update($chapter, $validated);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
@@ -120,6 +120,7 @@ class PageController extends Controller
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
|
||||
|
||||
|
20
app/Entities/EntityExistsRule.php
Normal file
20
app/Entities/EntityExistsRule.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Validation\Rules\Exists;
|
||||
|
||||
class EntityExistsRule implements \Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected string $type,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
$existsRule = (new Exists('entities', 'id'))
|
||||
->where('type', $this->type);
|
||||
return $existsRule->__toString();
|
||||
}
|
||||
}
|
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
use BookStack\Sorting\SortRule;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -15,26 +16,25 @@ use Illuminate\Support\Collection;
|
||||
* Class Book.
|
||||
*
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property Image|null $cover
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property ?SortRule $sortRule
|
||||
*/
|
||||
class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
|
||||
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
|
||||
protected $fillable = ['name'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
@@ -44,55 +44,6 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns book cover image, if book cover not exists return default cover image.
|
||||
*/
|
||||
public function getBookCover(int $width = 440, int $height = 250): string
|
||||
{
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cover image of the book.
|
||||
*/
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_book';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Book.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort set assigned to this book, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages within this book.
|
||||
* @return HasMany<Page, $this>
|
||||
@@ -107,7 +58,7 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
*/
|
||||
public function directPages(): HasMany
|
||||
{
|
||||
return $this->pages()->where('chapter_id', '=', '0');
|
||||
return $this->pages()->whereNull('chapter_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +67,8 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
*/
|
||||
public function chapters(): HasMany
|
||||
{
|
||||
return $this->hasMany(Chapter::class);
|
||||
return $this->hasMany(Chapter::class)
|
||||
->where('type', '=', 'chapter');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,4 +89,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
public function defaultTemplate(): EntityDefaultTemplate
|
||||
{
|
||||
return new EntityDefaultTemplate($this);
|
||||
}
|
||||
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
public function coverInfo(): EntityCover
|
||||
{
|
||||
return new EntityCover($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort rule assigned to this container, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -27,13 +26,13 @@ abstract class BookChild extends Entity
|
||||
/**
|
||||
* Change the book that this entity belongs to.
|
||||
*/
|
||||
public function changeBook(int $newBookId): Entity
|
||||
public function changeBook(int $newBookId): self
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->unsetRelation('book');
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
||||
|
@@ -2,34 +2,34 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
|
||||
protected $table = 'bookshelves';
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $fillable = ['name'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
* Should not be used directly since does not take into account permissions.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
* Should not be used directly since it does not take into account permissions.
|
||||
*/
|
||||
public function books()
|
||||
public function books(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
|
||||
->select(['entities.*', 'entity_container_data.*'])
|
||||
->withPivot('order')
|
||||
->orderBy('order', 'asc');
|
||||
}
|
||||
@@ -50,41 +50,6 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns shelf cover image, if cover not exists return default cover image.
|
||||
*/
|
||||
public function getBookCover(int $width = 440, int $height = 250): string
|
||||
{
|
||||
// TODO - Make generic, focused on books right now, Perhaps set-up a better image
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cover image of the shelf.
|
||||
* @return BelongsTo<Image, $this>
|
||||
*/
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_bookshelf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this shelf contains the given book.
|
||||
*/
|
||||
@@ -96,7 +61,7 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
/**
|
||||
* Add a book to the end of this shelf.
|
||||
*/
|
||||
public function appendBook(Book $book)
|
||||
public function appendBook(Book $book): void
|
||||
{
|
||||
if ($this->contains($book)) {
|
||||
return;
|
||||
@@ -106,12 +71,13 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
|
||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible shelf by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
public function coverInfo(): EntityCover
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
return new EntityCover($this);
|
||||
}
|
||||
|
||||
public function cover(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Image::class, 'image_id');
|
||||
}
|
||||
}
|
||||
|
@@ -2,27 +2,25 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class Chapter.
|
||||
*
|
||||
* @property Collection<Page> $pages
|
||||
* @property ?int $default_template_id
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
|
||||
{
|
||||
use HasFactory;
|
||||
use HtmlDescriptionTrait;
|
||||
use ContainerTrait;
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
@@ -50,14 +48,6 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Chapter.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
* @return Collection<Page>
|
||||
@@ -70,4 +60,9 @@ class Chapter extends BookChild implements HtmlDescriptionInterface
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function defaultTemplate(): EntityDefaultTemplate
|
||||
{
|
||||
return new EntityDefaultTemplate($this);
|
||||
}
|
||||
}
|
||||
|
26
app/Entities/Models/ContainerTrait.php
Normal file
26
app/Entities/Models/ContainerTrait.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityHtmlDescription;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* @mixin Entity
|
||||
*/
|
||||
trait ContainerTrait
|
||||
{
|
||||
public function descriptionInfo(): EntityHtmlDescription
|
||||
{
|
||||
return new EntityHtmlDescription($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<EntityContainerData, $this>
|
||||
*/
|
||||
public function relatedData(): HasOne
|
||||
{
|
||||
return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
|
||||
->where('entity_type', '=', $this->getMorphClass());
|
||||
}
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
interface CoverImageInterface
|
||||
{
|
||||
/**
|
||||
* Get the cover image for this item.
|
||||
*/
|
||||
public function cover(): BelongsTo;
|
||||
|
||||
/**
|
||||
* Get the type of the image model that is used when storing a cover image.
|
||||
*/
|
||||
public function coverImageTypeKey(): string;
|
||||
}
|
@@ -28,15 +28,17 @@ use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Class Entity
|
||||
* The base class for book-like items such as pages, chapters & books.
|
||||
* The base class for book-like items such as pages, chapters and books.
|
||||
* This is not a database model in itself but extended.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $type
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property Carbon $created_at
|
||||
@@ -77,6 +79,72 @@ abstract class Entity extends Model implements
|
||||
*/
|
||||
public float $searchFactor = 1.0;
|
||||
|
||||
/**
|
||||
* Set the table to be that used by all entities.
|
||||
*/
|
||||
protected $table = 'entities';
|
||||
|
||||
/**
|
||||
* Set a custom query builder for entities.
|
||||
*/
|
||||
protected static string $builder = EntityQueryBuilder::class;
|
||||
|
||||
public static array $commonFields = [
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'slug',
|
||||
'book_id',
|
||||
'chapter_id',
|
||||
'priority',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'owned_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the save method to also save the contents for convenience.
|
||||
*/
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
/** @var EntityPageData|EntityContainerData $contents */
|
||||
$contents = $this->relatedData()->firstOrNew();
|
||||
$contentFields = $this->getContentsAttributes();
|
||||
|
||||
foreach ($contentFields as $key => $value) {
|
||||
$contents->setAttribute($key, $value);
|
||||
unset($this->attributes[$key]);
|
||||
}
|
||||
|
||||
$this->setAttribute('type', $this->getMorphClass());
|
||||
$result = parent::save($options);
|
||||
$contentsResult = true;
|
||||
|
||||
if ($result && $contents->isDirty()) {
|
||||
$contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
|
||||
$contents->forceFill($contentsFillData);
|
||||
$contentsResult = $contents->save();
|
||||
$this->touch();
|
||||
}
|
||||
|
||||
$this->forceFill($contentFields);
|
||||
|
||||
return $result && $contentsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this item is a container item.
|
||||
*/
|
||||
public function isContainer(): bool
|
||||
{
|
||||
return $this instanceof Bookshelf ||
|
||||
$this instanceof Book ||
|
||||
$this instanceof Chapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
@@ -91,8 +159,8 @@ abstract class Entity extends Model implements
|
||||
public function scopeWithLastView(Builder $query)
|
||||
{
|
||||
$viewedAtQuery = View::query()->select('updated_at')
|
||||
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
|
||||
->where('viewable_type', '=', $this->getMorphClass())
|
||||
->whereColumn('viewable_id', '=', 'entities.id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type')
|
||||
->where('user_id', '=', user()->id)
|
||||
->take(1);
|
||||
|
||||
@@ -102,11 +170,12 @@ abstract class Entity extends Model implements
|
||||
/**
|
||||
* Query scope to get the total view count of the entities.
|
||||
*/
|
||||
public function scopeWithViewCount(Builder $query)
|
||||
public function scopeWithViewCount(Builder $query): void
|
||||
{
|
||||
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
|
||||
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
|
||||
->where('viewable_type', '=', $this->getMorphClass())->take(1);
|
||||
->whereColumn('viewable_id', '=', 'entities.id')
|
||||
->whereColumn('viewable_type', '=', 'entities.type')
|
||||
->take(1);
|
||||
|
||||
$query->addSelect(['view_count' => $viewCountQuery]);
|
||||
}
|
||||
@@ -162,7 +231,8 @@ abstract class Entity extends Model implements
|
||||
*/
|
||||
public function tags(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
|
||||
return $this->morphMany(Tag::class, 'entity')
|
||||
->orderBy('order', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +254,7 @@ abstract class Entity extends Model implements
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this entities restrictions.
|
||||
* Get this entities assigned permissions.
|
||||
*/
|
||||
public function permissions(): MorphMany
|
||||
{
|
||||
@@ -267,7 +337,7 @@ abstract class Entity extends Model implements
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a limited-length version of the entities name.
|
||||
* Gets a limited-length version of the entity name.
|
||||
*/
|
||||
public function getShortName(int $length = 25): string
|
||||
{
|
||||
@@ -377,4 +447,27 @@ abstract class Entity extends Model implements
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
|
||||
*/
|
||||
abstract public function relatedData(): HasOne;
|
||||
|
||||
/**
|
||||
* Get the attributes that are intended for the related contents model.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getContentsAttributes(): array
|
||||
{
|
||||
$contentFields = [];
|
||||
$contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
|
||||
|
||||
foreach ($this->attributes as $key => $value) {
|
||||
if (in_array($key, $contentModel::$fields)) {
|
||||
$contentFields[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $contentFields;
|
||||
}
|
||||
}
|
||||
|
52
app/Entities/Models/EntityContainerData.php
Normal file
52
app/Entities/Models/EntityContainerData.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $image_id
|
||||
* @property ?int $sort_rule_id
|
||||
*/
|
||||
class EntityContainerData extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'entity_id';
|
||||
public $incrementing = false;
|
||||
|
||||
public static array $fields = [
|
||||
'description',
|
||||
'description_html',
|
||||
'default_template_id',
|
||||
'image_id',
|
||||
'sort_rule_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Override the default set keys for save query method to make it work with composite keys.
|
||||
*/
|
||||
public function setKeysForSaveQuery($query): Builder
|
||||
{
|
||||
$query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
|
||||
->where('entity_type', '=', $this->entity_type);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default set keys for a select query method to make it work with composite keys.
|
||||
*/
|
||||
protected function setKeysForSelectQuery($query): Builder
|
||||
{
|
||||
$query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
|
||||
->where('entity_type', '=', $this->entity_type);
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
25
app/Entities/Models/EntityPageData.php
Normal file
25
app/Entities/Models/EntityPageData.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $page_id
|
||||
*/
|
||||
class EntityPageData extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'page_id';
|
||||
public $incrementing = false;
|
||||
|
||||
public static array $fields = [
|
||||
'draft',
|
||||
'template',
|
||||
'revision_count',
|
||||
'editor',
|
||||
'html',
|
||||
'text',
|
||||
'markdown',
|
||||
];
|
||||
}
|
38
app/Entities/Models/EntityQueryBuilder.php
Normal file
38
app/Entities/Models/EntityQueryBuilder.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
|
||||
class EntityQueryBuilder extends Builder
|
||||
{
|
||||
/**
|
||||
* Create a new Eloquent query builder instance.
|
||||
*/
|
||||
public function __construct(QueryBuilder $query)
|
||||
{
|
||||
parent::__construct($query);
|
||||
|
||||
$this->withGlobalScope('entity', new EntityScope());
|
||||
}
|
||||
|
||||
public function withoutGlobalScope($scope): static
|
||||
{
|
||||
// Prevent removal of the entity scope
|
||||
if ($scope === 'entity') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return parent::withoutGlobalScope($scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the default forceDelete method to add type filter onto the query
|
||||
* since it specifically ignores scopes by default.
|
||||
*/
|
||||
public function forceDelete()
|
||||
{
|
||||
return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
|
||||
}
|
||||
}
|
27
app/Entities/Models/EntityScope.php
Normal file
27
app/Entities/Models/EntityScope.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
|
||||
class EntityScope implements Scope
|
||||
{
|
||||
/**
|
||||
* Apply the scope to a given Eloquent query builder.
|
||||
*/
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder = $builder->where('type', '=', $model->getMorphClass());
|
||||
if ($model instanceof Page) {
|
||||
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', 'entities.id');
|
||||
} else {
|
||||
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model) {
|
||||
$join->on('entity_container_data.entity_id', '=', 'entities.id')
|
||||
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
18
app/Entities/Models/HasCoverInterface.php
Normal file
18
app/Entities/Models/HasCoverInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityCover;
|
||||
use BookStack\Uploads\Image;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
interface HasCoverInterface
|
||||
{
|
||||
public function coverInfo(): EntityCover;
|
||||
|
||||
/**
|
||||
* The cover image of this entity.
|
||||
* @return BelongsTo<Image, covariant Entity>
|
||||
*/
|
||||
public function cover(): BelongsTo;
|
||||
}
|
10
app/Entities/Models/HasDefaultTemplateInterface.php
Normal file
10
app/Entities/Models/HasDefaultTemplateInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityDefaultTemplate;
|
||||
|
||||
interface HasDefaultTemplateInterface
|
||||
{
|
||||
public function defaultTemplate(): EntityDefaultTemplate;
|
||||
}
|
10
app/Entities/Models/HasDescriptionInterface.php
Normal file
10
app/Entities/Models/HasDescriptionInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\EntityHtmlDescription;
|
||||
|
||||
interface HasDescriptionInterface
|
||||
{
|
||||
public function descriptionInfo(): EntityHtmlDescription;
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
interface HtmlDescriptionInterface
|
||||
{
|
||||
/**
|
||||
* Get the HTML-based description for this item.
|
||||
* By default, the content should be sanitised unless raw is set to true.
|
||||
*/
|
||||
public function descriptionHtml(bool $raw = false): string;
|
||||
|
||||
/**
|
||||
* Set the HTML-based description for this item.
|
||||
*/
|
||||
public function setDescriptionHtml(string $html, string|null $plaintext = null): void;
|
||||
}
|
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
trait HtmlDescriptionTrait
|
||||
{
|
||||
public function descriptionHtml(bool $raw = false): string
|
||||
{
|
||||
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
|
||||
if ($raw) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function setDescriptionHtml(string $html, string|null $plaintext = null): void
|
||||
{
|
||||
$this->description_html = $html;
|
||||
|
||||
if ($plaintext !== null) {
|
||||
$this->description = $plaintext;
|
||||
}
|
||||
|
||||
if (empty($html) && !empty($plaintext)) {
|
||||
$this->description_html = $this->descriptionHtml();
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Class Page.
|
||||
*
|
||||
* @property EntityPageData $pageData
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
@@ -33,12 +32,10 @@ class Page extends BookChild
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
public string $textField = 'text';
|
||||
public string $htmlField = 'html';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
protected $casts = [
|
||||
'draft' => 'boolean',
|
||||
@@ -57,10 +54,8 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the chapter that this page is in, If applicable.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function chapter()
|
||||
public function chapter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Chapter::class);
|
||||
}
|
||||
@@ -107,10 +102,8 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the attachments assigned to this page.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function attachments()
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
|
||||
}
|
||||
@@ -139,8 +132,16 @@ class Page extends BookChild
|
||||
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
|
||||
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
|
||||
$refreshed->setAttribute('raw_html', $refreshed->html);
|
||||
$refreshed->html = (new PageContent($refreshed))->render();
|
||||
$refreshed->setAttribute('html', (new PageContent($refreshed))->render());
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<EntityPageData, $this>
|
||||
*/
|
||||
public function relatedData(): HasOne
|
||||
{
|
||||
return $this->hasOne(EntityPageData::class, 'page_id', 'id');
|
||||
}
|
||||
}
|
||||
|
@@ -55,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries
|
||||
->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
|
@@ -60,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries
|
||||
return $this->start()->scopes('visible')->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
|
@@ -65,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries
|
||||
->scopes('visible')
|
||||
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'chapters.book_id');
|
||||
->from('entities as books')
|
||||
->where('type', '=', 'book')
|
||||
->whereColumn('books.id', '=', 'entities.book_id');
|
||||
}]));
|
||||
}
|
||||
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
}
|
||||
|
@@ -43,6 +43,17 @@ class EntityQueries
|
||||
return $queries->visibleForList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query of visible entities of the given type,
|
||||
* suitable for using the contents of the items.
|
||||
* @return Builder<Entity>
|
||||
*/
|
||||
public function visibleForContent(string $entityType): Builder
|
||||
{
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
return $queries->visibleForContent();
|
||||
}
|
||||
|
||||
protected function getQueriesForType(string $type): ProvidesEntityQueries
|
||||
{
|
||||
$queries = match ($type) {
|
||||
|
@@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $contentAttributes = [
|
||||
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'created_by', 'updated_by', 'owned_by',
|
||||
];
|
||||
protected static array $listAttributes = [
|
||||
@@ -82,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries
|
||||
->select($this->mergeBookSlugForSelect(static::$listAttributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Page>
|
||||
*/
|
||||
public function visibleForContent(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible');
|
||||
}
|
||||
|
||||
public function visibleForChapterList(int $chapterId): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
@@ -104,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries
|
||||
->where('created_by', '=', user()->id);
|
||||
}
|
||||
|
||||
public function visibleTemplates(): Builder
|
||||
public function visibleTemplates(bool $includeContents = false): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->where('template', '=', true);
|
||||
$base = $includeContents ? $this->visibleWithContents() : $this->visibleForList();
|
||||
return $base->where('template', '=', true);
|
||||
}
|
||||
|
||||
protected function mergeBookSlugForSelect(array $columns): array
|
||||
{
|
||||
return array_merge($columns, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'pages.book_id');
|
||||
->from('entities as books')
|
||||
->where('type', '=', 'book')
|
||||
->whereColumn('books.id', '=', 'entities.book_id');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
@@ -35,4 +35,11 @@ interface ProvidesEntityQueries
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
public function visibleForList(): Builder;
|
||||
|
||||
/**
|
||||
* Start a query for items that are visible, with selection
|
||||
* configured for using the content of the items found.
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
public function visibleForContent(): Builder;
|
||||
}
|
||||
|
@@ -3,13 +3,10 @@
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionTrait;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
@@ -33,17 +30,25 @@ class BaseRepo
|
||||
|
||||
/**
|
||||
* Create a new entity in the system.
|
||||
* @template T of Entity
|
||||
* @param T $entity
|
||||
* @return T
|
||||
*/
|
||||
public function create(Entity $entity, array $input)
|
||||
public function create(Entity $entity, array $input): Entity
|
||||
{
|
||||
$entity = (clone $entity)->refresh();
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->forceFill([
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'owned_by' => user()->id,
|
||||
]);
|
||||
$entity->refreshSlug();
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
@@ -53,24 +58,33 @@ class BaseRepo
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
|
||||
$this->referenceStore->updateForEntity($entity);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given entity.
|
||||
* @template T of Entity
|
||||
* @param T $entity
|
||||
* @return T
|
||||
*/
|
||||
public function update(Entity $entity, array $input)
|
||||
public function update(Entity $entity, array $input): Entity
|
||||
{
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
@@ -84,59 +98,35 @@ class BaseRepo
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given items' cover image, or clear it.
|
||||
* Update the given items' cover image or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$imageType = 'cover_' . $entity->type;
|
||||
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->coverInfo()->setImage($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$entity->cover()->dissociate();
|
||||
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
|
||||
$entity->coverInfo()->setImage(null);
|
||||
$entity->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the default page template used for this item.
|
||||
* Checks that, if changing, the provided value is a valid template and the user
|
||||
* has visibility of the provided page template id.
|
||||
*/
|
||||
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$entity->default_template_id = null;
|
||||
$entity->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$templateExists = $this->pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$entity->default_template_id = $templateExists ? $templateId : null;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the parent of the given entity, if any auto sort actions are set for it.
|
||||
* Sort the parent of the given entity if any auto sort actions are set for it.
|
||||
* Typically ran during create/update/insert events.
|
||||
*/
|
||||
public function sortParent(Entity $entity): void
|
||||
@@ -147,19 +137,22 @@ class BaseRepo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the description of the given entity from input data.
|
||||
*/
|
||||
protected function updateDescription(Entity $entity, array $input): void
|
||||
{
|
||||
if (!($entity instanceof HtmlDescriptionInterface)) {
|
||||
if (!$entity instanceof HasDescriptionInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($input['description_html'])) {
|
||||
$entity->setDescriptionHtml(
|
||||
$entity->descriptionInfo()->set(
|
||||
HtmlDescriptionFilter::filterFromString($input['description_html']),
|
||||
html_entity_decode(strip_tags($input['description_html']))
|
||||
);
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->setDescriptionHtml('', $input['description']);
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -30,19 +30,18 @@ class BookRepo
|
||||
public function create(array $input): Book
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input) {
|
||||
$book = new Book();
|
||||
|
||||
$this->baseRepo->create($book, $input);
|
||||
$book = $this->baseRepo->create(new Book(), $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
|
||||
$book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
|
||||
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
|
||||
$book->sort_rule_id = $defaultBookSortSetting;
|
||||
$book->save();
|
||||
}
|
||||
|
||||
$book->save();
|
||||
|
||||
return $book;
|
||||
}))->run();
|
||||
}
|
||||
@@ -52,28 +51,29 @@ class BookRepo
|
||||
*/
|
||||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
$book = $this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
|
||||
$book->defaultTemplate()->setFromId(intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
$book->save();
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given book's cover image, or clear it.
|
||||
* Update the given book's cover image or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void
|
||||
{
|
||||
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class BookRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Book $book)
|
||||
public function destroy(Book $book): void
|
||||
{
|
||||
$this->trashCan->softDestroyBook($book);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
@@ -25,8 +25,7 @@ class BookshelfRepo
|
||||
public function create(array $input, array $bookIds): Bookshelf
|
||||
{
|
||||
return (new DatabaseTransaction(function () use ($input, $bookIds) {
|
||||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$shelf = $this->baseRepo->create(new Bookshelf(), $input);
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
@@ -39,7 +38,7 @@ class BookshelfRepo
|
||||
*/
|
||||
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
|
||||
{
|
||||
$this->baseRepo->update($shelf, $input);
|
||||
$shelf = $this->baseRepo->update($shelf, $input);
|
||||
|
||||
if (!is_null($bookIds)) {
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
@@ -96,7 +95,7 @@ class BookshelfRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Bookshelf $shelf)
|
||||
public function destroy(Bookshelf $shelf): void
|
||||
{
|
||||
$this->trashCan->softDestroyShelf($shelf);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
|
@@ -33,8 +33,11 @@ class ChapterRepo
|
||||
$chapter = new Chapter();
|
||||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
|
||||
|
||||
$chapter = $this->baseRepo->create($chapter, $input);
|
||||
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
|
||||
|
||||
$chapter->save();
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
@@ -48,12 +51,13 @@ class ChapterRepo
|
||||
*/
|
||||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
$chapter = $this->baseRepo->update($chapter, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
|
||||
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
$chapter->save();
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
@@ -66,7 +70,7 @@ class ChapterRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Chapter $chapter)
|
||||
public function destroy(Chapter $chapter): void
|
||||
{
|
||||
$this->trashCan->softDestroyChapter($chapter);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
@@ -93,7 +97,7 @@ class ChapterRepo
|
||||
}
|
||||
|
||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||
$chapter->changeBook($parent->id);
|
||||
$chapter = $chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
|
@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
|
||||
|
||||
class DeletionRepo
|
||||
{
|
||||
private TrashCan $trashCan;
|
||||
|
||||
public function __construct(TrashCan $trashCan)
|
||||
{
|
||||
$this->trashCan = $trashCan;
|
||||
public function __construct(
|
||||
protected TrashCan $trashCan
|
||||
) {
|
||||
}
|
||||
|
||||
public function restore(int $id): int
|
||||
|
@@ -37,7 +37,7 @@ class PageRepo
|
||||
/**
|
||||
* Get a new draft page belonging to the given parent entity.
|
||||
*/
|
||||
public function getNewDraftPage(Entity $parent)
|
||||
public function getNewDraftPage(Entity $parent): Page
|
||||
{
|
||||
$page = (new Page())->forceFill([
|
||||
'name' => trans('entities.pages_initial_name'),
|
||||
@@ -46,6 +46,9 @@ class PageRepo
|
||||
'updated_by' => user()->id,
|
||||
'draft' => true,
|
||||
'editor' => PageEditorType::getSystemDefault()->value,
|
||||
'html' => '',
|
||||
'markdown' => '',
|
||||
'text' => '',
|
||||
]);
|
||||
|
||||
if ($parent instanceof Chapter) {
|
||||
@@ -55,17 +58,18 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
|
||||
if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
|
||||
if ($defaultTemplate) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
'markdown' => $defaultTemplate->markdown,
|
||||
]);
|
||||
$page->text = (new PageContent($page))->toPlainText();
|
||||
}
|
||||
|
||||
(new DatabaseTransaction(function () use ($page) {
|
||||
$page->save();
|
||||
$page->refresh()->rebuildPermissions();
|
||||
$page->rebuildPermissions();
|
||||
}))->run();
|
||||
|
||||
return $page;
|
||||
@@ -81,7 +85,8 @@ class PageRepo
|
||||
$draft->revision_count = 1;
|
||||
$draft->priority = $this->getNewPriority($draft);
|
||||
$this->updateTemplateStatusAndContentFromInput($draft, $input);
|
||||
$this->baseRepo->update($draft, $input);
|
||||
|
||||
$draft = $this->baseRepo->update($draft, $input);
|
||||
$draft->rebuildPermissions();
|
||||
|
||||
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
|
||||
@@ -112,12 +117,12 @@ class PageRepo
|
||||
public function update(Page $page, array $input): Page
|
||||
{
|
||||
// Hold the old details to compare later
|
||||
$oldHtml = $page->html;
|
||||
$oldName = $page->name;
|
||||
$oldHtml = $page->html;
|
||||
$oldMarkdown = $page->markdown;
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$page = $this->baseRepo->update($page, $input);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
@@ -176,12 +181,12 @@ class PageRepo
|
||||
/**
|
||||
* Save a page update draft.
|
||||
*/
|
||||
public function updatePageDraft(Page $page, array $input)
|
||||
public function updatePageDraft(Page $page, array $input): Page|PageRevision
|
||||
{
|
||||
// If the page itself is a draft simply update that
|
||||
// If the page itself is a draft, simply update that
|
||||
if ($page->draft) {
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$page->fill($input);
|
||||
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
|
||||
$page->save();
|
||||
|
||||
return $page;
|
||||
@@ -209,7 +214,7 @@ class PageRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(Page $page)
|
||||
public function destroy(Page $page): void
|
||||
{
|
||||
$this->trashCan->softDestroyPage($page);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
@@ -279,7 +284,7 @@ class PageRepo
|
||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$page->changeBook($newBookId);
|
||||
$page = $page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
@@ -23,7 +23,7 @@ class RevisionRepo
|
||||
|
||||
/**
|
||||
* Get a user update_draft page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
* Checks for an existing revision before providing a fresh one.
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
@@ -72,7 +72,7 @@ class RevisionRepo
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
protected function deleteOldRevisions(Page $page): void
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
|
@@ -3,13 +3,10 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Sorting\BookSortMap;
|
||||
use BookStack\Sorting\BookSortMapItem;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
@@ -29,7 +26,7 @@ class BookContents
|
||||
{
|
||||
$maxPage = $this->book->pages()
|
||||
->where('draft', '=', false)
|
||||
->where('chapter_id', '=', 0)
|
||||
->whereDoesntHave('chapter')
|
||||
->max('priority');
|
||||
|
||||
$maxChapter = $this->book->chapters()
|
||||
@@ -80,11 +77,11 @@ class BookContents
|
||||
protected function bookChildSortFunc(): callable
|
||||
{
|
||||
return function (Entity $entity) {
|
||||
if (isset($entity['draft']) && $entity['draft']) {
|
||||
if ($entity->getAttribute('draft') ?? false) {
|
||||
return -100;
|
||||
}
|
||||
|
||||
return $entity['priority'] ?? 0;
|
||||
return $entity->getAttribute('priority') ?? 0;
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -6,8 +6,8 @@ use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
@@ -106,8 +106,8 @@ class Cloner
|
||||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity instanceof CoverImageInterface) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($entity instanceof HasCoverInterface) {
|
||||
$cover = $entity->coverInfo()->getImage();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
|
75
app/Entities/Tools/EntityCover.php
Normal file
75
app/Entities/Tools/EntityCover.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityCover
|
||||
{
|
||||
public function __construct(
|
||||
protected Book|Bookshelf $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function imageQuery(): Builder
|
||||
{
|
||||
return Image::query()->where('id', '=', $this->entity->image_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cover image exists for this entity.
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->entity->image_id !== null && $this->imageQuery()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assigned cover image model.
|
||||
*/
|
||||
public function getImage(): Image|null
|
||||
{
|
||||
if ($this->entity->image_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cover = $this->imageQuery()->first();
|
||||
if ($cover instanceof Image) {
|
||||
return $cover;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cover image URL, or the given default if none assigned/existing.
|
||||
*/
|
||||
public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
|
||||
{
|
||||
if (!$this->entity->image_id) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getImage()?->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the image to use as the cover for this entity.
|
||||
*/
|
||||
public function setImage(Image|null $image): void
|
||||
{
|
||||
if ($image === null) {
|
||||
$this->entity->image_id = null;
|
||||
} else {
|
||||
$this->entity->image_id = $image->id;
|
||||
}
|
||||
}
|
||||
}
|
60
app/Entities/Tools/EntityDefaultTemplate.php
Normal file
60
app/Entities/Tools/EntityDefaultTemplate.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
|
||||
class EntityDefaultTemplate
|
||||
{
|
||||
public function __construct(
|
||||
protected Book|Chapter $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default template ID for this entity.
|
||||
*/
|
||||
public function setFromId(int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($this->entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$this->entity->default_template_id = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$pageQueries = app()->make(PageQueries::class);
|
||||
$templateExists = $pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$this->entity->default_template_id = $templateExists ? $templateId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default template for this entity (if visible).
|
||||
*/
|
||||
public function get(): Page|null
|
||||
{
|
||||
if (!$this->entity->default_template_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pageQueries = app()->make(PageQueries::class);
|
||||
$page = $pageQueries->visibleTemplates(true)
|
||||
->where('id', '=', $this->entity->default_template_id)
|
||||
->first();
|
||||
|
||||
if ($page instanceof Page) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
60
app/Entities/Tools/EntityHtmlDescription.php
Normal file
60
app/Entities/Tools/EntityHtmlDescription.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
protected string $html = '';
|
||||
protected string $plain = '';
|
||||
|
||||
public function __construct(
|
||||
protected Book|Chapter|Bookshelf $entity,
|
||||
) {
|
||||
$this->html = $this->entity->description_html ?? '';
|
||||
$this->plain = $this->entity->description ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the description from HTML code.
|
||||
* Optionally takes plaintext to use for the model also.
|
||||
*/
|
||||
public function set(string $html, string|null $plaintext = null): void
|
||||
{
|
||||
$this->html = $html;
|
||||
$this->entity->description_html = $this->html;
|
||||
|
||||
if ($plaintext !== null) {
|
||||
$this->plain = $plaintext;
|
||||
$this->entity->description = $this->plain;
|
||||
}
|
||||
|
||||
if (empty($html) && !empty($plaintext)) {
|
||||
$this->html = $this->getHtml();
|
||||
$this->entity->description_html = $this->html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description as HTML.
|
||||
* Optionally returns the raw HTML if requested.
|
||||
*/
|
||||
public function getHtml(bool $raw = false): string
|
||||
{
|
||||
$html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
|
||||
if ($raw) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
{
|
||||
return $this->plain;
|
||||
}
|
||||
}
|
@@ -34,6 +34,7 @@ class HierarchyTransformer
|
||||
/** @var Page $page */
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->save();
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
|
@@ -19,7 +19,7 @@ class MixedEntityListLoader
|
||||
* This will look for a model id and type via 'name_id' and 'name_type'.
|
||||
* @param Model[] $relations
|
||||
*/
|
||||
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
|
||||
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
|
||||
{
|
||||
$idsByType = [];
|
||||
foreach ($relations as $relation) {
|
||||
@@ -33,7 +33,7 @@ class MixedEntityListLoader
|
||||
$idsByType[$type][] = $id;
|
||||
}
|
||||
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$type = $relation->getAttribute($relationName . '_type');
|
||||
@@ -49,13 +49,13 @@ class MixedEntityListLoader
|
||||
* @param array<string, int[]> $idsByType
|
||||
* @return array<string, array<int, Model>>
|
||||
*/
|
||||
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
|
||||
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
|
||||
{
|
||||
$modelMap = [];
|
||||
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$models = $this->queries->visibleForList($type)
|
||||
->whereIn('id', $ids)
|
||||
$base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type);
|
||||
$models = $base->whereIn('id', $ids)
|
||||
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
|
||||
->get();
|
||||
|
||||
|
@@ -284,7 +284,7 @@ class PageContent
|
||||
/**
|
||||
* Get a plain-text visualisation of this page.
|
||||
*/
|
||||
protected function toPlainText(): string
|
||||
public function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
|
||||
|
@@ -6,9 +6,10 @@ use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\CoverImageInterface;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
@@ -140,6 +141,7 @@ class TrashCan
|
||||
protected function destroyShelf(Bookshelf $shelf): int
|
||||
{
|
||||
$this->destroyCommonRelations($shelf);
|
||||
$shelf->books()->detach();
|
||||
$shelf->forceDelete();
|
||||
|
||||
return 1;
|
||||
@@ -167,6 +169,7 @@ class TrashCan
|
||||
}
|
||||
|
||||
$this->destroyCommonRelations($book);
|
||||
$book->shelves()->detach();
|
||||
$book->forceDelete();
|
||||
|
||||
return $count + 1;
|
||||
@@ -209,15 +212,19 @@ class TrashCan
|
||||
$attachmentService->deleteFile($attachment);
|
||||
}
|
||||
|
||||
// Remove book template usages
|
||||
$this->queries->books->start()
|
||||
// Remove use as a template
|
||||
EntityContainerData::query()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
|
||||
// Remove chapter template usages
|
||||
$this->queries->chapters->start()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
// TODO - Handle related images (uploaded_to for gallery/drawings).
|
||||
// Should maybe reset to null
|
||||
// But does that present visibility/permission issues if they used to retain their old
|
||||
// unused ID?
|
||||
// If so, might be better to leave them as-is like before, but ensure the maintenance
|
||||
// cleanup command/action can find these "orphaned" images and delete them.
|
||||
// But that would leave potential attachment to new pages on increment reset scenarios.
|
||||
// Need to review permission scenarios for null field values relative to storage options.
|
||||
|
||||
$page->forceDelete();
|
||||
|
||||
@@ -398,9 +405,11 @@ class TrashCan
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
|
||||
if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
|
||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
$imageService->destroy($entity->cover()->first());
|
||||
$imageService->destroy($entity->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
$entity->relatedData()->delete();
|
||||
}
|
||||
}
|
||||
|
@@ -284,7 +284,7 @@ class ExportFormatter
|
||||
public function bookToPlainText(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = $book->name . "\n" . $book->description;
|
||||
$text = $book->name . "\n" . $book->descriptionInfo()->getPlain();
|
||||
$text = rtrim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
@@ -318,7 +318,7 @@ class ExportFormatter
|
||||
{
|
||||
$text = '# ' . $chapter->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
@@ -338,7 +338,7 @@ class ExportFormatter
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
|
||||
$description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
@@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->description_html = $model->descriptionInfo()->getHtml();
|
||||
|
||||
if ($model->cover) {
|
||||
$instance->cover = $files->referenceForImage($model->cover);
|
||||
if ($model->coverInfo()->exists()) {
|
||||
$instance->cover = $files->referenceForImage($model->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
@@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->description_html = $model->descriptionInfo()->getHtml();
|
||||
$instance->priority = $model->priority;
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
|
@@ -135,8 +135,8 @@ class ZipImportRunner
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
]);
|
||||
|
||||
if ($book->cover) {
|
||||
$this->references->addImage($book->cover, null);
|
||||
if ($book->coverInfo()->getImage()) {
|
||||
$this->references->addImage($book->coverInfo()->getImage(), null);
|
||||
}
|
||||
|
||||
$children = [
|
||||
@@ -197,8 +197,8 @@ class ZipImportRunner
|
||||
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown,
|
||||
'html' => $exportPage->html,
|
||||
'markdown' => $exportPage->markdown ?? '',
|
||||
'html' => $exportPage->html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
]);
|
||||
|
||||
|
@@ -40,10 +40,6 @@ class PermissionApplicator
|
||||
$ownerField = $ownable->getOwnerFieldName();
|
||||
$ownableFieldVal = $ownable->getAttribute($ownerField);
|
||||
|
||||
if (is_null($ownableFieldVal)) {
|
||||
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
|
||||
}
|
||||
|
||||
$isOwner = $user->id === $ownableFieldVal;
|
||||
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
|
||||
|
||||
@@ -144,10 +140,10 @@ class PermissionApplicator
|
||||
/** @var Builder $query */
|
||||
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
|
||||
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
$query->select('page_id')->from('entity_page_data')
|
||||
->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
|
||||
->where('pages.draft', '=', false);
|
||||
->where('entity_page_data.draft', '=', false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -197,17 +193,17 @@ class PermissionApplicator
|
||||
{
|
||||
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
|
||||
return $this->restrictEntityQuery($query)
|
||||
->where(function ($query) use ($fullPageIdColumn) {
|
||||
/** @var Builder $query */
|
||||
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where('pages.draft', '=', false);
|
||||
})->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where('pages.draft', '=', true)
|
||||
->where('pages.created_by', '=', $this->currentUser()->id);
|
||||
->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('entities')
|
||||
->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
|
||||
->whereColumn('entities.id', '=', $fullPageIdColumn)
|
||||
->where('entities.type', '=', 'page')
|
||||
->where(function (QueryBuilder $query) {
|
||||
$query->where('entity_page_data.draft', '=', false)
|
||||
->orWhere(function (QueryBuilder $query) {
|
||||
$query->where('entity_page_data.draft', '=', true)
|
||||
->where('entities.created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -20,10 +20,10 @@ class ReferenceFetcher
|
||||
* Query and return the references pointing to the given entity.
|
||||
* Loads the commonly required relations while taking permissions into account.
|
||||
*/
|
||||
public function getReferencesToEntity(Entity $entity): Collection
|
||||
public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection
|
||||
{
|
||||
$references = $this->queryReferencesToEntity($entity)->get();
|
||||
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true);
|
||||
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents);
|
||||
|
||||
return $references;
|
||||
}
|
||||
|
@@ -3,9 +3,9 @@
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HtmlDescriptionInterface;
|
||||
use BookStack\Entities\Models\HtmlDescriptionTrait;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\RevisionRepo;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
@@ -36,7 +36,7 @@ class ReferenceUpdater
|
||||
protected function getReferencesToUpdate(Entity $entity): array
|
||||
{
|
||||
/** @var Reference[] $references */
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all();
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$pages = $entity->pages()->get(['id']);
|
||||
@@ -44,7 +44,7 @@ class ReferenceUpdater
|
||||
$children = $pages->concat($chapters);
|
||||
foreach ($children as $bookChild) {
|
||||
/** @var Reference[] $childRefs */
|
||||
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
|
||||
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all();
|
||||
array_push($references, ...$childRefs);
|
||||
}
|
||||
}
|
||||
@@ -64,16 +64,16 @@ class ReferenceUpdater
|
||||
$this->updateReferencesWithinPage($entity, $oldLink, $newLink);
|
||||
}
|
||||
|
||||
if ($entity instanceof HtmlDescriptionInterface) {
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void
|
||||
protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void
|
||||
{
|
||||
$entity = (clone $entity)->refresh();
|
||||
$html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink);
|
||||
$entity->setDescriptionHtml($html);
|
||||
$description = $entity->descriptionInfo();
|
||||
$html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink);
|
||||
$description->set($html);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
|
@@ -33,22 +33,22 @@ class BookSorter
|
||||
*/
|
||||
public function runBookAutoSort(Book $book): void
|
||||
{
|
||||
$set = $book->sortRule;
|
||||
if (!$set) {
|
||||
$rule = $book->sortRule()->first();
|
||||
if (!($rule instanceof SortRule)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sortFunctions = array_map(function (SortRuleOperation $op) {
|
||||
return $op->getSortFunction();
|
||||
}, $set->getOperations());
|
||||
}, $rule->getOperations());
|
||||
|
||||
$chapters = $book->chapters()
|
||||
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
|
||||
->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at')
|
||||
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
|
||||
|
||||
/** @var (Chapter|Book)[] $topItems */
|
||||
$topItems = [
|
||||
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
|
||||
...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
|
||||
...$chapters,
|
||||
];
|
||||
|
||||
@@ -155,11 +155,12 @@ class BookSorter
|
||||
|
||||
// Action the required changes
|
||||
if ($bookChanged) {
|
||||
$model->changeBook($newBook->id);
|
||||
$model = $model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
$model->unsetRelation('chapter');
|
||||
}
|
||||
|
||||
if ($priorityChanged) {
|
||||
|
@@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable
|
||||
|
||||
public function books(): HasMany
|
||||
{
|
||||
return $this->hasMany(Book::class);
|
||||
return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id');
|
||||
}
|
||||
|
||||
public static function allByName(): Collection
|
||||
|
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -88,7 +89,9 @@ class SortRuleController extends Controller
|
||||
|
||||
if ($booksAssigned > 0) {
|
||||
if ($confirmed) {
|
||||
$rule->books()->update(['sort_rule_id' => null]);
|
||||
EntityContainerData::query()
|
||||
->where('sort_rule_id', $rule->id)
|
||||
->update(['sort_rule_id' => null]);
|
||||
} else {
|
||||
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads\Controllers;
|
||||
|
||||
use BookStack\Entities\EntityExistsRule;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -173,13 +174,13 @@ class AttachmentApiController extends ApiController
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
|
||||
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
|
||||
'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'uploaded_to' => ['integer', 'exists:pages,id'],
|
||||
'uploaded_to' => ['integer', new EntityExistsRule('page')],
|
||||
'file' => $this->attachmentService->getFileValidationRules(),
|
||||
'link' => ['string', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads\Controllers;
|
||||
|
||||
use BookStack\Entities\EntityExistsRule;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
@@ -34,7 +35,7 @@ class AttachmentController extends Controller
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
|
||||
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
|
||||
]);
|
||||
|
||||
@@ -144,7 +145,7 @@ class AttachmentController extends Controller
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
|
||||
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
|
||||
]);
|
||||
|
@@ -184,7 +184,7 @@ class ImageService
|
||||
/** @var Image $image */
|
||||
foreach ($images as $image) {
|
||||
$searchQuery = '%' . basename($image->path) . '%';
|
||||
$inPage = DB::table('pages')
|
||||
$inPage = DB::table('entity_page_data')
|
||||
->where('html', 'like', $searchQuery)->count() > 0;
|
||||
|
||||
$inRevision = false;
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Entities\EntityExistsRule;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
@@ -6,12 +6,14 @@ use BookStack\Access\UserInviteException;
|
||||
use BookStack\Access\UserInviteService;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
use DB;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -181,6 +183,7 @@ class UserRepo
|
||||
if (!is_null($newOwner)) {
|
||||
$this->migrateOwnership($user, $newOwner);
|
||||
}
|
||||
// TODO - Should be be nullifying ownership instead?
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::USER_DELETE, $user);
|
||||
@@ -203,14 +206,12 @@ class UserRepo
|
||||
/**
|
||||
* Migrate ownership of items in the system from one user to another.
|
||||
*/
|
||||
protected function migrateOwnership(User $fromUser, User $toUser)
|
||||
protected function migrateOwnership(User $fromUser, User $toUser): void
|
||||
{
|
||||
$entities = (new EntityProvider())->all();
|
||||
foreach ($entities as $instance) {
|
||||
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
|
||||
DB::table('entities')
|
||||
->where('owned_by', '=', $fromUser->id)
|
||||
->update(['owned_by' => $toUser->id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an avatar image for a user and set it as their avatar.
|
||||
|
@@ -26,7 +26,8 @@ class ChapterFactory extends Factory
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'description' => $description,
|
||||
'description_html' => '<p>' . e($description) . '</p>'
|
||||
'description_html' => '<p>' . e($description) . '</p>',
|
||||
'priority' => 5,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -31,6 +31,7 @@ class PageFactory extends Factory
|
||||
'text' => strip_tags($html),
|
||||
'revision_count' => 1,
|
||||
'editor' => 'wysiwyg',
|
||||
'priority' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -25,9 +25,6 @@ return new class extends Migration
|
||||
$table->unsignedInteger('owner_id')->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild permissions
|
||||
app(JointPermissionBuilder::class)->rebuildForAll();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entities', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('type', 10)->index();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
|
||||
$table->unsignedBigInteger('book_id')->nullable()->index();
|
||||
$table->unsignedBigInteger('chapter_id')->nullable()->index();
|
||||
$table->unsignedInteger('priority')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->timestamp('updated_at')->nullable()->index();
|
||||
$table->timestamp('deleted_at')->nullable()->index();
|
||||
|
||||
$table->unsignedInteger('created_by')->nullable();
|
||||
$table->unsignedInteger('updated_by')->nullable();
|
||||
$table->unsignedInteger('owned_by')->nullable()->index();
|
||||
|
||||
$table->primary(['id', 'type'], 'entities_pk');
|
||||
});
|
||||
|
||||
Schema::create('entity_container_data', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('entity_id');
|
||||
$table->string('entity_type', 10);
|
||||
$table->text('description');
|
||||
$table->text('description_html');
|
||||
|
||||
$table->unsignedBigInteger('default_template_id')->nullable();
|
||||
$table->unsignedInteger('image_id')->nullable();
|
||||
$table->unsignedInteger('sort_rule_id')->nullable();
|
||||
|
||||
$table->primary(['entity_id', 'entity_type'], 'entity_container_data_pk');
|
||||
});
|
||||
|
||||
Schema::create('entity_page_data', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('page_id')->primary();
|
||||
|
||||
$table->boolean('draft')->index();
|
||||
$table->boolean('template')->index();
|
||||
$table->unsignedInteger('revision_count');
|
||||
$table->string('editor', 50);
|
||||
|
||||
$table->longText('html');
|
||||
$table->longText('text');
|
||||
$table->longText('markdown');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('entities');
|
||||
Schema::dropIfExists('entity_container_data');
|
||||
Schema::dropIfExists('entity_page_data');
|
||||
}
|
||||
};
|
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Start a transaction to avoid leaving a message DB on error
|
||||
DB::beginTransaction();
|
||||
|
||||
// Migrate book/shelf data to entities
|
||||
foreach (['books' => 'book', 'bookshelves' => 'bookshelf'] as $table => $type) {
|
||||
DB::table('entities')->insertUsing([
|
||||
'id', 'type', 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
|
||||
], DB::table($table)->select([
|
||||
'id', DB::raw("'{$type}'"), 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
|
||||
]));
|
||||
}
|
||||
|
||||
// Migrate chapter data to entities
|
||||
DB::table('entities')->insertUsing([
|
||||
'id', 'type', 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
|
||||
], DB::table('chapters')->select([
|
||||
'id', DB::raw("'chapter'"), 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
|
||||
]));
|
||||
|
||||
DB::table('entities')->insertUsing([
|
||||
'id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
|
||||
], DB::table('pages')->select([
|
||||
'id', DB::raw("'page'"), 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
|
||||
]));
|
||||
|
||||
// Migrate shelf data to entity_container_data
|
||||
DB::table('entity_container_data')->insertUsing([
|
||||
'entity_id', 'entity_type', 'description', 'description_html', 'image_id',
|
||||
], DB::table('bookshelves')->select([
|
||||
'id', DB::raw("'bookshelf'"), 'description', 'description_html', 'image_id',
|
||||
]));
|
||||
|
||||
// Migrate book data to entity_container_data
|
||||
DB::table('entity_container_data')->insertUsing([
|
||||
'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id'
|
||||
], DB::table('books')->select([
|
||||
'id', DB::raw("'book'"), 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id'
|
||||
]));
|
||||
|
||||
// Migrate chapter data to entity_container_data
|
||||
DB::table('entity_container_data')->insertUsing([
|
||||
'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id',
|
||||
], DB::table('chapters')->select([
|
||||
'id', DB::raw("'chapter'"), 'description', 'description_html', 'default_template_id',
|
||||
]));
|
||||
|
||||
// Migrate page data to entity_page_data
|
||||
DB::table('entity_page_data')->insertUsing([
|
||||
'page_id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown',
|
||||
], DB::table('pages')->select([
|
||||
'id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown',
|
||||
]));
|
||||
|
||||
// Fix up data - Convert 0 id references to null
|
||||
DB::table('entities')->where('created_by', '=', 0)->update(['created_by' => null]);
|
||||
DB::table('entities')->where('updated_by', '=', 0)->update(['updated_by' => null]);
|
||||
DB::table('entities')->where('owned_by', '=', 0)->update(['owned_by' => null]);
|
||||
DB::table('entities')->where('chapter_id', '=', 0)->update(['chapter_id' => null]);
|
||||
|
||||
// Fix up data - Convert any missing id-based references to null
|
||||
$userIdQuery = DB::table('users')->select('id');
|
||||
DB::table('entities')->whereNotIn('created_by', $userIdQuery)->update(['created_by' => null]);
|
||||
DB::table('entities')->whereNotIn('updated_by', $userIdQuery)->update(['updated_by' => null]);
|
||||
DB::table('entities')->whereNotIn('owned_by', $userIdQuery)->update(['owned_by' => null]);
|
||||
DB::table('entities')->whereNotIn('chapter_id', DB::table('chapters')->select('id'))->update(['chapter_id' => null]);
|
||||
|
||||
// Commit our changes within our transaction
|
||||
DB::commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No action here since the actual data remains in the database for the old tables,
|
||||
// so data reversion actions are done in a later migration when the old tables are dropped.
|
||||
}
|
||||
};
|
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use BookStack\Permissions\JointPermissionBuilder;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* @var array<string, string|array<string>> $columnByTable
|
||||
*/
|
||||
protected static array $columnByTable = [
|
||||
'activities' => 'loggable_id',
|
||||
'attachments' => 'uploaded_to',
|
||||
'bookshelves_books' => ['bookshelf_id', 'book_id'],
|
||||
'comments' => 'entity_id',
|
||||
'deletions' => 'deletable_id',
|
||||
'entity_permissions' => 'entity_id',
|
||||
'favourites' => 'favouritable_id',
|
||||
'images' => 'uploaded_to',
|
||||
'joint_permissions' => 'entity_id',
|
||||
'page_revisions' => 'page_id',
|
||||
'references' => ['from_id', 'to_id'],
|
||||
'search_terms' => 'entity_id',
|
||||
'tags' => 'entity_id',
|
||||
'views' => 'viewable_id',
|
||||
'watches' => 'watchable_id',
|
||||
];
|
||||
|
||||
protected static array $nullable = [
|
||||
'activities.loggable_id',
|
||||
'images.uploaded_to',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Drop foreign key constraints
|
||||
Schema::table('bookshelves_books', function (Blueprint $table) {
|
||||
$table->dropForeign(['book_id']);
|
||||
$table->dropForeign(['bookshelf_id']);
|
||||
});
|
||||
|
||||
// Update column types to unsigned big integers
|
||||
foreach (static::$columnByTable as $table => $column) {
|
||||
$tableName = $table;
|
||||
Schema::table($table, function (Blueprint $table) use ($tableName, $column) {
|
||||
if (is_string($column)) {
|
||||
$column = [$column];
|
||||
}
|
||||
|
||||
foreach ($column as $col) {
|
||||
if (in_array($tableName . '.' . $col, static::$nullable)) {
|
||||
$table->unsignedBigInteger($col)->nullable()->change();
|
||||
} else {
|
||||
$table->unsignedBigInteger($col)->change();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert image zero values to null
|
||||
DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]);
|
||||
|
||||
// Rebuild joint permissions if needed
|
||||
// This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes
|
||||
// made for this release would mean our current logic would not be compatible with
|
||||
// the database changes being made. This is based on a count since any joint permissions
|
||||
// would have been truncated in the previous migration.
|
||||
if (\Illuminate\Support\Facades\DB::table('joint_permissions')->count() === 0) {
|
||||
app(JointPermissionBuilder::class)->rebuildForAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Convert image null values back to zeros
|
||||
DB::table('images')->whereNull('uploaded_to')->update(['uploaded_to' => '0']);
|
||||
|
||||
// Revert columns to standard integers
|
||||
foreach (static::$columnByTable as $table => $column) {
|
||||
$tableName = $table;
|
||||
Schema::table($table, function (Blueprint $table) use ($tableName, $column) {
|
||||
if (is_string($column)) {
|
||||
$column = [$column];
|
||||
}
|
||||
|
||||
foreach ($column as $col) {
|
||||
if ($tableName . '.' . $col === 'activities.loggable_id') {
|
||||
$table->unsignedInteger($col)->nullable()->change();
|
||||
} else if ($tableName . '.' . $col === 'images.uploaded_to') {
|
||||
$table->unsignedInteger($col)->default(0)->change();
|
||||
} else {
|
||||
$table->unsignedInteger($col)->change();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-add foreign key constraints
|
||||
Schema::table('bookshelves_books', function (Blueprint $table) {
|
||||
$table->foreign('bookshelf_id')->references('id')->on('bookshelves')
|
||||
->onUpdate('cascade')->onDelete('cascade');
|
||||
$table->foreign('book_id')->references('id')->on('books')
|
||||
->onUpdate('cascade')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
};
|
162
database/migrations/2025_09_15_134813_drop_old_entity_tables.php
Normal file
162
database/migrations/2025_09_15_134813_drop_old_entity_tables.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('pages');
|
||||
Schema::dropIfExists('chapters');
|
||||
Schema::dropIfExists('books');
|
||||
Schema::dropIfExists('bookshelves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('pages', function (Blueprint $table) {
|
||||
$table->unsignedInteger('id', true)->primary();
|
||||
$table->integer('book_id')->index();
|
||||
$table->integer('chapter_id')->index();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->longText('html');
|
||||
$table->longText('text');
|
||||
$table->integer('priority')->index();
|
||||
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->timestamp('updated_at')->nullable()->index();
|
||||
$table->integer('created_by')->index();
|
||||
$table->integer('updated_by')->index();
|
||||
|
||||
$table->boolean('draft')->default(0)->index();
|
||||
$table->longText('markdown');
|
||||
$table->integer('revision_count');
|
||||
$table->boolean('template')->default(0)->index();
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
|
||||
$table->unsignedInteger('owned_by')->index();
|
||||
$table->string('editor', 50)->default('');
|
||||
});
|
||||
|
||||
Schema::create('chapters', function (Blueprint $table) {
|
||||
$table->unsignedInteger('id', true)->primary();
|
||||
$table->integer('book_id')->index();
|
||||
$table->string('slug')->index();
|
||||
$table->text('name');
|
||||
$table->text('description');
|
||||
$table->integer('priority')->index();
|
||||
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
$table->integer('created_by')->index();
|
||||
$table->integer('updated_by')->index();
|
||||
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
$table->unsignedInteger('owned_by')->index();
|
||||
$table->text('description_html');
|
||||
$table->integer('default_template_id')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('books', function (Blueprint $table) {
|
||||
$table->unsignedInteger('id', true)->primary();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->text('description');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
|
||||
$table->integer('created_by')->index();
|
||||
$table->integer('updated_by')->index();
|
||||
|
||||
$table->integer('image_id')->nullable();
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
$table->unsignedInteger('owned_by')->index();
|
||||
|
||||
$table->integer('default_template_id')->nullable();
|
||||
$table->text('description_html');
|
||||
$table->unsignedInteger('sort_rule_id')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('bookshelves', function (Blueprint $table) {
|
||||
$table->unsignedInteger('id', true)->primary();
|
||||
$table->string('name', 180);
|
||||
$table->string('slug', 180)->index();
|
||||
$table->text('description');
|
||||
|
||||
$table->integer('created_by')->index();
|
||||
$table->integer('updated_by')->index();
|
||||
$table->integer('image_id')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->nullable();
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
|
||||
$table->unsignedInteger('owned_by')->index();
|
||||
$table->text('description_html');
|
||||
});
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
// Revert nulls back to zeros
|
||||
DB::table('entities')->whereNull('created_by')->update(['created_by' => 0]);
|
||||
DB::table('entities')->whereNull('updated_by')->update(['updated_by' => 0]);
|
||||
DB::table('entities')->whereNull('owned_by')->update(['owned_by' => 0]);
|
||||
DB::table('entities')->whereNull('chapter_id')->update(['chapter_id' => 0]);
|
||||
|
||||
// Restore data back into pages table
|
||||
$pageFields = [
|
||||
'id', 'book_id', 'chapter_id', 'name', 'slug', 'html', 'text', 'priority', 'created_at', 'updated_at',
|
||||
'created_by', 'updated_by', 'draft', 'markdown', 'revision_count', 'template', 'deleted_at', 'owned_by', 'editor'
|
||||
];
|
||||
$pageQuery = DB::table('entities')->select($pageFields)
|
||||
->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
|
||||
->where('type', '=', 'page');
|
||||
DB::table('pages')->insertUsing($pageFields, $pageQuery);
|
||||
|
||||
// Restore data back into chapters table
|
||||
$containerJoinClause = function (JoinClause $join) {
|
||||
return $join->on('entities.id', '=', 'entity_container_data.entity_id')
|
||||
->on('entities.type', '=', 'entity_container_data.entity_type');
|
||||
};
|
||||
$chapterFields = [
|
||||
'id', 'book_id', 'slug', 'name', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by',
|
||||
'deleted_at', 'owned_by', 'description_html', 'default_template_id'
|
||||
];
|
||||
$chapterQuery = DB::table('entities')->select($chapterFields)
|
||||
->leftJoin('entity_container_data', $containerJoinClause)
|
||||
->where('type', '=', 'chapter');
|
||||
DB::table('chapters')->insertUsing($chapterFields, $chapterQuery);
|
||||
|
||||
// Restore data back into books table
|
||||
$bookFields = [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id',
|
||||
'deleted_at', 'owned_by', 'default_template_id', 'description_html', 'sort_rule_id'
|
||||
];
|
||||
$bookQuery = DB::table('entities')->select($bookFields)
|
||||
->leftJoin('entity_container_data', $containerJoinClause)
|
||||
->where('type', '=', 'book');
|
||||
DB::table('books')->insertUsing($bookFields, $bookQuery);
|
||||
|
||||
// Restore data back into bookshelves table
|
||||
$shelfFields = [
|
||||
'id', 'name', 'slug', 'description', 'created_by', 'updated_by', 'image_id', 'created_at', 'updated_at',
|
||||
'deleted_at', 'owned_by', 'description_html',
|
||||
];
|
||||
$shelfQuery = DB::table('entities')->select($shelfFields)
|
||||
->leftJoin('entity_container_data', $containerJoinClause)
|
||||
->where('type', '=', 'bookshelf');
|
||||
DB::table('bookshelves')->insertUsing($shelfFields, $shelfQuery);
|
||||
|
||||
DB::commit();
|
||||
}
|
||||
};
|
@@ -12,7 +12,10 @@ use BookStack\Permissions\Models\RolePermission;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -39,25 +42,32 @@ class DummyContentSeeder extends Seeder
|
||||
|
||||
$byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
|
||||
|
||||
Book::factory()->count(5)->create($byData)
|
||||
Book::factory()->count(5)->make($byData)
|
||||
->each(function ($book) use ($byData) {
|
||||
$book->save();
|
||||
$chapters = Chapter::factory()->count(3)->create($byData)
|
||||
->each(function ($chapter) use ($book, $byData) {
|
||||
$pages = Page::factory()->count(3)->make(array_merge($byData, ['book_id' => $book->id]));
|
||||
$chapter->pages()->saveMany($pages);
|
||||
$this->saveManyOnRelation($pages, $chapter->pages());
|
||||
});
|
||||
$pages = Page::factory()->count(3)->make($byData);
|
||||
$book->chapters()->saveMany($chapters);
|
||||
$book->pages()->saveMany($pages);
|
||||
$this->saveManyOnRelation($chapters, $book->chapters());
|
||||
$this->saveManyOnRelation($pages, $book->pages());
|
||||
});
|
||||
|
||||
$largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
|
||||
$largeBook = Book::factory()->make(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
|
||||
$largeBook->save();
|
||||
|
||||
$pages = Page::factory()->count(200)->make($byData);
|
||||
$chapters = Chapter::factory()->count(50)->make($byData);
|
||||
$largeBook->pages()->saveMany($pages);
|
||||
$largeBook->chapters()->saveMany($chapters);
|
||||
$this->saveManyOnRelation($pages, $largeBook->pages());
|
||||
$this->saveManyOnRelation($chapters, $largeBook->chapters());
|
||||
|
||||
$shelves = Bookshelf::factory()->count(10)->make($byData);
|
||||
foreach ($shelves as $shelf) {
|
||||
$shelf->save();
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::factory()->count(10)->create($byData);
|
||||
$largeBook->shelves()->attach($shelves->pluck('id'));
|
||||
|
||||
// Assign API permission to editor role and create an API key
|
||||
@@ -75,4 +85,15 @@ class DummyContentSeeder extends Seeder
|
||||
app(JointPermissionBuilder::class)->rebuildForAll();
|
||||
app(SearchIndex::class)->indexAllEntities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inefficient workaround for saving many on a relation since we can't directly insert
|
||||
* entities since we split them across tables.
|
||||
*/
|
||||
protected function saveManyOnRelation(Collection $entities, HasMany $relation): void
|
||||
{
|
||||
foreach ($entities as $entity) {
|
||||
$relation->save($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -52,7 +52,7 @@
|
||||
"name": "Cool Animals",
|
||||
"slug": "cool-animals",
|
||||
"book_id": 16,
|
||||
"chapter_id": 0,
|
||||
"chapter_id": null,
|
||||
"draft": false,
|
||||
"template": false,
|
||||
"created_at": "2021-12-19T18:22:11.000000Z",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": 358,
|
||||
"book_id": 1,
|
||||
"chapter_id": 0,
|
||||
"chapter_id": null,
|
||||
"name": "My API Page",
|
||||
"slug": "my-api-page",
|
||||
"html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": 306,
|
||||
"book_id": 1,
|
||||
"chapter_id": 0,
|
||||
"chapter_id": null,
|
||||
"name": "A page written in markdown",
|
||||
"slug": "a-page-written-in-markdown",
|
||||
"html": "<h1 id=\"bkmrk-this-is-my-cool-page\">This is my cool page! With some included text</h1>",
|
||||
|
@@ -10,7 +10,7 @@
|
||||
"deletable": {
|
||||
"id": 2582,
|
||||
"book_id": 25,
|
||||
"chapter_id": 0,
|
||||
"chapter_id": null,
|
||||
"name": "A Wonderful Page",
|
||||
"slug": "a-wonderful-page",
|
||||
"priority": 9,
|
||||
|
@@ -18,7 +18,7 @@
|
||||
|
||||
@include('form.image-picker', [
|
||||
'defaultImage' => url('/book_default_cover.png'),
|
||||
'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') ,
|
||||
'currentImage' => (($model ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')),
|
||||
'name' => 'image',
|
||||
'imageClass' => 'cover'
|
||||
])
|
||||
|
@@ -1,11 +1,16 @@
|
||||
@php
|
||||
/**
|
||||
* @var \BookStack\Entities\Models\Book $book
|
||||
*/
|
||||
@endphp
|
||||
<a href="{{ $book->getUrl() }}" class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
|
||||
<div class="entity-list-item-image bg-book" style="background-image: url('{{ $book->getBookCover() }}')">
|
||||
<div class="entity-list-item-image bg-book" style="background-image: url('{{ $book->coverInfo()->getUrl() }}')">
|
||||
@icon('book')
|
||||
</div>
|
||||
<div class="content">
|
||||
<h4 class="entity-list-item-name break-text">{{ $book->name }}</h4>
|
||||
<div class="entity-item-snippet">
|
||||
<p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->description }}</p>
|
||||
<p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->descriptionInfo()->getPlain() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
@@ -8,8 +8,8 @@
|
||||
|
||||
@push('social-meta')
|
||||
<meta property="og:description" content="{{ Str::limit($book->description, 100, '...') }}">
|
||||
@if($book->cover)
|
||||
<meta property="og:image" content="{{ $book->getBookCover() }}">
|
||||
@if($book->coverInfo()->exists())
|
||||
<meta property="og:image" content="{{ $book->coverInfo()->getUrl() }}">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<main class="content-wrap card">
|
||||
<h1 class="break-text">{{$book->name}}</h1>
|
||||
<div refs="entity-search@contentView" class="book-content">
|
||||
<div class="text-muted break-text">{!! $book->descriptionHtml() !!}</div>
|
||||
<div class="text-muted break-text">{!! $book->descriptionInfo()->getHtml() !!}</div>
|
||||
@if(count($bookChildren) > 0)
|
||||
<div class="entity-list book-contents">
|
||||
@foreach($bookChildren as $childElement)
|
||||
|
@@ -24,7 +24,7 @@
|
||||
<main class="content-wrap card">
|
||||
<h1 class="break-text">{{ $chapter->name }}</h1>
|
||||
<div refs="entity-search@contentView" class="chapter-content">
|
||||
<div class="text-muted break-text">{!! $chapter->descriptionHtml() !!}</div>
|
||||
<div class="text-muted break-text">{!! $chapter->descriptionInfo()->getHtml() !!}</div>
|
||||
@if(count($pages) > 0)
|
||||
<div class="entity-list book-contents">
|
||||
@foreach($pages as $page)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<a href="{{ $entity->getUrl() }}" class="grid-card"
|
||||
data-entity-type="{{ $entity->getType() }}" data-entity-id="{{ $entity->id }}">
|
||||
<div class="bg-{{ $entity->getType() }} featured-image-container-wrap">
|
||||
<div class="featured-image-container" @if($entity->cover) style="background-image: url('{{ $entity->getBookCover() }}')"@endif>
|
||||
<div class="featured-image-container" @if($entity->coverInfo()->exists()) style="background-image: url('{{ $entity->coverInfo()->getUrl() }}')"@endif>
|
||||
</div>
|
||||
@icon($entity->getType())
|
||||
</div>
|
||||
|
@@ -5,7 +5,7 @@
|
||||
@section('content')
|
||||
|
||||
<h1 style="font-size: 4.8em">{{$book->name}}</h1>
|
||||
<div>{!! $book->descriptionHtml() !!}</div>
|
||||
<div>{!! $book->descriptionInfo()->getHtml() !!}</div>
|
||||
|
||||
@include('exports.parts.book-contents-menu', ['children' => $bookChildren])
|
||||
|
||||
|
@@ -5,7 +5,7 @@
|
||||
@section('content')
|
||||
|
||||
<h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
|
||||
<div>{!! $chapter->descriptionHtml() !!}</div>
|
||||
<div>{!! $chapter->descriptionInfo()->getHtml() !!}</div>
|
||||
|
||||
@include('exports.parts.chapter-contents-menu', ['pages' => $pages])
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<div class="page-break"></div>
|
||||
<h1 id="chapter-{{$chapter->id}}">{{ $chapter->name }}</h1>
|
||||
|
||||
<div>{!! $chapter->descriptionHtml() !!}</div>
|
||||
<div>{!! $chapter->descriptionInfo()->getHtml() !!}</div>
|
||||
|
||||
@if(count($chapter->visible_pages) > 0)
|
||||
@foreach($chapter->visible_pages as $page)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<textarea component="wysiwyg-input"
|
||||
option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
|
||||
id="description_html" name="description_html" rows="5"
|
||||
@if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionHtml()}}@endif</textarea>
|
||||
@if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionInfo()->getHtml() }}@endif</textarea>
|
||||
@if($errors->has('description_html'))
|
||||
<div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
|
||||
@endif
|
@@ -26,9 +26,12 @@
|
||||
@icon('more')
|
||||
</button>
|
||||
<div refs="dropdown@menu shelf-sort@sort-button-container" class="dropdown-menu" role="menu">
|
||||
<button type="button" class="text-item" data-sort="name">{{ trans('entities.books_sort_name') }}</button>
|
||||
<button type="button" class="text-item" data-sort="created">{{ trans('entities.books_sort_created') }}</button>
|
||||
<button type="button" class="text-item" data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
|
||||
<button type="button" class="text-item"
|
||||
data-sort="name">{{ trans('entities.books_sort_name') }}</button>
|
||||
<button type="button" class="text-item"
|
||||
data-sort="created">{{ trans('entities.books_sort_created') }}</button>
|
||||
<button type="button" class="text-item"
|
||||
data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +45,8 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="books" id="shelf-sort-all-books-label">{{ trans('entities.shelves_add_books') }}</label>
|
||||
<input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
|
||||
<input type="text" refs="shelf-sort@book-search" class="scroll-box-search"
|
||||
placeholder="{{ trans('common.search') }}">
|
||||
<ul refs="shelf-sort@all-book-list"
|
||||
aria-labelledby="shelf-sort-all-books-label"
|
||||
class="scroll-box available-option-list">
|
||||
@@ -54,7 +58,6 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="form-group collapsible" component="collapsible" id="logo-control">
|
||||
<button refs="collapsible@trigger" type="button" class="collapse-title text-link" aria-expanded="false">
|
||||
<label>{{ trans('common.cover_image') }}</label>
|
||||
@@ -64,7 +67,7 @@
|
||||
|
||||
@include('form.image-picker', [
|
||||
'defaultImage' => url('/book_default_cover.png'),
|
||||
'currentImage' => (isset($shelf) && $shelf->cover) ? $shelf->getBookCover() : url('/book_default_cover.png') ,
|
||||
'currentImage' => (($shelf ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')),
|
||||
'name' => 'image',
|
||||
'imageClass' => 'cover'
|
||||
])
|
||||
@@ -81,7 +84,8 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group text-right">
|
||||
<a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}"
|
||||
class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('entities.shelves_save') }}</button>
|
||||
</div>
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<a href="{{ $shelf->getUrl() }}" class="shelf entity-list-item" data-entity-type="bookshelf" data-entity-id="{{$shelf->id}}">
|
||||
<div class="entity-list-item-image bg-bookshelf @if($shelf->image_id) has-image @endif" style="background-image: url('{{ $shelf->getBookCover() }}')">
|
||||
<div class="entity-list-item-image bg-bookshelf @if($shelf->coverInfo()->exists()) has-image @endif" style="background-image: url('{{ $shelf->coverInfo()->getUrl() }}')">
|
||||
@icon('bookshelf')
|
||||
</div>
|
||||
<div class="content py-xs">
|
||||
|
@@ -2,8 +2,8 @@
|
||||
|
||||
@push('social-meta')
|
||||
<meta property="og:description" content="{{ Str::limit($shelf->description, 100, '...') }}">
|
||||
@if($shelf->cover)
|
||||
<meta property="og:image" content="{{ $shelf->getBookCover() }}">
|
||||
@if($shelf->coverInfo()->exists())
|
||||
<meta property="og:image" content="{{ $shelf->coverInfo()->getUrl() }}">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<div class="book-content">
|
||||
<div class="text-muted break-text">{!! $shelf->descriptionHtml() !!}</div>
|
||||
<div class="text-muted break-text">{!! $shelf->descriptionInfo()->getHtml() !!}</div>
|
||||
@if(count($sortedVisibleShelfBooks) > 0)
|
||||
@if($view === 'list')
|
||||
<div class="entity-list">
|
||||
|
@@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase
|
||||
{
|
||||
use TestsApi;
|
||||
|
||||
protected $endpoint = '/api/books';
|
||||
protected string $endpoint = '/api/books';
|
||||
|
||||
public function test_requests_succeed_with_default_auth()
|
||||
{
|
||||
|
@@ -47,8 +47,8 @@ class BooksApiTest extends TestCase
|
||||
[
|
||||
'id' => $book->id,
|
||||
'cover' => [
|
||||
'id' => $book->cover->id,
|
||||
'url' => $book->cover->url,
|
||||
'id' => $book->coverInfo()->getImage()->id,
|
||||
'url' => $book->coverInfo()->getImage()->url,
|
||||
],
|
||||
],
|
||||
]]);
|
||||
@@ -94,7 +94,7 @@ class BooksApiTest extends TestCase
|
||||
]);
|
||||
|
||||
$resp->assertJson($expectedDetails);
|
||||
$this->assertDatabaseHas('books', $expectedDetails);
|
||||
$this->assertDatabaseHasEntityData('book', $expectedDetails);
|
||||
}
|
||||
|
||||
public function test_book_name_needed_to_create()
|
||||
@@ -153,23 +153,23 @@ class BooksApiTest extends TestCase
|
||||
$directChildCount = $book->directPages()->count() + $book->chapters()->count();
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJsonCount($directChildCount, 'contents');
|
||||
$resp->assertJson([
|
||||
'contents' => [
|
||||
[
|
||||
'type' => 'chapter',
|
||||
|
||||
$contents = $resp->json('contents');
|
||||
$respChapter = array_values(array_filter($contents, fn ($item) => ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0];
|
||||
$this->assertArrayMapIncludes([
|
||||
'id' => $chapter->id,
|
||||
'type' => 'chapter',
|
||||
'name' => $chapter->name,
|
||||
'slug' => $chapter->slug,
|
||||
'pages' => [
|
||||
[
|
||||
], $respChapter);
|
||||
|
||||
$respPage = array_values(array_filter($respChapter['pages'], fn ($item) => ($item['id'] === $chapterPage->id)))[0];
|
||||
|
||||
$this->assertArrayMapIncludes([
|
||||
'id' => $chapterPage->id,
|
||||
'name' => $chapterPage->name,
|
||||
'slug' => $chapterPage->slug,
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
], $respPage);
|
||||
}
|
||||
|
||||
public function test_read_endpoint_contents_nested_pages_has_permissions_applied()
|
||||
@@ -224,14 +224,14 @@ class BooksApiTest extends TestCase
|
||||
$resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details);
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API']));
|
||||
$this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API']));
|
||||
}
|
||||
|
||||
public function test_update_increments_updated_date_if_only_tags_are_sent()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$book = $this->entities->book();
|
||||
DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
|
||||
$details = [
|
||||
'tags' => [['name' => 'Category', 'value' => 'Testing']],
|
||||
@@ -247,7 +247,7 @@ class BooksApiTest extends TestCase
|
||||
$this->actingAsApiEditor();
|
||||
/** @var Book $book */
|
||||
$book = $this->entities->book();
|
||||
$this->assertNull($book->cover);
|
||||
$this->assertNull($book->coverInfo()->getImage());
|
||||
$file = $this->files->uploadedImage('image.png');
|
||||
|
||||
// Ensure cover image can be set via API
|
||||
@@ -257,7 +257,7 @@ class BooksApiTest extends TestCase
|
||||
$book->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$this->assertNotNull($book->cover);
|
||||
$this->assertNotNull($book->coverInfo()->getImage());
|
||||
|
||||
// Ensure further updates without image do not clear cover image
|
||||
$resp = $this->put($this->baseEndpoint . "/{$book->id}", [
|
||||
@@ -266,7 +266,7 @@ class BooksApiTest extends TestCase
|
||||
$book->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$this->assertNotNull($book->cover);
|
||||
$this->assertNotNull($book->coverInfo()->getImage());
|
||||
|
||||
// Ensure update with null image property clears image
|
||||
$resp = $this->put($this->baseEndpoint . "/{$book->id}", [
|
||||
@@ -275,7 +275,7 @@ class BooksApiTest extends TestCase
|
||||
$book->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$this->assertNull($book->cover);
|
||||
$this->assertNull($book->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
public function test_delete_endpoint()
|
||||
|
@@ -91,7 +91,7 @@ class ChaptersApiTest extends TestCase
|
||||
'description' => 'A chapter created via the API',
|
||||
]);
|
||||
$resp->assertJson($expectedDetails);
|
||||
$this->assertDatabaseHas('chapters', $expectedDetails);
|
||||
$this->assertDatabaseHasEntityData('chapter', $expectedDetails);
|
||||
}
|
||||
|
||||
public function test_chapter_name_needed_to_create()
|
||||
@@ -155,7 +155,7 @@ class ChaptersApiTest extends TestCase
|
||||
'owned_by' => $page->owned_by,
|
||||
'created_by' => $page->created_by,
|
||||
'updated_by' => $page->updated_by,
|
||||
'book_id' => $page->id,
|
||||
'book_id' => $page->book->id,
|
||||
'chapter_id' => $chapter->id,
|
||||
'priority' => $page->priority,
|
||||
'book_slug' => $chapter->book->slug,
|
||||
@@ -213,7 +213,7 @@ class ChaptersApiTest extends TestCase
|
||||
$resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('chapters', array_merge($details, [
|
||||
$this->assertDatabaseHasEntityData('chapter', array_merge($details, [
|
||||
'id' => $chapter->id, 'description' => 'A chapter updated via the API'
|
||||
]));
|
||||
}
|
||||
@@ -222,7 +222,7 @@ class ChaptersApiTest extends TestCase
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$chapter = $this->entities->chapter();
|
||||
DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
$chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
|
||||
$details = [
|
||||
'tags' => [['name' => 'Category', 'value' => 'Testing']],
|
||||
@@ -244,8 +244,8 @@ class ChaptersApiTest extends TestCase
|
||||
$resp->assertOk();
|
||||
$chapter->refresh();
|
||||
|
||||
$this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]);
|
||||
$this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]);
|
||||
$this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]);
|
||||
$this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]);
|
||||
}
|
||||
|
||||
public function test_update_with_new_book_id_requires_delete_permission()
|
||||
|
@@ -280,7 +280,7 @@ class ContentPermissionsApiTest extends TestCase
|
||||
]);
|
||||
|
||||
$resp->assertOk();
|
||||
$this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]);
|
||||
$this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]);
|
||||
$this->assertDatabaseHas('entity_permissions', [
|
||||
'entity_id' => $page->id,
|
||||
'entity_type' => 'page',
|
||||
|
@@ -286,7 +286,7 @@ class PagesApiTest extends TestCase
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$page = $this->entities->page();
|
||||
DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
$page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
|
||||
$details = [
|
||||
'tags' => [['name' => 'Category', 'value' => 'Testing']],
|
||||
|
@@ -144,7 +144,7 @@ class RecycleBinApiTest extends TestCase
|
||||
|
||||
$deletion = Deletion::query()->orderBy('id')->first();
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
$this->assertDatabaseHasEntityData('page', [
|
||||
'id' => $page->id,
|
||||
'deleted_at' => $page->deleted_at,
|
||||
]);
|
||||
@@ -154,7 +154,7 @@ class RecycleBinApiTest extends TestCase
|
||||
'restore_count' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
$this->assertDatabaseHasEntityData('page', [
|
||||
'id' => $page->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
@@ -168,7 +168,7 @@ class RecycleBinApiTest extends TestCase
|
||||
|
||||
$deletion = Deletion::query()->orderBy('id')->first();
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
$this->assertDatabaseHasEntityData('page', [
|
||||
'id' => $page->id,
|
||||
'deleted_at' => $page->deleted_at,
|
||||
]);
|
||||
@@ -178,6 +178,6 @@ class RecycleBinApiTest extends TestCase
|
||||
'delete_count' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
|
||||
$this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
|
||||
}
|
||||
}
|
||||
|
@@ -48,8 +48,8 @@ class ShelvesApiTest extends TestCase
|
||||
[
|
||||
'id' => $shelf->id,
|
||||
'cover' => [
|
||||
'id' => $shelf->cover->id,
|
||||
'url' => $shelf->cover->url,
|
||||
'id' => $shelf->coverInfo()->getImage()->id,
|
||||
'url' => $shelf->coverInfo()->getImage()->url,
|
||||
],
|
||||
],
|
||||
]]);
|
||||
@@ -102,7 +102,7 @@ class ShelvesApiTest extends TestCase
|
||||
]);
|
||||
|
||||
$resp->assertJson($expectedDetails);
|
||||
$this->assertDatabaseHas('bookshelves', $expectedDetails);
|
||||
$this->assertDatabaseHasEntityData('bookshelf', $expectedDetails);
|
||||
}
|
||||
|
||||
public function test_shelf_name_needed_to_create()
|
||||
@@ -181,14 +181,14 @@ class ShelvesApiTest extends TestCase
|
||||
$resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details);
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API']));
|
||||
$this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API']));
|
||||
}
|
||||
|
||||
public function test_update_increments_updated_date_if_only_tags_are_sent()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$shelf = Bookshelf::visible()->first();
|
||||
DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
$shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]);
|
||||
|
||||
$details = [
|
||||
'tags' => [['name' => 'Category', 'value' => 'Testing']],
|
||||
@@ -222,7 +222,7 @@ class ShelvesApiTest extends TestCase
|
||||
$this->actingAsApiEditor();
|
||||
/** @var Book $shelf */
|
||||
$shelf = Bookshelf::visible()->first();
|
||||
$this->assertNull($shelf->cover);
|
||||
$this->assertNull($shelf->coverInfo()->getImage());
|
||||
$file = $this->files->uploadedImage('image.png');
|
||||
|
||||
// Ensure cover image can be set via API
|
||||
@@ -232,7 +232,7 @@ class ShelvesApiTest extends TestCase
|
||||
$shelf->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$this->assertNotNull($shelf->cover);
|
||||
$this->assertNotNull($shelf->coverInfo()->getImage());
|
||||
|
||||
// Ensure further updates without image do not clear cover image
|
||||
$resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [
|
||||
@@ -241,7 +241,7 @@ class ShelvesApiTest extends TestCase
|
||||
$shelf->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$this->assertNotNull($shelf->cover);
|
||||
$this->assertNotNull($shelf->coverInfo()->getImage());
|
||||
|
||||
// Ensure update with null image property clears image
|
||||
$resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [
|
||||
@@ -250,7 +250,7 @@ class ShelvesApiTest extends TestCase
|
||||
$shelf->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$this->assertNull($shelf->cover);
|
||||
$this->assertNull($shelf->coverInfo()->getImage());
|
||||
}
|
||||
|
||||
public function test_delete_endpoint()
|
||||
|
@@ -6,6 +6,7 @@ use BookStack\Access\Mfa\MfaValue;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Users\Models\Role;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -166,6 +167,36 @@ class MfaConfigurationTest extends TestCase
|
||||
$this->assertEquals(0, $admin->mfaValues()->count());
|
||||
}
|
||||
|
||||
public function test_mfa_required_if_set_on_role()
|
||||
{
|
||||
$user = $this->users->viewer();
|
||||
$user->password = Hash::make('password');
|
||||
$user->save();
|
||||
/** @var Role $role */
|
||||
$role = $user->roles()->first();
|
||||
$role->mfa_enforced = true;
|
||||
$role->save();
|
||||
|
||||
$resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']);
|
||||
$this->assertFalse(auth()->check());
|
||||
$resp->assertRedirect('/mfa/verify');
|
||||
}
|
||||
|
||||
public function test_mfa_required_if_mfa_option_configured()
|
||||
{
|
||||
$user = $this->users->viewer();
|
||||
$user->password = Hash::make('password');
|
||||
$user->save();
|
||||
$user->mfaValues()->create([
|
||||
'method' => MfaValue::METHOD_TOTP,
|
||||
'value' => 'test',
|
||||
]);
|
||||
|
||||
$resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']);
|
||||
$this->assertFalse(auth()->check());
|
||||
$resp->assertRedirect('/mfa/verify');
|
||||
}
|
||||
|
||||
public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login()
|
||||
{
|
||||
$admin = $this->users->admin();
|
||||
|
@@ -19,7 +19,7 @@ class UpdateUrlCommandTest extends TestCase
|
||||
->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y')
|
||||
->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
$this->assertDatabaseHasEntityData('page', [
|
||||
'id' => $page->id,
|
||||
'html' => '<a href="https://cats.example.com/donkeys"></a>',
|
||||
]);
|
||||
@@ -40,7 +40,7 @@ class UpdateUrlCommandTest extends TestCase
|
||||
->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
|
||||
|
||||
foreach ($models as $model) {
|
||||
$this->assertDatabaseHas($model->getTable(), [
|
||||
$this->assertDatabaseHasEntityData($model->getMorphClass(), [
|
||||
'id' => $model->id,
|
||||
'description_html' => '<a href="https://cats.example.com/donkeys"></a>',
|
||||
]);
|
||||
|
@@ -91,7 +91,7 @@ class BookShelfTest extends TestCase
|
||||
]));
|
||||
$resp->assertRedirect();
|
||||
$editorId = $this->users->editor()->id;
|
||||
$this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
|
||||
$this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
|
||||
|
||||
$shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
|
||||
$shelfPage = $this->get($shelf->getUrl());
|
||||
@@ -117,11 +117,12 @@ class BookShelfTest extends TestCase
|
||||
|
||||
$lastImage = Image::query()->orderByDesc('id')->firstOrFail();
|
||||
$shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first();
|
||||
$this->assertDatabaseHas('bookshelves', [
|
||||
'id' => $shelf->id,
|
||||
$this->assertDatabaseHas('entity_container_data', [
|
||||
'entity_id' => $shelf->id,
|
||||
'entity_type' => 'bookshelf',
|
||||
'image_id' => $lastImage->id,
|
||||
]);
|
||||
$this->assertEquals($lastImage->id, $shelf->cover->id);
|
||||
$this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id);
|
||||
$this->assertEquals('cover_bookshelf', $lastImage->type);
|
||||
}
|
||||
|
||||
@@ -247,7 +248,7 @@ class BookShelfTest extends TestCase
|
||||
$this->assertSessionHas('success');
|
||||
|
||||
$editorId = $this->users->editor()->id;
|
||||
$this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
|
||||
$this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
|
||||
|
||||
$shelfPage = $this->get($shelf->getUrl());
|
||||
$shelfPage->assertSee($shelfInfo['name']);
|
||||
|
@@ -27,7 +27,7 @@ class BookTest extends TestCase
|
||||
|
||||
$resp = $this->get('/books/my-first-book');
|
||||
$resp->assertSee($book->name);
|
||||
$resp->assertSee($book->description);
|
||||
$resp->assertSee($book->descriptionInfo()->getPlain());
|
||||
}
|
||||
|
||||
public function test_create_uses_different_slugs_when_name_reused()
|
||||
@@ -362,12 +362,12 @@ class BookTest extends TestCase
|
||||
$coverImageFile = $this->files->uploadedImage('cover.png');
|
||||
$bookRepo->updateCoverImage($book, $coverImageFile);
|
||||
|
||||
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
|
||||
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect();
|
||||
/** @var Book $copy */
|
||||
$copy = Book::query()->where('name', '=', 'My copy book')->first();
|
||||
|
||||
$this->assertNotNull($copy->cover);
|
||||
$this->assertNotEquals($book->cover->id, $copy->cover->id);
|
||||
$this->assertNotNull($copy->coverInfo()->getImage());
|
||||
$this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id);
|
||||
}
|
||||
|
||||
public function test_copy_adds_book_to_shelves_if_edit_permissions_allows()
|
||||
|
@@ -35,8 +35,8 @@ class ConvertTest extends TestCase
|
||||
/** @var Book $newBook */
|
||||
$newBook = Book::query()->orderBy('id', 'desc')->first();
|
||||
|
||||
$this->assertDatabaseMissing('chapters', ['id' => $chapter->id]);
|
||||
$this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]);
|
||||
$this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']);
|
||||
$this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]);
|
||||
$this->assertCount(1, $newBook->tags);
|
||||
$this->assertEquals('Category', $newBook->tags->first()->name);
|
||||
$this->assertEquals('Penguins', $newBook->tags->first()->value);
|
||||
@@ -100,7 +100,7 @@ class ConvertTest extends TestCase
|
||||
|
||||
// Checks for new shelf
|
||||
$resp->assertRedirectContains('/shelves/');
|
||||
$this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]);
|
||||
$this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']);
|
||||
$this->assertCount(1, $newShelf->tags);
|
||||
$this->assertEquals('Category', $newShelf->tags->first()->name);
|
||||
$this->assertEquals('Ducks', $newShelf->tags->first()->value);
|
||||
@@ -112,8 +112,8 @@ class ConvertTest extends TestCase
|
||||
$this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf);
|
||||
|
||||
// Checks for old book to contain child pages
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']);
|
||||
$this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]);
|
||||
$this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']);
|
||||
$this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]);
|
||||
|
||||
// Checks for nested page
|
||||
$chapterChildPage->refresh();
|
||||
|
@@ -18,7 +18,7 @@ class DefaultTemplateTest extends TestCase
|
||||
];
|
||||
|
||||
$this->asEditor()->post('/books', $details);
|
||||
$this->assertDatabaseHas('books', $details);
|
||||
$this->assertDatabaseHasEntityData('book', $details);
|
||||
}
|
||||
|
||||
public function test_creating_chapter_with_default_template()
|
||||
@@ -31,7 +31,7 @@ class DefaultTemplateTest extends TestCase
|
||||
];
|
||||
|
||||
$this->asEditor()->post($book->getUrl('/create-chapter'), $details);
|
||||
$this->assertDatabaseHas('chapters', $details);
|
||||
$this->assertDatabaseHasEntityData('chapter', $details);
|
||||
}
|
||||
|
||||
public function test_updating_book_with_default_template()
|
||||
@@ -40,10 +40,10 @@ class DefaultTemplateTest extends TestCase
|
||||
$templatePage = $this->entities->templatePage();
|
||||
|
||||
$this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
|
||||
|
||||
$this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
|
||||
$this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_updating_chapter_with_default_template()
|
||||
@@ -52,10 +52,10 @@ class DefaultTemplateTest extends TestCase
|
||||
$templatePage = $this->entities->templatePage();
|
||||
|
||||
$this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]);
|
||||
$this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
|
||||
|
||||
$this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']);
|
||||
$this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
|
||||
$this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_default_book_template_cannot_be_set_if_not_a_template()
|
||||
@@ -65,7 +65,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$this->assertFalse($page->template);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
|
||||
$this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_default_chapter_template_cannot_be_set_if_not_a_template()
|
||||
@@ -75,7 +75,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$this->assertFalse($page->template);
|
||||
|
||||
$this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]);
|
||||
$this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
|
||||
$this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
|
||||
$this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_default_chapter_template_cannot_be_set_if_not_have_access()
|
||||
@@ -96,7 +96,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
|
||||
$this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
|
||||
}
|
||||
|
||||
public function test_inaccessible_book_default_template_can_be_set_if_unchanged()
|
||||
@@ -106,7 +106,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
|
||||
}
|
||||
|
||||
public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged()
|
||||
@@ -116,7 +116,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$this->permissions->disableEntityInheritedPermissions($templatePage);
|
||||
|
||||
$this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
|
||||
$this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
|
||||
}
|
||||
|
||||
public function test_default_page_template_option_shows_on_book_form()
|
||||
@@ -173,7 +173,7 @@ class DefaultTemplateTest extends TestCase
|
||||
$templatePage->forceFill(['html' => '<p>My template page</p>', 'markdown' => '# My template page'])->save();
|
||||
$book = $this->bookUsingDefaultTemplate($templatePage);
|
||||
|
||||
$this->asEditor()->get($book->getUrl('/create-page'));
|
||||
$this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect();
|
||||
$latestPage = $book->pages()
|
||||
->where('draft', '=', true)
|
||||
->where('template', '=', false)
|
||||
@@ -251,7 +251,7 @@ class DefaultTemplateTest extends TestCase
|
||||
|
||||
$this->post($book->getUrl('/create-guest-page'), [
|
||||
'name' => 'My guest page with template'
|
||||
]);
|
||||
])->assertRedirect();
|
||||
$latestBookPage = $book->pages()
|
||||
->where('draft', '=', false)
|
||||
->where('template', '=', false)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user