From bf8e7f3393d48e6300c4d8775daeb40d55ea2017 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 16 May 2021 00:29:56 +0100 Subject: [PATCH 01/10] Started addition of favourite system --- app/Actions/Favourite.php | 17 +++++ app/Actions/View.php | 4 +- app/Entities/Models/Entity.php | 22 +++++- app/Http/Controllers/FavouriteController.php | 76 +++++++++++++++++++ app/Interfaces/Favouritable.php | 11 +++ ...1_05_15_173110_create_favourites_table.php | 36 +++++++++ resources/icons/star-circle.svg | 1 - resources/icons/star-outline.svg | 1 + resources/lang/en/activities.php | 4 + resources/lang/en/common.php | 2 + resources/views/pages/show.blade.php | 3 + .../entity-favourite-action.blade.php | 12 +++ routes/web.php | 5 ++ 13 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 app/Actions/Favourite.php create mode 100644 app/Http/Controllers/FavouriteController.php create mode 100644 app/Interfaces/Favouritable.php create mode 100644 database/migrations/2021_05_15_173110_create_favourites_table.php create mode 100644 resources/icons/star-outline.svg create mode 100644 resources/views/partials/entity-favourite-action.blade.php diff --git a/app/Actions/Favourite.php b/app/Actions/Favourite.php new file mode 100644 index 000000000..107a76578 --- /dev/null +++ b/app/Actions/Favourite.php @@ -0,0 +1,17 @@ +morphTo(); + } +} diff --git a/app/Actions/View.php b/app/Actions/View.php index e9841293b..c5ec6a38d 100644 --- a/app/Actions/View.php +++ b/app/Actions/View.php @@ -1,6 +1,7 @@ morphTo(); } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index d4b477304..be63cec2b 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -2,6 +2,7 @@ use BookStack\Actions\Activity; use BookStack\Actions\Comment; +use BookStack\Actions\Favourite; use BookStack\Actions\Tag; use BookStack\Actions\View; use BookStack\Auth\Permissions\EntityPermission; @@ -9,6 +10,7 @@ use BookStack\Auth\Permissions\JointPermission; use BookStack\Entities\Tools\SearchIndex; use BookStack\Entities\Tools\SlugGenerator; use BookStack\Facades\Permissions; +use BookStack\Interfaces\Favouritable; use BookStack\Interfaces\Sluggable; use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; @@ -38,7 +40,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static Builder withLastView() * @method static Builder withViewCount() */ -abstract class Entity extends Model implements Sluggable +abstract class Entity extends Model implements Sluggable, Favouritable { use SoftDeletes; use HasCreatorAndUpdater; @@ -297,4 +299,22 @@ abstract class Entity extends Model implements Sluggable $this->slug = app(SlugGenerator::class)->generate($this); return $this->slug; } + + /** + * @inheritdoc + */ + public function favourites(): MorphMany + { + return $this->morphMany(Favourite::class, 'favouritable'); + } + + /** + * Check if the entity is a favourite of the current user. + */ + public function isFavourite(): bool + { + return $this->favourites() + ->where('user_id', '=', user()->id) + ->exists(); + } } diff --git a/app/Http/Controllers/FavouriteController.php b/app/Http/Controllers/FavouriteController.php new file mode 100644 index 000000000..8a26eac8e --- /dev/null +++ b/app/Http/Controllers/FavouriteController.php @@ -0,0 +1,76 @@ +getValidatedModelFromRequest($request); + $favouritable->favourites()->firstOrCreate([ + 'user_id' => user()->id, + ]); + + $this->showSuccessNotification(trans('activities.favourite_add_notification', [ + 'name' => $favouritable->name, + ])); + return redirect()->back(); + } + + /** + * Remove an item as a favourite. + */ + public function remove(Request $request) + { + $favouritable = $this->getValidatedModelFromRequest($request); + $favouritable->favourites()->where([ + 'user_id' => user()->id, + ])->delete(); + + $this->showSuccessNotification(trans('activities.favourite_remove_notification', [ + 'name' => $favouritable->name, + ])); + return redirect()->back(); + } + + /** + * @throws \Illuminate\Validation\ValidationException + * @throws \Exception + */ + protected function getValidatedModelFromRequest(Request $request): Favouritable + { + $modelInfo = $this->validate($request, [ + 'type' => 'required|string', + 'id' => 'required|integer', + ]); + + if (!class_exists($modelInfo['type'])) { + throw new \Exception('Model not found'); + } + + /** @var Model $model */ + $model = new $modelInfo['type']; + if (! $model instanceof Favouritable) { + throw new \Exception('Model not favouritable'); + } + + $modelInstance = $model->newQuery() + ->where('id', '=', $modelInfo['id']) + ->first(['id', 'name']); + + $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance)); + if (is_null($modelInstance) || $inaccessibleEntity) { + throw new \Exception('Model instance not found'); + } + + return $modelInstance; + } +} diff --git a/app/Interfaces/Favouritable.php b/app/Interfaces/Favouritable.php new file mode 100644 index 000000000..dd335feed --- /dev/null +++ b/app/Interfaces/Favouritable.php @@ -0,0 +1,11 @@ +increments('id'); + $table->integer('user_id')->index(); + $table->integer('favouritable_id'); + $table->string('favouritable_type', 100); + $table->timestamps(); + + $table->index(['favouritable_id', 'favouritable_type'], 'favouritable_index'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('favourites'); + } +} diff --git a/resources/icons/star-circle.svg b/resources/icons/star-circle.svg index 0e3f16879..a7667e48f 100644 --- a/resources/icons/star-circle.svg +++ b/resources/icons/star-circle.svg @@ -1,4 +1,3 @@ - \ No newline at end of file diff --git a/resources/icons/star-outline.svg b/resources/icons/star-outline.svg new file mode 100644 index 000000000..4e83ab42b --- /dev/null +++ b/resources/icons/star-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index fe937b061..5917de2cf 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -43,6 +43,10 @@ return [ 'bookshelf_delete' => 'deleted bookshelf', 'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted', + // Favourites + 'favourite_add_notification' => '":name" has been added to your favourites', + 'favourite_remove_notification' => '":name" has been removed from your favourites', + // Other 'commented_on' => 'commented on', 'permissions_update' => 'updated permissions', diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 855c1c807..e198878ad 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -40,6 +40,8 @@ return [ 'remove' => 'Remove', 'add' => 'Add', 'fullscreen' => 'Fullscreen', + 'favourite' => 'Favourite', + 'unfavourite' => 'Unfavourite', // Sort Options 'sort_options' => 'Sort Options', diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 13125464a..73a107cf7 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -151,6 +151,9 @@
{{--Export--}} + @if(signedInUser()) + @include('partials.entity-favourite-action', ['entity' => $page, 'alreadyFavourite' => $page->isFavourite()]) + @endif @include('partials.entity-export-menu', ['entity' => $page]) diff --git a/resources/views/partials/entity-favourite-action.blade.php b/resources/views/partials/entity-favourite-action.blade.php new file mode 100644 index 000000000..49ba6aa5d --- /dev/null +++ b/resources/views/partials/entity-favourite-action.blade.php @@ -0,0 +1,12 @@ +@php + $isFavourite = $entity->isFavourite(); +@endphp +
+ {{ csrf_field() }} + + + +
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 730c795f8..69823b4ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -152,9 +152,14 @@ Route::group(['middleware' => 'auth'], function () { // User Search Route::get('/search/users/select', 'UserSearchController@forSelect'); + // Template System Route::get('/templates', 'PageTemplateController@list'); Route::get('/templates/{templateId}', 'PageTemplateController@get'); + // Favourites + Route::post('/favourites/add', 'FavouriteController@add'); + Route::post('/favourites/remove', 'FavouriteController@remove'); + // Other Pages Route::get('/', 'HomeController@index'); Route::get('/home', 'HomeController@index'); From db9aa410960ed38ebdbae2ee6ce0ac44ee0eb068 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 16 May 2021 01:07:20 +0100 Subject: [PATCH 02/10] Started writing testing for favourites --- tests/FavouriteTest.php | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/FavouriteTest.php diff --git a/tests/FavouriteTest.php b/tests/FavouriteTest.php new file mode 100644 index 000000000..ce5caf5c2 --- /dev/null +++ b/tests/FavouriteTest.php @@ -0,0 +1,59 @@ +first(); + $editor = $this->getEditor(); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertElementContains('button', 'Favourite'); + $resp->assertElementExists('form[method="POST"][action$="/favourites/add"]'); + + $resp = $this->post('/favourites/add', [ + 'type' => get_class($page), + 'id' => $page->id, + ]); + $resp->assertRedirect($page->getUrl()); + $resp->assertSessionHas('success', "\"{$page->name}\" has been added to your favourites"); + + $this->assertDatabaseHas('favourites', [ + 'user_id' => $editor->id, + 'favouritable_type' => $page->getMorphClass(), + 'favouritable_id' => $page->id, + ]); + } + + public function test_page_remove_favourite_flow() + { + $page = Page::query()->first(); + $editor = $this->getEditor(); + Favourite::query()->forceCreate([ + 'user_id' => $editor->id, + 'favouritable_id' => $page->id, + 'favouritable_type' => $page->getMorphClass(), + ]); + + $resp = $this->actingAs($editor)->get($page->getUrl()); + $resp->assertElementContains('button', 'Unfavourite'); + $resp->assertElementExists('form[method="POST"][action$="/favourites/remove"]'); + + $resp = $this->post('/favourites/remove', [ + 'type' => get_class($page), + 'id' => $page->id, + ]); + $resp->assertRedirect($page->getUrl()); + $resp->assertSessionHas('success', "\"{$page->name}\" has been removed from your favourites"); + + $this->assertDatabaseMissing('favourites', [ + 'user_id' => $editor->id, + ]); + } + +} \ No newline at end of file From 3ca149137e5b33422f82456e1512fba417322968 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 16 May 2021 10:26:28 +0100 Subject: [PATCH 03/10] Added faviourtes to other entity types --- resources/views/books/show.blade.php | 3 +++ resources/views/chapters/show.blade.php | 3 +++ resources/views/pages/show.blade.php | 3 +-- resources/views/shelves/show.blade.php | 5 +++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index def198bdd..b850127ff 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -118,6 +118,9 @@
+ @if(signedInUser()) + @include('partials.entity-favourite-action', ['entity' => $book]) + @endif @include('partials.entity-export-menu', ['entity' => $book]) diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index db02ebcc4..8aa3be111 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -123,6 +123,9 @@
+ @if(signedInUser()) + @include('partials.entity-favourite-action', ['entity' => $chapter]) + @endif @include('partials.entity-export-menu', ['entity' => $chapter]) diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 73a107cf7..5baf2a16c 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -150,9 +150,8 @@
- {{--Export--}} @if(signedInUser()) - @include('partials.entity-favourite-action', ['entity' => $page, 'alreadyFavourite' => $page->isFavourite()]) + @include('partials.entity-favourite-action', ['entity' => $page]) @endif @include('partials.entity-export-menu', ['entity' => $page]) diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 7ed36c906..431fa54cc 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -133,6 +133,11 @@ @endif + @if(signedInUser()) +
+ @include('partials.entity-favourite-action', ['entity' => $shelf]) + @endif + @stop From 93fd869ba36cb8e74690af95d28236084d159b63 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 16 May 2021 10:49:37 +0100 Subject: [PATCH 04/10] Started refactoring of view service Phasing out the view service from being a generic 'service' class, moving the core create/delete methods into the model. The idea is that the existing query work will need to interlink with the favourite system so maybe we have a (or many composable) query building classes rather than mixing query building and create/delete work as per the old service. --- app/Actions/View.php | 39 ++++++++++++++++++++ app/Actions/ViewService.php | 36 ------------------ app/Console/Commands/ClearViews.php | 3 +- app/Entities/Models/Entity.php | 3 +- app/Http/Controllers/BookController.php | 3 +- app/Http/Controllers/BookshelfController.php | 3 +- app/Http/Controllers/ChapterController.php | 3 +- app/Http/Controllers/PageController.php | 3 +- app/Interfaces/Viewable.php | 11 ++++++ 9 files changed, 62 insertions(+), 42 deletions(-) create mode 100644 app/Interfaces/Viewable.php diff --git a/app/Actions/View.php b/app/Actions/View.php index c5ec6a38d..62e03d9f4 100644 --- a/app/Actions/View.php +++ b/app/Actions/View.php @@ -1,8 +1,19 @@ morphTo(); } + + /** + * Increment the current user's view count for the given viewable model. + */ + public static function incrementFor(Viewable $viewable): int + { + $user = user(); + if (is_null($user) || $user->isDefault()) { + return 0; + } + + /** @var View $view */ + $view = $viewable->views()->firstOrNew([ + 'user_id' => $user->id, + ], ['views' => 0]); + + $view->save(['views' => $view->views + 1]); + + return $view->views; + } + + /** + * Clear all views from the system. + */ + public static function clearAll() + { + static::query()->truncate(); + } } diff --git a/app/Actions/ViewService.php b/app/Actions/ViewService.php index a4e620d4b..febc93af3 100644 --- a/app/Actions/ViewService.php +++ b/app/Actions/ViewService.php @@ -1,7 +1,6 @@ entityProvider = $entityProvider; } - /** - * Add a view to the given entity. - * @param \BookStack\Entities\Models\Entity $entity - * @return int - */ - public function add(Entity $entity) - { - $user = user(); - if ($user === null || $user->isDefault()) { - return 0; - } - $view = $entity->views()->where('user_id', '=', $user->id)->first(); - // Add view if model exists - if ($view) { - $view->increment('views'); - return $view->views; - } - - // Otherwise create new view count - $entity->views()->save($this->view->newInstance([ - 'user_id' => $user->id, - 'views' => 1 - ])); - - return 1; - } - /** * Get the entities with the most views. * @param int $count @@ -106,12 +78,4 @@ class ViewService return $all->sortByDesc('last_viewed_at')->slice(0, $count); } - - /** - * Reset all view counts by deleting all views. - */ - public function resetAll() - { - $this->view->truncate(); - } } diff --git a/app/Console/Commands/ClearViews.php b/app/Console/Commands/ClearViews.php index 35356210b..693d93639 100644 --- a/app/Console/Commands/ClearViews.php +++ b/app/Console/Commands/ClearViews.php @@ -2,6 +2,7 @@ namespace BookStack\Console\Commands; +use BookStack\Actions\View; use Illuminate\Console\Command; class ClearViews extends Command @@ -36,7 +37,7 @@ class ClearViews extends Command */ public function handle() { - \Views::resetAll(); + View::clearAll(); $this->comment('Views cleared'); } } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index be63cec2b..561876769 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -12,6 +12,7 @@ use BookStack\Entities\Tools\SlugGenerator; use BookStack\Facades\Permissions; use BookStack\Interfaces\Favouritable; use BookStack\Interfaces\Sluggable; +use BookStack\Interfaces\Viewable; use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasOwner; @@ -40,7 +41,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static Builder withLastView() * @method static Builder withViewCount() */ -abstract class Entity extends Model implements Sluggable, Favouritable +abstract class Entity extends Model implements Sluggable, Favouritable, Viewable { use SoftDeletes; use HasCreatorAndUpdater; diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 59c205d0a..d111f9f0a 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -2,6 +2,7 @@ use Activity; use BookStack\Actions\ActivityType; +use BookStack\Actions\View; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Tools\PermissionsUpdater; @@ -112,7 +113,7 @@ class BookController extends Controller $bookChildren = (new BookContents($book))->getTree(true); $bookParentShelves = $book->shelves()->visible()->get(); - Views::add($book); + View::incrementFor($book); if ($request->has('shelf')) { $this->entityContextManager->setShelfContext(intval($request->get('shelf'))); } diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 03b3cad54..b4795db09 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -1,6 +1,7 @@ values() ->all(); - Views::add($shelf); + View::incrementFor($shelf); $this->entityContextManager->setShelfContext($shelf->id); $view = setting()->getForCurrentUser('bookshelf_view_type'); diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 1d69df2a2..fbef81582 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -1,5 +1,6 @@ book))->getTree(); $pages = $chapter->getVisiblePages(); - Views::add($chapter); + View::incrementFor($chapter); $this->setPageTitle($chapter->getShortName()); return view('chapters.show', [ diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 30d33ad48..769ea8e69 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -1,5 +1,6 @@ load(['comments.createdBy']); } - Views::add($page); + View::incrementFor($page); $this->setPageTitle($page->getShortName()); return view('pages.show', [ 'page' => $page, diff --git a/app/Interfaces/Viewable.php b/app/Interfaces/Viewable.php new file mode 100644 index 000000000..4a7b6a013 --- /dev/null +++ b/app/Interfaces/Viewable.php @@ -0,0 +1,11 @@ + Date: Wed, 19 May 2021 23:37:23 +0100 Subject: [PATCH 05/10] Started building system for cross-model queries --- app/Auth/Permissions/PermissionService.php | 3 +- .../Tools/RelationMultiModelQuery.php | 154 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 app/Entities/Tools/RelationMultiModelQuery.php diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php index c5bdc8070..1c82f8e08 100644 --- a/app/Auth/Permissions/PermissionService.php +++ b/app/Auth/Permissions/PermissionService.php @@ -580,8 +580,9 @@ class PermissionService /** * Filter items that have entities set as a polymorphic relation. + * @param Builder|\Illuminate\Database\Query\Builder $query */ - public function filterRestrictedEntityRelations(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view'): Builder + public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view') { $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; diff --git a/app/Entities/Tools/RelationMultiModelQuery.php b/app/Entities/Tools/RelationMultiModelQuery.php new file mode 100644 index 000000000..68a90acd0 --- /dev/null +++ b/app/Entities/Tools/RelationMultiModelQuery.php @@ -0,0 +1,154 @@ + */ + protected $lookupModels = []; + + /** @var Model */ + protected $relation; + + /** @var string */ + protected $polymorphicFieldName; + + public function __construct(Model $relation, string $polymorphicFieldName) + { + $this->relation = $relation; + $this->polymorphicFieldName = $polymorphicFieldName; + } + + /** + * Set the query to look up the given entity type. + */ + public function forEntity(string $class, array $columns): self + { + $this->lookupModels[$class] = $columns; + return $this; + } + + /** + * Set the query to look up all entity types. + */ + public function forAllEntities(): self + { + $this->lookupModels[Page::class] = ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text']; + $this->lookupModels[Chapter::class] = ['id', 'name', 'slug', 'book_id', 'description']; + $this->lookupModels[Book::class] = ['id', 'name', 'slug', 'description', 'image_id']; + $this->lookupModels[Bookshelf::class] = ['id', 'name', 'slug', 'description', 'image_id']; + return $this; + } + + /** + * Build the core query to run. + */ + protected function build(): Builder + { + $query = $this->relation->newQuery()->toBase(); + $relationTable = $this->relation->getTable(); + $modelTables = []; + + // Load model selects & joins + foreach ($this->lookupModels as $lookupModel => $columns) { + /** @var Entity $model */ + $model = (new $lookupModel); + $table = $model->getTable(); + $modelTables[] = $table; + $query->addSelect($this->tableColumnsToSelectArray($table, $columns)); + $query->leftJoin($table, function (JoinClause $join) use ($table, $relationTable, $model) { + $polyPrefix = $relationTable . '.' . $this->polymorphicFieldName; + $join->on($polyPrefix . '_id', '=', $table . '.id'); + $join->where($polyPrefix . '_type', '=', $model->getMorphClass()); + $join->whereNull($table . '.deleted_at'); + }); + } + + // Where we have a model result + $query->where(function (Builder $query) use ($modelTables) { + foreach ($modelTables as $table) { + $query->orWhereNotNull($table . '.id'); + } + }); + + $this->applyPermissionsToQuery($query, 'view'); + + return $query; + } + + protected function applyPermissionsToQuery(Builder $query, string $action) + { + $permissions = app()->make(PermissionService::class); + $permissions->filterRestrictedEntityRelations( + $query, + $this->relation->getTable(), + $this->polymorphicFieldName . '_id', + $this->polymorphicFieldName . '_type', + $action, + ); + } + + /** + * Create an array of select statements from the given table and column. + */ + protected function tableColumnsToSelectArray(string $table, array $columns): array + { + $selectArray = []; + foreach ($columns as $column) { + $selectArray[] = $table . '.' . $column . ' as '. $table . '_' . $column; + } + return $selectArray; + } + + /** + * Get the SQL from the core query being ran. + */ + public function toSql(): string + { + return $this->build()->toSql(); + } + + /** + * Run the query and get the results. + */ + public function run(): Collection + { + return $this->build()->get(); + } +} \ No newline at end of file From d0ff79ea6010f71861ce687c1f768264db3978e1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 22 May 2021 14:05:28 +0100 Subject: [PATCH 06/10] Revamped some complex queries, added favourites to home - Removed old view system and started use of new query classes instead. - Finished off RelationMultiModelQuery but found it was less efficient than x-many queries due to the amount of tables being scanned. Adding now for history but will delete as not used. - Updated recently viewed to use same query system as popular items rather than running and joining x-entities queries. - Added "Most Viewed Faviourites" listing to homepages. --- app/Actions/View.php | 2 +- app/Actions/ViewService.php | 81 ------- app/Config/app.php | 2 - app/Entities/Queries/EntityQuery.php | 17 ++ app/Entities/Queries/Popular.php | 29 +++ app/Entities/Queries/RecentlyViewed.php | 32 +++ app/Entities/Queries/TopFavourites.php | 36 +++ .../Tools/RelationMultiModelQuery.php | 218 +++++++++++++++--- app/Facades/Views.php | 16 -- app/Http/Controllers/HomeController.php | 9 +- app/Http/Controllers/SearchController.php | 3 +- resources/lang/en/entities.php | 1 + resources/views/common/home-sidebar.blade.php | 12 + resources/views/common/home.blade.php | 12 + resources/views/errors/404.blade.php | 6 +- 15 files changed, 337 insertions(+), 139 deletions(-) delete mode 100644 app/Actions/ViewService.php create mode 100644 app/Entities/Queries/EntityQuery.php create mode 100644 app/Entities/Queries/Popular.php create mode 100644 app/Entities/Queries/RecentlyViewed.php create mode 100644 app/Entities/Queries/TopFavourites.php delete mode 100644 app/Facades/Views.php diff --git a/app/Actions/View.php b/app/Actions/View.php index 62e03d9f4..de30900c7 100644 --- a/app/Actions/View.php +++ b/app/Actions/View.php @@ -42,7 +42,7 @@ class View extends Model 'user_id' => $user->id, ], ['views' => 0]); - $view->save(['views' => $view->views + 1]); + $view->forceFill(['views' => $view->views + 1])->save(); return $view->views; } diff --git a/app/Actions/ViewService.php b/app/Actions/ViewService.php deleted file mode 100644 index febc93af3..000000000 --- a/app/Actions/ViewService.php +++ /dev/null @@ -1,81 +0,0 @@ -view = $view; - $this->permissionService = $permissionService; - $this->entityProvider = $entityProvider; - } - - /** - * Get the entities with the most views. - * @param int $count - * @param int $page - * @param string|array $filterModels - * @param string $action - used for permission checking - * @return Collection - */ - public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view') - { - $skipCount = $count * $page; - $query = $this->permissionService - ->filterRestrictedEntityRelations($this->view->newQuery(), 'views', 'viewable_id', 'viewable_type', $action) - ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) - ->groupBy('viewable_id', 'viewable_type') - ->orderBy('view_count', 'desc'); - - if ($filterModels) { - $query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels)); - } - - return $query->with('viewable') - ->skip($skipCount) - ->take($count) - ->get() - ->pluck('viewable') - ->filter(); - } - - /** - * Get all recently viewed entities for the current user. - */ - public function getUserRecentlyViewed(int $count = 10, int $page = 1) - { - $user = user(); - if ($user === null || $user->isDefault()) { - return collect(); - } - - $all = collect(); - /** @var Entity $instance */ - foreach ($this->entityProvider->all() as $name => $instance) { - $items = $instance::visible()->withLastView() - ->having('last_viewed_at', '>', 0) - ->orderBy('last_viewed_at', 'desc') - ->skip($count * ($page - 1)) - ->take($count) - ->get(); - $all = $all->concat($items); - } - - return $all->sortByDesc('last_viewed_at')->slice(0, $count); - } -} diff --git a/app/Config/app.php b/app/Config/app.php index 065845f96..88f38423a 100755 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -184,11 +184,9 @@ return [ // Custom BookStack 'Activity' => BookStack\Facades\Activity::class, - 'Views' => BookStack\Facades\Views::class, 'Images' => BookStack\Facades\Images::class, 'Permissions' => BookStack\Facades\Permissions::class, 'Theme' => BookStack\Facades\Theme::class, - ], // Proxy configuration diff --git a/app/Entities/Queries/EntityQuery.php b/app/Entities/Queries/EntityQuery.php new file mode 100644 index 000000000..bd920c3a5 --- /dev/null +++ b/app/Entities/Queries/EntityQuery.php @@ -0,0 +1,17 @@ +make(PermissionService::class); + } + + protected function entityProvider(): EntityProvider + { + return app()->make(EntityProvider::class); + } +} \ No newline at end of file diff --git a/app/Entities/Queries/Popular.php b/app/Entities/Queries/Popular.php new file mode 100644 index 000000000..98db2fe62 --- /dev/null +++ b/app/Entities/Queries/Popular.php @@ -0,0 +1,29 @@ +permissionService() + ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action) + ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) + ->groupBy('viewable_id', 'viewable_type') + ->orderBy('view_count', 'desc'); + + if ($filterModels) { + $query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels)); + } + + return $query->with('viewable') + ->skip($count * ($page - 1)) + ->take($count) + ->get() + ->pluck('viewable') + ->filter(); + } + +} \ No newline at end of file diff --git a/app/Entities/Queries/RecentlyViewed.php b/app/Entities/Queries/RecentlyViewed.php new file mode 100644 index 000000000..d528fea44 --- /dev/null +++ b/app/Entities/Queries/RecentlyViewed.php @@ -0,0 +1,32 @@ +isDefault()) { + return collect(); + } + + $query = $this->permissionService()->filterRestrictedEntityRelations( + View::query(), + 'views', + 'viewable_id', + 'viewable_type', + 'view' + ) + ->orderBy('views.updated_at', 'desc') + ->where('user_id', '=', user()->id); + + return $query->with('viewable') + ->skip(($page - 1) * $count) + ->take($count) + ->get() + ->pluck('viewable') + ->filter(); + } +} diff --git a/app/Entities/Queries/TopFavourites.php b/app/Entities/Queries/TopFavourites.php new file mode 100644 index 000000000..aaacdc5ad --- /dev/null +++ b/app/Entities/Queries/TopFavourites.php @@ -0,0 +1,36 @@ +isDefault()) { + return collect(); + } + + $query = $this->permissionService() + ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', 'view') + ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) + ->groupBy('viewable_id', 'viewable_type') + ->rightJoin('favourites', function (JoinClause $join) { + $join->on('views.viewable_id', '=', 'favourites.favouritable_id'); + $join->on('views.viewable_type', '=', 'favourites.favouritable_type'); + $join->where('favourites.user_id', '=', user()->id); + }) + ->orderBy('view_count', 'desc'); + + return $query->with('viewable') + ->skip($count * ($page - 1)) + ->take($count) + ->get() + ->pluck('viewable') + ->filter(); + } + +} \ No newline at end of file diff --git a/app/Entities/Tools/RelationMultiModelQuery.php b/app/Entities/Tools/RelationMultiModelQuery.php index 68a90acd0..c992fe454 100644 --- a/app/Entities/Tools/RelationMultiModelQuery.php +++ b/app/Entities/Tools/RelationMultiModelQuery.php @@ -18,28 +18,6 @@ use Illuminate\Support\Collection; */ class RelationMultiModelQuery { - - // TODO - Hydrate results to models - // TODO - Allow setting additional wheres and all-model columns (From the core relation - eg, last_viewed_at) - -//select views.updated_at as last_viewed_at, -//b.id as book_id, b.name as book_name, b.slug as book_slug, b.description as book_description, -//s.id as bookshelf_id, s.name as bookshelf_name, s.slug as bookshelf_slug, s.description as bookshelf_description, -//c.id as chapter_id, c.name as chapter_name, c.slug as chapter_slug, c.description as chapter_description, -//p.id as page_id, p.name as page_name, p.slug as page_slug, p.text as page_description -//from views -//left join bookshelves s on (s.id = views.viewable_id and views.viewable_type = 'BookStack\\Bookshelf' and s.deleted_at is null) -//left join books b on (b.id = views.viewable_id and views.viewable_type = 'BookStack\\Book' and b.deleted_at is null) -//left join chapters c on (c.id = views.viewable_id and views.viewable_type = 'BookStack\\Chapter' and c.deleted_at is null) -//left join pages p on (p.id = views.viewable_id and views.viewable_type = 'BookStack\\Page' and p.deleted_at is null) -//# Permissions -//where exists( -//select * from joint_permissions jp where jp.entity_id = views.viewable_id and jp.entity_type = views.viewable_type -//and jp.action = 'view' and jp.role_id in (1, 2, 3, 6, 12) and (jp.has_permission = 1 or (jp.has_permission_own = 1 and jp.owned_by = 1)) -//) -//and (s.id is not null or b.id is not null or c.id is not null or p.id is not null) -//and views.user_id = 1 - /** @var array */ protected $lookupModels = []; @@ -49,9 +27,52 @@ class RelationMultiModelQuery /** @var string */ protected $polymorphicFieldName; - public function __construct(Model $relation, string $polymorphicFieldName) + /** + * The keys are relation fields to fetch. + * The values are the name to use for the resulting model attribute. + * @var array + */ + protected $relationFields = []; + + /** + * An array of [string $col, string $operator, mixed $value] where conditions. + * @var array> + */ + protected $relationWheres = []; + + /** + * Field on the relation field to order by. + * @var ?array[string $column, string $direction] + */ + protected $orderByRelationField = null; + + /** + * Number of results to take + * @var ?int + */ + protected $take = null; + + /** + * Number of results to skip. + * @var ?int + */ + protected $skip = null; + + /** + * Callback that will receive the query for any advanced customization. + * @var ?callable + */ + protected $queryCustomizer = null; + + /** + * @throws \Exception + */ + public function __construct(string $relation, string $polymorphicFieldName) { - $this->relation = $relation; + $this->relation = (new $relation); + if (!$this->relation instanceof Model) { + throw new \Exception('Given relation must be a model instance class'); + } $this->polymorphicFieldName = $polymorphicFieldName; } @@ -76,6 +97,78 @@ class RelationMultiModelQuery return $this; } + /** + * Bring back a field from the relation object with the model results. + */ + public function withRelationField(string $fieldName, string $modelAttributeName): self + { + $this->relationFields[$fieldName] = $modelAttributeName; + return $this; + } + + /** + * Add a where condition to the query for the main relation table. + */ + public function whereRelation(string $column, string $operator, $value): self + { + $this->relationWheres[] = [$column, $operator, $value]; + return $this; + } + + /** + * Order by the given relation column. + */ + public function orderByRelation(string $column, string $direction = 'asc'): self + { + $this->orderByRelationField = [$column, $direction]; + return $this; + } + + /** + * Skip the given $count of results in the query. + */ + public function skip(?int $count): self + { + $this->skip = $count; + return $this; + } + + /** + * Take the given $count of results in the query. + */ + public function take(?int $count): self + { + $this->take = $count; + return $this; + } + + /** + * Pass a callable, which will receive the base query + * to perform additional custom operations on the query. + */ + public function customizeUsing(callable $customizer): self + { + $this->queryCustomizer = $customizer; + return $this; + } + + /** + * Get the SQL from the core query being ran. + */ + public function toSql(): string + { + return $this->build()->toSql(); + } + + /** + * Run the query and get the results. + */ + public function run(): Collection + { + $results = $this->build()->get(); + return $this->hydrateModelsFromResults($results); + } + /** * Build the core query to run. */ @@ -85,6 +178,14 @@ class RelationMultiModelQuery $relationTable = $this->relation->getTable(); $modelTables = []; + // Load relation fields + foreach ($this->relationFields as $relationField => $alias) { + $query->addSelect( + $relationTable . '.' . $relationField . ' as ' + . $relationTable . '@' . $relationField + ); + } + // Load model selects & joins foreach ($this->lookupModels as $lookupModel => $columns) { /** @var Entity $model */ @@ -107,11 +208,34 @@ class RelationMultiModelQuery } }); + // Add relation wheres + foreach ($this->relationWheres as [$column, $operator, $value]) { + $query->where($relationTable . '.' . $column, $operator, $value); + } + + // Skip and take + if (!is_null($this->skip)) { + $query->skip($this->skip); + } + if (!is_null($this->take)) { + $query->take($this->take); + } + if (!is_null($this->queryCustomizer)) { + $customizer = $this->queryCustomizer; + $customizer($query); + } + if (!is_null($this->orderByRelationField)) { + $query->orderBy($relationTable . '.' . $this->orderByRelationField[0], $this->orderByRelationField[1]); + } + $this->applyPermissionsToQuery($query, 'view'); return $query; } + /** + * Run the query through the permission system. + */ protected function applyPermissionsToQuery(Builder $query, string $action) { $permissions = app()->make(PermissionService::class); @@ -131,24 +255,54 @@ class RelationMultiModelQuery { $selectArray = []; foreach ($columns as $column) { - $selectArray[] = $table . '.' . $column . ' as '. $table . '_' . $column; + $selectArray[] = $table . '.' . $column . ' as ' . $table . '@' . $column; } return $selectArray; } /** - * Get the SQL from the core query being ran. + * Hydrate a collection of result data into models. */ - public function toSql(): string + protected function hydrateModelsFromResults(Collection $results): Collection { - return $this->build()->toSql(); + $modelByIdColumn = []; + foreach ($this->lookupModels as $lookupModel => $columns) { + /** @var Model $model */ + $model = new $lookupModel; + $modelByIdColumn[$model->getTable() . '@id'] = $model; + } + + return $results->map(function ($result) use ($modelByIdColumn) { + foreach ($modelByIdColumn as $idColumn => $modelInstance) { + if (isset($result->$idColumn)) { + return $this->hydrateModelFromResult($modelInstance, $result); + } + } + return null; + }); } /** - * Run the query and get the results. + * Hydrate the given model type with the database result. */ - public function run(): Collection + protected function hydrateModelFromResult(Model $model, \stdClass $result): Model { - return $this->build()->get(); + $modelPrefix = $model->getTable() . '@'; + $relationPrefix = $this->relation->getTable() . '@'; + $attrs = []; + + foreach ((array) $result as $col => $value) { + if (strpos($col, $modelPrefix) === 0) { + $attrName = substr($col, strlen($modelPrefix)); + $attrs[$attrName] = $value; + } + if (strpos($col, $relationPrefix) === 0) { + $col = substr($col, strlen($relationPrefix)); + $attrName = $this->relationFields[$col]; + $attrs[$attrName] = $value; + } + } + + return $model->newInstance()->forceFill($attrs); } -} \ No newline at end of file +} diff --git a/app/Facades/Views.php b/app/Facades/Views.php deleted file mode 100644 index f5357112e..000000000 --- a/app/Facades/Views.php +++ /dev/null @@ -1,16 +0,0 @@ - 0 ? 0.5 : 1; $recents = $this->isSignedIn() ? - Views::getUserRecentlyViewed(12*$recentFactor, 1) + (new RecentlyViewed)->run(12*$recentFactor, 1) : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); + $faviourites = (new TopFavourites)->run(6, 1); $recentlyUpdatedPages = Page::visible()->with('book') ->where('draft', false) ->orderBy('updated_at', 'desc') - ->take(12) + ->take($faviourites->count() > 0 ? 6 : 12) ->get(); $homepageOptions = ['default', 'books', 'bookshelves', 'page']; @@ -51,6 +53,7 @@ class HomeController extends Controller 'recents' => $recents, 'recentlyUpdatedPages' => $recentlyUpdatedPages, 'draftPages' => $draftPages, + 'favourites' => $faviourites, ]; // Add required list ordering & sorting for books & shelves views. diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index bb824fd9b..1f77e6377 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,6 +1,7 @@ searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results']; } else { - $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission); + $entities = (new Popular)->run(20, 0, $entityTypes, $permission); } return view('search.entity-ajax-list', ['entities' => $entities]); diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 1661bae57..6c3341368 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -27,6 +27,7 @@ return [ 'images' => 'Images', 'my_recent_drafts' => 'My Recent Drafts', 'my_recently_viewed' => 'My Recently Viewed', + 'my_most_viewed_favourites' => 'My Most Viewed Favourites', 'no_pages_viewed' => 'You have not viewed any pages', 'no_pages_recently_created' => 'No pages have been recently created', 'no_pages_recently_updated' => 'No pages have been recently updated', diff --git a/resources/views/common/home-sidebar.blade.php b/resources/views/common/home-sidebar.blade.php index 4c36ce61a..b66c8495d 100644 --- a/resources/views/common/home-sidebar.blade.php +++ b/resources/views/common/home-sidebar.blade.php @@ -5,6 +5,18 @@ @endif +@if(count($favourites) > 0) +
+

{{ trans('entities.my_most_viewed_favourites') }}

+
+ @include('partials.entity-list', [ + 'entities' => $favourites, + 'style' => 'compact', + ]) +
+
+@endif +
{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}
@include('partials.entity-list', [ diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php index ad503463e..acfca97b2 100644 --- a/resources/views/common/home.blade.php +++ b/resources/views/common/home.blade.php @@ -42,6 +42,18 @@
+ @if(count($favourites) > 0) +
+

{{ trans('entities.my_most_viewed_favourites') }}

+
+ @include('partials.entity-list', [ + 'entities' => $favourites, + 'style' => 'compact', + ]) +
+
+ @endif +

{{ trans('entities.recently_updated_pages') }}

diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index b3325ba82..d4d8ed76b 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -26,7 +26,7 @@

{{ trans('entities.pages_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['page']), 'style' => 'compact'])
@@ -34,7 +34,7 @@

{{ trans('entities.books_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['book']), 'style' => 'compact'])
@@ -42,7 +42,7 @@

{{ trans('entities.chapters_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['chapter']), 'style' => 'compact'])
From 27942f5ce8cf2a18073cc8a933aea6fcce6ffe3e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 22 May 2021 14:07:57 +0100 Subject: [PATCH 07/10] Deleted redundant complex relationmultimodel query class --- .../Tools/RelationMultiModelQuery.php | 308 ------------------ 1 file changed, 308 deletions(-) delete mode 100644 app/Entities/Tools/RelationMultiModelQuery.php diff --git a/app/Entities/Tools/RelationMultiModelQuery.php b/app/Entities/Tools/RelationMultiModelQuery.php deleted file mode 100644 index c992fe454..000000000 --- a/app/Entities/Tools/RelationMultiModelQuery.php +++ /dev/null @@ -1,308 +0,0 @@ - */ - protected $lookupModels = []; - - /** @var Model */ - protected $relation; - - /** @var string */ - protected $polymorphicFieldName; - - /** - * The keys are relation fields to fetch. - * The values are the name to use for the resulting model attribute. - * @var array - */ - protected $relationFields = []; - - /** - * An array of [string $col, string $operator, mixed $value] where conditions. - * @var array> - */ - protected $relationWheres = []; - - /** - * Field on the relation field to order by. - * @var ?array[string $column, string $direction] - */ - protected $orderByRelationField = null; - - /** - * Number of results to take - * @var ?int - */ - protected $take = null; - - /** - * Number of results to skip. - * @var ?int - */ - protected $skip = null; - - /** - * Callback that will receive the query for any advanced customization. - * @var ?callable - */ - protected $queryCustomizer = null; - - /** - * @throws \Exception - */ - public function __construct(string $relation, string $polymorphicFieldName) - { - $this->relation = (new $relation); - if (!$this->relation instanceof Model) { - throw new \Exception('Given relation must be a model instance class'); - } - $this->polymorphicFieldName = $polymorphicFieldName; - } - - /** - * Set the query to look up the given entity type. - */ - public function forEntity(string $class, array $columns): self - { - $this->lookupModels[$class] = $columns; - return $this; - } - - /** - * Set the query to look up all entity types. - */ - public function forAllEntities(): self - { - $this->lookupModels[Page::class] = ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text']; - $this->lookupModels[Chapter::class] = ['id', 'name', 'slug', 'book_id', 'description']; - $this->lookupModels[Book::class] = ['id', 'name', 'slug', 'description', 'image_id']; - $this->lookupModels[Bookshelf::class] = ['id', 'name', 'slug', 'description', 'image_id']; - return $this; - } - - /** - * Bring back a field from the relation object with the model results. - */ - public function withRelationField(string $fieldName, string $modelAttributeName): self - { - $this->relationFields[$fieldName] = $modelAttributeName; - return $this; - } - - /** - * Add a where condition to the query for the main relation table. - */ - public function whereRelation(string $column, string $operator, $value): self - { - $this->relationWheres[] = [$column, $operator, $value]; - return $this; - } - - /** - * Order by the given relation column. - */ - public function orderByRelation(string $column, string $direction = 'asc'): self - { - $this->orderByRelationField = [$column, $direction]; - return $this; - } - - /** - * Skip the given $count of results in the query. - */ - public function skip(?int $count): self - { - $this->skip = $count; - return $this; - } - - /** - * Take the given $count of results in the query. - */ - public function take(?int $count): self - { - $this->take = $count; - return $this; - } - - /** - * Pass a callable, which will receive the base query - * to perform additional custom operations on the query. - */ - public function customizeUsing(callable $customizer): self - { - $this->queryCustomizer = $customizer; - return $this; - } - - /** - * Get the SQL from the core query being ran. - */ - public function toSql(): string - { - return $this->build()->toSql(); - } - - /** - * Run the query and get the results. - */ - public function run(): Collection - { - $results = $this->build()->get(); - return $this->hydrateModelsFromResults($results); - } - - /** - * Build the core query to run. - */ - protected function build(): Builder - { - $query = $this->relation->newQuery()->toBase(); - $relationTable = $this->relation->getTable(); - $modelTables = []; - - // Load relation fields - foreach ($this->relationFields as $relationField => $alias) { - $query->addSelect( - $relationTable . '.' . $relationField . ' as ' - . $relationTable . '@' . $relationField - ); - } - - // Load model selects & joins - foreach ($this->lookupModels as $lookupModel => $columns) { - /** @var Entity $model */ - $model = (new $lookupModel); - $table = $model->getTable(); - $modelTables[] = $table; - $query->addSelect($this->tableColumnsToSelectArray($table, $columns)); - $query->leftJoin($table, function (JoinClause $join) use ($table, $relationTable, $model) { - $polyPrefix = $relationTable . '.' . $this->polymorphicFieldName; - $join->on($polyPrefix . '_id', '=', $table . '.id'); - $join->where($polyPrefix . '_type', '=', $model->getMorphClass()); - $join->whereNull($table . '.deleted_at'); - }); - } - - // Where we have a model result - $query->where(function (Builder $query) use ($modelTables) { - foreach ($modelTables as $table) { - $query->orWhereNotNull($table . '.id'); - } - }); - - // Add relation wheres - foreach ($this->relationWheres as [$column, $operator, $value]) { - $query->where($relationTable . '.' . $column, $operator, $value); - } - - // Skip and take - if (!is_null($this->skip)) { - $query->skip($this->skip); - } - if (!is_null($this->take)) { - $query->take($this->take); - } - if (!is_null($this->queryCustomizer)) { - $customizer = $this->queryCustomizer; - $customizer($query); - } - if (!is_null($this->orderByRelationField)) { - $query->orderBy($relationTable . '.' . $this->orderByRelationField[0], $this->orderByRelationField[1]); - } - - $this->applyPermissionsToQuery($query, 'view'); - - return $query; - } - - /** - * Run the query through the permission system. - */ - protected function applyPermissionsToQuery(Builder $query, string $action) - { - $permissions = app()->make(PermissionService::class); - $permissions->filterRestrictedEntityRelations( - $query, - $this->relation->getTable(), - $this->polymorphicFieldName . '_id', - $this->polymorphicFieldName . '_type', - $action, - ); - } - - /** - * Create an array of select statements from the given table and column. - */ - protected function tableColumnsToSelectArray(string $table, array $columns): array - { - $selectArray = []; - foreach ($columns as $column) { - $selectArray[] = $table . '.' . $column . ' as ' . $table . '@' . $column; - } - return $selectArray; - } - - /** - * Hydrate a collection of result data into models. - */ - protected function hydrateModelsFromResults(Collection $results): Collection - { - $modelByIdColumn = []; - foreach ($this->lookupModels as $lookupModel => $columns) { - /** @var Model $model */ - $model = new $lookupModel; - $modelByIdColumn[$model->getTable() . '@id'] = $model; - } - - return $results->map(function ($result) use ($modelByIdColumn) { - foreach ($modelByIdColumn as $idColumn => $modelInstance) { - if (isset($result->$idColumn)) { - return $this->hydrateModelFromResult($modelInstance, $result); - } - } - return null; - }); - } - - /** - * Hydrate the given model type with the database result. - */ - protected function hydrateModelFromResult(Model $model, \stdClass $result): Model - { - $modelPrefix = $model->getTable() . '@'; - $relationPrefix = $this->relation->getTable() . '@'; - $attrs = []; - - foreach ((array) $result as $col => $value) { - if (strpos($col, $modelPrefix) === 0) { - $attrName = substr($col, strlen($modelPrefix)); - $attrs[$attrName] = $value; - } - if (strpos($col, $relationPrefix) === 0) { - $col = substr($col, strlen($relationPrefix)); - $attrName = $this->relationFields[$col]; - $attrs[$attrName] = $value; - } - } - - return $model->newInstance()->forceFill($attrs); - } -} From 1e0aa7ee2cf88c53e110ffc0ee51884c37b7509c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 23 May 2021 13:34:08 +0100 Subject: [PATCH 08/10] Added favourites page with link from header and home --- app/Entities/Queries/TopFavourites.php | 8 +++----- app/Http/Controllers/FavouriteController.php | 19 +++++++++++++++++++ app/Http/Controllers/HomeController.php | 6 +++--- app/Http/Controllers/PageController.php | 4 ++-- resources/lang/en/entities.php | 1 + .../detailed-listing-paginated.blade.php} | 4 ++-- .../detailed-listing-with-more.blade.php | 19 +++++++++++++++++++ resources/views/common/header.blade.php | 3 +++ resources/views/common/home-sidebar.blade.php | 4 +++- resources/views/common/home.blade.php | 4 +++- routes/web.php | 1 + 11 files changed, 59 insertions(+), 14 deletions(-) rename resources/views/{pages/detailed-listing.blade.php => common/detailed-listing-paginated.blade.php} (82%) create mode 100644 resources/views/common/detailed-listing-with-more.blade.php diff --git a/app/Entities/Queries/TopFavourites.php b/app/Entities/Queries/TopFavourites.php index aaacdc5ad..a527c2a4e 100644 --- a/app/Entities/Queries/TopFavourites.php +++ b/app/Entities/Queries/TopFavourites.php @@ -1,13 +1,12 @@ isDefault()) { @@ -26,11 +25,10 @@ class TopFavourites extends EntityQuery ->orderBy('view_count', 'desc'); return $query->with('viewable') - ->skip($count * ($page - 1)) + ->skip($skip) ->take($count) ->get() ->pluck('viewable') ->filter(); } - -} \ No newline at end of file +} diff --git a/app/Http/Controllers/FavouriteController.php b/app/Http/Controllers/FavouriteController.php index 8a26eac8e..f4aeb4faa 100644 --- a/app/Http/Controllers/FavouriteController.php +++ b/app/Http/Controllers/FavouriteController.php @@ -3,12 +3,31 @@ namespace BookStack\Http\Controllers; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Queries\TopFavourites; use BookStack\Interfaces\Favouritable; use BookStack\Model; use Illuminate\Http\Request; class FavouriteController extends Controller { + /** + * Show a listing of all favourite items for the current user. + */ + public function index(Request $request) + { + $viewCount = 20; + $page = intval($request->get('page', 1)); + $favourites = (new TopFavourites)->run($viewCount + 1, (($page - 1) * $viewCount)); + + $hasMoreLink = ($favourites->count() > $viewCount) ? url("/favourites?page=" . ($page+1)) : null; + + return view('common.detailed-listing-with-more', [ + 'title' => trans('entities.my_favourites'), + 'entities' => $favourites->slice(0, $viewCount), + 'hasMoreLink' => $hasMoreLink, + ]); + } + /** * Add a new item as a favourite. */ diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index f5ab21204..7bc170526 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -35,11 +35,11 @@ class HomeController extends Controller $recents = $this->isSignedIn() ? (new RecentlyViewed)->run(12*$recentFactor, 1) : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); - $faviourites = (new TopFavourites)->run(6, 1); + $favourites = (new TopFavourites)->run(6); $recentlyUpdatedPages = Page::visible()->with('book') ->where('draft', false) ->orderBy('updated_at', 'desc') - ->take($faviourites->count() > 0 ? 6 : 12) + ->take($favourites->count() > 0 ? 6 : 12) ->get(); $homepageOptions = ['default', 'books', 'bookshelves', 'page']; @@ -53,7 +53,7 @@ class HomeController extends Controller 'recents' => $recents, 'recentlyUpdatedPages' => $recentlyUpdatedPages, 'draftPages' => $draftPages, - 'favourites' => $faviourites, + 'favourites' => $favourites, ]; // Add required list ordering & sorting for books & shelves views. diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 769ea8e69..134c22081 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -338,9 +338,9 @@ class PageController extends Controller ->paginate(20) ->setPath(url('/pages/recently-updated')); - return view('pages.detailed-listing', [ + return view('common.detailed-listing-paginated', [ 'title' => trans('entities.recently_updated_pages'), - 'pages' => $pages + 'entities' => $pages ]); } diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 6c3341368..462402f33 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -28,6 +28,7 @@ return [ 'my_recent_drafts' => 'My Recent Drafts', 'my_recently_viewed' => 'My Recently Viewed', 'my_most_viewed_favourites' => 'My Most Viewed Favourites', + 'my_favourites' => 'My Favourites', 'no_pages_viewed' => 'You have not viewed any pages', 'no_pages_recently_created' => 'No pages have been recently created', 'no_pages_recently_updated' => 'No pages have been recently updated', diff --git a/resources/views/pages/detailed-listing.blade.php b/resources/views/common/detailed-listing-paginated.blade.php similarity index 82% rename from resources/views/pages/detailed-listing.blade.php rename to resources/views/common/detailed-listing-paginated.blade.php index c2bbdb537..af9490a41 100644 --- a/resources/views/pages/detailed-listing.blade.php +++ b/resources/views/common/detailed-listing-paginated.blade.php @@ -6,11 +6,11 @@

{{ $title }}

- @include('partials.entity-list', ['entities' => $pages, 'style' => 'detailed']) + @include('partials.entity-list', ['entities' => $entities, 'style' => 'detailed'])
- {!! $pages->links() !!} + {!! $entities->links() !!}
diff --git a/resources/views/common/detailed-listing-with-more.blade.php b/resources/views/common/detailed-listing-with-more.blade.php new file mode 100644 index 000000000..5790f2065 --- /dev/null +++ b/resources/views/common/detailed-listing-with-more.blade.php @@ -0,0 +1,19 @@ +@extends('simple-layout') + +@section('body') +
+
+

{{ $title }}

+ +
+ @include('partials.entity-list', ['entities' => $entities, 'style' => 'detailed']) +
+ +
+ @if($hasMoreLink) + {{ trans('common.more') }} + @endif +
+
+
+@stop \ No newline at end of file diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 4799aba24..274a09996 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -61,6 +61,9 @@ {{ $currentUser->getShortName(9) }} @icon('caret-down')