1
0
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:
Dan Brown
2025-10-18 13:14:30 +01:00
committed by GitHub
parent 146a6c01cc
commit 4c7d6420ee
120 changed files with 1598 additions and 595 deletions

View File

@@ -11,7 +11,6 @@ class MfaSession
*/
public function isRequiredForUser(User $user): bool
{
// TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
}

View File

@@ -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'],

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -116,6 +116,7 @@ class BookshelfController extends Controller
]);
$sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get()

View File

@@ -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();

View File

@@ -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());
}

View File

@@ -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());

View 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();
}
}

View File

@@ -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
* @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);
}
}

View File

@@ -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);

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View 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());
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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;
}
}

View 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',
];
}

View 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();
}
}

View 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());
});
}
}
}

View 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;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityDefaultTemplate;
interface HasDefaultTemplateInterface
{
public function defaultTemplate(): EntityDefaultTemplate;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityHtmlDescription;
interface HasDescriptionInterface
{
public function descriptionInfo(): EntityHtmlDescription;
}

View File

@@ -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;
}

View File

@@ -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();
}
}
}

View File

@@ -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');
}
}

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');
}
}

View File

@@ -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) {

View File

@@ -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');
}]);
}
}

View File

@@ -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;
}

View File

@@ -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']);
}
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;
};
}

View File

@@ -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);
}

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -34,6 +34,7 @@ class HierarchyTransformer
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
$page->changeBook($book->id);
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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";
}

View File

@@ -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());

View File

@@ -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());

View File

@@ -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 ?? []),
]);

View File

@@ -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,18 +193,18 @@ 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);
});
});
});
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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]);
}

View File

@@ -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'],
],

View File

@@ -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'],
]);

View File

@@ -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;

View File

@@ -2,6 +2,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;

View File

@@ -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,13 +206,11 @@ 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)
->update(['owned_by' => $toUser->id]);
}
DB::table('entities')
->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
/**

View File

@@ -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,
];
}
}

View File

@@ -31,6 +31,7 @@ class PageFactory extends Factory
'text' => strip_tags($html),
'revision_count' => 1,
'editor' => 'wysiwyg',
'priority' => 1,
];
}
}

View File

@@ -25,9 +25,6 @@ return new class extends Migration
$table->unsignedInteger('owner_id')->nullable()->index();
});
}
// Rebuild permissions
app(JointPermissionBuilder::class)->rebuildForAll();
}
/**

View File

@@ -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');
}
};

View File

@@ -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.
}
};

View File

@@ -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');
});
}
};

View 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();
}
};

View File

@@ -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,40 +42,58 @@ 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
$apiPermission = RolePermission::getByName('access-api');
$editorRole->attachPermission($apiPermission);
$token = (new ApiToken())->forceFill([
'user_id' => $editorUser->id,
'name' => 'Testing API key',
'user_id' => $editorUser->id,
'name' => 'Testing API key',
'expires_at' => ApiToken::defaultExpiry(),
'secret' => Hash::make('password'),
'token_id' => 'apitoken',
'secret' => Hash::make('password'),
'token_id' => 'apitoken',
]);
$token->save();
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);
}
}
}

View File

@@ -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",

View File

@@ -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>",

View File

@@ -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>",

View File

@@ -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,

View File

@@ -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'
])

View File

@@ -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>

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>

View File

@@ -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])

View File

@@ -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])

View File

@@ -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)

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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()
{

View File

@@ -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',
'id' => $chapter->id,
'name' => $chapter->name,
'slug' => $chapter->slug,
'pages' => [
[
'id' => $chapterPage->id,
'name' => $chapterPage->name,
'slug' => $chapterPage->slug,
]
]
]
]
]);
$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,
], $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()

View File

@@ -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()

View File

@@ -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',

View File

@@ -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']],

View File

@@ -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']);
}
}

View File

@@ -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()

View File

@@ -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();

View File

@@ -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>',
]);

View File

@@ -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']);

View File

@@ -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()

View File

@@ -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();

View File

@@ -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