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..de30900c7 100644 --- a/app/Actions/View.php +++ b/app/Actions/View.php @@ -1,7 +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->forceFill(['views' => $view->views + 1])->save(); + + 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 deleted file mode 100644 index a4e620d4b..000000000 --- a/app/Actions/ViewService.php +++ /dev/null @@ -1,117 +0,0 @@ -view = $view; - $this->permissionService = $permissionService; - $this->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 - * @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); - } - - /** - * Reset all view counts by deleting all views. - */ - public function resetAll() - { - $this->view->truncate(); - } -} diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php index c5bdc8070..456598653 100644 --- a/app/Auth/Permissions/PermissionService.php +++ b/app/Auth/Permissions/PermissionService.php @@ -580,14 +580,15 @@ 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]; $q = $query->where(function ($query) use ($tableDetails, $action) { $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) { - $permissionQuery->select('id')->from('joint_permissions') + $permissionQuery->select(['role_id'])->from('joint_permissions') ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn']) ->where('action', '=', $action) diff --git a/app/Auth/User.php b/app/Auth/User.php index 9855ab4e7..2f3c00a4b 100644 --- a/app/Auth/User.php +++ b/app/Auth/User.php @@ -1,5 +1,6 @@ hasMany(ApiToken::class); } + /** + * Get the favourite instances for this user. + */ + public function favourites(): HasMany + { + return $this->hasMany(Favourite::class); + } + /** * Get the last activity time for this user. */ diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php index e437ff1e3..aa7653411 100644 --- a/app/Auth/UserRepo.php +++ b/app/Auth/UserRepo.php @@ -184,6 +184,7 @@ class UserRepo { $user->socialAccounts()->delete(); $user->apiTokens()->delete(); + $user->favourites()->delete(); $user->delete(); // Delete user profile images 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/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 d4b477304..561876769 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,7 +10,9 @@ 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\Interfaces\Viewable; use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasOwner; @@ -38,7 +41,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, Viewable { use SoftDeletes; use HasCreatorAndUpdater; @@ -297,4 +300,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/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..1434180d3 --- /dev/null +++ b/app/Entities/Queries/TopFavourites.php @@ -0,0 +1,33 @@ +isDefault()) { + return collect(); + } + + $query = $this->permissionService() + ->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view') + ->select('favourites.*') + ->leftJoin('views', function (JoinClause $join) { + $join->on('favourites.favouritable_id', '=', 'views.viewable_id'); + $join->on('favourites.favouritable_type', '=', 'views.viewable_type'); + $join->where('views.user_id', '=', user()->id); + }) + ->orderBy('views.views', 'desc') + ->where('favourites.user_id', '=', user()->id); + + return $query->with('favouritable') + ->skip($skip) + ->take($count) + ->get() + ->pluck('favouritable') + ->filter(); + } +} diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index df98fd318..bf3792835 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -317,6 +317,7 @@ class TrashCan $entity->jointPermissions()->delete(); $entity->searchTerms()->delete(); $entity->deletions()->delete(); + $entity->favourites()->delete(); if ($entity instanceof HasCoverImage && $entity->cover) { $imageService = app()->make(ImageService::class); 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 @@ -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/FavouriteController.php b/app/Http/Controllers/FavouriteController.php new file mode 100644 index 000000000..f4aeb4faa --- /dev/null +++ b/app/Http/Controllers/FavouriteController.php @@ -0,0 +1,95 @@ +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. + */ + public function add(Request $request) + { + $favouritable = $this->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/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 1ffb99f8d..7bc170526 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,11 +2,12 @@ use Activity; use BookStack\Entities\Models\Book; +use BookStack\Entities\Queries\RecentlyViewed; +use BookStack\Entities\Queries\TopFavourites; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookshelfRepo; -use Illuminate\Http\Response; use Views; class HomeController extends Controller @@ -32,12 +33,13 @@ class HomeController extends Controller $recentFactor = count($draftPages) > 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(); + $favourites = (new TopFavourites)->run(6); $recentlyUpdatedPages = Page::visible()->with('book') ->where('draft', false) ->orderBy('updated_at', 'desc') - ->take(12) + ->take($favourites->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' => $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 30d33ad48..134c22081 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, @@ -337,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/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index bb824fd9b..859857500 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,6 +1,6 @@ viewService = $viewService; $this->searchRunner = $searchRunner; $this->entityContextManager = $entityContextManager; } @@ -82,7 +79,7 @@ class SearchController extends Controller $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}'; $entities = $this->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/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 @@ +app->make(ActivityService::class); }); - $this->app->singleton('views', function () { - return $this->app->make(ViewService::class); - }); - $this->app->singleton('images', function () { return $this->app->make(ImageService::class); }); diff --git a/database/migrations/2021_05_15_173110_create_favourites_table.php b/database/migrations/2021_05_15_173110_create_favourites_table.php new file mode 100644 index 000000000..783bf5825 --- /dev/null +++ b/database/migrations/2021_05_15_173110_create_favourites_table.php @@ -0,0 +1,36 @@ +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/lang/en/entities.php b/resources/lang/en/entities.php index 1661bae57..462402f33 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -27,6 +27,8 @@ return [ 'images' => 'Images', '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/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 @@