*/
public function isRequiredForUser(User $user): bool
{
- // TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
}
$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'],
$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;
}
$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;
}
]);
$sort = $listOptions->getSort();
+
$sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get()
$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 {
$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();
$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());
}
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
+
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
--- /dev/null
+<?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();
+ }
+}
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;
* 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.
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>
*/
public function directPages(): HasMany
{
- return $this->pages()->where('chapter_id', '=', '0');
+ return $this->pages()->whereNull('chapter_id');
}
/**
*/
public function chapters(): HasMany
{
- return $this->hasMany(Chapter::class);
+ return $this->hasMany(Chapter::class)
+ ->where('type', '=', 'chapter');
}
/**
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);
+ }
}
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
-use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
/**
* 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);
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');
}
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.
*/
/**
* 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;
$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');
}
}
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.
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>
->orderBy('priority', 'asc')
->get();
}
+
+ public function defaultTemplate(): EntityDefaultTemplate
+ {
+ return new EntityDefaultTemplate($this);
+ }
}
--- /dev/null
+<?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());
+ }
+}
+++ /dev/null
-<?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;
-}
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
*/
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.
*/
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);
/**
* 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]);
}
*/
public function tags(): MorphMany
{
- return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
+ return $this->morphMany(Tag::class, 'entity')
+ ->orderBy('order', 'asc');
}
/**
}
/**
- * Get this entities restrictions.
+ * Get this entities assigned permissions.
*/
public function permissions(): MorphMany
{
}
/**
- * 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
{
{
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;
+ }
}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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',
+ ];
+}
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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());
+ });
+ }
+ }
+}
--- /dev/null
+<?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;
+}
--- /dev/null
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityDefaultTemplate;
+
+interface HasDefaultTemplateInterface
+{
+ public function defaultTemplate(): EntityDefaultTemplate;
+}
--- /dev/null
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityHtmlDescription;
+
+interface HasDescriptionInterface
+{
+ public function descriptionInfo(): EntityHtmlDescription;
+}
+++ /dev/null
-<?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;
-}
+++ /dev/null
-<?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();
- }
- }
-}
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;
/**
* Class Page.
- *
+ * @property EntityPageData $pageData
* @property int $chapter_id
* @property string $html
* @property string $markdown
{
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',
/**
* Get the chapter that this page is in, If applicable.
- *
- * @return BelongsTo
*/
- public function chapter()
+ public function chapter(): BelongsTo
{
return $this->belongsTo(Chapter::class);
}
/**
* 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');
}
$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');
+ }
}
->select(static::$listAttributes);
}
+ public function visibleForContent(): Builder
+ {
+ return $this->start()->scopes('visible');
+ }
+
public function visibleForListWithCover(): Builder
{
return $this->visibleForList()->with('cover');
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');
->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');
+ }
}
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) {
{
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 = [
->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()
->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');
}]);
}
}
* @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;
}
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;
/**
* 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'])) {
$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'])) {
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();
- $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;
+ $this->imageRepo->destroyImage($entity->coverInfo()->getImage());
+ $entity->coverInfo()->setImage(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
}
}
+ /**
+ * 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']);
}
}
}
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();
}
*/
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);
}
*
* @throws Exception
*/
- public function destroy(Book $book)
+ public function destroy(Book $book): void
{
$this->trashCan->softDestroyBook($book);
Activity::add(ActivityType::BOOK_DELETE, $book);
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);
*/
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);
*
* @throws Exception
*/
- public function destroy(Bookshelf $shelf)
+ public function destroy(Bookshelf $shelf): void
{
$this->trashCan->softDestroyShelf($shelf);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
$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);
*/
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);
*
* @throws Exception
*/
- public function destroy(Chapter $chapter)
+ public function destroy(Chapter $chapter): void
{
$this->trashCan->softDestroyChapter($chapter);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
}
return (new DatabaseTransaction(function () use ($chapter, $parent) {
- $chapter->changeBook($parent->id);
+ $chapter = $chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
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
/**
* 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'),
'updated_by' => user()->id,
'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value,
+ 'html' => '',
+ 'markdown' => '',
+ 'text' => '',
]);
if ($parent instanceof Chapter) {
$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;
$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');
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++;
/**
* 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;
*
* @throws Exception
*/
- public function destroy(Page $page)
+ public function destroy(Page $page): void
{
$this->trashCan->softDestroyPage($page);
Activity::add(ActivityType::PAGE_DELETE, $page);
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);
/**
* 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
{
/**
* 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) {
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
{
$maxPage = $this->book->pages()
->where('draft', '=', false)
- ->where('chapter_id', '=', 0)
+ ->whereDoesntHave('chapter')
->max('priority');
$maxChapter = $this->book->chapters()
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;
};
}
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;
$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);
}
--- /dev/null
+<?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;
+ }
+ }
+}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?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;
+ }
+}
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
+ $page->save();
$page->changeBook($book->id);
}
* 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) {
$idsByType[$type][] = $id;
}
- $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
+ $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
* @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();
/**
* Get a plain-text visualisation of this page.
*/
- protected function toPlainText(): string
+ public function toPlainText(): string
{
$html = $this->render(true);
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;
protected function destroyShelf(Bookshelf $shelf): int
{
$this->destroyCommonRelations($shelf);
+ $shelf->books()->detach();
$shelf->forceDelete();
return 1;
}
$this->destroyCommonRelations($book);
+ $book->shelves()->detach();
$book->forceDelete();
return $count + 1;
$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();
$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();
}
}
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 = [];
{
$text = '# ' . $chapter->name . "\n\n";
- $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
+ $description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}
$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";
}
$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());
$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());
'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 = [
$this->pageRepo->publishDraft($page, [
'name' => $exportPage->name,
- 'markdown' => $exportPage->markdown,
- 'html' => $exportPage->html,
+ 'markdown' => $exportPage->markdown ?? '',
+ 'html' => $exportPage->html ?? '',
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
]);
$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);
/** @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);
});
});
}
{
$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);
+ });
+ });
});
}
* 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;
}
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;
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']);
$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);
}
}
$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();
}
*/
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,
];
// 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) {
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
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\EntityContainerData;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
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]);
}
namespace BookStack\Uploads\Controllers;
+use BookStack\Entities\EntityExistsRule;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\FileUploadException;
use BookStack\Http\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'],
],
namespace BookStack\Uploads\Controllers;
+use BookStack\Entities\EntityExistsRule;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\FileUploadException;
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()),
]);
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'],
]);
/** @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;
namespace BookStack\Users\Controllers;
+use BookStack\Entities\EntityExistsRule;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
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;
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
+ // TODO - Should be be nullifying ownership instead?
}
Activity::add(ActivityType::USER_DELETE, $user);
/**
* 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]);
}
/**
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $description,
- 'description_html' => '<p>' . e($description) . '</p>'
+ 'description_html' => '<p>' . e($description) . '</p>',
+ 'priority' => 5,
];
}
}
'text' => strip_tags($html),
'revision_count' => 1,
'editor' => 'wysiwyg',
+ 'priority' => 1,
];
}
}
$table->unsignedInteger('owner_id')->nullable()->index();
});
}
-
- // Rebuild permissions
- app(JointPermissionBuilder::class)->rebuildForAll();
}
/**
--- /dev/null
+<?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');
+ }
+};
--- /dev/null
+<?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.
+ }
+};
--- /dev/null
+<?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');
+ });
+ }
+};
--- /dev/null
+<?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();
+ }
+};
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;
$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);
+ }
+ }
}
"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",
{
"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>",
{
"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>",
"deletable": {
"id": 2582,
"book_id": 25,
- "chapter_id": 0,
+ "chapter_id": null,
"name": "A Wonderful Page",
"slug": "a-wonderful-page",
"priority": 9,
@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'
])
+@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>
\ No newline at end of file
@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
<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)
<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)
<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>
@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])
@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])
<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)
<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
\ No newline at end of file
@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>
</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">
</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>
@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'
])
</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>
<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">
@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
</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">
{
use TestsApi;
- protected $endpoint = '/api/books';
+ protected string $endpoint = '/api/books';
public function test_requests_succeed_with_default_auth()
{
[
'id' => $book->id,
'cover' => [
- 'id' => $book->cover->id,
- 'url' => $book->cover->url,
+ 'id' => $book->coverInfo()->getImage()->id,
+ 'url' => $book->coverInfo()->getImage()->url,
],
],
]]);
]);
$resp->assertJson($expectedDetails);
- $this->assertDatabaseHas('books', $expectedDetails);
+ $this->assertDatabaseHasEntityData('book', $expectedDetails);
}
public function test_book_name_needed_to_create()
$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()
$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']],
$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
$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}", [
$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}", [
$book->refresh();
$resp->assertStatus(200);
- $this->assertNull($book->cover);
+ $this->assertNull($book->coverInfo()->getImage());
}
public function test_delete_endpoint()
'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()
'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,
$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'
]));
}
{
$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']],
$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()
]);
$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',
{
$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']],
$deletion = Deletion::query()->orderBy('id')->first();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'deleted_at' => $page->deleted_at,
]);
'restore_count' => 1,
]);
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'deleted_at' => null,
]);
$deletion = Deletion::query()->orderBy('id')->first();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'deleted_at' => $page->deleted_at,
]);
'delete_count' => 1,
]);
- $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
}
}
[
'id' => $shelf->id,
'cover' => [
- 'id' => $shelf->cover->id,
- 'url' => $shelf->cover->url,
+ 'id' => $shelf->coverInfo()->getImage()->id,
+ 'url' => $shelf->coverInfo()->getImage()->url,
],
],
]]);
]);
$resp->assertJson($expectedDetails);
- $this->assertDatabaseHas('bookshelves', $expectedDetails);
+ $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails);
}
public function test_shelf_name_needed_to_create()
$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']],
$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
$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}", [
$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}", [
$shelf->refresh();
$resp->assertStatus(200);
- $this->assertNull($shelf->cover);
+ $this->assertNull($shelf->coverInfo()->getImage());
}
public function test_delete_endpoint()
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;
$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();
->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>',
]);
->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>',
]);
]));
$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());
$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);
}
$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']);
$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()
$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()
/** @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);
// 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);
$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();
];
$this->asEditor()->post('/books', $details);
- $this->assertDatabaseHas('books', $details);
+ $this->assertDatabaseHasEntityData('book', $details);
}
public function test_creating_chapter_with_default_template()
];
$this->asEditor()->post($book->getUrl('/create-chapter'), $details);
- $this->assertDatabaseHas('chapters', $details);
+ $this->assertDatabaseHasEntityData('chapter', $details);
}
public function test_updating_book_with_default_template()
$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()
$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()
$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()
$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]);
}
$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()
$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()
$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()
$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()
$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)
$this->post($book->getUrl('/create-guest-page'), [
'name' => 'My guest page with template'
- ]);
+ ])->assertRedirect();
$latestBookPage = $book->pages()
->where('draft', '=', false)
->where('template', '=', false)
]);
$resp->assertOk();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $draft->id,
'draft' => true,
'name' => 'My updated draft',
'markdown' => '# My markdown page',
]);
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $draft->id,
'draft' => false,
'slug' => 'my-page',
$resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
$resp->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'markdown' => $details['markdown'],
'id' => $draft->id,
'draft' => false,
$restoreReq->assertRedirect($page->getUrl());
$pageView = $this->get($page->getUrl());
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'markdown' => '## New Content def456',
]);
];
$this->put($page->getUrl(), $pageUpdateData);
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'template' => false,
]);
$this->permissions->grantUserRolePermissions($editor, ['templates-manage']);
$this->put($page->getUrl(), $pageUpdateData);
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'template' => true,
]);
$resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
$resp->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'markdown' => $details['markdown'],
'name' => $details['name'],
'id' => $draft->id,
]);
$movePageResp->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'name' => 'My copied test page',
'created_by' => $viewer->id,
'book_id' => $newBook->id,
$this->asEditor()->get($book->getUrl('/create-page'));
$this->get($book->getUrl('/create-page'));
- [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
+ [$pageA, $pageB] = $book->pages()->whereNull('chapter_id')->get();
$pageA->html = '<p>hello tester</p>';
$pageA->save();
$pageB->name = 'The second page in this test';
$bookData = $zip->data['book'];
$this->assertEquals($book->id, $bookData['id']);
$this->assertEquals($book->name, $bookData['name']);
- $this->assertEquals($book->descriptionHtml(), $bookData['description_html']);
+ $this->assertEquals($book->descriptionInfo()->getHtml(), $bookData['description_html']);
$this->assertCount(2, $bookData['tags']);
$this->assertCount($book->directPages()->count(), $bookData['pages']);
$this->assertCount($book->chapters()->count(), $bookData['chapters']);
$bookRepo = $this->app->make(BookRepo::class);
$coverImageFile = $this->files->uploadedImage('cover.png');
$bookRepo->updateCoverImage($book, $coverImageFile);
- $coverImage = $book->cover()->first();
+ $coverImage = $book->coverInfo()->getImage();
$zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
$zip = ZipTestHelper::extractFromZipResponse($zipResp);
$chapterData = $zip->data['chapter'];
$this->assertEquals($chapter->id, $chapterData['id']);
$this->assertEquals($chapter->name, $chapterData['name']);
- $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']);
+ $this->assertEquals($chapter->descriptionInfo()->getHtml(), $chapterData['description_html']);
$this->assertCount(2, $chapterData['tags']);
$this->assertEquals($chapter->priority, $chapterData['priority']);
$this->assertCount($chapter->pages()->count(), $chapterData['pages']);
// Book checks
$this->assertEquals('Import test', $book->name);
- $this->assertFileExists(public_path($book->cover->path));
+ $this->assertFileExists(public_path($book->coverInfo()->getImage()->path));
$this->assertCount(2, $book->tags);
$this->assertEquals('Cat', $book->tags()->first()->value);
$this->assertCount(2, $book->chapters);
public function pageNotWithinChapter(): Page
{
- return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0));
+ return $this->page(fn(Builder $query) => $query->whereNull('chapter_id'));
}
public function templatePage(): Page
$resp = $this->asEditor()->get($book->getUrl());
$tags = $this->getOpenGraphTags($resp);
- $this->assertEquals($book->getBookCover(), $tags['image']);
+ $this->assertEquals($book->coverInfo()->getUrl(), $tags['image']);
}
public function test_shelf_tags()
$resp = $this->asEditor()->get($shelf->getUrl());
$tags = $this->getOpenGraphTags($resp);
- $this->assertEquals($shelf->getBookCover(), $tags['image']);
+ $this->assertEquals($shelf->coverInfo()->getUrl(), $tags['image']);
}
/**
$user = User::query()->where('id', '!=', $page->owned_by)->first();
$this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]);
- $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]);
+ $this->assertDatabaseHasEntityData('page', ['owned_by' => $user->id, 'id' => $page->id]);
}
public function test_changing_chapter_owner()
$user = User::query()->where('id', '!=', $chapter->owned_by)->first();
$this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]);
- $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]);
+ $this->assertDatabaseHasEntityData('chapter', ['owned_by' => $user->id, 'id' => $chapter->id]);
}
public function test_changing_book_owner()
$user = User::query()->where('id', '!=', $book->owned_by)->first();
$this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]);
- $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]);
+ $this->assertDatabaseHasEntityData('book', ['owned_by' => $user->id, 'id' => $book->id]);
}
public function test_changing_shelf_owner()
$user = User::query()->where('id', '!=', $shelf->owned_by)->first();
$this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]);
- $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]);
+ $this->assertDatabaseHasEntityData('bookshelf', ['owned_by' => $user->id, 'id' => $shelf->id]);
}
}
public function test_book_sort_view_permission()
{
- /** @var Book $firstBook */
- $firstBook = Book::query()->first();
- /** @var Book $secondBook */
- $secondBook = Book::query()->find(2);
+ $firstBook = $this->entities->book();
+ $secondBook = $this->entities->book();
$this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
$this->setRestrictionsForTestRoles($secondBook, ['view']);
$resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit'));
$user = $this->users->guest();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'name' => 'My guest page',
'chapter_id' => $chapter->id,
'created_by' => $user->id,
}
$oldUrl = $shelf->getUrl();
- $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']);
+ $this->put($shelf->getUrl(), ['name' => 'My updated shelf link'])->assertRedirect();
$shelf->refresh();
$this->assertNotEquals($oldUrl, $shelf->getUrl());
namespace Tests\Settings;
use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Carbon;
$emptyReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0);
- $this->assertDatabaseMissing('books', ['id' => $book->id]);
- $this->assertDatabaseMissing('pages', ['id' => $page->id]);
- $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
- $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
+ $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
+ $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
+ $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
+ $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
$itemCount = 2 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin');
public function test_entity_restore()
{
$book = $this->entities->bookHasChaptersAndPages();
- $this->asEditor()->delete($book->getUrl());
+ $this->asEditor()->delete($book->getUrl())->assertRedirect();
$deletion = Deletion::query()->firstOrFail();
- $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
- $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+ $this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+ $this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
$restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
$restoreReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0);
- $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
- $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+ $this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+ $this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
$itemCount = 1 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin');
$deleteReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0);
- $this->assertDatabaseMissing('books', ['id' => $book->id]);
- $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
- $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
+ $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
+ $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
+ $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
+ $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
$itemCount = 1 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin');
]);
}
+ public function test_permanent_book_delete_removes_shelf_relation_data()
+ {
+ $book = $this->entities->book();
+ $shelf = $this->entities->shelf();
+ $shelf->books()->attach($book);
+ $this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]);
+
+ $this->asEditor()->delete($book->getUrl());
+ $deletion = $book->deletions()->firstOrFail();
+ $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
+
+ $this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]);
+ }
+
+ public function test_permanent_shelf_delete_removes_book_relation_data()
+ {
+ $book = $this->entities->book();
+ $shelf = $this->entities->shelf();
+ $shelf->books()->attach($book);
+ $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+
+ $this->asEditor()->delete($shelf->getUrl());
+ $deletion = $shelf->deletions()->firstOrFail();
+ $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
+
+ $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+ }
+
public function test_auto_clear_functionality_works()
{
config()->set('app.recycle_bin_lifetime', 5);
$otherPage = $this->entities->page();
$this->asEditor()->delete($page->getUrl());
- $this->assertDatabaseHas('pages', ['id' => $page->id]);
+ $this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
$this->assertEquals(1, Deletion::query()->count());
Carbon::setTestNow(Carbon::now()->addDays(6));
$this->asEditor()->delete($otherPage->getUrl());
$this->assertEquals(1, Deletion::query()->count());
- $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
}
public function test_auto_clear_functionality_with_negative_time_keeps_forever()
$this->asEditor()->delete($otherPage->getUrl());
$this->assertEquals(2, Deletion::query()->count());
- $this->assertDatabaseHas('pages', ['id' => $page->id]);
+ $this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
}
public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
$page = $this->entities->page();
$this->asEditor()->delete($page->getUrl());
- $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+ $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
$this->assertEquals(0, Deletion::query()->count());
}
$sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]);
$sortResp->assertRedirect($newBook->getUrl());
$sortResp->assertStatus(302);
- $this->assertDatabaseHas('chapters', [
+ $this->assertDatabaseHasEntityData('chapter', [
'id' => $chapterToMove->id,
'book_id' => $newBook->id,
'priority' => 0,
];
$this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
]);
}
];
$this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
]);
}
];
$this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
]);
}
];
$this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
]);
}
];
$this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
]);
}
];
$this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
]);
}
{
$book = $this->entities->bookHasChaptersAndPages();
$chapter = $book->chapters()->first();
- \DB::table('chapters')->where('id', '=', $chapter->id)->update([
+ Chapter::query()->where('id', '=', $chapter->id)->update([
'priority' => 10001,
'updated_at' => \Carbon\Carbon::now()->subYear(5),
]);
$book = $this->entities->bookHasChaptersAndPages();
$book->chapters()->forceDelete();
/** @var Page[] $pages */
- $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get();
+ $pages = $book->pages()->whereNull('chapter_id')->take(2)->get();
$book->pages()->whereNotIn('id', $pages->pluck('id'))->delete();
$resp = $this->asEditor()->get($book->getUrl());
$movePageResp = $this->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id,
- ]);
+ ])->assertRedirect();
$page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']);
$resp->assertRedirect('/settings/sorting');
$this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
- $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]);
+ $this->assertDatabaseMissing('entity_container_data', ['sort_rule_id' => $rule->id]);
}
public function test_page_create_triggers_book_sort()
]);
$resp->assertOk();
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'book_id' => $book->id,
'name' => '1111 page',
'priority' => $book->chapters()->count() + 1,
}
foreach ($namesToAdd as $index => $name) {
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'book_id' => $book->id,
'name' => $name,
'priority' => $index + 1,
}
foreach ($namesToAdd as $index => $name) {
- $this->assertDatabaseHas('pages', [
+ $this->assertDatabaseHasEntityData('page', [
'book_id' => $book->id,
'name' => $name,
'priority' => $index + 1,
use BookStack\Http\HttpClientHistory;
use BookStack\Http\HttpRequestService;
use BookStack\Settings\SettingService;
-use BookStack\Users\Models\User;
+use Exception;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Testing\Assert as PHPUnit;
+use Illuminate\Testing\Constraints\HasInDatabase;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Ssddanbrown\AssertHtml\TestsHtml;
$this->assertDatabaseHas('activities', $detailsToCheck);
}
+
+ /**
+ * Assert the database has the given data for an entity type.
+ */
+ protected function assertDatabaseHasEntityData(string $type, array $data = []): self
+ {
+ $entityFields = array_intersect_key($data, array_flip(Entity::$commonFields));
+ $extraFields = array_diff_key($data, $entityFields);
+ $extraTable = $type === 'page' ? 'entity_page_data' : 'entity_container_data';
+ $entityFields['type'] = $type;
+
+ $this->assertThat(
+ $this->getTable('entities'),
+ new HasInDatabase($this->getConnection(null, 'entities'), $entityFields)
+ );
+
+ if (!empty($extraFields)) {
+ $id = $entityFields['id'] ?? DB::table($this->getTable('entities'))
+ ->where($entityFields)->orderByDesc('id')->first()->id ?? null;
+ if (is_null($id)) {
+ throw new Exception('Failed to find entity id for asserting database data');
+ }
+
+ if ($type !== 'page') {
+ $extraFields['entity_id'] = $id;
+ $extraFields['entity_type'] = $type;
+ } else {
+ $extraFields['page_id'] = $id;
+ }
+
+ $this->assertThat(
+ $this->getTable($extraTable),
+ new HasInDatabase($this->getConnection(null, $extraTable), $extraFields)
+ );
+ }
+
+ return $this;
+ }
}
$owner = $page->ownedBy;
$newOwner = User::query()->where('id', '!=', $owner->id)->first();
- $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]);
- $this->assertDatabaseHas('pages', [
+ $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect();
+ $this->assertDatabaseHasEntityData('page', [
'id' => $page->id,
'owned_by' => $newOwner->id,
]);