mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-23 03:12:32 +01:00
Merge pull request #4729 from BookStackApp/description_wysiwyg
Simple WYSIWYG for description fields and comments
This commit is contained in:
commit
529f7bd1bc
@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$references->updateForAllPages();
|
||||
$references->updateForAll();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
|
@ -46,6 +46,9 @@ class UpdateUrlCommand extends Command
|
||||
$columnsToUpdateByTable = [
|
||||
'attachments' => ['path'],
|
||||
'pages' => ['html', 'text', 'markdown'],
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
'comments' => ['html', 'text'],
|
||||
|
@ -45,7 +45,7 @@ class BookApiController extends ApiController
|
||||
|
||||
$book = $this->bookRepo->create($requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,9 +56,9 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()
|
||||
->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])
|
||||
->findOrFail($id);
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
@ -89,7 +89,7 @@ class BookApiController extends ApiController
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$book = $this->bookRepo->update($book, $requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,21 +108,35 @@ class BookApiController extends ApiController
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Book $book): Book
|
||||
{
|
||||
$book = clone $book;
|
||||
$book->unsetRelations()->refresh();
|
||||
|
||||
$book->load(['tags', 'cover']);
|
||||
$book->makeVisible('description_html')
|
||||
->setAttribute('description_html', $book->descriptionHtml());
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
];
|
||||
|
@ -93,7 +93,7 @@ class BookController extends Controller
|
||||
$this->checkPermission('book-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
@ -138,7 +138,7 @@ class BookController extends Controller
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $book),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -168,7 +168,7 @@ class BookController extends Controller
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
|
@ -12,11 +12,9 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookshelfApiController extends ApiController
|
||||
{
|
||||
protected BookshelfRepo $bookshelfRepo;
|
||||
|
||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||
{
|
||||
$this->bookshelfRepo = $bookshelfRepo;
|
||||
public function __construct(
|
||||
protected BookshelfRepo $bookshelfRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,7 +46,7 @@ class BookshelfApiController extends ApiController
|
||||
$bookIds = $request->get('books', []);
|
||||
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,12 +54,14 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->with([
|
||||
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->forJsonDisplay($shelf);
|
||||
$shelf->load([
|
||||
'createdBy', 'updatedBy', 'ownedBy',
|
||||
'books' => function (BelongsToMany $query) {
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
},
|
||||
])->findOrFail($id);
|
||||
]);
|
||||
|
||||
return response()->json($shelf);
|
||||
}
|
||||
@ -86,7 +86,7 @@ class BookshelfApiController extends ApiController
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -105,22 +105,36 @@ class BookshelfApiController extends ApiController
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
|
||||
{
|
||||
$shelf = clone $shelf;
|
||||
$shelf->unsetRelations()->refresh();
|
||||
|
||||
$shelf->load(['tags', 'cover']);
|
||||
$shelf->makeVisible('description_html')
|
||||
->setAttribute('description_html', $shelf->descriptionHtml());
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -18,15 +18,11 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookshelfController extends Controller
|
||||
{
|
||||
protected BookshelfRepo $shelfRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->shelfRepo = $shelfRepo;
|
||||
$this->shelfContext = $shelfContext;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -81,10 +77,10 @@ class BookshelfController extends Controller
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
@ -129,7 +125,7 @@ class BookshelfController extends Controller
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -164,10 +160,10 @@ class BookshelfController extends Controller
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
if ($request->has('image_reset')) {
|
||||
|
@ -15,18 +15,20 @@ class ChapterApiController extends ApiController
|
||||
{
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'book_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'book_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
],
|
||||
'update' => [
|
||||
'book_id' => ['integer'],
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'book_id' => ['integer'],
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
],
|
||||
];
|
||||
|
||||
@ -61,7 +63,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$chapter = $this->chapterRepo->create($requestData, $book);
|
||||
|
||||
return response()->json($chapter->load(['tags']));
|
||||
return response()->json($this->forJsonDisplay($chapter));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -69,9 +71,15 @@ class ChapterApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
}])->findOrFail($id);
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->forJsonDisplay($chapter);
|
||||
|
||||
$chapter->load([
|
||||
'createdBy', 'updatedBy', 'ownedBy',
|
||||
'pages' => function (HasMany $query) {
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
}
|
||||
]);
|
||||
|
||||
return response()->json($chapter);
|
||||
}
|
||||
@ -93,7 +101,7 @@ class ChapterApiController extends ApiController
|
||||
try {
|
||||
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
|
||||
} catch (Exception $exception) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
@ -103,7 +111,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
|
||||
|
||||
return response()->json($updatedChapter->load(['tags']));
|
||||
return response()->json($this->forJsonDisplay($updatedChapter));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,4 +127,16 @@ class ChapterApiController extends ApiController
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Chapter $chapter): Chapter
|
||||
{
|
||||
$chapter = clone $chapter;
|
||||
$chapter->unsetRelations()->refresh();
|
||||
|
||||
$chapter->load(['tags']);
|
||||
$chapter->makeVisible('description_html')
|
||||
->setAttribute('description_html', $chapter->descriptionHtml());
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
}
|
||||
|
@ -22,13 +22,10 @@ use Throwable;
|
||||
|
||||
class ChapterController extends Controller
|
||||
{
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected ReferenceFetcher $referenceFetcher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,14 +48,16 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function store(Request $request, string $bookSlug)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$chapter = $this->chapterRepo->create($request->all(), $book);
|
||||
$chapter = $this->chapterRepo->create($validated, $book);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
@ -87,7 +86,7 @@ class ChapterController extends Controller
|
||||
'pages' => $pages,
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -111,10 +110,16 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
$this->chapterRepo->update($chapter, $request->all());
|
||||
$this->chapterRepo->update($chapter, $validated);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
@ -155,7 +155,7 @@ class PageController extends Controller
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $page),
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -26,11 +26,12 @@ use Illuminate\Support\Collection;
|
||||
class Book extends Entity implements HasCoverImage
|
||||
{
|
||||
use HasFactory;
|
||||
use HasHtmlDescription;
|
||||
|
||||
public $searchFactor = 1.2;
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
|
||||
protected $fillable = ['name'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
|
@ -65,7 +65,7 @@ abstract class BookChild extends Entity
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
|
||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
|
@ -11,14 +11,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
class Bookshelf extends Entity implements HasCoverImage
|
||||
{
|
||||
use HasFactory;
|
||||
use HasHtmlDescription;
|
||||
|
||||
protected $table = 'bookshelves';
|
||||
|
||||
public $searchFactor = 1.2;
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['image_id', 'deleted_at'];
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
|
@ -15,11 +15,12 @@ use Illuminate\Support\Collection;
|
||||
class Chapter extends BookChild
|
||||
{
|
||||
use HasFactory;
|
||||
use HasHtmlDescription;
|
||||
|
||||
public $searchFactor = 1.2;
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['pivot', 'deleted_at'];
|
||||
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
|
@ -57,12 +57,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
/**
|
||||
* @var string - Name of property where the main text content is found
|
||||
*/
|
||||
public $textField = 'description';
|
||||
public string $textField = 'description';
|
||||
|
||||
/**
|
||||
* @var string - Name of the property where the main HTML content is found
|
||||
*/
|
||||
public string $htmlField = 'description_html';
|
||||
|
||||
/**
|
||||
* @var float - Multiplier for search indexing.
|
||||
*/
|
||||
public $searchFactor = 1.0;
|
||||
public float $searchFactor = 1.0;
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
|
21
app/Entities/Models/HasHtmlDescription.php
Normal file
21
app/Entities/Models/HasHtmlDescription.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
|
||||
/**
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
*/
|
||||
trait HasHtmlDescription
|
||||
{
|
||||
/**
|
||||
* Get the HTML description for this book.
|
||||
*/
|
||||
public function descriptionHtml(): string
|
||||
{
|
||||
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
}
|
@ -37,7 +37,8 @@ class Page extends BookChild
|
||||
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
public $textField = 'text';
|
||||
public string $textField = 'text';
|
||||
public string $htmlField = 'html';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
|
||||
|
@ -5,22 +5,22 @@ namespace BookStack\Entities\Repos;
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Entities\Models\HasHtmlDescription;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BaseRepo
|
||||
{
|
||||
protected TagRepo $tagRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
$this->imageRepo = $imageRepo;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
public function __construct(
|
||||
protected TagRepo $tagRepo,
|
||||
protected ImageRepo $imageRepo,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
protected ReferenceStore $referenceStore,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -29,6 +29,7 @@ class BaseRepo
|
||||
public function create(Entity $entity, array $input)
|
||||
{
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->forceFill([
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
@ -44,6 +45,7 @@ class BaseRepo
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
$this->referenceStore->updateForEntity($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -54,6 +56,7 @@ class BaseRepo
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$this->updateDescription($entity, $input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
@ -69,9 +72,10 @@ class BaseRepo
|
||||
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
$this->referenceStore->updateForEntity($entity);
|
||||
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
|
||||
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,4 +103,21 @@ class BaseRepo
|
||||
$entity->save();
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateDescription(Entity $entity, array $input): void
|
||||
{
|
||||
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var HasHtmlDescription $entity */
|
||||
if (isset($input['description_html'])) {
|
||||
$entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
|
||||
$entity->description = html_entity_decode(strip_tags($input['description_html']));
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->description = $input['description'];
|
||||
$entity->description_html = '';
|
||||
$entity->description_html = $entity->descriptionHtml();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -162,7 +162,6 @@ class PageRepo
|
||||
$this->baseRepo->update($draft, $input);
|
||||
|
||||
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
|
||||
$this->referenceStore->updateForPage($draft);
|
||||
$draft->refresh();
|
||||
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
@ -182,7 +181,6 @@ class PageRepo
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
@ -301,13 +299,13 @@ class PageRepo
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForPage($page);
|
||||
$this->referenceStore->updateForEntity($page);
|
||||
|
||||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
|
||||
if ($oldUrl !== $page->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
|
||||
$this->referenceUpdater->updateEntityReferences($page, $oldUrl);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
|
103
app/Entities/Tools/MixedEntityListLoader.php
Normal file
103
app/Entities/Tools/MixedEntityListLoader.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class MixedEntityListLoader
|
||||
{
|
||||
protected array $listAttributes = [
|
||||
'page' => ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text', 'draft'],
|
||||
'chapter' => ['id', 'name', 'slug', 'book_id', 'description'],
|
||||
'book' => ['id', 'name', 'slug', 'description'],
|
||||
'bookshelf' => ['id', 'name', 'slug', 'description'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected EntityProvider $entityProvider
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently load in entities for listing onto the given list
|
||||
* where entities are set as a relation via the given name.
|
||||
* 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): void
|
||||
{
|
||||
$idsByType = [];
|
||||
foreach ($relations as $relation) {
|
||||
$type = $relation->getAttribute($relationName . '_type');
|
||||
$id = $relation->getAttribute($relationName . '_id');
|
||||
|
||||
if (!isset($idsByType[$type])) {
|
||||
$idsByType[$type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$type][] = $id;
|
||||
}
|
||||
|
||||
$modelMap = $this->idsByTypeToModelMap($idsByType);
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$type = $relation->getAttribute($relationName . '_type');
|
||||
$id = $relation->getAttribute($relationName . '_id');
|
||||
$related = $modelMap[$type][strval($id)] ?? null;
|
||||
if ($related) {
|
||||
$relation->setRelation($relationName, $related);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int[]> $idsByType
|
||||
* @return array<string, array<int, Model>>
|
||||
*/
|
||||
protected function idsByTypeToModelMap(array $idsByType): array
|
||||
{
|
||||
$modelMap = [];
|
||||
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
if (!isset($this->listAttributes[$type])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$instance = $this->entityProvider->get($type);
|
||||
$models = $instance->newQuery()
|
||||
->select($this->listAttributes[$type])
|
||||
->scopes('visible')
|
||||
->whereIn('id', $ids)
|
||||
->with($this->getRelationsToEagerLoad($type))
|
||||
->get();
|
||||
|
||||
if (count($models) > 0) {
|
||||
$modelMap[$type] = [];
|
||||
}
|
||||
|
||||
foreach ($models as $model) {
|
||||
$modelMap[$type][strval($model->id)] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
return $modelMap;
|
||||
}
|
||||
|
||||
protected function getRelationsToEagerLoad(string $type): array
|
||||
{
|
||||
$toLoad = [];
|
||||
$loadVisible = fn (Relation $query) => $query->scopes('visible');
|
||||
|
||||
if ($type === 'chapter' || $type === 'page') {
|
||||
$toLoad['book'] = $loadVisible;
|
||||
}
|
||||
|
||||
if ($type === 'page') {
|
||||
$toLoad['chapter'] = $loadVisible;
|
||||
}
|
||||
|
||||
return $toLoad;
|
||||
}
|
||||
}
|
@ -10,11 +10,9 @@ use BookStack\Http\Controller;
|
||||
|
||||
class ReferenceController extends Controller
|
||||
{
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected ReferenceFetcher $referenceFetcher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -23,7 +21,7 @@ class ReferenceController extends Controller
|
||||
public function page(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($page);
|
||||
|
||||
return view('pages.references', [
|
||||
'page' => $page,
|
||||
@ -37,7 +35,7 @@ class ReferenceController extends Controller
|
||||
public function chapter(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($chapter);
|
||||
|
||||
return view('chapters.references', [
|
||||
'chapter' => $chapter,
|
||||
@ -51,7 +49,7 @@ class ReferenceController extends Controller
|
||||
public function book(string $slug)
|
||||
{
|
||||
$book = Book::getBySlug($slug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($book);
|
||||
|
||||
return view('books.references', [
|
||||
'book' => $book,
|
||||
@ -65,7 +63,7 @@ class ReferenceController extends Controller
|
||||
public function shelf(string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($shelf);
|
||||
|
||||
return view('shelves.references', [
|
||||
'shelf' => $shelf,
|
||||
|
@ -3,65 +3,51 @@
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ReferenceFetcher
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $mixedEntityListLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query and return the page references pointing to the given entity.
|
||||
* Query and return the references pointing to the given entity.
|
||||
* Loads the commonly required relations while taking permissions into account.
|
||||
*/
|
||||
public function getPageReferencesToEntity(Entity $entity): Collection
|
||||
public function getReferencesToEntity(Entity $entity): Collection
|
||||
{
|
||||
$baseQuery = $this->queryPageReferencesToEntity($entity)
|
||||
->with([
|
||||
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
|
||||
'from.book' => fn (Relation $query) => $query->scopes('visible'),
|
||||
'from.chapter' => fn (Relation $query) => $query->scopes('visible'),
|
||||
]);
|
||||
|
||||
$references = $this->permissions->restrictEntityRelationQuery(
|
||||
$baseQuery,
|
||||
'references',
|
||||
'from_id',
|
||||
'from_type'
|
||||
)->get();
|
||||
$references = $this->queryReferencesToEntity($entity)->get();
|
||||
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from');
|
||||
|
||||
return $references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of page references pointing to the given entity.
|
||||
* Returns the count of references pointing to the given entity.
|
||||
* Takes permissions into account.
|
||||
*/
|
||||
public function getPageReferenceCountToEntity(Entity $entity): int
|
||||
public function getReferenceCountToEntity(Entity $entity): int
|
||||
{
|
||||
$count = $this->permissions->restrictEntityRelationQuery(
|
||||
$this->queryPageReferencesToEntity($entity),
|
||||
return $this->queryReferencesToEntity($entity)->count();
|
||||
}
|
||||
|
||||
protected function queryReferencesToEntity(Entity $entity): Builder
|
||||
{
|
||||
$baseQuery = Reference::query()
|
||||
->where('to_type', '=', $entity->getMorphClass())
|
||||
->where('to_id', '=', $entity->id);
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery(
|
||||
$baseQuery,
|
||||
'references',
|
||||
'from_id',
|
||||
'from_type'
|
||||
)->count();
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected function queryPageReferencesToEntity(Entity $entity): Builder
|
||||
{
|
||||
return Reference::query()
|
||||
->where('to_type', '=', $entity->getMorphClass())
|
||||
->where('to_id', '=', $entity->id)
|
||||
->where('from_type', '=', (new Page())->getMorphClass());
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,60 +2,62 @@
|
||||
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ReferenceStore
|
||||
{
|
||||
/**
|
||||
* Update the outgoing references for the given page.
|
||||
*/
|
||||
public function updateForPage(Page $page): void
|
||||
{
|
||||
$this->updateForPages([$page]);
|
||||
public function __construct(
|
||||
protected EntityProvider $entityProvider
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the outgoing references for all pages in the system.
|
||||
* Update the outgoing references for the given entity.
|
||||
*/
|
||||
public function updateForAllPages(): void
|
||||
public function updateForEntity(Entity $entity): void
|
||||
{
|
||||
Reference::query()
|
||||
->where('from_type', '=', (new Page())->getMorphClass())
|
||||
->delete();
|
||||
|
||||
Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) {
|
||||
$this->updateForPages($pages->all());
|
||||
});
|
||||
$this->updateForEntities([$entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the outgoing references for the pages in the given array.
|
||||
* Update the outgoing references for all entities in the system.
|
||||
*/
|
||||
public function updateForAll(): void
|
||||
{
|
||||
Reference::query()->delete();
|
||||
|
||||
foreach ($this->entityProvider->all() as $entity) {
|
||||
$entity->newQuery()->select(['id', $entity->htmlField])->chunk(100, function (Collection $entities) {
|
||||
$this->updateForEntities($entities->all());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the outgoing references for the entities in the given array.
|
||||
*
|
||||
* @param Page[] $pages
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function updateForPages(array $pages): void
|
||||
protected function updateForEntities(array $entities): void
|
||||
{
|
||||
if (count($pages) === 0) {
|
||||
if (count($entities) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parser = CrossLinkParser::createWithEntityResolvers();
|
||||
$references = [];
|
||||
|
||||
$pageIds = array_map(fn (Page $page) => $page->id, $pages);
|
||||
Reference::query()
|
||||
->where('from_type', '=', $pages[0]->getMorphClass())
|
||||
->whereIn('from_id', $pageIds)
|
||||
->delete();
|
||||
$this->dropReferencesFromEntities($entities);
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$models = $parser->extractLinkedModels($page->html);
|
||||
foreach ($entities as $entity) {
|
||||
$models = $parser->extractLinkedModels($entity->getAttribute($entity->htmlField));
|
||||
|
||||
foreach ($models as $model) {
|
||||
$references[] = [
|
||||
'from_id' => $page->id,
|
||||
'from_type' => $page->getMorphClass(),
|
||||
'from_id' => $entity->id,
|
||||
'from_type' => $entity->getMorphClass(),
|
||||
'to_id' => $model->id,
|
||||
'to_type' => $model->getMorphClass(),
|
||||
];
|
||||
@ -66,4 +68,29 @@ class ReferenceStore
|
||||
Reference::query()->insert($referenceDataChunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the existing references originating from the given entities.
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function dropReferencesFromEntities(array $entities): void
|
||||
{
|
||||
$IdsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$type = $entity->getMorphClass();
|
||||
if (!isset($IdsByType[$type])) {
|
||||
$IdsByType[$type] = [];
|
||||
}
|
||||
|
||||
$IdsByType[$type][] = $entity->id;
|
||||
}
|
||||
|
||||
foreach ($IdsByType as $type => $entityIds) {
|
||||
Reference::query()
|
||||
->where('from_type', '=', $type)
|
||||
->whereIn('from_id', $entityIds)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ namespace BookStack\References;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasHtmlDescription;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\RevisionRepo;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
@ -12,20 +13,19 @@ class ReferenceUpdater
|
||||
{
|
||||
public function __construct(
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
protected RevisionRepo $revisionRepo
|
||||
protected RevisionRepo $revisionRepo,
|
||||
) {
|
||||
}
|
||||
|
||||
public function updateEntityPageReferences(Entity $entity, string $oldLink)
|
||||
public function updateEntityReferences(Entity $entity, string $oldLink): void
|
||||
{
|
||||
$references = $this->getReferencesToUpdate($entity);
|
||||
$newLink = $entity->getUrl();
|
||||
|
||||
/** @var Reference $reference */
|
||||
foreach ($references as $reference) {
|
||||
/** @var Page $page */
|
||||
$page = $reference->from;
|
||||
$this->updateReferencesWithinPage($page, $oldLink, $newLink);
|
||||
/** @var Entity $entity */
|
||||
$entity = $reference->from;
|
||||
$this->updateReferencesWithinEntity($entity, $oldLink, $newLink);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ class ReferenceUpdater
|
||||
protected function getReferencesToUpdate(Entity $entity): array
|
||||
{
|
||||
/** @var Reference[] $references */
|
||||
$references = $this->referenceFetcher->getPageReferencesToEntity($entity)->values()->all();
|
||||
$references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$pages = $entity->pages()->get(['id']);
|
||||
@ -43,7 +43,7 @@ class ReferenceUpdater
|
||||
$children = $pages->concat($chapters);
|
||||
foreach ($children as $bookChild) {
|
||||
/** @var Reference[] $childRefs */
|
||||
$childRefs = $this->referenceFetcher->getPageReferencesToEntity($bookChild)->values()->all();
|
||||
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
|
||||
array_push($references, ...$childRefs);
|
||||
}
|
||||
}
|
||||
@ -57,7 +57,28 @@ class ReferenceUpdater
|
||||
return array_values($deduped);
|
||||
}
|
||||
|
||||
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink)
|
||||
protected function updateReferencesWithinEntity(Entity $entity, string $oldLink, string $newLink): void
|
||||
{
|
||||
if ($entity instanceof Page) {
|
||||
$this->updateReferencesWithinPage($entity, $oldLink, $newLink);
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array(HasHtmlDescription::class, class_uses($entity))) {
|
||||
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateReferencesWithinDescription(Entity $entity, string $oldLink, string $newLink): void
|
||||
{
|
||||
/** @var HasHtmlDescription&Entity $entity */
|
||||
$entity = (clone $entity)->refresh();
|
||||
$html = $this->updateLinksInHtml($entity->description_html ?: '', $oldLink, $newLink);
|
||||
$entity->description_html = $html;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink): void
|
||||
{
|
||||
$page = (clone $page)->refresh();
|
||||
$html = $this->updateLinksInHtml($page->html, $oldLink, $newLink);
|
||||
|
@ -87,7 +87,7 @@ class MaintenanceController extends Controller
|
||||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
|
||||
|
||||
try {
|
||||
$referenceStore->updateForAllPages();
|
||||
$referenceStore->updateForAll();
|
||||
$this->showSuccessNotification(trans('settings.maint_regen_references_success'));
|
||||
} catch (\Exception $exception) {
|
||||
$this->showErrorNotification($exception->getMessage());
|
||||
|
79
app/Util/HtmlDescriptionFilter.php
Normal file
79
app/Util/HtmlDescriptionFilter.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use DOMAttr;
|
||||
use DOMElement;
|
||||
use DOMNamedNodeMap;
|
||||
use DOMNode;
|
||||
|
||||
/**
|
||||
* Filter to ensure HTML input for description content remains simple and
|
||||
* to a limited allow-list of elements and attributes.
|
||||
* More for consistency and to prevent nuisance rather than for security
|
||||
* (which would be done via a separate content filter and CSP).
|
||||
*/
|
||||
class HtmlDescriptionFilter
|
||||
{
|
||||
/**
|
||||
* @var array<string, string[]>
|
||||
*/
|
||||
protected static array $allowedAttrsByElements = [
|
||||
'p' => [],
|
||||
'a' => ['href', 'title'],
|
||||
'ol' => [],
|
||||
'ul' => [],
|
||||
'li' => [],
|
||||
'strong' => [],
|
||||
'em' => [],
|
||||
'br' => [],
|
||||
];
|
||||
|
||||
public static function filterFromString(string $html): string
|
||||
{
|
||||
if (empty(trim($html))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$doc = new HtmlDocument($html);
|
||||
|
||||
$topLevel = [...$doc->getBodyChildren()];
|
||||
foreach ($topLevel as $child) {
|
||||
/** @var DOMNode $child */
|
||||
if ($child instanceof DOMElement) {
|
||||
static::filterElement($child);
|
||||
} else {
|
||||
$child->parentNode->removeChild($child);
|
||||
}
|
||||
}
|
||||
|
||||
return $doc->getBodyInnerHtml();
|
||||
}
|
||||
|
||||
protected static function filterElement(DOMElement $element): void
|
||||
{
|
||||
$elType = strtolower($element->tagName);
|
||||
$allowedAttrs = static::$allowedAttrsByElements[$elType] ?? null;
|
||||
if (is_null($allowedAttrs)) {
|
||||
$element->remove();
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var DOMNamedNodeMap $attrs */
|
||||
$attrs = $element->attributes;
|
||||
for ($i = $attrs->length - 1; $i >= 0; $i--) {
|
||||
/** @var DOMAttr $attr */
|
||||
$attr = $attrs->item($i);
|
||||
$name = strtolower($attr->name);
|
||||
if (!in_array($name, $allowedAttrs)) {
|
||||
$element->removeAttribute($attr->name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($element->childNodes as $child) {
|
||||
if ($child instanceof DOMElement) {
|
||||
static::filterElement($child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -21,10 +21,12 @@ class BookFactory extends Factory
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
$description = $this->faker->paragraph();
|
||||
return [
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'description' => $this->faker->paragraph(),
|
||||
'description' => $description,
|
||||
'description_html' => '<p>' . e($description) . '</p>'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -21,10 +21,12 @@ class BookshelfFactory extends Factory
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
$description = $this->faker->paragraph();
|
||||
return [
|
||||
'name' => $this->faker->sentence,
|
||||
'slug' => Str::random(10),
|
||||
'description' => $this->faker->paragraph,
|
||||
'description' => $description,
|
||||
'description_html' => '<p>' . e($description) . '</p>'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -21,10 +21,12 @@ class ChapterFactory extends Factory
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
$description = $this->faker->paragraph();
|
||||
return [
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'description' => $this->faker->paragraph(),
|
||||
'description' => $description,
|
||||
'description_html' => '<p>' . e($description) . '</p>'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
$addColumn = fn(Blueprint $table) => $table->text('description_html');
|
||||
|
||||
Schema::table('books', $addColumn);
|
||||
Schema::table('chapters', $addColumn);
|
||||
Schema::table('bookshelves', $addColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$removeColumn = fn(Blueprint $table) => $table->removeColumn('description_html');
|
||||
|
||||
Schema::table('books', $removeColumn);
|
||||
Schema::table('chapters', $removeColumn);
|
||||
Schema::table('bookshelves', $removeColumn);
|
||||
}
|
||||
};
|
@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
@ -38,7 +39,7 @@ class DummyContentSeeder extends Seeder
|
||||
|
||||
$byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
|
||||
|
||||
\BookStack\Entities\Models\Book::factory()->count(5)->create($byData)
|
||||
Book::factory()->count(5)->create($byData)
|
||||
->each(function ($book) use ($byData) {
|
||||
$chapters = Chapter::factory()->count(3)->create($byData)
|
||||
->each(function ($chapter) use ($book, $byData) {
|
||||
@ -50,7 +51,7 @@ class DummyContentSeeder extends Seeder
|
||||
$book->pages()->saveMany($pages);
|
||||
});
|
||||
|
||||
$largeBook = \BookStack\Entities\Models\Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
|
||||
$largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
|
||||
$pages = Page::factory()->count(200)->make($byData);
|
||||
$chapters = Chapter::factory()->count(50)->make($byData);
|
||||
$largeBook->pages()->saveMany($pages);
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "My own book",
|
||||
"description": "This is my own little book",
|
||||
"default_template_id": 12,
|
||||
"description_html": "<p>This is <strong>my</strong> own little book created via the API</p>",
|
||||
"default_template_id": 2427,
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Top Content"},
|
||||
{"name": "Rating", "value": "Highest"}
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "My updated book",
|
||||
"description": "This is my book with updated details",
|
||||
"default_template_id": 12,
|
||||
"description_html": "<p>This is my book with <em>updated</em> details</p>",
|
||||
"default_template_id": 2427,
|
||||
"tags": [
|
||||
{"name": "Subject", "value": "Updates"}
|
||||
]
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"book_id": 1,
|
||||
"name": "My fantastic new chapter",
|
||||
"description": "This is a great new chapter that I've created via the API",
|
||||
"description_html": "<p>This is a <strong>great new chapter</strong> that I've created via the API</p>",
|
||||
"priority": 15,
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Top Content"},
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"book_id": 1,
|
||||
"name": "My fantastic updated chapter",
|
||||
"description": "This is an updated chapter that I've altered via the API",
|
||||
"description_html": "<p>This is an <strong>updated chapter</strong> that I've altered via the API</p>",
|
||||
"priority": 16,
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Kinda Good Content"},
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"name": "My shelf",
|
||||
"description": "This is my shelf with some books",
|
||||
"books": [5,1,3]
|
||||
"description_html": "<p>This is <strong>my shelf</strong> with some books</p>",
|
||||
"books": [5,1,3],
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Learning"}
|
||||
]
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "My updated shelf",
|
||||
"description": "This is my update shelf with some books",
|
||||
"description_html": "<p>This is my <em>updated shelf</em> with some books</p>",
|
||||
"books": [5,1,3]
|
||||
}
|
@ -1,12 +1,26 @@
|
||||
{
|
||||
"id": 15,
|
||||
"name": "My new book",
|
||||
"slug": "my-new-book",
|
||||
"description": "This is a book created via the API",
|
||||
"id": 226,
|
||||
"name": "My own book",
|
||||
"slug": "my-own-book",
|
||||
"description": "This is my own little book created via the API",
|
||||
"created_at": "2023-12-22T14:22:28.000000Z",
|
||||
"updated_at": "2023-12-22T14:22:28.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"default_template_id": 12,
|
||||
"updated_at": "2020-01-12T14:05:11.000000Z",
|
||||
"created_at": "2020-01-12T14:05:11.000000Z"
|
||||
"default_template_id": 2427,
|
||||
"description_html": "<p>This is <strong>my<\/strong> own little book created via the API<\/p>",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Top Content",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Highest",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"cover": null
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
"name": "My own book",
|
||||
"slug": "my-own-book",
|
||||
"description": "This is my own little book",
|
||||
"description_html": "<p>This is my own <em>little</em> book</p>",
|
||||
"created_at": "2020-01-12T14:09:59.000000Z",
|
||||
"updated_at": "2020-01-12T14:11:51.000000Z",
|
||||
"created_by": {
|
||||
|
@ -1,12 +1,21 @@
|
||||
{
|
||||
"id": 16,
|
||||
"id": 226,
|
||||
"name": "My updated book",
|
||||
"slug": "my-updated-book",
|
||||
"description": "This is my book with updated details",
|
||||
"created_at": "2020-01-12T14:09:59.000000Z",
|
||||
"updated_at": "2020-01-12T14:16:10.000000Z",
|
||||
"created_at": "2023-12-22T14:22:28.000000Z",
|
||||
"updated_at": "2023-12-22T14:24:07.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"default_template_id": 12
|
||||
"default_template_id": 2427,
|
||||
"description_html": "<p>This is my book with <em>updated<\/em> details<\/p>",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Subject",
|
||||
"value": "Updates",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"cover": null
|
||||
}
|
@ -1,15 +1,16 @@
|
||||
{
|
||||
"id": 74,
|
||||
"id": 668,
|
||||
"book_id": 1,
|
||||
"slug": "my-fantastic-new-chapter",
|
||||
"name": "My fantastic new chapter",
|
||||
"description": "This is a great new chapter that I've created via the API",
|
||||
"priority": 15,
|
||||
"created_at": "2023-12-22T14:26:28.000000Z",
|
||||
"updated_at": "2023-12-22T14:26:28.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"updated_at": "2020-05-22T22:59:55.000000Z",
|
||||
"created_at": "2020-05-22T22:59:55.000000Z",
|
||||
"description_html": "<p>This is a <strong>great new chapter<\/strong> that I've created via the API<\/p>",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
@ -19,7 +20,7 @@
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Highest",
|
||||
"order": 1
|
||||
"order": 0
|
||||
}
|
||||
]
|
||||
}
|
@ -4,6 +4,7 @@
|
||||
"slug": "content-creation",
|
||||
"name": "Content Creation",
|
||||
"description": "How to create documentation on whatever subject you need to write about.",
|
||||
"description_html": "<p>How to create <strong>documentation</strong> on whatever subject you need to write about.</p>",
|
||||
"priority": 3,
|
||||
"created_at": "2019-05-05T21:49:56.000000Z",
|
||||
"updated_at": "2019-09-28T11:24:23.000000Z",
|
||||
|
@ -1,16 +1,16 @@
|
||||
{
|
||||
"id": 75,
|
||||
"id": 668,
|
||||
"book_id": 1,
|
||||
"slug": "my-fantastic-updated-chapter",
|
||||
"name": "My fantastic updated chapter",
|
||||
"description": "This is an updated chapter that I've altered via the API",
|
||||
"priority": 16,
|
||||
"created_at": "2020-05-22T23:03:35.000000Z",
|
||||
"updated_at": "2020-05-22T23:07:20.000000Z",
|
||||
"created_at": "2023-12-22T14:26:28.000000Z",
|
||||
"updated_at": "2023-12-22T14:27:59.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"book_slug": "bookstack-demo-site",
|
||||
"description_html": "<p>This is an <strong>updated chapter<\/strong> that I've altered via the API<\/p>",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
@ -20,7 +20,7 @@
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Medium",
|
||||
"order": 1
|
||||
"order": 0
|
||||
}
|
||||
]
|
||||
}
|
@ -1,11 +1,20 @@
|
||||
{
|
||||
"id": 14,
|
||||
"id": 20,
|
||||
"name": "My shelf",
|
||||
"slug": "my-shelf",
|
||||
"description": "This is my shelf with some books",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"created_at": "2023-12-22T14:33:52.000000Z",
|
||||
"updated_at": "2023-12-22T14:33:52.000000Z",
|
||||
"owned_by": 1,
|
||||
"created_at": "2020-04-10T13:24:09.000000Z",
|
||||
"updated_at": "2020-04-10T13:24:09.000000Z"
|
||||
"description_html": "<p>This is <strong>my shelf<\/strong> with some books<\/p>",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Learning",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"cover": null
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
"name": "My shelf",
|
||||
"slug": "my-shelf",
|
||||
"description": "This is my shelf with some books",
|
||||
"description_html": "<p>This is my shelf with some books</p>",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
|
@ -1,11 +1,20 @@
|
||||
{
|
||||
"id": 14,
|
||||
"id": 20,
|
||||
"name": "My updated shelf",
|
||||
"slug": "my-updated-shelf",
|
||||
"description": "This is my update shelf with some books",
|
||||
"description": "This is my updated shelf with some books",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"created_at": "2023-12-22T14:33:52.000000Z",
|
||||
"updated_at": "2023-12-22T14:35:00.000000Z",
|
||||
"owned_by": 1,
|
||||
"created_at": "2020-04-10T13:24:09.000000Z",
|
||||
"updated_at": "2020-04-10T13:48:22.000000Z"
|
||||
"description_html": "<p>This is my <em>updated shelf<\/em> with some books<\/p>",
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Learning",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"cover": null
|
||||
}
|
@ -23,7 +23,7 @@ return [
|
||||
'meta_updated' => 'Updated :timeLength',
|
||||
'meta_updated_name' => 'Updated :timeLength by :user',
|
||||
'meta_owned_name' => 'Owned by :user',
|
||||
'meta_reference_page_count' => 'Referenced on :count page|Referenced on :count pages',
|
||||
'meta_reference_count' => 'Referenced by :count item|Referenced by :count items',
|
||||
'entity_select' => 'Entity Select',
|
||||
'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
|
||||
'images' => 'Images',
|
||||
@ -409,7 +409,7 @@ return [
|
||||
// References
|
||||
'references' => 'References',
|
||||
'references_none' => 'There are no tracked references to this item.',
|
||||
'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.',
|
||||
'references_to_desc' => 'Listed below is all the known content in the system that links to this item.',
|
||||
|
||||
// Watch Options
|
||||
'watch' => 'Watch',
|
||||
|
@ -15,8 +15,15 @@ export class EntitySelectorPopup extends Component {
|
||||
window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
|
||||
}
|
||||
|
||||
show(callback, searchText = '') {
|
||||
/**
|
||||
* Show the selector popup.
|
||||
* @param {Function} callback
|
||||
* @param {String} searchText
|
||||
* @param {EntitySelectorSearchOptions} searchOptions
|
||||
*/
|
||||
show(callback, searchText = '', searchOptions = {}) {
|
||||
this.callback = callback;
|
||||
this.getSelector().configureSearchOptions(searchOptions);
|
||||
this.getPopup().show();
|
||||
|
||||
if (searchText) {
|
||||
|
@ -1,6 +1,13 @@
|
||||
import {onChildEvent} from '../services/dom';
|
||||
import {Component} from './component';
|
||||
|
||||
/**
|
||||
* @typedef EntitySelectorSearchOptions
|
||||
* @property entityTypes string
|
||||
* @property entityPermission string
|
||||
* @property searchEndpoint string
|
||||
*/
|
||||
|
||||
/**
|
||||
* Entity Selector
|
||||
*/
|
||||
@ -8,21 +15,35 @@ export class EntitySelector extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
|
||||
this.entityPermission = this.$opts.entityPermission || 'view';
|
||||
this.searchEndpoint = this.$opts.searchEndpoint || '/search/entity-selector';
|
||||
|
||||
this.input = this.$refs.input;
|
||||
this.searchInput = this.$refs.search;
|
||||
this.loading = this.$refs.loading;
|
||||
this.resultsContainer = this.$refs.results;
|
||||
|
||||
this.searchOptions = {
|
||||
entityTypes: this.$opts.entityTypes || 'page,book,chapter',
|
||||
entityPermission: this.$opts.entityPermission || 'view',
|
||||
searchEndpoint: this.$opts.searchEndpoint || '',
|
||||
};
|
||||
|
||||
this.search = '';
|
||||
this.lastClick = 0;
|
||||
|
||||
this.setupListeners();
|
||||
this.showLoading();
|
||||
this.initialLoad();
|
||||
|
||||
if (this.searchOptions.searchEndpoint) {
|
||||
this.initialLoad();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EntitySelectorSearchOptions} options
|
||||
*/
|
||||
configureSearchOptions(options) {
|
||||
Object.assign(this.searchOptions, options);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
@ -103,6 +124,10 @@ export class EntitySelector extends Component {
|
||||
}
|
||||
|
||||
initialLoad() {
|
||||
if (!this.searchOptions.searchEndpoint) {
|
||||
throw new Error('Search endpoint not set for entity-selector load');
|
||||
}
|
||||
|
||||
window.$http.get(this.searchUrl()).then(resp => {
|
||||
this.resultsContainer.innerHTML = resp.data;
|
||||
this.hideLoading();
|
||||
@ -110,10 +135,15 @@ export class EntitySelector extends Component {
|
||||
}
|
||||
|
||||
searchUrl() {
|
||||
return `${this.searchEndpoint}?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||
const query = `types=${encodeURIComponent(this.searchOptions.entityTypes)}&permission=${encodeURIComponent(this.searchOptions.entityPermission)}`;
|
||||
return `${this.searchOptions.searchEndpoint}?${query}`;
|
||||
}
|
||||
|
||||
searchEntities(searchTerm) {
|
||||
if (!this.searchOptions.searchEndpoint) {
|
||||
throw new Error('Search endpoint not set for entity-selector load');
|
||||
}
|
||||
|
||||
this.input.value = '';
|
||||
const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
|
||||
window.$http.get(url).then(resp => {
|
||||
|
@ -58,3 +58,4 @@ export {TriLayout} from './tri-layout';
|
||||
export {UserSelect} from './user-select';
|
||||
export {WebhookEvents} from './webhook-events';
|
||||
export {WysiwygEditor} from './wysiwyg-editor';
|
||||
export {WysiwygInput} from './wysiwyg-input';
|
||||
|
@ -14,6 +14,8 @@ export class PagePicker extends Component {
|
||||
this.defaultDisplay = this.$refs.defaultDisplay;
|
||||
this.buttonSep = this.$refs.buttonSeperator;
|
||||
|
||||
this.selectorEndpoint = this.$opts.selectorEndpoint;
|
||||
|
||||
this.value = this.input.value;
|
||||
this.setupListeners();
|
||||
}
|
||||
@ -33,6 +35,10 @@ export class PagePicker extends Component {
|
||||
const selectorPopup = window.$components.first('entity-selector-popup');
|
||||
selectorPopup.show(entity => {
|
||||
this.setValue(entity.id, entity.name);
|
||||
}, '', {
|
||||
searchEndpoint: this.selectorEndpoint,
|
||||
entityTypes: 'page',
|
||||
entityPermission: 'view',
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {build as buildEditorConfig} from '../wysiwyg/config';
|
||||
import {buildForEditor as buildEditorConfig} from '../wysiwyg/config';
|
||||
import {Component} from './component';
|
||||
|
||||
export class WysiwygEditor extends Component {
|
||||
@ -6,17 +6,13 @@ export class WysiwygEditor extends Component {
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
||||
this.pageId = this.$opts.pageId;
|
||||
this.textDirection = this.$opts.textDirection;
|
||||
this.isDarkMode = document.documentElement.classList.contains('dark-mode');
|
||||
|
||||
this.tinyMceConfig = buildEditorConfig({
|
||||
language: this.$opts.language,
|
||||
containerElement: this.elem,
|
||||
darkMode: this.isDarkMode,
|
||||
textDirection: this.textDirection,
|
||||
darkMode: document.documentElement.classList.contains('dark-mode'),
|
||||
textDirection: this.$opts.textDirection,
|
||||
drawioUrl: this.getDrawIoUrl(),
|
||||
pageId: Number(this.pageId),
|
||||
pageId: Number(this.$opts.pageId),
|
||||
translations: {
|
||||
imageUploadErrorText: this.$opts.imageUploadErrorText,
|
||||
serverUploadLimitText: this.$opts.serverUploadLimitText,
|
||||
|
26
resources/js/components/wysiwyg-input.js
Normal file
26
resources/js/components/wysiwyg-input.js
Normal file
@ -0,0 +1,26 @@
|
||||
import {Component} from './component';
|
||||
import {buildForInput} from '../wysiwyg/config';
|
||||
|
||||
export class WysiwygInput extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
||||
const config = buildForInput({
|
||||
language: this.$opts.language,
|
||||
containerElement: this.elem,
|
||||
darkMode: document.documentElement.classList.contains('dark-mode'),
|
||||
textDirection: this.textDirection,
|
||||
translations: {
|
||||
imageUploadErrorText: this.$opts.imageUploadErrorText,
|
||||
serverUploadLimitText: this.$opts.serverUploadLimitText,
|
||||
},
|
||||
translationMap: window.editor_translations,
|
||||
});
|
||||
|
||||
window.tinymce.init(config).then(editors => {
|
||||
this.editor = editors[0];
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -73,7 +73,11 @@ export class Actions {
|
||||
const selectedText = selectionText || entity.name;
|
||||
const newText = `[${selectedText}](${entity.link})`;
|
||||
this.#replaceSelection(newText, newText.length, selectionRange);
|
||||
}, selectionText);
|
||||
}, selectionText, {
|
||||
searchEndpoint: '/search/entity-selector',
|
||||
entityTypes: 'page,book,chapter,bookshelf',
|
||||
entityPermission: 'view',
|
||||
});
|
||||
}
|
||||
|
||||
// Show draw.io if enabled and handle save.
|
||||
|
@ -85,7 +85,11 @@ function filePickerCallback(callback, value, meta) {
|
||||
text: entity.name,
|
||||
title: entity.name,
|
||||
});
|
||||
}, selectionText);
|
||||
}, selectionText, {
|
||||
searchEndpoint: '/search/entity-selector',
|
||||
entityTypes: 'page,book,chapter,bookshelf',
|
||||
entityPermission: 'view',
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.filetype === 'image') {
|
||||
@ -217,7 +221,7 @@ body {
|
||||
* @param {WysiwygConfigOptions} options
|
||||
* @return {Object}
|
||||
*/
|
||||
export function build(options) {
|
||||
export function buildForEditor(options) {
|
||||
// Set language
|
||||
window.tinymce.addI18n(options.language, options.translationMap);
|
||||
|
||||
@ -290,6 +294,54 @@ export function build(options) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WysiwygConfigOptions} options
|
||||
* @return {RawEditorOptions}
|
||||
*/
|
||||
export function buildForInput(options) {
|
||||
// Set language
|
||||
window.tinymce.addI18n(options.language, options.translationMap);
|
||||
|
||||
// BookStack Version
|
||||
const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
|
||||
|
||||
// Return config object
|
||||
return {
|
||||
width: '100%',
|
||||
height: '185px',
|
||||
target: options.containerElement,
|
||||
cache_suffix: `?version=${version}`,
|
||||
content_css: [
|
||||
window.baseUrl('/dist/styles.css'),
|
||||
],
|
||||
branding: false,
|
||||
skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
|
||||
body_class: 'wysiwyg-input',
|
||||
browser_spellcheck: true,
|
||||
relative_urls: false,
|
||||
language: options.language,
|
||||
directionality: options.textDirection,
|
||||
remove_script_host: false,
|
||||
document_base_url: window.baseUrl('/'),
|
||||
end_container_on_empty_block: true,
|
||||
remove_trailing_brs: false,
|
||||
statusbar: false,
|
||||
menubar: false,
|
||||
plugins: 'link autolink lists',
|
||||
contextmenu: false,
|
||||
toolbar: 'bold italic link bullist numlist',
|
||||
content_style: getContentStyle(options),
|
||||
file_picker_types: 'file',
|
||||
file_picker_callback: filePickerCallback,
|
||||
init_instance_callback(editor) {
|
||||
const head = editor.getDoc().querySelector('head');
|
||||
head.innerHTML += fetchCustomHeadContent();
|
||||
|
||||
editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WysiwygConfigOptions
|
||||
* @property {Element} containerElement
|
||||
|
@ -58,6 +58,10 @@ export function register(editor) {
|
||||
|
||||
editor.selection.collapse(false);
|
||||
editor.focus();
|
||||
}, selectionText);
|
||||
}, selectionText, {
|
||||
searchEndpoint: '/search/entity-selector',
|
||||
entityTypes: 'page,book,chapter,bookshelf',
|
||||
entityPermission: 'view',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -406,6 +406,15 @@ input[type=color] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.description-input > .tox-tinymce {
|
||||
border: 1px solid #DDD !important;
|
||||
@include lightDark(border-color, #DDD !important, #000 !important);
|
||||
border-radius: 3px;
|
||||
.tox-toolbar__primary {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
|
@ -23,6 +23,13 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wysiwyg-input.mce-content-body {
|
||||
padding-block-start: 1rem;
|
||||
padding-block-end: 1rem;
|
||||
outline: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Default styles for our custom root nodes
|
||||
.page-content.mce-content-body doc-root {
|
||||
display: block;
|
||||
|
@ -1,3 +1,6 @@
|
||||
@push('head')
|
||||
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
|
||||
@endpush
|
||||
|
||||
{{ csrf_field() }}
|
||||
<div class="form-group title-input">
|
||||
@ -6,8 +9,8 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group description-input">
|
||||
<label for="description">{{ trans('common.description') }}</label>
|
||||
@include('form.textarea', ['name' => 'description'])
|
||||
<label for="description_html">{{ trans('common.description') }}</label>
|
||||
@include('form.description-html-input')
|
||||
</div>
|
||||
|
||||
<div class="form-group collapsible" component="collapsible" id="logo-control">
|
||||
@ -36,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group collapsible" component="collapsible" id="template-control">
|
||||
<button refs="collapsible@trigger" type="button" class="collapse-title text-primary" aria-expanded="false">
|
||||
<button refs="collapsible@trigger" type="button" class="collapse-title text-link" aria-expanded="false">
|
||||
<label for="template-manager">{{ trans('entities.books_default_template') }}</label>
|
||||
</button>
|
||||
<div refs="collapsible@content" class="collapse-content">
|
||||
@ -50,6 +53,7 @@
|
||||
'name' => 'default_template_id',
|
||||
'placeholder' => trans('entities.books_default_template_select'),
|
||||
'value' => $book->default_template_id ?? null,
|
||||
'selectorEndpoint' => '/search/entity-selector-templates',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
@ -62,4 +66,5 @@
|
||||
<button type="submit" class="button">{{ trans('entities.books_save') }}</button>
|
||||
</div>
|
||||
|
||||
@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
|
||||
@include('entities.selector-popup')
|
||||
@include('form.editor-translations')
|
@ -26,7 +26,7 @@
|
||||
<main class="content-wrap card">
|
||||
<h1 class="break-text">{{$book->name}}</h1>
|
||||
<div refs="entity-search@contentView" class="book-content">
|
||||
<p class="text-muted">{!! nl2br(e($book->description)) !!}</p>
|
||||
<div class="text-muted break-text">{!! $book->descriptionHtml() !!}</div>
|
||||
@if(count($bookChildren) > 0)
|
||||
<div class="entity-list book-contents">
|
||||
@foreach($bookChildren as $childElement)
|
||||
|
@ -1,14 +1,16 @@
|
||||
@push('head')
|
||||
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
|
||||
@endpush
|
||||
|
||||
{!! csrf_field() !!}
|
||||
|
||||
{{ csrf_field() }}
|
||||
<div class="form-group title-input">
|
||||
<label for="name">{{ trans('common.name') }}</label>
|
||||
@include('form.text', ['name' => 'name', 'autofocus' => true])
|
||||
</div>
|
||||
|
||||
<div class="form-group description-input">
|
||||
<label for="description">{{ trans('common.description') }}</label>
|
||||
@include('form.textarea', ['name' => 'description'])
|
||||
<label for="description_html">{{ trans('common.description') }}</label>
|
||||
@include('form.description-html-input')
|
||||
</div>
|
||||
|
||||
<div class="form-group collapsible" component="collapsible" id="logo-control">
|
||||
@ -24,3 +26,6 @@
|
||||
<a href="{{ isset($chapter) ? $chapter->getUrl() : $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
|
||||
<button type="submit" class="button">{{ trans('entities.chapters_save') }}</button>
|
||||
</div>
|
||||
|
||||
@include('entities.selector-popup')
|
||||
@include('form.editor-translations')
|
@ -24,7 +24,7 @@
|
||||
<main class="content-wrap card">
|
||||
<h1 class="break-text">{{ $chapter->name }}</h1>
|
||||
<div refs="entity-search@contentView" class="chapter-content">
|
||||
<p class="text-muted break-text">{!! nl2br(e($chapter->description)) !!}</p>
|
||||
<div class="text-muted break-text">{!! $chapter->descriptionHtml() !!}</div>
|
||||
@if(count($pages) > 0)
|
||||
<div class="entity-list book-contents">
|
||||
@foreach($pages as $page)
|
||||
|
@ -64,7 +64,7 @@
|
||||
<a href="{{ $entity->getUrl('/references') }}" class="entity-meta-item">
|
||||
@icon('reference')
|
||||
<div>
|
||||
{!! trans_choice('entities.meta_reference_page_count', $referenceCount, ['count' => $referenceCount]) !!}
|
||||
{{ trans_choice('entities.meta_reference_count', $referenceCount, ['count' => $referenceCount]) }}
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
|
@ -5,7 +5,7 @@
|
||||
<div class="popup-title">{{ trans('entities.entity_select') }}</div>
|
||||
<button refs="popup@hide" type="button" class="popup-header-close">@icon('close')</button>
|
||||
</div>
|
||||
@include('entities.selector', ['name' => 'entity-selector'])
|
||||
@include('entities.selector', ['name' => 'entity-selector', 'selectorEndpoint' => ''])
|
||||
<div class="popup-footer">
|
||||
<button refs="entity-selector-popup@select" type="button" disabled class="button">{{ trans('common.select') }}</button>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@
|
||||
@section('content')
|
||||
|
||||
<h1 style="font-size: 4.8em">{{$book->name}}</h1>
|
||||
<p>{{ $book->description }}</p>
|
||||
<div>{!! $book->descriptionHtml() !!}</div>
|
||||
|
||||
@include('exports.parts.book-contents-menu', ['children' => $bookChildren])
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
@section('content')
|
||||
|
||||
<h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
|
||||
<p>{{ $chapter->description }}</p>
|
||||
<div>{!! $chapter->descriptionHtml() !!}</div>
|
||||
|
||||
@include('exports.parts.chapter-contents-menu', ['pages' => $pages])
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="page-break"></div>
|
||||
<h1 id="chapter-{{$chapter->id}}">{{ $chapter->name }}</h1>
|
||||
|
||||
<p>{{ $chapter->description }}</p>
|
||||
<div>{!! $chapter->descriptionHtml() !!}</div>
|
||||
|
||||
@if(count($chapter->visible_pages) > 0)
|
||||
@foreach($chapter->visible_pages as $page)
|
||||
|
8
resources/views/form/description-html-input.blade.php
Normal file
8
resources/views/form/description-html-input.blade.php
Normal file
@ -0,0 +1,8 @@
|
||||
<textarea component="wysiwyg-input"
|
||||
option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
|
||||
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'))
|
||||
<div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
|
||||
@endif
|
@ -1,6 +1,7 @@
|
||||
|
||||
{{--Depends on entity selector popup--}}
|
||||
<div component="page-picker">
|
||||
<div component="page-picker"
|
||||
option:page-picker:selector-endpoint="{{ $selectorEndpoint }}">
|
||||
<div class="input-base overflow-hidden height-auto">
|
||||
<span @if($value) hidden @endif refs="page-picker@default-display" class="text-muted italic">{{ $placeholder }}</span>
|
||||
<a @if(!$value) hidden @endif href="{{ url('/link/' . $value) }}" target="_blank" rel="noopener" class="text-page" refs="page-picker@display">#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::query()->visible()->find($value)->name ?? '' : '' }}</a>
|
||||
|
@ -18,4 +18,4 @@
|
||||
<div class="text-neg text-small">{{ $errors->first('html') }}</div>
|
||||
@endif
|
||||
|
||||
@include('pages.parts.editor-translations')
|
||||
@include('form.editor-translations')
|
@ -3,7 +3,7 @@
|
||||
@section('card')
|
||||
<h1 id="customization" class="list-heading">{{ trans('settings.app_customization') }}</h1>
|
||||
<form action="{{ url("/settings/customization") }}" method="POST" enctype="multipart/form-data">
|
||||
{!! csrf_field() !!}
|
||||
{{ csrf_field() }}
|
||||
<input type="hidden" name="section" value="customization">
|
||||
|
||||
<div class="setting-list">
|
||||
@ -133,7 +133,12 @@
|
||||
</select>
|
||||
|
||||
<div refs="setting-homepage-control@page-picker-container" style="display: none;" class="mt-m">
|
||||
@include('form.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
|
||||
@include('form.page-picker', [
|
||||
'name' => 'setting-app-homepage',
|
||||
'placeholder' => trans('settings.app_homepage_select'),
|
||||
'value' => setting('app-homepage'),
|
||||
'selectorEndpoint' => '/search/entity-selector',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -168,5 +173,5 @@
|
||||
@endsection
|
||||
|
||||
@section('after-content')
|
||||
@include('entities.selector-popup', ['entityTypes' => 'page'])
|
||||
@include('entities.selector-popup')
|
||||
@endsection
|
||||
|
@ -1,13 +1,16 @@
|
||||
{{ csrf_field() }}
|
||||
@push('head')
|
||||
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
|
||||
@endpush
|
||||
|
||||
{{ csrf_field() }}
|
||||
<div class="form-group title-input">
|
||||
<label for="name">{{ trans('common.name') }}</label>
|
||||
@include('form.text', ['name' => 'name', 'autofocus' => true])
|
||||
</div>
|
||||
|
||||
<div class="form-group description-input">
|
||||
<label for="description">{{ trans('common.description') }}</label>
|
||||
@include('form.textarea', ['name' => 'description'])
|
||||
<label for="description_html">{{ trans('common.description') }}</label>
|
||||
@include('form.description-html-input')
|
||||
</div>
|
||||
|
||||
<div component="shelf-sort" class="grid half gap-xl">
|
||||
@ -84,4 +87,7 @@
|
||||
<div class="form-group text-right">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@include('entities.selector-popup')
|
||||
@include('form.editor-translations')
|
@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<div class="book-content">
|
||||
<p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
|
||||
<div class="text-muted break-text">{!! $shelf->descriptionHtml() !!}</div>
|
||||
@if(count($sortedVisibleShelfBooks) > 0)
|
||||
@if($view === 'list')
|
||||
<div class="entity-list">
|
||||
|
@ -33,8 +33,8 @@ class BooksApiTest extends TestCase
|
||||
$this->actingAsApiEditor();
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$details = [
|
||||
'name' => 'My API book',
|
||||
'description' => 'A book created via the API',
|
||||
'name' => 'My API book',
|
||||
'description' => 'A book created via the API',
|
||||
'default_template_id' => $templatePage->id,
|
||||
];
|
||||
|
||||
@ -42,10 +42,35 @@ class BooksApiTest extends TestCase
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
$resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
|
||||
$resp->assertJson(array_merge($details, [
|
||||
'id' => $newItem->id,
|
||||
'slug' => $newItem->slug,
|
||||
'description_html' => '<p>A book created via the API</p>',
|
||||
]));
|
||||
$this->assertActivityExists('book_create', $newItem);
|
||||
}
|
||||
|
||||
public function test_create_endpoint_with_html()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$details = [
|
||||
'name' => 'My API book',
|
||||
'description_html' => '<p>A book <em>created</em> <strong>via</strong> the API</p>',
|
||||
];
|
||||
|
||||
$resp = $this->postJson($this->baseEndpoint, $details);
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
$expectedDetails = array_merge($details, [
|
||||
'id' => $newItem->id,
|
||||
'description' => 'A book created via the API',
|
||||
]);
|
||||
|
||||
$resp->assertJson($expectedDetails);
|
||||
$this->assertDatabaseHas('books', $expectedDetails);
|
||||
}
|
||||
|
||||
public function test_book_name_needed_to_create()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
@ -61,7 +86,7 @@ class BooksApiTest extends TestCase
|
||||
'validation' => [
|
||||
'name' => ['The name field is required.'],
|
||||
],
|
||||
'code' => 422,
|
||||
'code' => 422,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@ -128,7 +153,7 @@ class BooksApiTest extends TestCase
|
||||
$templatePage = $this->entities->templatePage();
|
||||
$details = [
|
||||
'name' => 'My updated API book',
|
||||
'description' => 'A book created via the API',
|
||||
'description' => 'A book updated via the API',
|
||||
'default_template_id' => $templatePage->id,
|
||||
];
|
||||
|
||||
@ -136,10 +161,29 @@ class BooksApiTest extends TestCase
|
||||
$book->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson(array_merge($details, ['id' => $book->id, 'slug' => $book->slug]));
|
||||
$resp->assertJson(array_merge($details, [
|
||||
'id' => $book->id,
|
||||
'slug' => $book->slug,
|
||||
'description_html' => '<p>A book updated via the API</p>',
|
||||
]));
|
||||
$this->assertActivityExists('book_update', $book);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_with_html()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$book = $this->entities->book();
|
||||
$details = [
|
||||
'name' => 'My updated API book',
|
||||
'description_html' => '<p>A book <strong>updated</strong> via the API</p>',
|
||||
];
|
||||
|
||||
$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']));
|
||||
}
|
||||
|
||||
public function test_update_increments_updated_date_if_only_tags_are_sent()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
|
@ -51,7 +51,11 @@ class ChaptersApiTest extends TestCase
|
||||
$resp = $this->postJson($this->baseEndpoint, $details);
|
||||
$resp->assertStatus(200);
|
||||
$newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
$resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
|
||||
$resp->assertJson(array_merge($details, [
|
||||
'id' => $newItem->id,
|
||||
'slug' => $newItem->slug,
|
||||
'description_html' => '<p>A chapter created via the API</p>',
|
||||
]));
|
||||
$this->assertDatabaseHas('tags', [
|
||||
'entity_id' => $newItem->id,
|
||||
'entity_type' => $newItem->getMorphClass(),
|
||||
@ -62,6 +66,28 @@ class ChaptersApiTest extends TestCase
|
||||
$this->assertActivityExists('chapter_create', $newItem);
|
||||
}
|
||||
|
||||
public function test_create_endpoint_with_html()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$book = $this->entities->book();
|
||||
$details = [
|
||||
'name' => 'My API chapter',
|
||||
'description_html' => '<p>A chapter <strong>created</strong> via the API</p>',
|
||||
'book_id' => $book->id,
|
||||
];
|
||||
|
||||
$resp = $this->postJson($this->baseEndpoint, $details);
|
||||
$resp->assertStatus(200);
|
||||
$newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
|
||||
$expectedDetails = array_merge($details, [
|
||||
'id' => $newItem->id,
|
||||
'description' => 'A chapter created via the API',
|
||||
]);
|
||||
$resp->assertJson($expectedDetails);
|
||||
$this->assertDatabaseHas('chapters', $expectedDetails);
|
||||
}
|
||||
|
||||
public function test_chapter_name_needed_to_create()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
@ -131,7 +157,7 @@ class ChaptersApiTest extends TestCase
|
||||
$chapter = $this->entities->chapter();
|
||||
$details = [
|
||||
'name' => 'My updated API chapter',
|
||||
'description' => 'A chapter created via the API',
|
||||
'description' => 'A chapter updated via the API',
|
||||
'tags' => [
|
||||
[
|
||||
'name' => 'freshtag',
|
||||
@ -146,11 +172,31 @@ class ChaptersApiTest extends TestCase
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson(array_merge($details, [
|
||||
'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id,
|
||||
'id' => $chapter->id,
|
||||
'slug' => $chapter->slug,
|
||||
'book_id' => $chapter->book_id,
|
||||
'description_html' => '<p>A chapter updated via the API</p>',
|
||||
]));
|
||||
$this->assertActivityExists('chapter_update', $chapter);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_with_html()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$chapter = $this->entities->chapter();
|
||||
$details = [
|
||||
'name' => 'My updated API chapter',
|
||||
'description_html' => '<p>A chapter <em>updated</em> via the API</p>',
|
||||
];
|
||||
|
||||
$resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
|
||||
$resp->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('chapters', array_merge($details, [
|
||||
'id' => $chapter->id, 'description' => 'A chapter updated via the API'
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_update_increments_updated_date_if_only_tags_are_sent()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
|
@ -52,7 +52,7 @@ class SearchApiTest extends TestCase
|
||||
public function test_all_endpoint_returns_items_with_preview_html()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
$book->update(['name' => 'name with superuniquevalue within', 'description' => 'Description with superuniquevalue within']);
|
||||
$book->forceFill(['name' => 'name with superuniquevalue within', 'description' => 'Description with superuniquevalue within'])->save();
|
||||
$book->indexForSearch();
|
||||
|
||||
$resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
|
||||
|
@ -42,7 +42,11 @@ class ShelvesApiTest extends TestCase
|
||||
$resp = $this->postJson($this->baseEndpoint, array_merge($details, ['books' => [$books[0]->id, $books[1]->id]]));
|
||||
$resp->assertStatus(200);
|
||||
$newItem = Bookshelf::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
$resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
|
||||
$resp->assertJson(array_merge($details, [
|
||||
'id' => $newItem->id,
|
||||
'slug' => $newItem->slug,
|
||||
'description_html' => '<p>A shelf created via the API</p>',
|
||||
]));
|
||||
$this->assertActivityExists('bookshelf_create', $newItem);
|
||||
foreach ($books as $index => $book) {
|
||||
$this->assertDatabaseHas('bookshelves_books', [
|
||||
@ -53,6 +57,28 @@ class ShelvesApiTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_create_endpoint_with_html()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
|
||||
$details = [
|
||||
'name' => 'My API shelf',
|
||||
'description_html' => '<p>A <strong>shelf</strong> created via the API</p>',
|
||||
];
|
||||
|
||||
$resp = $this->postJson($this->baseEndpoint, $details);
|
||||
$resp->assertStatus(200);
|
||||
$newItem = Bookshelf::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
|
||||
|
||||
$expectedDetails = array_merge($details, [
|
||||
'id' => $newItem->id,
|
||||
'description' => 'A shelf created via the API',
|
||||
]);
|
||||
|
||||
$resp->assertJson($expectedDetails);
|
||||
$this->assertDatabaseHas('bookshelves', $expectedDetails);
|
||||
}
|
||||
|
||||
public function test_shelf_name_needed_to_create()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
@ -102,17 +128,36 @@ class ShelvesApiTest extends TestCase
|
||||
$shelf = Bookshelf::visible()->first();
|
||||
$details = [
|
||||
'name' => 'My updated API shelf',
|
||||
'description' => 'A shelf created via the API',
|
||||
'description' => 'A shelf updated via the API',
|
||||
];
|
||||
|
||||
$resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details);
|
||||
$shelf->refresh();
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson(array_merge($details, ['id' => $shelf->id, 'slug' => $shelf->slug]));
|
||||
$resp->assertJson(array_merge($details, [
|
||||
'id' => $shelf->id,
|
||||
'slug' => $shelf->slug,
|
||||
'description_html' => '<p>A shelf updated via the API</p>',
|
||||
]));
|
||||
$this->assertActivityExists('bookshelf_update', $shelf);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_with_html()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
$shelf = Bookshelf::visible()->first();
|
||||
$details = [
|
||||
'name' => 'My updated API shelf',
|
||||
'description_html' => '<p>A shelf <em>updated</em> via the API</p>',
|
||||
];
|
||||
|
||||
$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']));
|
||||
}
|
||||
|
||||
public function test_update_increments_updated_date_if_only_tags_are_sent()
|
||||
{
|
||||
$this->actingAsApiEditor();
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Tests\TestCase;
|
||||
@ -24,6 +25,28 @@ class UpdateUrlCommandTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_command_updates_description_html()
|
||||
{
|
||||
/** @var Entity[] $models */
|
||||
$models = [$this->entities->book(), $this->entities->chapter(), $this->entities->shelf()];
|
||||
|
||||
foreach ($models as $model) {
|
||||
$model->description_html = '<a href="https://example.com/donkeys"></a>';
|
||||
$model->save();
|
||||
}
|
||||
|
||||
$this->artisan('bookstack:update-url https://example.com https://cats.example.com')
|
||||
->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');
|
||||
|
||||
foreach ($models as $model) {
|
||||
$this->assertDatabaseHas($model->getTable(), [
|
||||
'id' => $model->id,
|
||||
'description_html' => '<a href="https://cats.example.com/donkeys"></a>',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_command_requires_valid_url()
|
||||
{
|
||||
$badUrlMessage = 'The given urls are expected to be full urls starting with http:// or https://';
|
||||
|
@ -77,8 +77,8 @@ class BookShelfTest extends TestCase
|
||||
{
|
||||
$booksToInclude = Book::take(2)->get();
|
||||
$shelfInfo = [
|
||||
'name' => 'My test book' . Str::random(4),
|
||||
'description' => 'Test book description ' . Str::random(10),
|
||||
'name' => 'My test shelf' . Str::random(4),
|
||||
'description_html' => '<p>Test book description ' . Str::random(10) . '</p>',
|
||||
];
|
||||
$resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [
|
||||
'books' => $booksToInclude->implode('id', ','),
|
||||
@ -96,7 +96,7 @@ class BookShelfTest extends TestCase
|
||||
$shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
|
||||
$shelfPage = $this->get($shelf->getUrl());
|
||||
$shelfPage->assertSee($shelfInfo['name']);
|
||||
$shelfPage->assertSee($shelfInfo['description']);
|
||||
$shelfPage->assertSee($shelfInfo['description_html'], false);
|
||||
$this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Category');
|
||||
$this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Tag Value');
|
||||
|
||||
@ -107,8 +107,8 @@ class BookShelfTest extends TestCase
|
||||
public function test_shelves_create_sets_cover_image()
|
||||
{
|
||||
$shelfInfo = [
|
||||
'name' => 'My test book' . Str::random(4),
|
||||
'description' => 'Test book description ' . Str::random(10),
|
||||
'name' => 'My test shelf' . Str::random(4),
|
||||
'description_html' => '<p>Test book description ' . Str::random(10) . '</p>',
|
||||
];
|
||||
|
||||
$imageFile = $this->files->uploadedImage('shelf-test.png');
|
||||
@ -174,7 +174,7 @@ class BookShelfTest extends TestCase
|
||||
// Set book ordering
|
||||
$this->asAdmin()->put($shelf->getUrl(), [
|
||||
'books' => $books->implode('id', ','),
|
||||
'tags' => [], 'description' => 'abc', 'name' => 'abc',
|
||||
'tags' => [], 'description_html' => 'abc', 'name' => 'abc',
|
||||
]);
|
||||
$this->assertEquals(3, $shelf->books()->count());
|
||||
$shelf->refresh();
|
||||
@ -207,7 +207,7 @@ class BookShelfTest extends TestCase
|
||||
// Set book ordering
|
||||
$this->asAdmin()->put($shelf->getUrl(), [
|
||||
'books' => $books->implode('id', ','),
|
||||
'tags' => [], 'description' => 'abc', 'name' => 'abc',
|
||||
'tags' => [], 'description_html' => 'abc', 'name' => 'abc',
|
||||
]);
|
||||
$this->assertEquals(3, $shelf->books()->count());
|
||||
$shelf->refresh();
|
||||
@ -229,8 +229,8 @@ class BookShelfTest extends TestCase
|
||||
|
||||
$booksToInclude = Book::take(2)->get();
|
||||
$shelfInfo = [
|
||||
'name' => 'My test book' . Str::random(4),
|
||||
'description' => 'Test book description ' . Str::random(10),
|
||||
'name' => 'My test shelf' . Str::random(4),
|
||||
'description_html' => '<p>Test book description ' . Str::random(10) . '</p>',
|
||||
];
|
||||
|
||||
$resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [
|
||||
@ -251,7 +251,7 @@ class BookShelfTest extends TestCase
|
||||
|
||||
$shelfPage = $this->get($shelf->getUrl());
|
||||
$shelfPage->assertSee($shelfInfo['name']);
|
||||
$shelfPage->assertSee($shelfInfo['description']);
|
||||
$shelfPage->assertSee($shelfInfo['description_html'], false);
|
||||
$this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Category');
|
||||
$this->withHtml($shelfPage)->assertElementContains('.tag-item', 'Test Tag Value');
|
||||
|
||||
@ -270,8 +270,8 @@ class BookShelfTest extends TestCase
|
||||
$testName = 'Test Book in Shelf Name';
|
||||
|
||||
$createBookResp = $this->asEditor()->post($shelf->getUrl('/create-book'), [
|
||||
'name' => $testName,
|
||||
'description' => 'Book in shelf description',
|
||||
'name' => $testName,
|
||||
'description_html' => 'Book in shelf description',
|
||||
]);
|
||||
$createBookResp->assertRedirect();
|
||||
|
||||
@ -372,8 +372,8 @@ class BookShelfTest extends TestCase
|
||||
{
|
||||
// Create shelf
|
||||
$shelfInfo = [
|
||||
'name' => 'My test shelf' . Str::random(4),
|
||||
'description' => 'Test shelf description ' . Str::random(10),
|
||||
'name' => 'My test shelf' . Str::random(4),
|
||||
'description_html' => '<p>Test shelf description ' . Str::random(10) . '</p>',
|
||||
];
|
||||
|
||||
$this->asEditor()->post('/shelves', $shelfInfo);
|
||||
@ -381,8 +381,8 @@ class BookShelfTest extends TestCase
|
||||
|
||||
// Create book and add to shelf
|
||||
$this->asEditor()->post($shelf->getUrl('/create-book'), [
|
||||
'name' => 'Test book name',
|
||||
'description' => 'Book in shelf description',
|
||||
'name' => 'Test book name',
|
||||
'description_html' => '<p>Book in shelf description</p>',
|
||||
]);
|
||||
|
||||
$newBook = Book::query()->orderBy('id', 'desc')->first();
|
||||
@ -403,4 +403,15 @@ class BookShelfTest extends TestCase
|
||||
$resp = $this->asEditor()->get($shelf->getUrl('/create-book'));
|
||||
$this->withHtml($resp)->assertElementContains('form a[href="' . $shelf->getUrl() . '"]', 'Cancel');
|
||||
}
|
||||
|
||||
public function test_show_view_displays_description_if_no_description_html_set()
|
||||
{
|
||||
$shelf = $this->entities->shelf();
|
||||
$shelf->description_html = '';
|
||||
$shelf->description = "My great\ndescription\n\nwith newlines";
|
||||
$shelf->save();
|
||||
|
||||
$resp = $this->asEditor()->get($shelf->getUrl());
|
||||
$resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ class BookTest extends TestCase
|
||||
$resp = $this->get('/create-book');
|
||||
$this->withHtml($resp)->assertElementContains('form[action="' . url('/books') . '"][method="POST"]', 'Save Book');
|
||||
|
||||
$resp = $this->post('/books', $book->only('name', 'description'));
|
||||
$resp = $this->post('/books', $book->only('name', 'description_html'));
|
||||
$resp->assertRedirect('/books/my-first-book');
|
||||
|
||||
$resp = $this->get('/books/my-first-book');
|
||||
@ -36,8 +36,8 @@ class BookTest extends TestCase
|
||||
'name' => 'My First Book',
|
||||
]);
|
||||
|
||||
$this->asEditor()->post('/books', $book->only('name', 'description'));
|
||||
$this->asEditor()->post('/books', $book->only('name', 'description'));
|
||||
$this->asEditor()->post('/books', $book->only('name', 'description_html'));
|
||||
$this->asEditor()->post('/books', $book->only('name', 'description_html'));
|
||||
|
||||
$books = Book::query()->where('name', '=', $book->name)
|
||||
->orderBy('id', 'desc')
|
||||
@ -52,9 +52,9 @@ class BookTest extends TestCase
|
||||
{
|
||||
// Cheeky initial update to refresh slug
|
||||
$this->asEditor()->post('books', [
|
||||
'name' => 'My book with tags',
|
||||
'description' => 'A book with tags',
|
||||
'tags' => [
|
||||
'name' => 'My book with tags',
|
||||
'description_html' => '<p>A book with tags</p>',
|
||||
'tags' => [
|
||||
[
|
||||
'name' => 'Category',
|
||||
'value' => 'Donkey Content',
|
||||
@ -79,23 +79,23 @@ class BookTest extends TestCase
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
// Cheeky initial update to refresh slug
|
||||
$this->asEditor()->put($book->getUrl(), ['name' => $book->name . '5', 'description' => $book->description]);
|
||||
$this->asEditor()->put($book->getUrl(), ['name' => $book->name . '5', 'description_html' => $book->description_html]);
|
||||
$book->refresh();
|
||||
|
||||
$newName = $book->name . ' Updated';
|
||||
$newDesc = $book->description . ' with more content';
|
||||
$newDesc = $book->description_html . '<p>with more content</p>';
|
||||
|
||||
$resp = $this->get($book->getUrl('/edit'));
|
||||
$resp->assertSee($book->name);
|
||||
$resp->assertSee($book->description);
|
||||
$resp->assertSee($book->description_html);
|
||||
$this->withHtml($resp)->assertElementContains('form[action="' . $book->getUrl() . '"]', 'Save Book');
|
||||
|
||||
$resp = $this->put($book->getUrl(), ['name' => $newName, 'description' => $newDesc]);
|
||||
$resp = $this->put($book->getUrl(), ['name' => $newName, 'description_html' => $newDesc]);
|
||||
$resp->assertRedirect($book->getUrl() . '-updated');
|
||||
|
||||
$resp = $this->get($book->getUrl() . '-updated');
|
||||
$resp->assertSee($newName);
|
||||
$resp->assertSee($newDesc);
|
||||
$resp->assertSee($newDesc, false);
|
||||
}
|
||||
|
||||
public function test_update_sets_tags()
|
||||
@ -184,7 +184,7 @@ class BookTest extends TestCase
|
||||
|
||||
public function test_recently_viewed_books_updates_as_expected()
|
||||
{
|
||||
$books = Book::all()->take(2);
|
||||
$books = Book::take(2)->get();
|
||||
|
||||
$resp = $this->asAdmin()->get('/books');
|
||||
$this->withHtml($resp)->assertElementNotContains('#recents', $books[0]->name)
|
||||
@ -200,7 +200,7 @@ class BookTest extends TestCase
|
||||
|
||||
public function test_popular_books_updates_upon_visits()
|
||||
{
|
||||
$books = Book::all()->take(2);
|
||||
$books = Book::take(2)->get();
|
||||
|
||||
$resp = $this->asAdmin()->get('/books');
|
||||
$this->withHtml($resp)->assertElementNotContains('#popular', $books[0]->name)
|
||||
@ -262,6 +262,33 @@ class BookTest extends TestCase
|
||||
$this->assertEquals('parta-partb-partc', $book->slug);
|
||||
}
|
||||
|
||||
public function test_description_limited_to_specific_html()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
|
||||
$input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section></p>';
|
||||
$expected = '<p>Content<a href="#cat">a</a></p>';
|
||||
|
||||
$this->asEditor()->put($book->getUrl(), [
|
||||
'name' => $book->name,
|
||||
'description_html' => $input
|
||||
]);
|
||||
|
||||
$book->refresh();
|
||||
$this->assertEquals($expected, $book->description_html);
|
||||
}
|
||||
|
||||
public function test_show_view_displays_description_if_no_description_html_set()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
$book->description_html = '';
|
||||
$book->description = "My great\ndescription\n\nwith newlines";
|
||||
$book->save();
|
||||
|
||||
$resp = $this->asEditor()->get($book->getUrl());
|
||||
$resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
|
||||
}
|
||||
|
||||
public function test_show_view_has_copy_button()
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
@ -291,6 +318,8 @@ class BookTest extends TestCase
|
||||
|
||||
$resp->assertRedirect($copy->getUrl());
|
||||
$this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count());
|
||||
|
||||
$this->get($copy->getUrl())->assertSee($book->description_html, false);
|
||||
}
|
||||
|
||||
public function test_copy_does_not_copy_non_visible_content()
|
||||
|
@ -23,12 +23,23 @@ class ChapterTest extends TestCase
|
||||
$resp = $this->get($book->getUrl('/create-chapter'));
|
||||
$this->withHtml($resp)->assertElementContains('form[action="' . $book->getUrl('/create-chapter') . '"][method="POST"]', 'Save Chapter');
|
||||
|
||||
$resp = $this->post($book->getUrl('/create-chapter'), $chapter->only('name', 'description'));
|
||||
$resp = $this->post($book->getUrl('/create-chapter'), $chapter->only('name', 'description_html'));
|
||||
$resp->assertRedirect($book->getUrl('/chapter/my-first-chapter'));
|
||||
|
||||
$resp = $this->get($book->getUrl('/chapter/my-first-chapter'));
|
||||
$resp->assertSee($chapter->name);
|
||||
$resp->assertSee($chapter->description);
|
||||
$resp->assertSee($chapter->description_html, false);
|
||||
}
|
||||
|
||||
public function test_show_view_displays_description_if_no_description_html_set()
|
||||
{
|
||||
$chapter = $this->entities->chapter();
|
||||
$chapter->description_html = '';
|
||||
$chapter->description = "My great\ndescription\n\nwith newlines";
|
||||
$chapter->save();
|
||||
|
||||
$resp = $this->asEditor()->get($chapter->getUrl());
|
||||
$resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
|
||||
}
|
||||
|
||||
public function test_delete()
|
||||
|
@ -42,6 +42,7 @@ class ConvertTest extends TestCase
|
||||
$this->assertEquals('Penguins', $newBook->tags->first()->value);
|
||||
$this->assertEquals($chapter->name, $newBook->name);
|
||||
$this->assertEquals($chapter->description, $newBook->description);
|
||||
$this->assertEquals($chapter->description_html, $newBook->description_html);
|
||||
|
||||
$this->assertActivityExists(ActivityType::BOOK_CREATE_FROM_CHAPTER, $newBook);
|
||||
}
|
||||
@ -105,6 +106,7 @@ class ConvertTest extends TestCase
|
||||
$this->assertEquals('Ducks', $newShelf->tags->first()->value);
|
||||
$this->assertEquals($book->name, $newShelf->name);
|
||||
$this->assertEquals($book->description, $newShelf->description);
|
||||
$this->assertEquals($book->description_html, $newShelf->description_html);
|
||||
$this->assertEquals($newShelf->books()->count(), $bookChapterCount + 1);
|
||||
$this->assertEquals($systemBookCount + $bookChapterCount, Book::query()->count());
|
||||
$this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf);
|
||||
|
@ -107,18 +107,18 @@ class ExportTest extends TestCase
|
||||
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
|
||||
}
|
||||
|
||||
public function test_book_html_export_shows_chapter_descriptions()
|
||||
public function test_book_html_export_shows_html_descriptions()
|
||||
{
|
||||
$chapterDesc = 'My custom test chapter description ' . Str::random(12);
|
||||
$chapter = $this->entities->chapter();
|
||||
$chapter->description = $chapterDesc;
|
||||
$book = $this->entities->bookHasChaptersAndPages();
|
||||
$chapter = $book->chapters()->first();
|
||||
$book->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
|
||||
$chapter->description_html = '<p>A chapter description with <strong>HTML</strong> within!</p>';
|
||||
$book->save();
|
||||
$chapter->save();
|
||||
|
||||
$book = $chapter->book;
|
||||
$this->asEditor();
|
||||
|
||||
$resp = $this->get($book->getUrl('/export/html'));
|
||||
$resp->assertSee($chapterDesc);
|
||||
$resp = $this->asEditor()->get($book->getUrl('/export/html'));
|
||||
$resp->assertSee($book->description_html, false);
|
||||
$resp->assertSee($chapter->description_html, false);
|
||||
}
|
||||
|
||||
public function test_chapter_text_export()
|
||||
@ -174,6 +174,16 @@ class ExportTest extends TestCase
|
||||
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
|
||||
}
|
||||
|
||||
public function test_chapter_html_export_shows_html_descriptions()
|
||||
{
|
||||
$chapter = $this->entities->chapter();
|
||||
$chapter->description_html = '<p>A description with <strong>HTML</strong> within!</p>';
|
||||
$chapter->save();
|
||||
|
||||
$resp = $this->asEditor()->get($chapter->getUrl('/export/html'));
|
||||
$resp->assertSee($chapter->description_html, false);
|
||||
}
|
||||
|
||||
public function test_page_html_export_contains_custom_head_if_set()
|
||||
{
|
||||
$page = $this->entities->page();
|
||||
|
@ -30,7 +30,30 @@ class ReferencesTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_references_deleted_on_entity_delete()
|
||||
public function test_references_created_on_book_chapter_bookshelf_update()
|
||||
{
|
||||
$entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->shelf()];
|
||||
$shelf = $this->entities->shelf();
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$entity->refresh();
|
||||
$this->assertDatabaseMissing('references', ['from_id' => $entity->id, 'from_type' => $entity->getMorphClass()]);
|
||||
|
||||
$this->asEditor()->put($entity->getUrl(), [
|
||||
'name' => 'Reference test',
|
||||
'description_html' => '<a href="' . $shelf->getUrl() . '">Testing</a>',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('references', [
|
||||
'from_id' => $entity->id,
|
||||
'from_type' => $entity->getMorphClass(),
|
||||
'to_id' => $shelf->id,
|
||||
'to_type' => $shelf->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_references_deleted_on_page_delete()
|
||||
{
|
||||
$pageA = $this->entities->page();
|
||||
$pageB = $this->entities->page();
|
||||
@ -48,6 +71,25 @@ class ReferencesTest extends TestCase
|
||||
$this->assertDatabaseMissing('references', ['to_id' => $pageA->id, 'to_type' => $pageA->getMorphClass()]);
|
||||
}
|
||||
|
||||
public function test_references_from_deleted_on_book_chapter_shelf_delete()
|
||||
{
|
||||
$entities = [$this->entities->chapter(), $this->entities->book(), $this->entities->shelf()];
|
||||
$shelf = $this->entities->shelf();
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$this->createReference($entity, $shelf);
|
||||
$this->assertDatabaseHas('references', ['from_id' => $entity->id, 'from_type' => $entity->getMorphClass()]);
|
||||
|
||||
$this->asEditor()->delete($entity->getUrl());
|
||||
app(TrashCan::class)->empty();
|
||||
|
||||
$this->assertDatabaseMissing('references', [
|
||||
'from_id' => $entity->id,
|
||||
'from_type' => $entity->getMorphClass()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_references_to_count_visible_on_entity_show_view()
|
||||
{
|
||||
$entities = $this->entities->all();
|
||||
@ -60,13 +102,13 @@ class ReferencesTest extends TestCase
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$resp = $this->get($entity->getUrl());
|
||||
$resp->assertSee('Referenced on 1 page');
|
||||
$resp->assertDontSee('Referenced on 1 pages');
|
||||
$resp->assertSee('Referenced by 1 item');
|
||||
$resp->assertDontSee('Referenced by 1 items');
|
||||
}
|
||||
|
||||
$this->createReference($otherPage, $entities['page']);
|
||||
$resp = $this->get($entities['page']->getUrl());
|
||||
$resp->assertSee('Referenced on 2 pages');
|
||||
$resp->assertSee('Referenced by 2 items');
|
||||
}
|
||||
|
||||
public function test_references_to_visible_on_references_page()
|
||||
@ -203,6 +245,32 @@ class ReferencesTest extends TestCase
|
||||
$this->assertEquals($expected, $page->markdown);
|
||||
}
|
||||
|
||||
public function test_description_links_from_book_chapter_shelf_updated_on_url_change()
|
||||
{
|
||||
$entities = [$this->entities->chapter(), $this->entities->book(), $this->entities->shelf()];
|
||||
$shelf = $this->entities->shelf();
|
||||
$this->asEditor();
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$this->put($entity->getUrl(), [
|
||||
'name' => 'Reference test',
|
||||
'description_html' => '<a href="' . $shelf->getUrl() . '">Testing</a>',
|
||||
]);
|
||||
}
|
||||
|
||||
$oldUrl = $shelf->getUrl();
|
||||
$this->put($shelf->getUrl(), ['name' => 'My updated shelf link']);
|
||||
$shelf->refresh();
|
||||
$this->assertNotEquals($oldUrl, $shelf->getUrl());
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$oldHtml = $entity->description_html;
|
||||
$entity->refresh();
|
||||
$this->assertNotEquals($oldHtml, $entity->description_html);
|
||||
$this->assertStringContainsString($shelf->getUrl(), $entity->description_html);
|
||||
}
|
||||
}
|
||||
|
||||
protected function createReference(Model $from, Model $to)
|
||||
{
|
||||
(new Reference())->forceFill([
|
||||
|
@ -21,7 +21,7 @@ class RegenerateReferencesTest extends TestCase
|
||||
public function test_action_runs_reference_regen()
|
||||
{
|
||||
$this->mock(ReferenceStore::class)
|
||||
->shouldReceive('updateForAllPages')
|
||||
->shouldReceive('updateForAll')
|
||||
->once();
|
||||
|
||||
$resp = $this->asAdmin()->post('/settings/maintenance/regenerate-references');
|
||||
@ -45,7 +45,7 @@ class RegenerateReferencesTest extends TestCase
|
||||
public function test_action_failed_shown_as_error_notification()
|
||||
{
|
||||
$this->mock(ReferenceStore::class)
|
||||
->shouldReceive('updateForAllPages')
|
||||
->shouldReceive('updateForAll')
|
||||
->andThrow(\Exception::class, 'A badger stopped the task');
|
||||
|
||||
$resp = $this->asAdmin()->post('/settings/maintenance/regenerate-references');
|
||||
|
Loading…
Reference in New Issue
Block a user