]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'master' of git://github.com/c0shea/BookStack into c0shea-master
authorDan Brown <redacted>
Thu, 17 Oct 2019 20:09:05 +0000 (21:09 +0100)
committerDan Brown <redacted>
Thu, 17 Oct 2019 20:09:05 +0000 (21:09 +0100)
122 files changed:
app/Actions/Activity.php
app/Actions/ActivityService.php
app/Actions/ViewService.php
app/Auth/Permissions/PermissionService.php
app/Auth/Role.php
app/Auth/User.php
app/Auth/UserRepo.php
app/Config/app.php
app/Entities/Book.php
app/Entities/BookChild.php [new file with mode: 0644]
app/Entities/Bookshelf.php
app/Entities/BreadcrumbsViewComposer.php
app/Entities/Chapter.php
app/Entities/Entity.php
app/Entities/EntityProvider.php
app/Entities/ExportService.php
app/Entities/HasCoverImage.php [new file with mode: 0644]
app/Entities/Managers/BookContents.php [new file with mode: 0644]
app/Entities/Managers/EntityContext.php [moved from app/Entities/EntityContextManager.php with 53% similarity]
app/Entities/Managers/PageContent.php [new file with mode: 0644]
app/Entities/Managers/PageEditActivity.php [new file with mode: 0644]
app/Entities/Managers/TrashCan.php [new file with mode: 0644]
app/Entities/Page.php
app/Entities/PageRevision.php
app/Entities/Repos/BaseRepo.php [new file with mode: 0644]
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php [new file with mode: 0644]
app/Entities/Repos/ChapterRepo.php [new file with mode: 0644]
app/Entities/Repos/EntityRepo.php [deleted file]
app/Entities/Repos/PageRepo.php
app/Entities/SlugGenerator.php [new file with mode: 0644]
app/Exceptions/MoveOperationException.php [new file with mode: 0644]
app/Exceptions/SortOperationException.php [new file with mode: 0644]
app/Facades/Permissions.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/Auth/ConfirmEmailController.php
app/Http/Controllers/Auth/ForgotPasswordController.php
app/Http/Controllers/Auth/RegisterController.php
app/Http/Controllers/Auth/ResetPasswordController.php
app/Http/Controllers/Auth/UserInviteController.php
app/Http/Controllers/BookController.php
app/Http/Controllers/BookExportController.php
app/Http/Controllers/BookSortController.php [new file with mode: 0644]
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/ChapterExportController.php
app/Http/Controllers/CommentController.php
app/Http/Controllers/Controller.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/Images/ImageController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/PageExportController.php
app/Http/Controllers/PageRevisionController.php [new file with mode: 0644]
app/Http/Controllers/PageTemplateController.php
app/Http/Controllers/PermissionController.php
app/Http/Controllers/SearchController.php
app/Http/Controllers/SettingController.php
app/Http/Controllers/UserController.php
app/Http/Kernel.php
app/Http/Middleware/GlobalViewData.php [new file with mode: 0644]
app/Http/Middleware/Localization.php
app/Providers/CustomFacadeProvider.php
dev/docker/Dockerfile
phpunit.xml
resources/js/components/markdown-editor.js
resources/js/components/page-comments.js
resources/js/components/wysiwyg-editor.js
resources/js/services/animations.js
resources/js/services/code.js
resources/js/services/events.js
resources/lang/en/settings.php
resources/lang/es_AR/validation.php
resources/lang/fr/auth.php
resources/lang/fr/common.php
resources/lang/fr/entities.php
resources/lang/fr/errors.php
resources/lang/fr/passwords.php
resources/lang/fr/settings.php
resources/lang/fr/validation.php
resources/lang/ru/settings.php
resources/lang/tr/activities.php [new file with mode: 0644]
resources/lang/tr/auth.php [new file with mode: 0644]
resources/lang/tr/common.php [new file with mode: 0644]
resources/lang/tr/components.php [new file with mode: 0644]
resources/lang/tr/entities.php [new file with mode: 0644]
resources/lang/tr/errors.php [new file with mode: 0644]
resources/lang/tr/pagination.php [new file with mode: 0644]
resources/lang/tr/passwords.php [new file with mode: 0644]
resources/lang/tr/settings.php [new file with mode: 0755]
resources/lang/tr/validation.php [new file with mode: 0644]
resources/sass/_text.scss
resources/sass/styles.scss
resources/views/auth/forms/login/ldap.blade.php
resources/views/auth/forms/login/standard.blade.php
resources/views/auth/login.blade.php
resources/views/books/export.blade.php
resources/views/books/list.blade.php
resources/views/comments/comment.blade.php
resources/views/components/custom-checkbox.blade.php
resources/views/components/toggle-switch.blade.php
resources/views/errors/404.blade.php
resources/views/form/entity-permissions.blade.php
resources/views/form/password.blade.php
resources/views/form/text.blade.php
resources/views/settings/index.blade.php
resources/views/settings/maintenance.blade.php
resources/views/shelves/form.blade.php
resources/views/shelves/show.blade.php
routes/web.php
tests/CommandsTest.php
tests/Entity/EntityTest.php
tests/Entity/PageContentTest.php
tests/Entity/PageDraftTest.php
tests/Entity/PageRevisionTest.php
tests/Entity/SortTest.php
tests/Entity/TagTest.php
tests/Permissions/RestrictionsTest.php
tests/SharedTestHelpers.php
tests/Unit/ConfigTest.php
tests/Unit/PageRepoTest.php [deleted file]
tests/Uploads/AttachmentTest.php
tests/Uploads/ImageTest.php

index 1ae1811e11461dbd94674c791b0c75143596d8b2..05f0129ddff7c322f8f900a3f5bdf4e6dba65fda 100644 (file)
@@ -3,13 +3,18 @@
 namespace BookStack\Actions;
 
 use BookStack\Auth\User;
+use BookStack\Entities\Entity;
 use BookStack\Model;
 
 /**
- * @property string  key
- * @property \User   user
- * @property \Entity entity
- * @property string  extra
+ * @property string $key
+ * @property User $user
+ * @property Entity $entity
+ * @property string $extra
+ * @property string $entity_type
+ * @property int $entity_id
+ * @property int $user_id
+ * @property int $book_id
  */
 class Activity extends Model
 {
index f4f82a6f4dfbbac6b63c2fb42a15fad0631f77e9..f56f1ca57e03c304c4f4fdcd7201293afe45f8ba 100644 (file)
@@ -1,8 +1,8 @@
 <?php namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\Book;
 use BookStack\Entities\Entity;
-use Session;
 
 class ActivityService
 {
@@ -12,7 +12,7 @@ class ActivityService
 
     /**
      * ActivityService constructor.
-     * @param \BookStack\Actions\Activity $activity
+     * @param Activity $activity
      * @param PermissionService $permissionService
      */
     public function __construct(Activity $activity, PermissionService $permissionService)
@@ -24,52 +24,57 @@ class ActivityService
 
     /**
      * Add activity data to database.
-     * @param Entity $entity
-     * @param        $activityKey
+     * @param \BookStack\Entities\Entity $entity
+     * @param string $activityKey
      * @param int $bookId
-     * @param bool $extra
      */
-    public function add(Entity $entity, $activityKey, $bookId = 0, $extra = false)
+    public function add(Entity $entity, string $activityKey, int $bookId = null)
     {
-        $activity = $this->activity->newInstance();
-        $activity->user_id = $this->user->id;
-        $activity->book_id = $bookId;
-        $activity->key = strtolower($activityKey);
-        if ($extra !== false) {
-            $activity->extra = $extra;
-        }
+        $activity = $this->newActivityForUser($activityKey, $bookId);
         $entity->activity()->save($activity);
         $this->setNotification($activityKey);
     }
 
     /**
-     * Adds a activity history with a message & without binding to a entity.
-     * @param            $activityKey
+     * Adds a activity history with a message, without binding to a entity.
+     * @param string $activityKey
+     * @param string $message
      * @param int $bookId
-     * @param bool|false $extra
      */
-    public function addMessage($activityKey, $bookId = 0, $extra = false)
+    public function addMessage(string $activityKey, string $message, int $bookId = null)
     {
-        $this->activity->user_id = $this->user->id;
-        $this->activity->book_id = $bookId;
-        $this->activity->key = strtolower($activityKey);
-        if ($extra !== false) {
-            $this->activity->extra = $extra;
-        }
-        $this->activity->save();
+        $this->newActivityForUser($activityKey, $bookId)->forceFill([
+            'extra' => $message
+        ])->save();
+
         $this->setNotification($activityKey);
     }
 
+    /**
+     * Get a new activity instance for the current user.
+     * @param string $key
+     * @param int|null $bookId
+     * @return Activity
+     */
+    protected function newActivityForUser(string $key, int $bookId = null)
+    {
+        return $this->activity->newInstance()->forceFill([
+            'key' => strtolower($key),
+            'user_id' => $this->user->id,
+            'book_id' => $bookId ?? 0,
+        ]);
+    }
 
     /**
      * Removes the entity attachment from each of its activities
      * and instead uses the 'extra' field with the entities name.
      * Used when an entity is deleted.
-     * @param Entity $entity
+     * @param \BookStack\Entities\Entity $entity
      * @return mixed
      */
     public function removeEntity(Entity $entity)
     {
+        // TODO - Rewrite to db query.
         $activities = $entity->activity;
         foreach ($activities as $activity) {
             $activity->extra = $entity->name;
@@ -90,7 +95,11 @@ class ActivityService
     {
         $activityList = $this->permissionService
             ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
-            ->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
+            ->orderBy('created_at', 'desc')
+            ->with('user', 'entity')
+            ->skip($count * $page)
+            ->take($count)
+            ->get();
 
         return $this->filterSimilar($activityList);
     }
@@ -98,7 +107,7 @@ class ActivityService
     /**
      * Gets the latest activity for an entity, Filtering out similar
      * items to prevent a message activity list.
-     * @param Entity $entity
+     * @param \BookStack\Entities\Entity $entity
      * @param int $count
      * @param int $page
      * @return array
@@ -171,7 +180,7 @@ class ActivityService
         $notificationTextKey = 'activities.' . $activityKey . '_notification';
         if (trans()->has($notificationTextKey)) {
             $message = trans($notificationTextKey);
-            Session::flash('success', $message);
+            session()->flash('success', $message);
         }
     }
 }
index 532f31c423ca06ae026bb931f03deb84e16bfc87..324bfaa4ef9fb14c722148529c4bb895692a12e8 100644 (file)
@@ -1,8 +1,10 @@
 <?php namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\Book;
 use BookStack\Entities\Entity;
 use BookStack\Entities\EntityProvider;
+use DB;
 use Illuminate\Support\Collection;
 
 class ViewService
@@ -13,8 +15,8 @@ class ViewService
 
     /**
      * ViewService constructor.
-     * @param \BookStack\Actions\View $view
-     * @param \BookStack\Auth\Permissions\PermissionService $permissionService
+     * @param View $view
+     * @param PermissionService $permissionService
      * @param EntityProvider $entityProvider
      */
     public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
@@ -26,7 +28,7 @@ class ViewService
 
     /**
      * Add a view to the given entity.
-     * @param Entity $entity
+     * @param \BookStack\Entities\Entity $entity
      * @return int
      */
     public function add(Entity $entity)
@@ -43,7 +45,7 @@ class ViewService
         }
 
         // Otherwise create new view count
-        $entity->views()->save($this->view->create([
+        $entity->views()->save($this->view->newInstance([
             'user_id' => $user->id,
             'views' => 1
         ]));
@@ -59,12 +61,12 @@ class ViewService
      * @param string $action - used for permission checking
      * @return Collection
      */
-    public function getPopular(int $count = 10, int $page = 0, $filterModels = null, string $action = 'view')
+    public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
     {
         $skipCount = $count * $page;
         $query = $this->permissionService
             ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
-            ->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
+            ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
             ->groupBy('viewable_id', 'viewable_type')
             ->orderBy('view_count', 'desc');
 
index a5ab4ea9a8e51109c7225bc82ccca1799470205d..97cc1ca241e84209f235136550378f3cb8f43f81 100644 (file)
@@ -215,7 +215,6 @@ class PermissionService
      * @param Collection $books
      * @param array $roles
      * @param bool $deleteOld
-     * @throws \Throwable
      */
     protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
     {
@@ -634,42 +633,40 @@ class PermissionService
     }
 
     /**
-     * Get the children of a book in an efficient single query, Filtered by the permission system.
-     * @param integer $book_id
-     * @param bool $filterDrafts
-     * @param bool $fetchPageContent
-     * @return QueryBuilder
+     * Limited the given entity query so that the query will only
+     * return items that the user has permission for the given ability.
      */
-    public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
+    public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
     {
-        $entities = $this->entityProvider;
-        $pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent))
-            ->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) {
-                $query->where('draft', '=', 0);
-                if (!$filterDrafts) {
-                    $query->orWhere(function ($query) {
-                        $query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
+        $this->clean();
+        return $query->where(function (Builder $parentQuery) use ($ability) {
+            $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
+                $permissionQuery->whereIn('role_id', $this->getRoles())
+                    ->where('action', '=', $ability)
+                    ->where(function (Builder $query) {
+                        $query->where('has_permission', '=', true)
+                            ->orWhere(function (Builder $query) {
+                                $query->where('has_permission_own', '=', true)
+                                    ->where('created_by', '=', $this->currentUser()->id);
+                            });
                     });
-                }
-            });
-        $chapterSelect = $this->db->table('chapters')->selectRaw($entities->chapter->entityRawQuery())->where('book_id', '=', $book_id);
-        $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
-            ->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
-
-        // Add joint permission filter
-        $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
-            ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
-            ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
-            ->where(function ($query) {
-                $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
-                    $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
-                });
             });
-        $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
+        });
+    }
 
-        $query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
-        $this->clean();
-        return  $query;
+    /**
+     * Extend the given page query to ensure draft items are not visible
+     * unless created by the given user.
+     */
+    public function enforceDraftVisiblityOnQuery(Builder $query): Builder
+    {
+        return $query->where(function (Builder $query) {
+            $query->where('draft', '=', false)
+                ->orWhere(function (Builder $query) {
+                    $query->where('draft', '=', true)
+                        ->where('created_by', '=', $this->currentUser()->id);
+                });
+        });
     }
 
     /**
@@ -684,12 +681,11 @@ class PermissionService
         if (strtolower($entityType) === 'page') {
             // Prevent drafts being visible to others.
             $query = $query->where(function ($query) {
-                $query->where('draft', '=', false);
-                if ($this->currentUser()) {
-                    $query->orWhere(function ($query) {
-                        $query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
+                $query->where('draft', '=', false)
+                    ->orWhere(function ($query) {
+                        $query->where('draft', '=', true)
+                            ->where('created_by', '=', $this->currentUser()->id);
                     });
-                }
             });
         }
 
index 917d8aa26192c0388fac58fa5970267f3e25969c..712f5299b7b1d2ff5485179621aeddf9a1c5efbd 100644 (file)
@@ -75,7 +75,7 @@ class Role extends Model
      */
     public static function getRole($roleName)
     {
-        return static::where('name', '=', $roleName)->first();
+        return static::query()->where('name', '=', $roleName)->first();
     }
 
     /**
@@ -85,7 +85,7 @@ class Role extends Model
      */
     public static function getSystemRole($roleName)
     {
-        return static::where('system_name', '=', $roleName)->first();
+        return static::query()->where('system_name', '=', $roleName)->first();
     }
 
     /**
@@ -94,6 +94,15 @@ class Role extends Model
      */
     public static function visible()
     {
-        return static::where('hidden', '=', false)->orderBy('name')->get();
+        return static::query()->where('hidden', '=', false)->orderBy('name')->get();
+    }
+
+    /**
+     * Get the roles that can be restricted.
+     * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
+     */
+    public static function restrictable()
+    {
+        return static::query()->where('system_name', '!=', 'admin')->get();
     }
 }
index 7ad14d9f0c2a495646eb4a4151f958a4a5e2fbf2..bce418a7421fb40ddeb34b0e0dacd192d7048e2a 100644 (file)
@@ -53,13 +53,24 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
      */
     protected $permissions;
 
+    /**
+     * This holds the default user when loaded.
+     * @var null|User
+     */
+    protected static $defaultUser = null;
+
     /**
      * Returns the default public user.
      * @return User
      */
     public static function getDefault()
     {
-        return static::where('system_name', '=', 'public')->first();
+        if (!is_null(static::$defaultUser)) {
+            return static::$defaultUser;
+        }
+        
+        static::$defaultUser = static::where('system_name', '=', 'public')->first();
+        return static::$defaultUser;
     }
 
     /**
index dec973f6c7acf67721e4dd31b20516c798714f48..a903e2c38324e27c1d646655d9758d8e1febb471 100644 (file)
@@ -1,32 +1,31 @@
 <?php namespace BookStack\Auth;
 
 use Activity;
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Book;
+use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Chapter;
+use BookStack\Entities\Page;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Uploads\Image;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Images;
+use Log;
 
 class UserRepo
 {
 
     protected $user;
     protected $role;
-    protected $entityRepo;
 
     /**
      * UserRepo constructor.
-     * @param User $user
-     * @param Role $role
-     * @param EntityRepo $entityRepo
      */
-    public function __construct(User $user, Role $role, EntityRepo $entityRepo)
+    public function __construct(User $user, Role $role)
     {
         $this->user = $user;
         $this->role = $role;
-        $this->entityRepo = $entityRepo;
     }
 
     /**
@@ -81,7 +80,7 @@ class UserRepo
      * Creates a new user and attaches a role to them.
      * @param array $data
      * @param boolean $verifyEmail
-     * @return \BookStack\Auth\User
+     * @return User
      */
     public function registerNew(array $data, $verifyEmail = false)
     {
@@ -121,7 +120,7 @@ class UserRepo
 
     /**
      * Checks if the give user is the only admin.
-     * @param \BookStack\Auth\User $user
+     * @param User $user
      * @return bool
      */
     public function isOnlyAdmin(User $user)
@@ -175,7 +174,7 @@ class UserRepo
      * Create a new basic instance of user.
      * @param array $data
      * @param boolean $verifyEmail
-     * @return \BookStack\Auth\User
+     * @return User
      */
     public function create(array $data, $verifyEmail = false)
     {
@@ -189,7 +188,7 @@ class UserRepo
 
     /**
      * Remove the given user from storage, Delete all related content.
-     * @param \BookStack\Auth\User $user
+     * @param User $user
      * @throws Exception
      */
     public function destroy(User $user)
@@ -206,7 +205,7 @@ class UserRepo
 
     /**
      * Get the latest activity for a user.
-     * @param \BookStack\Auth\User $user
+     * @param User $user
      * @param int $count
      * @param int $page
      * @return array
@@ -218,36 +217,35 @@ class UserRepo
 
     /**
      * Get the recently created content for this given user.
-     * @param \BookStack\Auth\User $user
-     * @param int $count
-     * @return mixed
      */
-    public function getRecentlyCreated(User $user, $count = 20)
+    public function getRecentlyCreated(User $user, int $count = 20): array
     {
-        $createdByUserQuery = function (Builder $query) use ($user) {
-            $query->where('created_by', '=', $user->id);
+        $query = function (Builder $query) use ($user, $count) {
+            return $query->orderBy('created_at', 'desc')
+                ->where('created_by', '=', $user->id)
+                ->take($count)
+                ->get();
         };
 
         return [
-            'pages'    => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
-            'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
-            'books'    => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
-            'shelves'  => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
+            'pages'    => $query(Page::visible()->where('draft', '=', false)),
+            'chapters' => $query(Chapter::visible()),
+            'books'    => $query(Book::visible()),
+            'shelves'  => $query(Bookshelf::visible()),
         ];
     }
 
     /**
      * Get asset created counts for the give user.
-     * @param \BookStack\Auth\User $user
-     * @return array
      */
-    public function getAssetCounts(User $user)
+    public function getAssetCounts(User $user): array
     {
+        $createdBy = ['created_by' => $user->id];
         return [
-            'pages'    => $this->entityRepo->getUserTotalCreated('page', $user),
-            'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
-            'books'    => $this->entityRepo->getUserTotalCreated('book', $user),
-            'shelves'    => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
+            'pages'    =>  Page::visible()->where($createdBy)->count(),
+            'chapters'    =>  Chapter::visible()->where($createdBy)->count(),
+            'books'    =>  Book::visible()->where($createdBy)->count(),
+            'shelves'    =>  Bookshelf::visible()->where($createdBy)->count(),
         ];
     }
 
@@ -260,16 +258,6 @@ class UserRepo
         return $this->role->newQuery()->orderBy('name', 'asc')->get();
     }
 
-    /**
-     * Get all the roles which can be given restricted access to
-     * other entities in the system.
-     * @return mixed
-     */
-    public function getRestrictableRoles()
-    {
-        return $this->role->where('system_name', '!=', 'admin')->get();
-    }
-
     /**
      * Get an avatar image for a user and set it as their avatar.
      * Returns early if avatars disabled or not set in config.
@@ -288,7 +276,7 @@ class UserRepo
             $user->save();
             return true;
         } catch (Exception $e) {
-            \Log::error('Failed to save user avatar image');
+            Log::error('Failed to save user avatar image');
             return false;
         }
     }
index b4a1076a973fd8058f4c86201a765791265ce450..b5bf891b2b672dd6547d4b15b00585e465ee068f 100755 (executable)
@@ -52,7 +52,7 @@ return [
     'locale' => env('APP_LANG', 'en'),
 
     // Locales available
-    'locales' => ['en', 'ar', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW'],
+    'locales' => ['en', 'ar', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW', 'tr'],
 
     //  Application Fallback Locale
     'fallback_locale' => 'en',
@@ -182,6 +182,7 @@ return [
         'Setting'  => BookStack\Facades\Setting::class,
         'Views'    => BookStack\Facades\Views::class,
         'Images'   => BookStack\Facades\Images::class,
+        'Permissions' => BookStack\Facades\Permissions::class,
 
     ],
 
index 7d3d5e4ae98bf0e670c436dba1df93fb7c86d92b..4e54457b80391aa223442d92e065f376a12b86a9 100644 (file)
@@ -1,22 +1,25 @@
 <?php namespace BookStack\Entities;
 
 use BookStack\Uploads\Image;
+use Exception;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Collection;
 
-class Book extends Entity
+/**
+ * Class Book
+ * @property string $description
+ * @property int $image_id
+ * @property Image|null $cover
+ * @package BookStack\Entities
+ */
+class Book extends Entity implements HasCoverImage
 {
     public $searchFactor = 2;
 
     protected $fillable = ['name', 'description', 'image_id'];
 
-    /**
-     * Get the morph class for this model.
-     * @return string
-     */
-    public function getMorphClass()
-    {
-        return 'BookStack\\Book';
-    }
-
     /**
      * Get the url for this book.
      * @param string|bool $path
@@ -45,7 +48,7 @@ class Book extends Entity
 
         try {
             $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
-        } catch (\Exception $err) {
+        } catch (Exception $err) {
             $cover = $default;
         }
         return $cover;
@@ -53,16 +56,23 @@ class Book extends Entity
 
     /**
      * Get the cover image of the book
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function cover()
+    public function cover(): BelongsTo
     {
         return $this->belongsTo(Image::class, 'image_id');
     }
 
+    /**
+     * Get the type of the image model that is used when storing a cover image.
+     */
+    public function coverImageTypeKey(): string
+    {
+        return 'cover_book';
+    }
+
     /**
      * Get all pages within this book.
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
+     * @return HasMany
      */
     public function pages()
     {
@@ -71,7 +81,7 @@ class Book extends Entity
 
     /**
      * Get the direct child pages of this book.
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
+     * @return HasMany
      */
     public function directPages()
     {
@@ -80,7 +90,7 @@ class Book extends Entity
 
     /**
      * Get all chapters within this book.
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
+     * @return HasMany
      */
     public function chapters()
     {
@@ -89,7 +99,7 @@ class Book extends Entity
 
     /**
      * Get the shelves this book is contained within.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     * @return BelongsToMany
      */
     public function shelves()
     {
@@ -97,22 +107,24 @@ class Book extends Entity
     }
 
     /**
-     * Get an excerpt of this book's description to the specified length or less.
-     * @param int $length
-     * @return string
+     * Get the direct child items within this book.
+     * @return Collection
      */
-    public function getExcerpt(int $length = 100)
+    public function getDirectChildren(): Collection
     {
-        $description = $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
+        $pages = $this->directPages()->visible()->get();
+        $chapters = $this->chapters()->visible()->get();
+        return $pages->contact($chapters)->sortBy('priority')->sortByDesc('draft');
     }
 
     /**
-     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * Get an excerpt of this book's description to the specified length or less.
+     * @param int $length
      * @return string
      */
-    public function entityRawQuery()
+    public function getExcerpt(int $length = 100)
     {
-        return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+        $description = $this->description;
+        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
     }
 }
diff --git a/app/Entities/BookChild.php b/app/Entities/BookChild.php
new file mode 100644 (file)
index 0000000..6eac437
--- /dev/null
@@ -0,0 +1,60 @@
+<?php namespace BookStack\Entities;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * Class BookChild
+ * @property int $book_id
+ * @property int $priority
+ * @property Book $book
+ * @method Builder whereSlugs(string $bookSlug, string $childSlug)
+ */
+class BookChild extends Entity
+{
+
+    /**
+     * Scope a query to find items where the the child has the given childSlug
+     * where its parent has the bookSlug.
+     */
+    public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
+    {
+        return $query->with('book')
+            ->whereHas('book', function (Builder $query) use ($bookSlug) {
+                $query->where('slug', '=', $bookSlug);
+            })
+            ->where('slug', '=', $childSlug);
+    }
+
+    /**
+     * Get the book this page sits in.
+     * @return BelongsTo
+     */
+    public function book(): BelongsTo
+    {
+        return $this->belongsTo(Book::class);
+    }
+
+    /**
+     * Change the book that this entity belongs to.
+     */
+    public function changeBook(int $newBookId): Entity
+    {
+        $this->book_id = $newBookId;
+        $this->refreshSlug();
+        $this->save();
+        $this->refresh();
+
+        // Update related activity
+        $this->activity()->update(['book_id' => $newBookId]);
+
+        // Update all child pages if a chapter
+        if ($this instanceof Chapter) {
+            foreach ($this->pages as $page) {
+                $page->changeBook($newBookId);
+            }
+        }
+
+        return $this;
+    }
+}
index 745611ba782704adc4bd30831081699e4cb6685c..62c7e2fe4f56c3e0a78f91105d6ba3c0da6dfad9 100644 (file)
@@ -1,8 +1,10 @@
 <?php namespace BookStack\Entities;
 
 use BookStack\Uploads\Image;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
-class Bookshelf extends Entity
+class Bookshelf extends Entity implements HasCoverImage
 {
     protected $table = 'bookshelves';
 
@@ -10,15 +12,6 @@ class Bookshelf extends Entity
 
     protected $fillable = ['name', 'description', 'image_id'];
 
-    /**
-     * Get the morph class for this model.
-     * @return string
-     */
-    public function getMorphClass()
-    {
-        return 'BookStack\\Bookshelf';
-    }
-
     /**
      * Get the books in this shelf.
      * Should not be used directly since does not take into account permissions.
@@ -31,6 +24,14 @@ class Bookshelf extends Entity
             ->orderBy('order', 'asc');
     }
 
+    /**
+     * Related books that are visible to the current user.
+     */
+    public function visibleBooks(): BelongsToMany
+    {
+        return $this->books()->visible();
+    }
+
     /**
      * Get the url for this bookshelf.
      * @param string|bool $path
@@ -68,13 +69,20 @@ class Bookshelf extends Entity
 
     /**
      * Get the cover image of the shelf
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function cover()
+    public function cover(): BelongsTo
     {
         return $this->belongsTo(Image::class, 'image_id');
     }
 
+    /**
+     * Get the type of the image model that is used when storing a cover image.
+     */
+    public function coverImageTypeKey(): string
+    {
+        return 'cover_shelf';
+    }
+
     /**
      * Get an excerpt of this book's description to the specified length or less.
      * @param int $length
@@ -87,21 +95,26 @@ class Bookshelf extends Entity
     }
 
     /**
-     * Return a generalised, common raw query that can be 'unioned' across entities.
-     * @return string
+     * Check if this shelf contains the given book.
+     * @param Book $book
+     * @return bool
      */
-    public function entityRawQuery()
+    public function contains(Book $book): bool
     {
-        return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+        return $this->books()->where('id', '=', $book->id)->count() > 0;
     }
 
     /**
-     * Check if this shelf contains the given book.
+     * Add a book to the end of this shelf.
      * @param Book $book
-     * @return bool
      */
-    public function contains(Book $book): bool 
+    public function appendBook(Book $book)
     {
-        return $this->books()->where('id', '=', $book->id)->count() > 0;
+        if ($this->contains($book)) {
+            return;
+        }
+
+        $maxOrder = $this->books()->max('order');
+        $this->books()->attach($book->id, ['order' => $maxOrder + 1]);
     }
 }
index e46d54ec26fe1a53ec1902165fdae93a115b571c..43d63d026021dd07edcb1165841018ee213b13a3 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace BookStack\Entities;
 
+use BookStack\Entities\Managers\EntityContext;
 use Illuminate\View\View;
 
 class BreadcrumbsViewComposer
@@ -9,9 +10,9 @@ class BreadcrumbsViewComposer
 
     /**
      * BreadcrumbsViewComposer constructor.
-     * @param EntityContextManager $entityContextManager
+     * @param EntityContext $entityContextManager
      */
-    public function __construct(EntityContextManager $entityContextManager)
+    public function __construct(EntityContext $entityContextManager)
     {
         $this->entityContextManager = $entityContextManager;
     }
index b204f1903034663e755a6ca85bb38cf7f26683f3..848bc6448bd467b853ef25c8982c0aa8b5ad6e4c 100644 (file)
@@ -1,29 +1,18 @@
 <?php namespace BookStack\Entities;
 
-class Chapter extends Entity
+use Illuminate\Support\Collection;
+
+/**
+ * Class Chapter
+ * @property Collection<Page> $pages
+ * @package BookStack\Entities
+ */
+class Chapter extends BookChild
 {
     public $searchFactor = 1.3;
 
     protected $fillable = ['name', 'description', 'priority', 'book_id'];
 
-    /**
-     * Get the morph class for this model.
-     * @return string
-     */
-    public function getMorphClass()
-    {
-        return 'BookStack\\Chapter';
-    }
-
-    /**
-     * Get the book this chapter is within.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
-    public function book()
-    {
-        return $this->belongsTo(Book::class);
-    }
-
     /**
      * Get the pages that this chapter contains.
      * @param string $dir
@@ -63,20 +52,22 @@ class Chapter extends Entity
     }
 
     /**
-     * Return a generalised, common raw query that can be 'unioned' across entities.
-     * @return string
+     * Check if this chapter has any child pages.
+     * @return bool
      */
-    public function entityRawQuery()
+    public function hasChildren()
     {
-        return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+        return count($this->pages) > 0;
     }
 
     /**
-     * Check if this chapter has any child pages.
-     * @return bool
+     * Get the visible pages in this chapter.
      */
-    public function hasChildren()
+    public function getVisiblePages(): Collection
     {
-        return count($this->pages) > 0;
+        return $this->pages()->visible()
+        ->orderBy('draft', 'desc')
+        ->orderBy('priority', 'asc')
+        ->get();
     }
 }
index 482d217662ae20b54f62578d88367b8daacb1098..5013c39cfcf2bf309f1931594ebe702dd00dc44c 100644 (file)
@@ -6,8 +6,11 @@ use BookStack\Actions\Tag;
 use BookStack\Actions\View;
 use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Facades\Permissions;
 use BookStack\Ownable;
 use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
 
 /**
@@ -15,7 +18,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
  * The base class for book-like items such as pages, chapters & books.
  * This is not a database model in itself but extended.
  *
- * @property integer $id
+ * @property int $id
  * @property string $name
  * @property string $slug
  * @property Carbon $created_at
@@ -23,6 +26,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
  * @property int $created_by
  * @property int $updated_by
  * @property boolean $restricted
+ * @property Collection $tags
+ * @method static Entity|Builder visible()
+ * @method static Entity|Builder hasPermission(string $permission)
+ * @method static Builder withLastView()
+ * @method static Builder withViewCount()
  *
  * @package BookStack\Entities
  */
@@ -40,14 +48,45 @@ class Entity extends Ownable
     public $searchFactor = 1.0;
 
     /**
-     * Get the morph class for this model.
-     * Set here since, due to folder changes, the namespace used
-     * in the database no longer matches the class namespace.
-     * @return string
+     * Get the entities that are visible to the current user.
+     */
+    public function scopeVisible(Builder $query)
+    {
+        return $this->scopeHasPermission($query, 'view');
+    }
+
+    /**
+     * Scope the query to those entities that the current user has the given permission for.
+     */
+    public function scopeHasPermission(Builder $query, string $permission)
+    {
+        return Permissions::restrictEntityQuery($query, $permission);
+    }
+
+    /**
+     * Query scope to get the last view from the current user.
      */
-    public function getMorphClass()
+    public function scopeWithLastView(Builder $query)
     {
-        return 'BookStack\\Entity';
+        $viewedAtQuery = View::query()->select('updated_at')
+            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
+            ->where('viewable_type', '=', $this->getMorphClass())
+            ->where('user_id', '=', user()->id)
+            ->take(1);
+
+        return $query->addSelect(['last_viewed_at' => $viewedAtQuery]);
+    }
+
+    /**
+     * Query scope to get the total view count of the entities.
+     */
+    public function scopeWithViewCount(Builder $query)
+    {
+        $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
+            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
+            ->where('viewable_type', '=', $this->getMorphClass())->take(1);
+
+        $query->addSelect(['view_count' => $viewCountQuery]);
     }
 
     /**
@@ -87,11 +126,12 @@ class Entity extends Ownable
 
     /**
      * Gets the activity objects for this entity.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+     * @return MorphMany
      */
     public function activity()
     {
-        return $this->morphMany(Activity::class, 'entity')->orderBy('created_at', 'desc');
+        return $this->morphMany(Activity::class, 'entity')
+            ->orderBy('created_at', 'desc');
     }
 
     /**
@@ -102,14 +142,9 @@ class Entity extends Ownable
         return $this->morphMany(View::class, 'viewable');
     }
 
-    public function viewCountQuery()
-    {
-        return $this->views()->selectRaw('viewable_id, sum(views) as view_count')->groupBy('viewable_id');
-    }
-
     /**
      * Get the Tag models that have been user assigned to this entity.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+     * @return MorphMany
      */
     public function tags()
     {
@@ -129,7 +164,7 @@ class Entity extends Ownable
 
     /**
      * Get the related search terms.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+     * @return MorphMany
      */
     public function searchTerms()
     {
@@ -158,7 +193,7 @@ class Entity extends Ownable
 
     /**
      * Get the entity jointPermissions this is connected to.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+     * @return MorphMany
      */
     public function jointPermissions()
     {
@@ -238,21 +273,40 @@ class Entity extends Ownable
     }
 
     /**
-     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * Get the url of this entity
+     * @param $path
      * @return string
      */
-    public function entityRawQuery()
+    public function getUrl($path = '/')
     {
-        return '';
+        return $path;
     }
 
     /**
-     * Get the url of this entity
-     * @param $path
-     * @return string
+     * Rebuild the permissions for this entity.
      */
-    public function getUrl($path = '/')
+    public function rebuildPermissions()
     {
-        return $path;
+        /** @noinspection PhpUnhandledExceptionInspection */
+        Permissions::buildJointPermissionsForEntity($this);
+    }
+
+    /**
+     * Index the current entity for search
+     */
+    public function indexForSearch()
+    {
+        $searchService = app()->make(SearchService::class);
+        $searchService->indexEntity($this);
+    }
+
+    /**
+     * Generate and set a new URL slug for this model.
+     */
+    public function refreshSlug(): string
+    {
+        $generator = new SlugGenerator($this);
+        $this->slug = $generator->generate();
+        return $this->slug;
     }
 }
index d0d4a7ad6c073a7737fdb0a03ef569fbef2487cb..6bf923b3112aa8e7387fd6eedeb601a601511dad 100644 (file)
@@ -39,11 +39,6 @@ class EntityProvider
 
     /**
      * EntityProvider constructor.
-     * @param Bookshelf $bookshelf
-     * @param Book $book
-     * @param Chapter $chapter
-     * @param Page $page
-     * @param PageRevision $pageRevision
      */
     public function __construct(
         Bookshelf $bookshelf,
@@ -62,9 +57,8 @@ class EntityProvider
     /**
      * Fetch all core entity types as an associated array
      * with their basic names as the keys.
-     * @return Entity[]
      */
-    public function all()
+    public function all(): array
     {
         return [
             'bookshelf' => $this->bookshelf,
@@ -76,10 +70,8 @@ class EntityProvider
 
     /**
      * Get an entity instance by it's basic name.
-     * @param string $type
-     * @return Entity
      */
-    public function get(string $type)
+    public function get(string $type): Entity
     {
         $type = strtolower($type);
         return $this->all()[$type];
@@ -87,15 +79,9 @@ class EntityProvider
 
     /**
      * Get the morph classes, as an array, for a single or multiple types.
-     * @param string|array $types
-     * @return array<string>
      */
-    public function getMorphClasses($types)
+    public function getMorphClasses(array $types): array
     {
-        if (is_string($types)) {
-            $types = [$types];
-        }
-
         $morphClasses = [];
         foreach ($types as $type) {
             $model = $this->get($type);
index 09635aa214f0b8e112583e2c747644030e5bd4b1..3ec867959a7d8b1046127e90534bff5050ca73f6 100644 (file)
@@ -1,35 +1,34 @@
 <?php namespace BookStack\Entities;
 
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Managers\PageContent;
 use BookStack\Uploads\ImageService;
+use DomPDF;
+use Exception;
+use SnappyPDF;
+use Throwable;
 
 class ExportService
 {
 
-    protected $entityRepo;
     protected $imageService;
 
     /**
      * ExportService constructor.
-     * @param EntityRepo $entityRepo
-     * @param ImageService $imageService
      */
-    public function __construct(EntityRepo $entityRepo, ImageService $imageService)
+    public function __construct(ImageService $imageService)
     {
-        $this->entityRepo = $entityRepo;
         $this->imageService = $imageService;
     }
 
     /**
      * Convert a page to a self-contained HTML file.
      * Includes required CSS & image content. Images are base64 encoded into the HTML.
-     * @param \BookStack\Entities\Page $page
-     * @return mixed|string
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function pageToContainedHtml(Page $page)
     {
-        $this->entityRepo->renderPage($page);
+        $page->html = (new PageContent($page))->render();
         $pageHtml = view('pages/export', [
             'page' => $page
         ])->render();
@@ -38,15 +37,13 @@ class ExportService
 
     /**
      * Convert a chapter to a self-contained HTML file.
-     * @param \BookStack\Entities\Chapter $chapter
-     * @return mixed|string
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function chapterToContainedHtml(Chapter $chapter)
     {
-        $pages = $this->entityRepo->getChapterChildren($chapter);
+        $pages = $chapter->getVisiblePages();
         $pages->each(function ($page) {
-            $page->html = $this->entityRepo->renderPage($page);
+            $page->html = (new PageContent($page))->render();
         });
         $html = view('chapters/export', [
             'chapter' => $chapter,
@@ -57,13 +54,11 @@ class ExportService
 
     /**
      * Convert a book to a self-contained HTML file.
-     * @param Book $book
-     * @return mixed|string
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function bookToContainedHtml(Book $book)
     {
-        $bookTree = $this->entityRepo->getBookChildren($book, true, true);
+        $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('books/export', [
             'book' => $book,
             'bookChildren' => $bookTree
@@ -73,13 +68,11 @@ class ExportService
 
     /**
      * Convert a page to a PDF file.
-     * @param Page $page
-     * @return mixed|string
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function pageToPdf(Page $page)
     {
-        $this->entityRepo->renderPage($page);
+        $page->html = (new PageContent($page))->render();
         $html = view('pages/pdf', [
             'page' => $page
         ])->render();
@@ -88,32 +81,30 @@ class ExportService
 
     /**
      * Convert a chapter to a PDF file.
-     * @param \BookStack\Entities\Chapter $chapter
-     * @return mixed|string
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function chapterToPdf(Chapter $chapter)
     {
-        $pages = $this->entityRepo->getChapterChildren($chapter);
+        $pages = $chapter->getVisiblePages();
         $pages->each(function ($page) {
-            $page->html = $this->entityRepo->renderPage($page);
+            $page->html = (new PageContent($page))->render();
         });
+
         $html = view('chapters/export', [
             'chapter' => $chapter,
             'pages' => $pages
         ])->render();
+
         return $this->htmlToPdf($html);
     }
 
     /**
-     * Convert a book to a PDF file
-     * @param \BookStack\Entities\Book $book
-     * @return string
-     * @throws \Throwable
+     * Convert a book to a PDF file.
+     * @throws Throwable
      */
     public function bookToPdf(Book $book)
     {
-        $bookTree = $this->entityRepo->getBookChildren($book, true, true);
+        $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('books/export', [
             'book' => $book,
             'bookChildren' => $bookTree
@@ -122,31 +113,27 @@ class ExportService
     }
 
     /**
-     * Convert normal webpage HTML to a PDF.
-     * @param $html
-     * @return string
-     * @throws \Exception
+     * Convert normal web-page HTML to a PDF.
+     * @throws Exception
      */
-    protected function htmlToPdf($html)
+    protected function htmlToPdf(string $html): string
     {
         $containedHtml = $this->containHtml($html);
         $useWKHTML = config('snappy.pdf.binary') !== false;
         if ($useWKHTML) {
-            $pdf = \SnappyPDF::loadHTML($containedHtml);
+            $pdf = SnappyPDF::loadHTML($containedHtml);
             $pdf->setOption('print-media-type', true);
         } else {
-            $pdf = \DomPDF::loadHTML($containedHtml);
+            $pdf = DomPDF::loadHTML($containedHtml);
         }
         return $pdf->output();
     }
 
     /**
      * Bundle of the contents of a html file to be self-contained.
-     * @param $htmlContent
-     * @return mixed|string
-     * @throws \Exception
+     * @throws Exception
      */
-    protected function containHtml($htmlContent)
+    protected function containHtml(string $htmlContent): string
     {
         $imageTagsOutput = [];
         preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
@@ -188,12 +175,10 @@ class ExportService
     /**
      * Converts the page contents into simple plain text.
      * This method filters any bad looking content to provide a nice final output.
-     * @param Page $page
-     * @return mixed
      */
-    public function pageToPlainText(Page $page)
+    public function pageToPlainText(Page $page): string
     {
-        $html = $this->entityRepo->renderPage($page);
+        $html = (new PageContent($page))->render();
         $text = strip_tags($html);
         // Replace multiple spaces with single spaces
         $text = preg_replace('/\ {2,}/', ' ', $text);
@@ -207,10 +192,8 @@ class ExportService
 
     /**
      * Convert a chapter into a plain text string.
-     * @param \BookStack\Entities\Chapter $chapter
-     * @return string
      */
-    public function chapterToPlainText(Chapter $chapter)
+    public function chapterToPlainText(Chapter $chapter): string
     {
         $text = $chapter->name . "\n\n";
         $text .= $chapter->description . "\n\n";
@@ -222,12 +205,10 @@ class ExportService
 
     /**
      * Convert a book into a plain text string.
-     * @param Book $book
-     * @return string
      */
-    public function bookToPlainText(Book $book)
+    public function bookToPlainText(Book $book): string
     {
-        $bookTree = $this->entityRepo->getBookChildren($book, true, true);
+        $bookTree = (new BookContents($book))->getTree(false, true);
         $text = $book->name . "\n\n";
         foreach ($bookTree as $bookChild) {
             if ($bookChild->isA('chapter')) {
diff --git a/app/Entities/HasCoverImage.php b/app/Entities/HasCoverImage.php
new file mode 100644 (file)
index 0000000..31277f4
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+
+namespace BookStack\Entities;
+
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+interface HasCoverImage
+{
+
+    /**
+     * Get the cover image for this item.
+     */
+    public function cover(): BelongsTo;
+
+    /**
+     * Get the type of the image model that is used when storing a cover image.
+     */
+    public function coverImageTypeKey(): string;
+}
diff --git a/app/Entities/Managers/BookContents.php b/app/Entities/Managers/BookContents.php
new file mode 100644 (file)
index 0000000..8b8d02c
--- /dev/null
@@ -0,0 +1,204 @@
+<?php namespace BookStack\Entities\Managers;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\BookChild;
+use BookStack\Entities\Chapter;
+use BookStack\Entities\Entity;
+use BookStack\Entities\Page;
+use BookStack\Exceptions\SortOperationException;
+use Illuminate\Support\Collection;
+
+class BookContents
+{
+
+    /**
+     * @var Book
+     */
+    protected $book;
+
+    /**
+     * BookContents constructor.
+     * @param $book
+     */
+    public function __construct(Book $book)
+    {
+        $this->book = $book;
+    }
+
+    /**
+     * Get the current priority of the last item
+     * at the top-level of the book.
+     */
+    public function getLastPriority(): int
+    {
+        $maxPage = Page::visible()->where('book_id', '=', $this->book->id)
+            ->where('draft', '=', false)
+            ->where('chapter_id', '=', 0)->max('priority');
+        $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
+            ->max('priority');
+        return max($maxChapter, $maxPage, 1);
+    }
+
+    /**
+     * Get the contents as a sorted collection tree.
+     * TODO - Support $renderPages option
+     */
+    public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
+    {
+        $pages = $this->getPages($showDrafts);
+        $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
+        $all = collect()->concat($pages)->concat($chapters);
+        $chapterMap = $chapters->keyBy('id');
+        $lonePages = collect();
+
+        $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
+            $chapter = $chapterMap->get($chapter_id);
+            if ($chapter) {
+                $chapter->setAttribute('pages', collect($pages)->sortBy($this->bookChildSortFunc()));
+            } else {
+                $lonePages = $lonePages->concat($pages);
+            }
+        });
+
+        $all->each(function (Entity $entity) {
+            $entity->setRelation('book', $this->book);
+        });
+
+        return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
+    }
+
+    /**
+     * Function for providing a sorting score for an entity in relation to the
+     * other items within the book.
+     */
+    protected function bookChildSortFunc(): callable
+    {
+        return function (Entity $entity) {
+            if (isset($entity['draft']) && $entity['draft']) {
+                return -100;
+            }
+            return $entity['priority'] ?? 0;
+        };
+    }
+
+    /**
+     * Get the visible pages within this book.
+     */
+    protected function getPages(bool $showDrafts = false): Collection
+    {
+        $query = Page::visible()->where('book_id', '=', $this->book->id);
+
+        if (!$showDrafts) {
+            $query->where('draft', '=', false);
+        }
+
+        return $query->get();
+    }
+
+    /**
+     * Sort the books content using the given map.
+     * The map is a single-dimension collection of objects in the following format:
+     *   {
+     *     +"id": "294" (ID of item)
+     *     +"sort": 1 (Sort order index)
+     *     +"parentChapter": false (ID of parent chapter, as string, or false)
+     *     +"type": "page" (Entity type of item)
+     *     +"book": "1" (Id of book to place item in)
+     *   }
+     *
+     * Returns a list of books that were involved in the operation.
+     * @throws SortOperationException
+     */
+    public function sortUsingMap(Collection $sortMap): Collection
+    {
+        // Load models into map
+        $this->loadModelsIntoSortMap($sortMap);
+        $booksInvolved = $this->getBooksInvolvedInSort($sortMap);
+
+        // Perform the sort
+        $sortMap->each(function ($mapItem) {
+            $this->applySortUpdates($mapItem);
+        });
+
+        // Update permissions and activity.
+        $booksInvolved->each(function (Book $book) {
+            $book->rebuildPermissions();
+        });
+
+        return $booksInvolved;
+    }
+
+    /**
+     * Using the given sort map item, detect changes for the related model
+     * and update it if required.
+     */
+    protected function applySortUpdates(\stdClass $sortMapItem)
+    {
+        /** @var BookChild $model */
+        $model = $sortMapItem->model;
+
+        $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
+        $bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
+        $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
+
+        if ($bookChanged) {
+            $model->changeBook($sortMapItem->book);
+        }
+
+        if ($chapterChanged) {
+            $model->chapter_id = intval($sortMapItem->parentChapter);
+            $model->save();
+        }
+
+        if ($priorityChanged) {
+            $model->priority = intval($sortMapItem->sort);
+            $model->save();
+        }
+    }
+
+    /**
+     * Load models from the database into the given sort map.
+     */
+    protected function loadModelsIntoSortMap(Collection $sortMap): void
+    {
+        $keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
+            return  $sortMapItem->type . ':' . $sortMapItem->id;
+        });
+        $pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
+        $chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
+
+        $pages = Page::visible()->whereIn('id', $pageIds)->get();
+        $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
+
+        foreach ($pages as $page) {
+            $sortItem = $keyMap->get('page:' . $page->id);
+            $sortItem->model = $page;
+        }
+
+        foreach ($chapters as $chapter) {
+            $sortItem = $keyMap->get('chapter:' . $chapter->id);
+            $sortItem->model = $chapter;
+        }
+    }
+
+    /**
+     * Get the books involved in a sort.
+     * The given sort map should have its models loaded first.
+     * @throws SortOperationException
+     */
+    protected function getBooksInvolvedInSort(Collection $sortMap): Collection
+    {
+        $bookIdsInvolved = collect([$this->book->id]);
+        $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
+        $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
+        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
+
+        $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
+
+        if (count($books) !== count($bookIdsInvolved)) {
+            throw new SortOperationException("Could not find all books requested in sort operation");
+        }
+
+        return $books;
+    }
+}
similarity index 53%
rename from app/Entities/EntityContextManager.php
rename to app/Entities/Managers/EntityContext.php
index 20be0de2b01a5e34fc61a6d0b2752b6d8c2ae903..551cd1a100c142f26cea88d8e86aaa282c768fa0 100644 (file)
@@ -1,44 +1,38 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Managers;
 
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Book;
+use BookStack\Entities\Bookshelf;
 use Illuminate\Session\Store;
 
-class EntityContextManager
+class EntityContext
 {
     protected $session;
-    protected $entityRepo;
 
     protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
 
     /**
      * EntityContextManager constructor.
-     * @param Store $session
-     * @param EntityRepo $entityRepo
      */
-    public function __construct(Store $session, EntityRepo $entityRepo)
+    public function __construct(Store $session)
     {
         $this->session = $session;
-        $this->entityRepo = $entityRepo;
     }
 
     /**
      * Get the current bookshelf context for the given book.
-     * @param Book $book
-     * @return Bookshelf|null
      */
-    public function getContextualShelfForBook(Book $book)
+    public function getContextualShelfForBook(Book $book): ?Bookshelf
     {
         $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
-        if (is_int($contextBookshelfId)) {
 
-            /** @var Bookshelf $shelf */
-            $shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId);
-
-            if ($shelf && $shelf->contains($book)) {
-                return $shelf;
-            }
+        if (!is_int($contextBookshelfId)) {
+            return null;
         }
-        return null;
+
+        $shelf = Bookshelf::visible()->find($contextBookshelfId);
+        $shelfContainsBook = $shelf && $shelf->contains($book);
+
+        return $shelfContainsBook ? $shelf : null;
     }
 
     /**
diff --git a/app/Entities/Managers/PageContent.php b/app/Entities/Managers/PageContent.php
new file mode 100644 (file)
index 0000000..36bc244
--- /dev/null
@@ -0,0 +1,304 @@
+<?php namespace BookStack\Entities\Managers;
+
+use BookStack\Entities\Page;
+use DOMDocument;
+use DOMElement;
+use DOMNodeList;
+use DOMXPath;
+
+class PageContent
+{
+
+    protected $page;
+
+    /**
+     * PageContent constructor.
+     */
+    public function __construct(Page $page)
+    {
+        $this->page = $page;
+    }
+
+    /**
+     * Update the content of the page with new provided HTML.
+     */
+    public function setNewHTML(string $html)
+    {
+        $this->page->html = $this->formatHtml($html);
+        $this->page->text = $this->toPlainText();
+    }
+
+    /**
+     * Formats a page's html to be tagged correctly within the system.
+     */
+    protected function formatHtml(string $htmlText): string
+    {
+        if ($htmlText == '') {
+            return $htmlText;
+        }
+
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
+
+        $container = $doc->documentElement;
+        $body = $container->childNodes->item(0);
+        $childNodes = $body->childNodes;
+
+        // Set ids on top-level nodes
+        $idMap = [];
+        foreach ($childNodes as $index => $childNode) {
+            $this->setUniqueId($childNode, $idMap);
+        }
+
+        // Ensure no duplicate ids within child items
+        $xPath = new DOMXPath($doc);
+        $idElems = $xPath->query('//body//*//*[@id]');
+        foreach ($idElems as $domElem) {
+            $this->setUniqueId($domElem, $idMap);
+        }
+
+        // Generate inner html as a string
+        $html = '';
+        foreach ($childNodes as $childNode) {
+            $html .= $doc->saveHTML($childNode);
+        }
+
+        return $html;
+    }
+
+    /**
+     * Set a unique id on the given DOMElement.
+     * A map for existing ID's should be passed in to check for current existence.
+     * @param DOMElement $element
+     * @param array $idMap
+     */
+    protected function setUniqueId($element, array &$idMap)
+    {
+        if (get_class($element) !== 'DOMElement') {
+            return;
+        }
+
+        // Overwrite id if not a BookStack custom id
+        $existingId = $element->getAttribute('id');
+        if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
+            $idMap[$existingId] = true;
+            return;
+        }
+
+        // Create an unique id for the element
+        // Uses the content as a basis to ensure output is the same every time
+        // the same content is passed through.
+        $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
+        $newId = urlencode($contentId);
+        $loopIndex = 0;
+
+        while (isset($idMap[$newId])) {
+            $newId = urlencode($contentId . '-' . $loopIndex);
+            $loopIndex++;
+        }
+
+        $element->setAttribute('id', $newId);
+        $idMap[$newId] = true;
+    }
+
+    /**
+     * Get a plain-text visualisation of this page.
+     */
+    protected function toPlainText(): string
+    {
+        $html = $this->render(true);
+        return strip_tags($html);
+    }
+
+    /**
+     * Render the page for viewing
+     */
+    public function render(bool $blankIncludes = false) : string
+    {
+        $content = $this->page->html;
+
+        if (!config('app.allow_content_scripts')) {
+            $content = $this->escapeScripts($content);
+        }
+
+        if ($blankIncludes) {
+            $content = $this->blankPageIncludes($content);
+        } else {
+            $content = $this->parsePageIncludes($content);
+        }
+
+        return $content;
+    }
+
+    /**
+     * Parse the headers on the page to get a navigation menu
+     */
+    public function getNavigation(string $htmlContent): array
+    {
+        if (empty($htmlContent)) {
+            return [];
+        }
+
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
+        $xPath = new DOMXPath($doc);
+        $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
+
+        return $headers ? $this->headerNodesToLevelList($headers) : [];
+    }
+
+    /**
+     * Convert a DOMNodeList into an array of readable header attributes
+     * with levels normalised to the lower header level.
+     */
+    protected function headerNodesToLevelList(DOMNodeList $nodeList): array
+    {
+        $tree = collect($nodeList)->map(function ($header) {
+            $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
+            $text = mb_substr($text, 0, 100);
+
+            return [
+                'nodeName' => strtolower($header->nodeName),
+                'level' => intval(str_replace('h', '', $header->nodeName)),
+                'link' => '#' . $header->getAttribute('id'),
+                'text' => $text,
+            ];
+        })->filter(function ($header) {
+            return mb_strlen($header['text']) > 0;
+        });
+
+        // Shift headers if only smaller headers have been used
+        $levelChange = ($tree->pluck('level')->min() - 1);
+        $tree = $tree->map(function ($header) use ($levelChange) {
+            $header['level'] -= ($levelChange);
+            return $header;
+        });
+
+        return $tree->toArray();
+    }
+
+    /**
+     * Remove any page include tags within the given HTML.
+     */
+    protected function blankPageIncludes(string $html) : string
+    {
+        return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
+    }
+
+    /**
+     * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
+     */
+    protected function parsePageIncludes(string $html) : string
+    {
+        $matches = [];
+        preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
+
+        foreach ($matches[1] as $index => $includeId) {
+            $fullMatch = $matches[0][$index];
+            $splitInclude = explode('#', $includeId, 2);
+
+            // Get page id from reference
+            $pageId = intval($splitInclude[0]);
+            if (is_nan($pageId)) {
+                continue;
+            }
+
+            // Find page and skip this if page not found
+            $matchedPage = Page::visible()->find($pageId);
+            if ($matchedPage === null) {
+                $html = str_replace($fullMatch, '', $html);
+                continue;
+            }
+
+            // If we only have page id, just insert all page html and continue.
+            if (count($splitInclude) === 1) {
+                $html = str_replace($fullMatch, $matchedPage->html, $html);
+                continue;
+            }
+
+            // Create and load HTML into a document
+            $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
+            $html = str_replace($fullMatch, trim($innerContent), $html);
+        }
+
+        return $html;
+    }
+
+
+    /**
+     * Fetch the content from a specific section of the given page.
+     */
+    protected function fetchSectionOfPage(Page $page, string $sectionId): string
+    {
+        $topLevelTags = ['table', 'ul', 'ol'];
+        $doc = new DOMDocument();
+        libxml_use_internal_errors(true);
+        $doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
+
+        // Search included content for the id given and blank out if not exists.
+        $matchingElem = $doc->getElementById($sectionId);
+        if ($matchingElem === null) {
+            return '';
+        }
+
+        // Otherwise replace the content with the found content
+        // Checks if the top-level wrapper should be included by matching on tag types
+        $innerContent = '';
+        $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
+        if ($isTopLevel) {
+            $innerContent .= $doc->saveHTML($matchingElem);
+        } else {
+            foreach ($matchingElem->childNodes as $childNode) {
+                $innerContent .= $doc->saveHTML($childNode);
+            }
+        }
+        libxml_clear_errors();
+
+        return $innerContent;
+    }
+
+    /**
+     * Escape script tags within HTML content.
+     */
+    protected function escapeScripts(string $html) : string
+    {
+        if (empty($html)) {
+            return $html;
+        }
+
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+        $xPath = new DOMXPath($doc);
+
+        // Remove standard script tags
+        $scriptElems = $xPath->query('//script');
+        foreach ($scriptElems as $scriptElem) {
+            $scriptElem->parentNode->removeChild($scriptElem);
+        }
+
+        // Remove data or JavaScript iFrames
+        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+        foreach ($badIframes as $badIframe) {
+            $badIframe->parentNode->removeChild($badIframe);
+        }
+
+        // Remove 'on*' attributes
+        $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
+        foreach ($onAttributes as $attr) {
+            /** @var \DOMAttr $attr*/
+            $attrName = $attr->nodeName;
+            $attr->parentNode->removeAttribute($attrName);
+        }
+
+        $html = '';
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        foreach ($topElems as $child) {
+            $html .= $doc->saveHTML($child);
+        }
+
+        return $html;
+    }
+}
diff --git a/app/Entities/Managers/PageEditActivity.php b/app/Entities/Managers/PageEditActivity.php
new file mode 100644 (file)
index 0000000..cebbf87
--- /dev/null
@@ -0,0 +1,74 @@
+<?php namespace BookStack\Entities\Managers;
+
+use BookStack\Entities\Page;
+use BookStack\Entities\PageRevision;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Builder;
+
+class PageEditActivity
+{
+
+    protected $page;
+
+    /**
+     * PageEditActivity constructor.
+     */
+    public function __construct(Page $page)
+    {
+        $this->page = $page;
+    }
+
+    /**
+     * Check if there's active editing being performed on this page.
+     * @return bool
+     */
+    public function hasActiveEditing(): bool
+    {
+        return $this->activePageEditingQuery(60)->count() > 0;
+    }
+
+    /**
+     * Get a notification message concerning the editing activity on the page.
+     */
+    public function activeEditingMessage(): string
+    {
+        $pageDraftEdits = $this->activePageEditingQuery(60)->get();
+        $count = $pageDraftEdits->count();
+
+        $userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
+        $timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
+        return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
+    }
+
+    /**
+     * Get the message to show when the user will be editing one of their drafts.
+     * @param PageRevision $draft
+     * @return string
+     */
+    public function getEditingActiveDraftMessage(PageRevision $draft): string
+    {
+        $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
+        if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
+            return $message;
+        }
+        return $message . "\n" . trans('entities.pages_draft_edited_notification');
+    }
+
+    /**
+     * A query to check for active update drafts on a particular page
+     * within the last given many minutes.
+     */
+    protected function activePageEditingQuery(int $withinMinutes): Builder
+    {
+        $checkTime = Carbon::now()->subMinutes($withinMinutes);
+        $query = PageRevision::query()
+            ->where('type', '=', 'update_draft')
+            ->where('page_id', '=', $this->page->id)
+            ->where('updated_at', '>', $this->page->updated_at)
+            ->where('created_by', '!=', user()->id)
+            ->where('updated_at', '>=', $checkTime)
+            ->with('createdBy');
+
+        return $query;
+    }
+}
diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php
new file mode 100644 (file)
index 0000000..1a32294
--- /dev/null
@@ -0,0 +1,109 @@
+<?php namespace BookStack\Entities\Managers;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Chapter;
+use BookStack\Entities\Entity;
+use BookStack\Entities\HasCoverImage;
+use BookStack\Entities\Page;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
+use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\ImageService;
+use Exception;
+use Illuminate\Contracts\Container\BindingResolutionException;
+
+class TrashCan
+{
+
+    /**
+     * Remove a bookshelf from the system.
+     * @throws Exception
+     */
+    public function destroyShelf(Bookshelf $shelf)
+    {
+        $this->destroyCommonRelations($shelf);
+        $shelf->delete();
+    }
+
+    /**
+     * Remove a book from the system.
+     * @throws NotifyException
+     * @throws BindingResolutionException
+     */
+    public function destroyBook(Book $book)
+    {
+        foreach ($book->pages as $page) {
+            $this->destroyPage($page);
+        }
+
+        foreach ($book->chapters as $chapter) {
+            $this->destroyChapter($chapter);
+        }
+
+        $this->destroyCommonRelations($book);
+        $book->delete();
+    }
+
+    /**
+     * Remove a page from the system.
+     * @throws NotifyException
+     */
+    public function destroyPage(Page $page)
+    {
+        // Check if set as custom homepage & remove setting if not used or throw error if active
+        $customHome = setting('app-homepage', '0:');
+        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
+            if (setting('app-homepage-type') === 'page') {
+                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
+            }
+            setting()->remove('app-homepage');
+        }
+
+        $this->destroyCommonRelations($page);
+
+        // Delete Attached Files
+        $attachmentService = app(AttachmentService::class);
+        foreach ($page->attachments as $attachment) {
+            $attachmentService->deleteFile($attachment);
+        }
+
+        $page->delete();
+    }
+
+    /**
+     * Remove a chapter from the system.
+     * @throws Exception
+     */
+    public function destroyChapter(Chapter $chapter)
+    {
+        if (count($chapter->pages) > 0) {
+            foreach ($chapter->pages as $page) {
+                $page->chapter_id = 0;
+                $page->save();
+            }
+        }
+
+        $this->destroyCommonRelations($chapter);
+        $chapter->delete();
+    }
+
+    /**
+     * Update entity relations to remove or update outstanding connections.
+     */
+    protected function destroyCommonRelations(Entity $entity)
+    {
+        Activity::removeEntity($entity);
+        $entity->views()->delete();
+        $entity->permissions()->delete();
+        $entity->tags()->delete();
+        $entity->comments()->delete();
+        $entity->jointPermissions()->delete();
+        $entity->searchTerms()->delete();
+
+        if ($entity instanceof HasCoverImage && $entity->cover) {
+            $imageService = app()->make(ImageService::class);
+            $imageService->destroy($entity->cover);
+        }
+    }
+}
index c32417418b7c0a2f0cd2e61f8b4856af17c476c5..76dc628fbf3f59e0bbbcf98897e715b743638213 100644 (file)
@@ -1,8 +1,25 @@
 <?php namespace BookStack\Entities;
 
 use BookStack\Uploads\Attachment;
-
-class Page extends Entity
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Permissions;
+
+/**
+ * Class Page
+ * @property int $chapter_id
+ * @property string $html
+ * @property string $markdown
+ * @property string $text
+ * @property bool $template
+ * @property bool $draft
+ * @property int $revision_count
+ * @property Chapter $chapter
+ * @property Collection $attachments
+ */
+class Page extends BookChild
 {
     protected $fillable = ['name', 'html', 'priority', 'markdown'];
 
@@ -11,12 +28,12 @@ class Page extends Entity
     public $textField = 'text';
 
     /**
-     * Get the morph class for this model.
-     * @return string
+     * Get the entities that are visible to the current user.
      */
-    public function getMorphClass()
+    public function scopeVisible(Builder $query)
     {
-        return 'BookStack\\Page';
+        $query = Permissions::enforceDraftVisiblityOnQuery($query);
+        return parent::scopeVisible($query);
     }
 
     /**
@@ -30,27 +47,17 @@ class Page extends Entity
         return $array;
     }
 
-    /**
-     * Get the book this page sits in.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
-    public function book()
-    {
-        return $this->belongsTo(Book::class);
-    }
-
     /**
      * Get the parent item
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function parent()
+    public function parent(): Entity
     {
-        return $this->chapter_id ? $this->chapter() : $this->book();
+        return $this->chapter_id ? $this->chapter : $this->book;
     }
 
     /**
      * Get the chapter that this page is in, If applicable.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     * @return BelongsTo
      */
     public function chapter()
     {
@@ -72,12 +79,12 @@ class Page extends Entity
      */
     public function revisions()
     {
-        return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
+        return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
     }
 
     /**
      * Get the attachments assigned to this page.
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
+     * @return HasMany
      */
     public function attachments()
     {
@@ -95,27 +102,17 @@ class Page extends Entity
         $midText = $this->draft ? '/draft/' : '/page/';
         $idComponent = $this->draft ? $this->id : urlencode($this->slug);
 
+        $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
         if ($path !== false) {
-            return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
+            $url .= '/' . trim($path, '/');
         }
 
-        return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
-    }
-
-    /**
-     * Return a generalised, common raw query that can be 'unioned' across entities.
-     * @param bool $withContent
-     * @return string
-     */
-    public function entityRawQuery($withContent = false)
-    {
-        $htmlQuery = $withContent ? 'html' : "'' as html";
-        return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
+        return url($url);
     }
 
     /**
      * Get the current revision for the page if existing
-     * @return \BookStack\Entities\PageRevision|null
+     * @return PageRevision|null
      */
     public function getCurrentRevision()
     {
index d30147bfc52b1789f2faf4ecf89d8e3b50345c09..13dc713ba43be37453ca525f06e91f143793476e 100644 (file)
@@ -2,7 +2,21 @@
 
 use BookStack\Auth\User;
 use BookStack\Model;
+use Carbon\Carbon;
 
+/**
+ * Class PageRevision
+ * @property int $page_id
+ * @property string $slug
+ * @property string $book_slug
+ * @property int $created_by
+ * @property Carbon $created_at
+ * @property string $type
+ * @property string $summary
+ * @property string $markdown
+ * @property string $html
+ * @property int $revision_number
+ */
 class PageRevision extends Model
 {
     protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
@@ -41,13 +55,18 @@ class PageRevision extends Model
 
     /**
      * Get the previous revision for the same page if existing
-     * @return \BookStack\PageRevision|null
+     * @return \BookStack\Entities\PageRevision|null
      */
     public function getPrevious()
     {
-        if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
-            return static::find($id);
+        $id = static::newQuery()->where('page_id', '=', $this->page_id)
+            ->where('id', '<', $this->id)
+            ->max('id');
+
+        if ($id) {
+            return static::query()->find($id);
         }
+
         return null;
     }
 
diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php
new file mode 100644 (file)
index 0000000..78ce505
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+namespace BookStack\Entities\Repos;
+
+use BookStack\Actions\TagRepo;
+use BookStack\Entities\Book;
+use BookStack\Entities\Entity;
+use BookStack\Entities\HasCoverImage;
+use BookStack\Exceptions\ImageUploadException;
+use BookStack\Uploads\ImageRepo;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Collection;
+
+class BaseRepo
+{
+
+    protected $tagRepo;
+    protected $imageRepo;
+
+
+    /**
+     * BaseRepo constructor.
+     * @param $tagRepo
+     */
+    public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
+    {
+        $this->tagRepo = $tagRepo;
+        $this->imageRepo = $imageRepo;
+    }
+
+    /**
+     * Create a new entity in the system
+     */
+    public function create(Entity $entity, array $input)
+    {
+        $entity->fill($input);
+        $entity->forceFill([
+            'created_by' => user()->id,
+            'updated_by' => user()->id,
+        ]);
+        $entity->refreshSlug();
+        $entity->save();
+
+        if (isset($input['tags'])) {
+            $this->tagRepo->saveTagsToEntity($entity, $input['tags']);
+        }
+
+        $entity->rebuildPermissions();
+        $entity->indexForSearch();
+    }
+
+    /**
+     * Update the given entity.
+     */
+    public function update(Entity $entity, array $input)
+    {
+        $entity->fill($input);
+        $entity->updated_by = user()->id;
+
+        if ($entity->isDirty('name')) {
+            $entity->refreshSlug();
+        }
+
+        $entity->save();
+
+        if (isset($input['tags'])) {
+            $this->tagRepo->saveTagsToEntity($entity, $input['tags']);
+        }
+
+        $entity->rebuildPermissions();
+        $entity->indexForSearch();
+    }
+
+    /**
+     * Update the given items' cover image, or clear it.
+     * @throws ImageUploadException
+     * @throws \Exception
+     */
+    public function updateCoverImage(HasCoverImage $entity, UploadedFile $coverImage = null, bool $removeImage = false)
+    {
+        if ($coverImage) {
+            $this->imageRepo->destroyImage($entity->cover);
+            $image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
+            $entity->cover()->associate($image);
+        }
+
+        if ($removeImage) {
+            $this->imageRepo->destroyImage($entity->cover);
+            $entity->image_id = 0;
+            $entity->save();
+        }
+    }
+
+    /**
+     * Update the permissions of an entity.
+     */
+    public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null)
+    {
+        $entity->restricted = $restricted;
+        $entity->permissions()->delete();
+
+        if (!is_null($permissions)) {
+            $entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) {
+                return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
+                    return [
+                        'role_id' => $roleId,
+                        'action' => strtolower($action),
+                    ] ;
+                });
+            });
+
+            $entity->permissions()->createMany($entityPermissionData);
+        }
+
+        $entity->save();
+        $entity->rebuildPermissions();
+    }
+}
index c82780fea39d4399b24034fd9f7deaf78e401863..7fcc80face7c3833fd87c991402f62fbd37e2926 100644 (file)
-<?php
-
-
-namespace BookStack\Entities\Repos;
-
+<?php namespace BookStack\Entities\Repos;
 
+use BookStack\Actions\TagRepo;
 use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Managers\TrashCan;
+use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\NotifyException;
+use BookStack\Uploads\ImageRepo;
+use Exception;
+use Illuminate\Contracts\Container\BindingResolutionException;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Collection;
 
-class BookRepo extends EntityRepo
+class BookRepo
 {
 
+    protected $baseRepo;
+    protected $tagRepo;
+    protected $imageRepo;
+
     /**
-     * Fetch a book by its slug.
-     * @param string $slug
-     * @return Book
-     * @throws NotFoundException
+     * BookRepo constructor.
+     * @param $tagRepo
      */
-    public function getBySlug(string $slug): Book
+    public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
     {
-        /** @var Book $book */
-        $book = $this->getEntityBySlug('book', $slug);
-        return $book;
+        $this->baseRepo = $baseRepo;
+        $this->tagRepo = $tagRepo;
+        $this->imageRepo = $imageRepo;
     }
 
     /**
-     * Append a Book to a BookShelf.
-     * @param Bookshelf $shelf
-     * @param Book $book
+     * Get all books in a paginated format.
      */
-    public function appendBookToShelf(Bookshelf $shelf, Book $book)
+    public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
     {
-        if ($shelf->contains($book)) {
-            return;
-        }
+        return Book::visible()->orderBy($sort, $order)->paginate($count);
+    }
 
-        $maxOrder = $shelf->books()->max('order');
-        $shelf->books()->attach($book->id, ['order' => $maxOrder + 1]);
+    /**
+     * Get the books that were most recently viewed by this user.
+     */
+    public function getRecentlyViewed(int $count = 20): Collection
+    {
+        return Book::visible()->withLastView()
+            ->having('last_viewed_at', '>', 0)
+            ->orderBy('last_viewed_at', 'desc')
+            ->take($count)->get();
     }
 
     /**
-     * Destroy the provided book and all its child entities.
-     * @param Book $book
-     * @throws NotifyException
-     * @throws \Throwable
+     * Get the most popular books in the system.
      */
-    public function destroyBook(Book $book)
+    public function getPopular(int $count = 20): Collection
     {
-        foreach ($book->pages as $page) {
-            $this->destroyPage($page);
-        }
+        return Book::visible()->withViewCount()
+            ->having('view_count', '>', 0)
+            ->orderBy('view_count', 'desc')
+            ->take($count)->get();
+    }
 
-        foreach ($book->chapters as $chapter) {
-            $this->destroyChapter($chapter);
+    /**
+     * Get the most recently created books from the system.
+     */
+    public function getRecentlyCreated(int $count = 20): Collection
+    {
+        return Book::visible()->orderBy('created_at', 'desc')
+            ->take($count)->get();
+    }
+
+    /**
+     * Get a book by its slug.
+     */
+    public function getBySlug(string $slug): Book
+    {
+        $book = Book::visible()->where('slug', '=', $slug)->first();
+
+        if ($book === null) {
+            throw new NotFoundException(trans('errors.book_not_found'));
         }
 
-        $this->destroyEntityCommonRelations($book);
-        $book->delete();
+        return $book;
+    }
+
+    /**
+     * Create a new book in the system
+     */
+    public function create(array $input): Book
+    {
+        $book = new Book();
+        $this->baseRepo->create($book, $input);
+        return $book;
+    }
+
+    /**
+     * Update the given book.
+     */
+    public function update(Book $book, array $input): Book
+    {
+        $this->baseRepo->update($book, $input);
+        return $book;
+    }
+
+    /**
+     * Update the given book's cover image, or clear it.
+     * @throws ImageUploadException
+     * @throws Exception
+     */
+    public function updateCoverImage(Book $book, UploadedFile $coverImage = null, bool $removeImage = false)
+    {
+        $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
     }
 
-}
\ No newline at end of file
+    /**
+     * Update the permissions of a book.
+     */
+    public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null)
+    {
+        $this->baseRepo->updatePermissions($book, $restricted, $permissions);
+    }
+
+    /**
+     * Remove a book from the system.
+     * @throws NotifyException
+     * @throws BindingResolutionException
+     */
+    public function destroy(Book $book)
+    {
+        $trashCan = new TrashCan();
+        $trashCan->destroyBook($book);
+    }
+}
diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php
new file mode 100644 (file)
index 0000000..ab4a518
--- /dev/null
@@ -0,0 +1,173 @@
+<?php namespace BookStack\Entities\Repos;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Managers\TrashCan;
+use BookStack\Exceptions\ImageUploadException;
+use BookStack\Exceptions\NotFoundException;
+use Exception;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Collection;
+
+class BookshelfRepo
+{
+    protected $baseRepo;
+
+    /**
+     * BookshelfRepo constructor.
+     * @param $baseRepo
+     */
+    public function __construct(BaseRepo $baseRepo)
+    {
+        $this->baseRepo = $baseRepo;
+    }
+
+    /**
+     * Get all bookshelves in a paginated format.
+     */
+    public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
+    {
+        return Bookshelf::visible()->with('visibleBooks')
+            ->orderBy($sort, $order)->paginate($count);
+    }
+
+    /**
+     * Get the bookshelves that were most recently viewed by this user.
+     */
+    public function getRecentlyViewed(int $count = 20): Collection
+    {
+        return Bookshelf::visible()->withLastView()
+            ->having('last_viewed_at', '>', 0)
+            ->orderBy('last_viewed_at', 'desc')
+            ->take($count)->get();
+    }
+
+    /**
+     * Get the most popular bookshelves in the system.
+     */
+    public function getPopular(int $count = 20): Collection
+    {
+        return Bookshelf::visible()->withViewCount()
+            ->having('view_count', '>', 0)
+            ->orderBy('view_count', 'desc')
+            ->take($count)->get();
+    }
+
+    /**
+     * Get the most recently created bookshelves from the system.
+     */
+    public function getRecentlyCreated(int $count = 20): Collection
+    {
+        return Bookshelf::visible()->orderBy('created_at', 'desc')
+            ->take($count)->get();
+    }
+
+    /**
+     * Get a shelf by its slug.
+     */
+    public function getBySlug(string $slug): Bookshelf
+    {
+        $shelf = Bookshelf::visible()->where('slug', '=', $slug)->first();
+
+        if ($shelf === null) {
+            throw new NotFoundException(trans('errors.bookshelf_not_found'));
+        }
+
+        return $shelf;
+    }
+
+    /**
+     * Create a new shelf in the system.
+     */
+    public function create(array $input, array $bookIds): Bookshelf
+    {
+        $shelf = new Bookshelf();
+        $this->baseRepo->create($shelf, $input);
+        $this->updateBooks($shelf, $bookIds);
+        return $shelf;
+    }
+
+    /**
+     * Create a new shelf in the system.
+     */
+    public function update(Bookshelf $shelf, array $input, array $bookIds): Bookshelf
+    {
+        $this->baseRepo->update($shelf, $input);
+        $this->updateBooks($shelf, $bookIds);
+        return $shelf;
+    }
+
+    /**
+     * Update which books are assigned to this shelf by
+     * syncing the given book ids.
+     * Function ensures the books are visible to the current user and existing.
+     */
+    protected function updateBooks(Bookshelf $shelf, array $bookIds)
+    {
+        $numericIDs = collect($bookIds)->map(function ($id) {
+            return intval($id);
+        });
+
+        $syncData = Book::visible()
+            ->whereIn('id', $bookIds)
+            ->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) {
+                return [$bookId => ['order' => $numericIDs->search($bookId)]];
+            });
+
+        $shelf->books()->sync($syncData);
+    }
+
+    /**
+     * Update the given shelf cover image, or clear it.
+     * @throws ImageUploadException
+     * @throws Exception
+     */
+    public function updateCoverImage(Bookshelf $shelf, UploadedFile $coverImage = null, bool $removeImage = false)
+    {
+        $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
+    }
+
+    /**
+     * Update the permissions of a bookshelf.
+     */
+    public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null)
+    {
+        $this->baseRepo->updatePermissions($shelf, $restricted, $permissions);
+    }
+
+    /**
+     * Copy down the permissions of the given shelf to all child books.
+     */
+    public function copyDownPermissions(Bookshelf $shelf): int
+    {
+        $shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
+        $shelfBooks = $shelf->books()->get();
+        $updatedBookCount = 0;
+
+        /** @var Book $book */
+        foreach ($shelfBooks as $book) {
+            if (!userCan('restrictions-manage', $book)) {
+                continue;
+            }
+            $book->permissions()->delete();
+            $book->restricted = $shelf->restricted;
+            $book->permissions()->createMany($shelfPermissions);
+            $book->save();
+            $book->rebuildPermissions();
+            $updatedBookCount++;
+        }
+
+        return $updatedBookCount;
+    }
+
+    /**
+     * Remove a bookshelf from the system.
+     * @throws Exception
+     */
+    public function destroy(Bookshelf $shelf)
+    {
+        $trashCan = new TrashCan();
+        $trashCan->destroyShelf($shelf);
+    }
+}
diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php
new file mode 100644 (file)
index 0000000..c6f3a2d
--- /dev/null
@@ -0,0 +1,108 @@
+<?php namespace BookStack\Entities\Repos;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\Chapter;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Managers\TrashCan;
+use BookStack\Exceptions\MoveOperationException;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\NotifyException;
+use Exception;
+use Illuminate\Contracts\Container\BindingResolutionException;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
+
+class ChapterRepo
+{
+
+    protected $baseRepo;
+
+    /**
+     * ChapterRepo constructor.
+     * @param $baseRepo
+     */
+    public function __construct(BaseRepo $baseRepo)
+    {
+        $this->baseRepo = $baseRepo;
+    }
+
+    /**
+     * Get a chapter via the slug.
+     * @throws NotFoundException
+     */
+    public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
+    {
+        $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first();
+
+        if ($chapter === null) {
+            throw new NotFoundException(trans('errors.chapter_not_found'));
+        }
+
+        return $chapter;
+    }
+
+    /**
+     * Create a new chapter in the system.
+     */
+    public function create(array $input, Book $parentBook): Chapter
+    {
+        $chapter = new Chapter();
+        $chapter->book_id = $parentBook->id;
+        $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
+        $this->baseRepo->create($chapter, $input);
+        return $chapter;
+    }
+
+    /**
+     * Update the given chapter.
+     */
+    public function update(Chapter $chapter, array $input): Chapter
+    {
+        $this->baseRepo->update($chapter, $input);
+        return $chapter;
+    }
+
+    /**
+     * Update the permissions of a chapter.
+     */
+    public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null)
+    {
+        $this->baseRepo->updatePermissions($chapter, $restricted, $permissions);
+    }
+
+    /**
+     * Remove a chapter from the system.
+     * @throws Exception
+     */
+    public function destroy(Chapter $chapter)
+    {
+        $trashCan = new TrashCan();
+        $trashCan->destroyChapter($chapter);
+    }
+
+    /**
+     * Move the given chapter into a new parent book.
+     * The $parentIdentifier must be a string of the following format:
+     * 'book:<id>' (book:5)
+     * @throws MoveOperationException
+     */
+    public function move(Chapter $chapter, string $parentIdentifier): Book
+    {
+        $stringExploded = explode(':', $parentIdentifier);
+        $entityType = $stringExploded[0];
+        $entityId = intval($stringExploded[1]);
+
+        if ($entityType !== 'book') {
+            throw new MoveOperationException('Chapters can only be moved into books');
+        }
+
+        $parent = Book::visible()->where('id', '=', $entityId)->first();
+        if ($parent === null) {
+            throw new MoveOperationException('Book to move chapter into not found');
+        }
+
+        $chapter->changeBook($parent->id);
+        $chapter->rebuildPermissions();
+        return $parent;
+    }
+}
diff --git a/app/Entities/Repos/EntityRepo.php b/app/Entities/Repos/EntityRepo.php
deleted file mode 100644 (file)
index 925e3c3..0000000
+++ /dev/null
@@ -1,902 +0,0 @@
-<?php namespace BookStack\Entities\Repos;
-
-use Activity;
-use BookStack\Actions\TagRepo;
-use BookStack\Actions\ViewService;
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\EntityProvider;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
-use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
-use BookStack\Uploads\AttachmentService;
-use DOMDocument;
-use DOMNode;
-use DOMXPath;
-use Illuminate\Contracts\Pagination\LengthAwarePaginator;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Query\Builder as QueryBuilder;
-use Illuminate\Http\Request;
-use Illuminate\Support\Collection;
-use Throwable;
-
-class EntityRepo
-{
-
-    /**
-     * @var EntityProvider
-     */
-    protected $entityProvider;
-
-    /**
-     * @var PermissionService
-     */
-    protected $permissionService;
-
-    /**
-     * @var ViewService
-     */
-    protected $viewService;
-
-    /**
-     * @var TagRepo
-     */
-    protected $tagRepo;
-
-    /**
-     * @var SearchService
-     */
-    protected $searchService;
-
-    /**
-     * EntityRepo constructor.
-     * @param EntityProvider $entityProvider
-     * @param ViewService $viewService
-     * @param PermissionService $permissionService
-     * @param TagRepo $tagRepo
-     * @param SearchService $searchService
-     */
-    public function __construct(
-        EntityProvider $entityProvider,
-        ViewService $viewService,
-        PermissionService $permissionService,
-        TagRepo $tagRepo,
-        SearchService $searchService
-    ) {
-        $this->entityProvider = $entityProvider;
-        $this->viewService = $viewService;
-        $this->permissionService = $permissionService;
-        $this->tagRepo = $tagRepo;
-        $this->searchService = $searchService;
-    }
-
-    /**
-     * Base query for searching entities via permission system
-     * @param string $type
-     * @param bool $allowDrafts
-     * @param string $permission
-     * @return QueryBuilder
-     */
-    protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
-    {
-        $q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission);
-        if (strtolower($type) === 'page' && !$allowDrafts) {
-            $q = $q->where('draft', '=', false);
-        }
-        return $q;
-    }
-
-    /**
-     * Check if an entity with the given id exists.
-     * @param $type
-     * @param $id
-     * @return bool
-     */
-    public function exists($type, $id)
-    {
-        return $this->entityQuery($type)->where('id', '=', $id)->exists();
-    }
-
-    /**
-     * Get an entity by ID
-     * @param string $type
-     * @param integer $id
-     * @param bool $allowDrafts
-     * @param bool $ignorePermissions
-     * @return Entity
-     */
-    public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
-    {
-        $query = $this->entityQuery($type, $allowDrafts);
-
-        if ($ignorePermissions) {
-            $query = $this->entityProvider->get($type)->newQuery();
-        }
-
-        return $query->find($id);
-    }
-
-    /**
-     * @param string $type
-     * @param []int $ids
-     * @param bool $allowDrafts
-     * @param bool $ignorePermissions
-     * @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
-     */
-    public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
-    {
-        $query = $this->entityQuery($type, $allowDrafts);
-
-        if ($ignorePermissions) {
-            $query = $this->entityProvider->get($type)->newQuery();
-        }
-
-        return $query->whereIn('id', $ids)->get();
-    }
-
-    /**
-     * Get an entity by its url slug.
-     * @param string $type
-     * @param string $slug
-     * @param string|null $bookSlug
-     * @return Entity
-     * @throws NotFoundException
-     */
-    public function getEntityBySlug(string $type, string $slug, string $bookSlug = null): Entity
-    {
-        $type = strtolower($type);
-        $query = $this->entityQuery($type)->where('slug', '=', $slug);
-
-        if ($type === 'chapter' || $type === 'page') {
-            $query = $query->where('book_id', '=', function (QueryBuilder $query) use ($bookSlug) {
-                $query->select('id')
-                    ->from($this->entityProvider->book->getTable())
-                    ->where('slug', '=', $bookSlug)->limit(1);
-            });
-        }
-
-        $entity = $query->first();
-
-        if ($entity === null) {
-            throw new NotFoundException(trans('errors.' . $type . '_not_found'));
-        }
-
-        return $entity;
-    }
-
-
-    /**
-     * Get all entities of a type with the given permission, limited by count unless count is false.
-     * @param string $type
-     * @param integer|bool $count
-     * @param string $permission
-     * @return Collection
-     */
-    public function getAll($type, $count = 20, $permission = 'view')
-    {
-        $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
-        if ($count !== false) {
-            $q = $q->take($count);
-        }
-        return $q->get();
-    }
-
-    /**
-     * Get all entities in a paginated format
-     * @param $type
-     * @param int $count
-     * @param string $sort
-     * @param string $order
-     * @param null|callable $queryAddition
-     * @return LengthAwarePaginator
-     */
-    public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null)
-    {
-        $query = $this->entityQuery($type);
-        $query = $this->addSortToQuery($query, $sort, $order);
-        if ($queryAddition) {
-            $queryAddition($query);
-        }
-        return $query->paginate($count);
-    }
-
-    /**
-     * Add sorting operations to an entity query.
-     * @param Builder $query
-     * @param string $sort
-     * @param string $order
-     * @return Builder
-     */
-    protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc')
-    {
-        $order = ($order === 'asc') ? 'asc' : 'desc';
-        $propertySorts = ['name', 'created_at', 'updated_at'];
-
-        if (in_array($sort, $propertySorts)) {
-            return $query->orderBy($sort, $order);
-        }
-
-        return $query;
-    }
-
-    /**
-     * Get the most recently created entities of the given type.
-     * @param string $type
-     * @param int $count
-     * @param int $page
-     * @param bool|callable $additionalQuery
-     * @return Collection
-     */
-    public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
-    {
-        $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
-            ->orderBy('created_at', 'desc');
-        if (strtolower($type) === 'page') {
-            $query = $query->where('draft', '=', false);
-        }
-        if ($additionalQuery !== false && is_callable($additionalQuery)) {
-            $additionalQuery($query);
-        }
-        return $query->skip($page * $count)->take($count)->get();
-    }
-
-    /**
-     * Get the most recently updated entities of the given type.
-     * @param string $type
-     * @param int $count
-     * @param int $page
-     * @param bool|callable $additionalQuery
-     * @return Collection
-     */
-    public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
-    {
-        $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
-            ->orderBy('updated_at', 'desc');
-        if (strtolower($type) === 'page') {
-            $query = $query->where('draft', '=', false);
-        }
-        if ($additionalQuery !== false && is_callable($additionalQuery)) {
-            $additionalQuery($query);
-        }
-        return $query->skip($page * $count)->take($count)->get();
-    }
-
-    /**
-     * Get the most recently viewed entities.
-     * @param string|bool $type
-     * @param int $count
-     * @param int $page
-     * @return mixed
-     */
-    public function getRecentlyViewed($type, $count = 10, $page = 0)
-    {
-        $filter = is_bool($type) ? false : $this->entityProvider->get($type);
-        return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
-    }
-
-    /**
-     * Get the latest pages added to the system with pagination.
-     * @param string $type
-     * @param int $count
-     * @return mixed
-     */
-    public function getRecentlyCreatedPaginated($type, $count = 20)
-    {
-        return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
-    }
-
-    /**
-     * Get the latest pages added to the system with pagination.
-     * @param string $type
-     * @param int $count
-     * @return mixed
-     */
-    public function getRecentlyUpdatedPaginated($type, $count = 20)
-    {
-        return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
-    }
-
-    /**
-     * Get the most popular entities base on all views.
-     * @param string $type
-     * @param int $count
-     * @param int $page
-     * @return mixed
-     */
-    public function getPopular(string $type, int $count = 10, int $page = 0)
-    {
-        return $this->viewService->getPopular($count, $page, $type);
-    }
-
-    /**
-     * Get draft pages owned by the current user.
-     * @param int $count
-     * @param int $page
-     * @return Collection
-     */
-    public function getUserDraftPages($count = 20, $page = 0)
-    {
-        return $this->entityProvider->page->where('draft', '=', true)
-            ->where('created_by', '=', user()->id)
-            ->orderBy('updated_at', 'desc')
-            ->skip($count * $page)->take($count)->get();
-    }
-
-    /**
-     * Get the number of entities the given user has created.
-     * @param string $type
-     * @param User $user
-     * @return int
-     */
-    public function getUserTotalCreated(string $type, User $user)
-    {
-        return $this->entityProvider->get($type)
-            ->where('created_by', '=', $user->id)->count();
-    }
-
-    /**
-     * Get the child items for a chapter sorted by priority but
-     * with draft items floated to the top.
-     * @param Bookshelf $bookshelf
-     * @return \Illuminate\Database\Eloquent\Collection|static[]
-     */
-    public function getBookshelfChildren(Bookshelf $bookshelf)
-    {
-        return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
-    }
-
-    /**
-     * Get the direct children of a book.
-     * @param Book $book
-     * @return \Illuminate\Database\Eloquent\Collection
-     */
-    public function getBookDirectChildren(Book $book)
-    {
-        $pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get();
-        $chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get();
-        return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft');
-    }
-
-    /**
-     * Get all child objects of a book.
-     * Returns a sorted collection of Pages and Chapters.
-     * Loads the book slug onto child elements to prevent access database access for getting the slug.
-     * @param Book $book
-     * @param bool $filterDrafts
-     * @param bool $renderPages
-     * @return mixed
-     */
-    public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
-    {
-        $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
-        $entities = [];
-        $parents = [];
-        $tree = [];
-
-        foreach ($q as $index => $rawEntity) {
-            if ($rawEntity->entity_type ===  $this->entityProvider->page->getMorphClass()) {
-                $entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity);
-                if ($renderPages) {
-                    $entities[$index]->html = $rawEntity->html;
-                    $entities[$index]->html = $this->renderPage($entities[$index]);
-                };
-            } else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) {
-                $entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity);
-                $key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
-                $parents[$key] = $entities[$index];
-                $parents[$key]->setAttribute('pages', collect());
-            }
-            if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
-                $tree[] = $entities[$index];
-            }
-            $entities[$index]->book = $book;
-        }
-
-        foreach ($entities as $entity) {
-            if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
-                continue;
-            }
-            $parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id;
-            if (!isset($parents[$parentKey])) {
-                $tree[] = $entity;
-                continue;
-            }
-            $chapter = $parents[$parentKey];
-            $chapter->pages->push($entity);
-        }
-
-        return collect($tree);
-    }
-
-    /**
-     * Get the child items for a chapter sorted by priority but
-     * with draft items floated to the top.
-     * @param Chapter $chapter
-     * @return \Illuminate\Database\Eloquent\Collection|static[]
-     */
-    public function getChapterChildren(Chapter $chapter)
-    {
-        return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
-            ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
-    }
-
-
-    /**
-     * Get the next sequential priority for a new child element in the given book.
-     * @param Book $book
-     * @return int
-     */
-    public function getNewBookPriority(Book $book)
-    {
-        $lastElem = $this->getBookChildren($book)->pop();
-        return $lastElem ? $lastElem->priority + 1 : 0;
-    }
-
-    /**
-     * Get a new priority for a new page to be added to the given chapter.
-     * @param Chapter $chapter
-     * @return int
-     */
-    public function getNewChapterPriority(Chapter $chapter)
-    {
-        $lastPage = $chapter->pages('DESC')->first();
-        return $lastPage !== null ? $lastPage->priority + 1 : 0;
-    }
-
-    /**
-     * Find a suitable slug for an entity.
-     * @param string $type
-     * @param string $name
-     * @param bool|integer $currentId
-     * @param bool|integer $bookId Only pass if type is not a book
-     * @return string
-     */
-    public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
-    {
-        $slug = $this->nameToSlug($name);
-        while ($this->slugExists($type, $slug, $currentId, $bookId)) {
-            $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
-        }
-        return $slug;
-    }
-
-    /**
-     * Check if a slug already exists in the database.
-     * @param string $type
-     * @param string $slug
-     * @param bool|integer $currentId
-     * @param bool|integer $bookId
-     * @return bool
-     */
-    protected function slugExists($type, $slug, $currentId = false, $bookId = false)
-    {
-        $query = $this->entityProvider->get($type)->where('slug', '=', $slug);
-        if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
-            $query = $query->where('book_id', '=', $bookId);
-        }
-        if ($currentId) {
-            $query = $query->where('id', '!=', $currentId);
-        }
-        return $query->count() > 0;
-    }
-
-    /**
-     * Updates entity restrictions from a request
-     * @param Request $request
-     * @param Entity $entity
-     * @throws Throwable
-     */
-    public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
-    {
-        $entity->restricted = $request->get('restricted', '') === 'true';
-        $entity->permissions()->delete();
-
-        if ($request->filled('restrictions')) {
-            foreach ($request->get('restrictions') as $roleId => $restrictions) {
-                foreach ($restrictions as $action => $value) {
-                    $entity->permissions()->create([
-                        'role_id' => $roleId,
-                        'action'  => strtolower($action)
-                    ]);
-                }
-            }
-        }
-
-        $entity->save();
-        $this->permissionService->buildJointPermissionsForEntity($entity);
-    }
-
-
-    /**
-     * Create a new entity from request input.
-     * Used for books and chapters.
-     * @param string $type
-     * @param array $input
-     * @param Book|null $book
-     * @return Entity
-     * @throws Throwable
-     */
-    public function createFromInput(string $type, array $input = [], Book $book = null)
-    {
-        $entityModel = $this->entityProvider->get($type)->newInstance($input);
-        $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $book ? $book->id : false);
-        $entityModel->created_by = user()->id;
-        $entityModel->updated_by = user()->id;
-
-        if ($book) {
-            $entityModel->book_id = $book->id;
-        }
-
-        $entityModel->save();
-
-        if (isset($input['tags'])) {
-            $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
-        }
-
-        $this->permissionService->buildJointPermissionsForEntity($entityModel);
-        $this->searchService->indexEntity($entityModel);
-        return $entityModel;
-    }
-
-    /**
-     * Update entity details from request input.
-     * Used for books and chapters
-     * @param string $type
-     * @param Entity $entityModel
-     * @param array $input
-     * @return Entity
-     * @throws Throwable
-     */
-    public function updateFromInput(string $type, Entity $entityModel, array $input = [])
-    {
-        if ($entityModel->name !== $input['name']) {
-            $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
-        }
-
-        $entityModel->fill($input);
-        $entityModel->updated_by = user()->id;
-        $entityModel->save();
-
-        if (isset($input['tags'])) {
-            $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
-        }
-
-        $this->permissionService->buildJointPermissionsForEntity($entityModel);
-        $this->searchService->indexEntity($entityModel);
-        return $entityModel;
-    }
-
-    /**
-     * Sync the books assigned to a shelf from a comma-separated list
-     * of book IDs.
-     * @param Bookshelf $shelf
-     * @param string $books
-     */
-    public function updateShelfBooks(Bookshelf $shelf, string $books)
-    {
-        $ids = explode(',', $books);
-
-        // Check books exist and match ordering
-        $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
-        $syncData = [];
-        foreach ($ids as $index => $id) {
-            if ($bookIds->contains($id)) {
-                $syncData[$id] = ['order' => $index];
-            }
-        }
-
-        $shelf->books()->sync($syncData);
-    }
-
-    /**
-     * Change the book that an entity belongs to.
-     * @param string $type
-     * @param integer $newBookId
-     * @param Entity $entity
-     * @param bool $rebuildPermissions
-     * @return Entity
-     */
-    public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
-    {
-        $entity->book_id = $newBookId;
-        // Update related activity
-        foreach ($entity->activity as $activity) {
-            $activity->book_id = $newBookId;
-            $activity->save();
-        }
-        $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
-        $entity->save();
-
-        // Update all child pages if a chapter
-        if (strtolower($type) === 'chapter') {
-            foreach ($entity->pages as $page) {
-                $this->changeBook('page', $newBookId, $page, false);
-            }
-        }
-
-        // Update permissions if applicable
-        if ($rebuildPermissions) {
-            $entity->load('book');
-            $this->permissionService->buildJointPermissionsForEntity($entity->book);
-        }
-
-        return $entity;
-    }
-
-    /**
-     * Alias method to update the book jointPermissions in the PermissionService.
-     * @param Book $book
-     */
-    public function buildJointPermissionsForBook(Book $book)
-    {
-        $this->permissionService->buildJointPermissionsForEntity($book);
-    }
-
-    /**
-     * Format a name as a url slug.
-     * @param $name
-     * @return string
-     */
-    protected function nameToSlug($name)
-    {
-        $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
-        $slug = preg_replace('/\s{2,}/', ' ', $slug);
-        $slug = str_replace(' ', '-', $slug);
-        if ($slug === "") {
-            $slug = substr(md5(rand(1, 500)), 0, 5);
-        }
-        return $slug;
-    }
-
-    /**
-     * Render the page for viewing
-     * @param Page $page
-     * @param bool $blankIncludes
-     * @return string
-     */
-    public function renderPage(Page $page, bool $blankIncludes = false) : string
-    {
-        $content = $page->html;
-
-        if (!config('app.allow_content_scripts')) {
-            $content = $this->escapeScripts($content);
-        }
-
-        if ($blankIncludes) {
-            $content = $this->blankPageIncludes($content);
-        } else {
-            $content = $this->parsePageIncludes($content);
-        }
-
-        return $content;
-    }
-
-    /**
-     * Remove any page include tags within the given HTML.
-     * @param string $html
-     * @return string
-     */
-    protected function blankPageIncludes(string $html) : string
-    {
-        return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
-    }
-
-    /**
-     * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
-     * @param string $html
-     * @return mixed|string
-     */
-    protected function parsePageIncludes(string $html) : string
-    {
-        $matches = [];
-        preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
-
-        $topLevelTags = ['table', 'ul', 'ol'];
-        foreach ($matches[1] as $index => $includeId) {
-            $splitInclude = explode('#', $includeId, 2);
-            $pageId = intval($splitInclude[0]);
-            if (is_nan($pageId)) {
-                continue;
-            }
-
-            $matchedPage = $this->getById('page', $pageId);
-            if ($matchedPage === null) {
-                $html = str_replace($matches[0][$index], '', $html);
-                continue;
-            }
-
-            if (count($splitInclude) === 1) {
-                $html = str_replace($matches[0][$index], $matchedPage->html, $html);
-                continue;
-            }
-
-            $doc = new DOMDocument();
-            libxml_use_internal_errors(true);
-            $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
-            $matchingElem = $doc->getElementById($splitInclude[1]);
-            if ($matchingElem === null) {
-                $html = str_replace($matches[0][$index], '', $html);
-                continue;
-            }
-            $innerContent = '';
-            $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
-            if ($isTopLevel) {
-                $innerContent .= $doc->saveHTML($matchingElem);
-            } else {
-                foreach ($matchingElem->childNodes as $childNode) {
-                    $innerContent .= $doc->saveHTML($childNode);
-                }
-            }
-            libxml_clear_errors();
-            $html = str_replace($matches[0][$index], trim($innerContent), $html);
-        }
-
-        return $html;
-    }
-
-    /**
-     * Escape script tags within HTML content.
-     * @param string $html
-     * @return string
-     */
-    protected function escapeScripts(string $html) : string
-    {
-        if ($html == '') {
-            return $html;
-        }
-
-        libxml_use_internal_errors(true);
-        $doc = new DOMDocument();
-        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
-        $xPath = new DOMXPath($doc);
-
-        // Remove standard script tags
-        $scriptElems = $xPath->query('//script');
-        foreach ($scriptElems as $scriptElem) {
-            $scriptElem->parentNode->removeChild($scriptElem);
-        }
-
-        // Remove data or JavaScript iFrames
-        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
-        foreach ($badIframes as $badIframe) {
-            $badIframe->parentNode->removeChild($badIframe);
-        }
-
-        // Remove 'on*' attributes
-        $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
-        foreach ($onAttributes as $attr) {
-            /** @var \DOMAttr $attr*/
-            $attrName = $attr->nodeName;
-            $attr->parentNode->removeAttribute($attrName);
-        }
-
-        $html = '';
-        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
-        foreach ($topElems as $child) {
-            $html .= $doc->saveHTML($child);
-        }
-
-        return $html;
-    }
-
-    /**
-     * Search for image usage within page content.
-     * @param $imageString
-     * @return mixed
-     */
-    public function searchForImage($imageString)
-    {
-        $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']);
-        foreach ($pages as $page) {
-            $page->url = $page->getUrl();
-            $page->html = '';
-            $page->text = '';
-        }
-        return count($pages) > 0 ? $pages : false;
-    }
-
-    /**
-     * Destroy a bookshelf instance
-     * @param Bookshelf $shelf
-     * @throws Throwable
-     */
-    public function destroyBookshelf(Bookshelf $shelf)
-    {
-        $this->destroyEntityCommonRelations($shelf);
-        $shelf->delete();
-    }
-
-    /**
-     * Destroy a chapter and its relations.
-     * @param Chapter $chapter
-     * @throws Throwable
-     */
-    public function destroyChapter(Chapter $chapter)
-    {
-        if (count($chapter->pages) > 0) {
-            foreach ($chapter->pages as $page) {
-                $page->chapter_id = 0;
-                $page->save();
-            }
-        }
-        $this->destroyEntityCommonRelations($chapter);
-        $chapter->delete();
-    }
-
-    /**
-     * Destroy a given page along with its dependencies.
-     * @param Page $page
-     * @throws NotifyException
-     * @throws Throwable
-     */
-    public function destroyPage(Page $page)
-    {
-        // Check if set as custom homepage & remove setting if not used or throw error if active
-        $customHome = setting('app-homepage', '0:');
-        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
-            if (setting('app-homepage-type') === 'page') {
-                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
-            }
-            setting()->remove('app-homepage');
-        }
-
-        $this->destroyEntityCommonRelations($page);
-
-        // Delete Attached Files
-        $attachmentService = app(AttachmentService::class);
-        foreach ($page->attachments as $attachment) {
-            $attachmentService->deleteFile($attachment);
-        }
-
-        $page->delete();
-    }
-
-    /**
-     * Destroy or handle the common relations connected to an entity.
-     * @param Entity $entity
-     * @throws Throwable
-     */
-    protected function destroyEntityCommonRelations(Entity $entity)
-    {
-        Activity::removeEntity($entity);
-        $entity->views()->delete();
-        $entity->permissions()->delete();
-        $entity->tags()->delete();
-        $entity->comments()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($entity);
-        $this->searchService->deleteEntityTerms($entity);
-    }
-
-    /**
-     * Copy the permissions of a bookshelf to all child books.
-     * Returns the number of books that had permissions updated.
-     * @param Bookshelf $bookshelf
-     * @return int
-     * @throws Throwable
-     */
-    public function copyBookshelfPermissions(Bookshelf $bookshelf)
-    {
-        $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
-        $shelfBooks = $bookshelf->books()->get();
-        $updatedBookCount = 0;
-
-        foreach ($shelfBooks as $book) {
-            if (!userCan('restrictions-manage', $book)) {
-                continue;
-            }
-            $book->permissions()->delete();
-            $book->restricted = $bookshelf->restricted;
-            $book->permissions()->createMany($shelfPermissions);
-            $book->save();
-            $this->permissionService->buildJointPermissionsForEntity($book);
-            $updatedBookCount++;
-        }
-
-        return $updatedBookCount;
-    }
-}
index ba59d1277706ef9f220e2e01c49f36c6f891f713..0fc68f95345189d6767087f04db9ec5ace2bb673 100644 (file)
 use BookStack\Entities\Book;
 use BookStack\Entities\Chapter;
 use BookStack\Entities\Entity;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Managers\TrashCan;
 use BookStack\Entities\Page;
 use BookStack\Entities\PageRevision;
-use Carbon\Carbon;
-use DOMDocument;
-use DOMElement;
-use DOMXPath;
+use BookStack\Exceptions\MoveOperationException;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Exceptions\PermissionsException;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Support\Collection;
 
-class PageRepo extends EntityRepo
+class PageRepo
 {
 
+    protected $baseRepo;
+
     /**
-     * Get page by slug.
-     * @param string $pageSlug
-     * @param string $bookSlug
-     * @return Page
-     * @throws \BookStack\Exceptions\NotFoundException
+     * PageRepo constructor.
      */
-    public function getBySlug(string $pageSlug, string $bookSlug)
+    public function __construct(BaseRepo $baseRepo)
     {
-        return $this->getEntityBySlug('page', $pageSlug, $bookSlug);
+        $this->baseRepo = $baseRepo;
     }
 
     /**
-     * Search through page revisions and retrieve the last page in the
-     * current book that has a slug equal to the one given.
-     * @param string $pageSlug
-     * @param string $bookSlug
-     * @return null|Page
+     * Get a page by ID.
+     * @throws NotFoundException
      */
-    public function getPageByOldSlug(string $pageSlug, string $bookSlug)
+    public function getById(int $id): Page
     {
-        $revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
-            ->whereHas('page', function ($query) {
-                $this->permissionService->enforceEntityRestrictions('page', $query);
+        $page = Page::visible()->with(['book'])->find($id);
+
+        if (!$page) {
+            throw new NotFoundException(trans('errors.page_not_found'));
+        }
+
+        return $page;
+    }
+
+    /**
+     * Get a page its book and own slug.
+     * @throws NotFoundException
+     */
+    public function getBySlug(string $bookSlug, string $pageSlug): Page
+    {
+        $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
+
+        if (!$page) {
+            throw new NotFoundException(trans('errors.page_not_found'));
+        }
+
+        return $page;
+    }
+
+    /**
+     * Get a page by its old slug but checking the revisions table
+     * for the last revision that matched the given page and book slug.
+     */
+    public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
+    {
+        $revision = PageRevision::query()
+            ->whereHas('page', function (Builder $query) {
+                $query->visible();
             })
+            ->where('slug', '=', $pageSlug)
             ->where('type', '=', 'version')
             ->where('book_slug', '=', $bookSlug)
             ->orderBy('created_at', 'desc')
-            ->with('page')->first();
-        return $revision !== null ? $revision->page : null;
+            ->with('page')
+            ->first();
+        return $revision ? $revision->page : null;
     }
 
     /**
-     * Updates a page with any fillable data and saves it into the database.
-     * @param Page $page
-     * @param int $book_id
-     * @param array $input
-     * @return Page
-     * @throws \Exception
+     * Get pages that have been marked as a template.
      */
-    public function updatePage(Page $page, int $book_id, array $input)
+    public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator
     {
-        // Hold the old details to compare later
-        $oldHtml = $page->html;
-        $oldName = $page->name;
+        $query = Page::visible()
+            ->where('template', '=', true)
+            ->orderBy('name', 'asc')
+            ->skip(($page - 1) * $count)
+            ->take($count);
+
+        if ($search) {
+            $query->where('name', 'like', '%' . $search . '%');
+        }
+
+        $paginator = $query->paginate($count, ['*'], 'page', $page);
+        $paginator->withPath('/templates');
 
-        // Prevent slug being updated if no name change
-        if ($page->name !== $input['name']) {
-            $page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
+        return $paginator;
+    }
+
+    /**
+     * Get a parent item via slugs.
+     */
+    public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
+    {
+        if ($chapterSlug !== null) {
+            return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
         }
 
-        // Save page tags if present
-        if (isset($input['tags'])) {
-            $this->tagRepo->saveTagsToEntity($page, $input['tags']);
+        return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
+    }
+
+    /**
+     * Get the draft copy of the given page for the current user.
+     */
+    public function getUserDraft(Page $page): ?PageRevision
+    {
+        $revision = $this->getUserDraftQuery($page)->first();
+        return $revision;
+    }
+
+    /**
+     * Get a new draft page belonging to the given parent entity.
+     */
+    public function getNewDraftPage(Entity $parent)
+    {
+        $page = (new Page())->forceFill([
+            'name' => trans('entities.pages_initial_name'),
+            'created_by' => user()->id,
+            'updated_by' => user()->id,
+            'draft' => true,
+        ]);
+
+        if ($parent instanceof Chapter) {
+            $page->chapter_id = $parent->id;
+            $page->book_id = $parent->book_id;
+        } else {
+            $page->book_id = $parent->id;
+        }
+
+        $page->save();
+        $page->refresh()->rebuildPermissions();
+        return $page;
+    }
+
+    /**
+     * Publish a draft page to make it a live, non-draft page.
+     */
+    public function publishDraft(Page $draft, array $input): Page
+    {
+        $this->baseRepo->update($draft, $input);
+        if (isset($input['template']) && userCan('templates-manage')) {
+            $draft->template = ($input['template'] === 'true');
         }
 
+        $pageContent = new PageContent($draft);
+        $pageContent->setNewHTML($input['html']);
+        $draft->draft = false;
+        $draft->revision_count = 1;
+        $draft->priority = $this->getNewPriority($draft);
+        $draft->refreshSlug();
+        $draft->save();
+
+        $this->savePageRevision($draft, trans('entities.pages_initial_revision'));
+        $draft->indexForSearch();
+        return $draft->refresh();
+    }
+
+    /**
+     * Update a page in the system.
+     */
+    public function update(Page $page, array $input): Page
+    {
+        // Hold the old details to compare later
+        $oldHtml = $page->html;
+        $oldName = $page->name;
+
         if (isset($input['template']) && userCan('templates-manage')) {
             $page->template = ($input['template'] === 'true');
         }
 
+        $this->baseRepo->update($page, $input);
+
         // Update with new details
-        $userId = user()->id;
         $page->fill($input);
-        $page->html = $this->formatHtml($input['html']);
-        $page->text = $this->pageToPlainText($page);
+        $pageContent = new PageContent($page);
+        $pageContent->setNewHTML($input['html']);
+        $page->revision_count++;
+
         if (setting('app-editor') !== 'markdown') {
             $page->markdown = '';
         }
-        $page->updated_by = $userId;
-        $page->revision_count++;
+
         $page->save();
 
         // Remove all update drafts for this user & page.
-        $this->userUpdatePageDraftsQuery($page, $userId)->delete();
+        $this->getUserDraftQuery($page)->delete();
 
         // Save a revision after updating
         $summary = $input['summary'] ?? null;
@@ -95,24 +203,20 @@ class PageRepo extends EntityRepo
             $this->savePageRevision($page, $summary);
         }
 
-        $this->searchService->indexEntity($page);
-
         return $page;
     }
 
     /**
      * Saves a page revision into the system.
-     * @param Page $page
-     * @param null|string $summary
-     * @return PageRevision
-     * @throws \Exception
      */
-    public function savePageRevision(Page $page, string $summary = null)
+    protected function savePageRevision(Page $page, string $summary = null)
     {
-        $revision = $this->entityProvider->pageRevision->newInstance($page->toArray());
+        $revision = new PageRevision($page->toArray());
+
         if (setting('app-editor') !== 'markdown') {
             $revision->markdown = '';
         }
+
         $revision->page_id = $page->id;
         $revision->slug = $page->slug;
         $revision->book_slug = $page->book->slug;
@@ -123,439 +227,233 @@ class PageRepo extends EntityRepo
         $revision->revision_number = $page->revision_count;
         $revision->save();
 
-        $revisionLimit = config('app.revision_limit');
-        if ($revisionLimit !== false) {
-            $revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id)
-                ->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
-            if ($revisionsToDelete->count() > 0) {
-                $this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
-            }
-        }
-
+        $this->deleteOldRevisions($page);
         return $revision;
     }
 
     /**
-     * Formats a page's html to be tagged correctly within the system.
-     * @param string $htmlText
-     * @return string
+     * Save a page update draft.
      */
-    protected function formatHtml(string $htmlText)
+    public function updatePageDraft(Page $page, array $input)
     {
-        if ($htmlText == '') {
-            return $htmlText;
-        }
-
-        libxml_use_internal_errors(true);
-        $doc = new DOMDocument();
-        $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
-
-        $container = $doc->documentElement;
-        $body = $container->childNodes->item(0);
-        $childNodes = $body->childNodes;
-
-        // Set ids on top-level nodes
-        $idMap = [];
-        foreach ($childNodes as $index => $childNode) {
-            $this->setUniqueId($childNode, $idMap);
-        }
-
-        // Ensure no duplicate ids within child items
-        $xPath = new DOMXPath($doc);
-        $idElems = $xPath->query('//body//*//*[@id]');
-        foreach ($idElems as $domElem) {
-            $this->setUniqueId($domElem, $idMap);
+        // If the page itself is a draft simply update that
+        if ($page->draft) {
+            $page->fill($input);
+            if (isset($input['html'])) {
+                $content = new PageContent($page);
+                $content->setNewHTML($input['html']);
+            }
+            $page->save();
+            return $page;
         }
 
-        // Generate inner html as a string
-        $html = '';
-        foreach ($childNodes as $childNode) {
-            $html .= $doc->saveHTML($childNode);
+        // Otherwise save the data to a revision
+        $draft = $this->getPageRevisionToUpdate($page);
+        $draft->fill($input);
+        if (setting('app-editor') !== 'markdown') {
+            $draft->markdown = '';
         }
 
-        return $html;
+        $draft->save();
+        return $draft;
     }
 
     /**
-     * Set a unique id on the given DOMElement.
-     * A map for existing ID's should be passed in to check for current existence.
-     * @param DOMElement $element
-     * @param array $idMap
+     * Destroy a page from the system.
+     * @throws NotifyException
      */
-    protected function setUniqueId($element, array &$idMap)
+    public function destroy(Page $page)
     {
-        if (get_class($element) !== 'DOMElement') {
-            return;
-        }
-
-        // Overwrite id if not a BookStack custom id
-        $existingId = $element->getAttribute('id');
-        if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
-            $idMap[$existingId] = true;
-            return;
-        }
-
-        // Create an unique id for the element
-        // Uses the content as a basis to ensure output is the same every time
-        // the same content is passed through.
-        $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
-        $newId = urlencode($contentId);
-        $loopIndex = 0;
-
-        while (isset($idMap[$newId])) {
-            $newId = urlencode($contentId . '-' . $loopIndex);
-            $loopIndex++;
-        }
-
-        $element->setAttribute('id', $newId);
-        $idMap[$newId] = true;
+        $trashCan = new TrashCan();
+        $trashCan->destroyPage($page);
     }
 
     /**
-     * Get the plain text version of a page's content.
-     * @param \BookStack\Entities\Page $page
-     * @return string
+     * Restores a revision's content back into a page.
      */
-    protected function pageToPlainText(Page $page) : string
+    public function restoreRevision(Page $page, int $revisionId): Page
     {
-        $html = $this->renderPage($page, true);
-        return strip_tags($html);
-    }
+        $page->revision_count++;
+        $this->savePageRevision($page);
 
-    /**
-     * Get a new draft page instance.
-     * @param Book $book
-     * @param Chapter|null $chapter
-     * @return \BookStack\Entities\Page
-     * @throws \Throwable
-     */
-    public function getDraftPage(Book $book, Chapter $chapter = null)
-    {
-        $page = $this->entityProvider->page->newInstance();
-        $page->name = trans('entities.pages_initial_name');
-        $page->created_by = user()->id;
+        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
+        $page->fill($revision->toArray());
+        $content = new PageContent($page);
+        $content->setNewHTML($page->html);
         $page->updated_by = user()->id;
-        $page->draft = true;
-
-        if ($chapter) {
-            $page->chapter_id = $chapter->id;
-        }
+        $page->refreshSlug();
+        $page->save();
 
-        $book->pages()->save($page);
-        $page = $this->entityProvider->page->find($page->id);
-        $this->permissionService->buildJointPermissionsForEntity($page);
+        $page->indexForSearch();
         return $page;
     }
 
     /**
-     * Save a page update draft.
-     * @param Page $page
-     * @param array $data
-     * @return PageRevision|Page
+     * Move the given page into a new parent book or chapter.
+     * The $parentIdentifier must be a string of the following format:
+     * 'book:<id>' (book:5)
+     * @throws MoveOperationException
+     * @throws PermissionsException
      */
-    public function updatePageDraft(Page $page, array $data = [])
+    public function move(Page $page, string $parentIdentifier): Book
     {
-        // If the page itself is a draft simply update that
-        if ($page->draft) {
-            $page->fill($data);
-            if (isset($data['html'])) {
-                $page->text = $this->pageToPlainText($page);
-            }
-            $page->save();
-            return $page;
+        $parent = $this->findParentByIdentifier($parentIdentifier);
+        if ($parent === null) {
+            throw new MoveOperationException('Book or chapter to move page into not found');
         }
 
-        // Otherwise save the data to a revision
-        $userId = user()->id;
-        $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
-
-        if ($drafts->count() > 0) {
-            $draft = $drafts->first();
-        } else {
-            $draft = $this->entityProvider->pageRevision->newInstance();
-            $draft->page_id = $page->id;
-            $draft->slug = $page->slug;
-            $draft->book_slug = $page->book->slug;
-            $draft->created_by = $userId;
-            $draft->type = 'update_draft';
+        if (!userCan('page-create', $parent)) {
+            throw new PermissionsException('User does not have permission to create a page within the new parent');
         }
 
-        $draft->fill($data);
-        if (setting('app-editor') !== 'markdown') {
-            $draft->markdown = '';
-        }
-
-        $draft->save();
-        return $draft;
+        $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
+        $page->rebuildPermissions();
+        return $parent;
     }
 
     /**
-     * Publish a draft page to make it a normal page.
-     * Sets the slug and updates the content.
-     * @param Page $draftPage
-     * @param array $input
-     * @return Page
-     * @throws \Exception
+     * Copy an existing page in the system.
+     * Optionally providing a new parent via string identifier and a new name.
+     * @throws MoveOperationException
+     * @throws PermissionsException
      */
-    public function publishPageDraft(Page $draftPage, array $input)
+    public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
     {
-        $draftPage->fill($input);
-
-        // Save page tags if present
-        if (isset($input['tags'])) {
-            $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
+        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
+        if ($parent === null) {
+            throw new MoveOperationException('Book or chapter to move page into not found');
         }
 
-        if (isset($input['template']) && userCan('templates-manage')) {
-            $draftPage->template = ($input['template'] === 'true');
+        if (!userCan('page-create', $parent)) {
+            throw new PermissionsException('User does not have permission to create a page within the new parent');
         }
 
-        $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
-        $draftPage->html = $this->formatHtml($input['html']);
-        $draftPage->text = $this->pageToPlainText($draftPage);
-        $draftPage->draft = false;
-        $draftPage->revision_count = 1;
-
-        $draftPage->save();
-        $this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
-        $this->searchService->indexEntity($draftPage);
-        return $draftPage;
-    }
-
-    /**
-     * The base query for getting user update drafts.
-     * @param Page $page
-     * @param $userId
-     * @return mixed
-     */
-    protected function userUpdatePageDraftsQuery(Page $page, int $userId)
-    {
-        return $this->entityProvider->pageRevision->where('created_by', '=', $userId)
-            ->where('type', 'update_draft')
-            ->where('page_id', '=', $page->id)
-            ->orderBy('created_at', 'desc');
-    }
+        $copyPage = $this->getNewDraftPage($parent);
+        $pageData = $page->getAttributes();
 
-    /**
-     * Get the latest updated draft revision for a particular page and user.
-     * @param Page $page
-     * @param $userId
-     * @return PageRevision|null
-     */
-    public function getUserPageDraft(Page $page, int $userId)
-    {
-        return $this->userUpdatePageDraftsQuery($page, $userId)->first();
-    }
+        // Update name
+        if (!empty($newName)) {
+            $pageData['name'] = $newName;
+        }
 
-    /**
-     * Get the notification message that informs the user that they are editing a draft page.
-     * @param PageRevision $draft
-     * @return string
-     */
-    public function getUserPageDraftMessage(PageRevision $draft)
-    {
-        $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
-        if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
-            return $message;
+        // Copy tags from previous page if set
+        if ($page->tags) {
+            $pageData['tags'] = [];
+            foreach ($page->tags as $tag) {
+                $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
+            }
         }
-        return $message . "\n" . trans('entities.pages_draft_edited_notification');
+
+        return $this->publishDraft($copyPage, $pageData);
     }
 
     /**
-     * A query to check for active update drafts on a particular page.
-     * @param Page $page
-     * @param int $minRange
-     * @return mixed
+     * Find a page parent entity via a identifier string in the format:
+     * {type}:{id}
+     * Example: (book:5)
+     * @throws MoveOperationException
      */
-    protected function activePageEditingQuery(Page $page, int $minRange = null)
+    protected function findParentByIdentifier(string $identifier): ?Entity
     {
-        $query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft')
-            ->where('page_id', '=', $page->id)
-            ->where('updated_at', '>', $page->updated_at)
-            ->where('created_by', '!=', user()->id)
-            ->with('createdBy');
+        $stringExploded = explode(':', $identifier);
+        $entityType = $stringExploded[0];
+        $entityId = intval($stringExploded[1]);
 
-        if ($minRange !== null) {
-            $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
+        if ($entityType !== 'book' && $entityType !== 'chapter') {
+            throw new MoveOperationException('Pages can only be in books or chapters');
         }
 
-        return $query;
+        $parentClass = $entityType === 'book' ? Book::class : Chapter::class;
+        return $parentClass::visible()->where('id', '=', $entityId)->first();
     }
 
     /**
-     * Check if a page is being actively editing.
-     * Checks for edits since last page updated.
-     * Passing in a minuted range will check for edits
-     * within the last x minutes.
-     * @param Page $page
-     * @param int $minRange
-     * @return bool
+     * Update the permissions of a page.
      */
-    public function isPageEditingActive(Page $page, int $minRange = null)
+    public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null)
     {
-        $draftSearch = $this->activePageEditingQuery($page, $minRange);
-        return $draftSearch->count() > 0;
+        $this->baseRepo->updatePermissions($page, $restricted, $permissions);
     }
 
     /**
-     * Get a notification message concerning the editing activity on a particular page.
-     * @param Page $page
-     * @param int $minRange
-     * @return string
+     * Change the page's parent to the given entity.
      */
-    public function getPageEditingActiveMessage(Page $page, int $minRange = null)
+    protected function changeParent(Page $page, Entity $parent)
     {
-        $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
+        $book = ($parent instanceof Book) ? $parent : $parent->book;
+        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
+        $page->save();
+
+        if ($page->book->id !== $book->id) {
+            $page->changeBook($book->id);
+        }
 
-        $userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
-        $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
-        return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
+        $page->load('book');
+        $book->rebuildPermissions();
     }
 
     /**
-     * Parse the headers on the page to get a navigation menu
-     * @param string $pageContent
-     * @return array
+     * Get a page revision to update for the given page.
+     * Checks for an existing revisions before providing a fresh one.
      */
-    public function getPageNav(string $pageContent)
+    protected function getPageRevisionToUpdate(Page $page): PageRevision
     {
-        if ($pageContent == '') {
-            return [];
-        }
-        libxml_use_internal_errors(true);
-        $doc = new DOMDocument();
-        $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
-        $xPath = new DOMXPath($doc);
-        $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
-
-        if (is_null($headers)) {
-            return [];
+        $drafts = $this->getUserDraftQuery($page)->get();
+        if ($drafts->count() > 0) {
+            return $drafts->first();
         }
 
-        $tree = collect($headers)->map(function ($header) {
-            $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
-            $text = mb_substr($text, 0, 100);
-
-            return [
-                'nodeName' => strtolower($header->nodeName),
-                'level' => intval(str_replace('h', '', $header->nodeName)),
-                'link' => '#' . $header->getAttribute('id'),
-                'text' => $text,
-            ];
-        })->filter(function ($header) {
-            return mb_strlen($header['text']) > 0;
-        });
-
-        // Shift headers if only smaller headers have been used
-        $levelChange = ($tree->pluck('level')->min() - 1);
-        $tree = $tree->map(function ($header) use ($levelChange) {
-            $header['level'] -= ($levelChange);
-            return $header;
-        });
-
-        return $tree->toArray();
+        $draft = new PageRevision();
+        $draft->page_id = $page->id;
+        $draft->slug = $page->slug;
+        $draft->book_slug = $page->book->slug;
+        $draft->created_by = user()->id;
+        $draft->type = 'update_draft';
+        return $draft;
     }
 
     /**
-     * Restores a revision's content back into a page.
-     * @param Page $page
-     * @param Book $book
-     * @param  int $revisionId
-     * @return Page
-     * @throws \Exception
+     * Delete old revisions, for the given page, from the system.
      */
-    public function restorePageRevision(Page $page, Book $book, int $revisionId)
+    protected function deleteOldRevisions(Page $page)
     {
-        $page->revision_count++;
-        $this->savePageRevision($page);
-        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
-        $page->fill($revision->toArray());
-        $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
-        $page->text = $this->pageToPlainText($page);
-        $page->updated_by = user()->id;
-        $page->save();
-        $this->searchService->indexEntity($page);
-        return $page;
-    }
+        $revisionLimit = config('app.revision_limit');
+        if ($revisionLimit === false) {
+            return;
+        }
 
-    /**
-     * Change the page's parent to the given entity.
-     * @param Page $page
-     * @param Entity $parent
-     * @throws \Throwable
-     */
-    public function changePageParent(Page $page, Entity $parent)
-    {
-        $book = $parent->isA('book') ? $parent : $parent->book;
-        $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
-        $page->save();
-        if ($page->book->id !== $book->id) {
-            $page = $this->changeBook('page', $book->id, $page);
+        $revisionsToDelete = PageRevision::query()
+            ->where('page_id', '=', $page->id)
+            ->orderBy('created_at', 'desc')
+            ->skip(intval($revisionLimit))
+            ->take(10)
+            ->get(['id']);
+        if ($revisionsToDelete->count() > 0) {
+            PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
         }
-        $page->load('book');
-        $this->permissionService->buildJointPermissionsForEntity($book);
     }
 
     /**
-     * Create a copy of a page in a new location with a new name.
-     * @param \BookStack\Entities\Page $page
-     * @param \BookStack\Entities\Entity $newParent
-     * @param string $newName
-     * @return \BookStack\Entities\Page
-     * @throws \Throwable
+     * Get a new priority for a page
      */
-    public function copyPage(Page $page, Entity $newParent, string $newName = '')
+    protected function getNewPriority(Page $page): int
     {
-        $newBook = $newParent->isA('book') ? $newParent : $newParent->book;
-        $newChapter = $newParent->isA('chapter') ? $newParent : null;
-        $copyPage = $this->getDraftPage($newBook, $newChapter);
-        $pageData = $page->getAttributes();
-
-        // Update name
-        if (!empty($newName)) {
-            $pageData['name'] = $newName;
+        if ($page->parent() instanceof Chapter) {
+            $lastPage = $page->parent()->pages('desc')->first();
+            return $lastPage ? $lastPage->priority + 1 : 0;
         }
 
-        // Copy tags from previous page if set
-        if ($page->tags) {
-            $pageData['tags'] = [];
-            foreach ($page->tags as $tag) {
-                $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
-            }
-        }
-
-        // Set priority
-        if ($newParent->isA('chapter')) {
-            $pageData['priority'] = $this->getNewChapterPriority($newParent);
-        } else {
-            $pageData['priority'] = $this->getNewBookPriority($newParent);
-        }
-
-        return $this->publishPageDraft($copyPage, $pageData);
+        return (new BookContents($page->book))->getLastPriority() + 1;
     }
 
     /**
-     * Get pages that have been marked as templates.
-     * @param int $count
-     * @param int $page
-     * @param string $search
-     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+     * Get the query to find the user's draft copies of the given page.
      */
-    public function getPageTemplates(int $count = 10, int $page = 1, string $search = '')
+    protected function getUserDraftQuery(Page $page)
     {
-        $query = $this->entityQuery('page')
-            ->where('template', '=', true)
-            ->orderBy('name', 'asc')
-            ->skip(($page - 1) * $count)
-            ->take($count);
-
-        if ($search) {
-            $query->where('name', 'like', '%' . $search . '%');
-        }
-
-        $paginator = $query->paginate($count, ['*'], 'page', $page);
-        $paginator->withPath('/templates');
-
-        return $paginator;
+        return PageRevision::query()->where('created_by', '=', user()->id)
+            ->where('type', 'update_draft')
+            ->where('page_id', '=', $page->id)
+            ->orderBy('created_at', 'desc');
     }
 }
diff --git a/app/Entities/SlugGenerator.php b/app/Entities/SlugGenerator.php
new file mode 100644 (file)
index 0000000..459a526
--- /dev/null
@@ -0,0 +1,62 @@
+<?php namespace BookStack\Entities;
+
+class SlugGenerator
+{
+
+    protected $entity;
+
+    /**
+     * SlugGenerator constructor.
+     * @param $entity
+     */
+    public function __construct(Entity $entity)
+    {
+        $this->entity = $entity;
+    }
+
+    /**
+     * Generate a fresh slug for the given entity.
+     * The slug will generated so it does not conflict within the same parent item.
+     */
+    public function generate(): string
+    {
+        $slug = $this->formatNameAsSlug($this->entity->name);
+        while ($this->slugInUse($slug)) {
+            $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
+        }
+        return $slug;
+    }
+
+    /**
+     * Format a name as a url slug.
+     */
+    protected function formatNameAsSlug(string $name): string
+    {
+        $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
+        $slug = preg_replace('/\s{2,}/', ' ', $slug);
+        $slug = str_replace(' ', '-', $slug);
+        if ($slug === "") {
+            $slug = substr(md5(rand(1, 500)), 0, 5);
+        }
+        return $slug;
+    }
+
+    /**
+     * Check if a slug is already in-use for this
+     * type of model within the same parent.
+     */
+    protected function slugInUse(string $slug): bool
+    {
+        $query = $this->entity->newQuery()->where('slug', '=', $slug);
+
+        if ($this->entity instanceof BookChild) {
+            $query->where('book_id', '=', $this->entity->book_id);
+        }
+
+        if ($this->entity->id) {
+            $query->where('id', '!=', $this->entity->id);
+        }
+
+        return $query->count() > 0;
+    }
+}
diff --git a/app/Exceptions/MoveOperationException.php b/app/Exceptions/MoveOperationException.php
new file mode 100644 (file)
index 0000000..c237dfa
--- /dev/null
@@ -0,0 +1,8 @@
+<?php namespace BookStack\Exceptions;
+
+use Exception;
+
+class MoveOperationException extends Exception
+{
+
+}
diff --git a/app/Exceptions/SortOperationException.php b/app/Exceptions/SortOperationException.php
new file mode 100644 (file)
index 0000000..8f91217
--- /dev/null
@@ -0,0 +1,8 @@
+<?php namespace BookStack\Exceptions;
+
+use Exception;
+
+class SortOperationException extends Exception
+{
+
+}
diff --git a/app/Facades/Permissions.php b/app/Facades/Permissions.php
new file mode 100644 (file)
index 0000000..c552d7c
--- /dev/null
@@ -0,0 +1,16 @@
+<?php namespace BookStack\Facades;
+
+use Illuminate\Support\Facades\Facade;
+
+class Permissions extends Facade
+{
+    /**
+     * Get the registered name of the component.
+     *
+     * @return string
+     */
+    protected static function getFacadeAccessor()
+    {
+        return 'permissions';
+    }
+}
index 1b063b4eaa8a865f62974510d6c05a494962833e..8f5da49ed83c10c5979b9e4b8eaa74ac324e14b9 100644 (file)
@@ -1,37 +1,37 @@
 <?php namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\FileUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Uploads\Attachment;
 use BookStack\Uploads\AttachmentService;
+use Exception;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
 class AttachmentController extends Controller
 {
     protected $attachmentService;
     protected $attachment;
-    protected $entityRepo;
+    protected $pageRepo;
 
     /**
      * AttachmentController constructor.
-     * @param \BookStack\Uploads\AttachmentService $attachmentService
-     * @param Attachment $attachment
-     * @param EntityRepo $entityRepo
      */
-    public function __construct(AttachmentService $attachmentService, Attachment $attachment, EntityRepo $entityRepo)
+    public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
     {
         $this->attachmentService = $attachmentService;
         $this->attachment = $attachment;
-        $this->entityRepo = $entityRepo;
+        $this->pageRepo = $pageRepo;
         parent::__construct();
     }
 
 
     /**
      * Endpoint at which attachments are uploaded to.
-     * @param Request $request
-     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
+     * @throws ValidationException
+     * @throws NotFoundException
      */
     public function upload(Request $request)
     {
@@ -41,7 +41,7 @@ class AttachmentController extends Controller
         ]);
 
         $pageId = $request->get('uploaded_to');
-        $page = $this->entityRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
 
         $this->checkPermission('attachment-create-all');
         $this->checkOwnablePermission('page-update', $page);
@@ -59,10 +59,8 @@ class AttachmentController extends Controller
 
     /**
      * Update an uploaded attachment.
-     * @param Request $request
-     * @param int $attachmentId
-     * @return mixed
-     * @throws \Illuminate\Validation\ValidationException
+     * @throws ValidationException
+     * @throws NotFoundException
      */
     public function uploadUpdate(Request $request, $attachmentId)
     {
@@ -72,7 +70,7 @@ class AttachmentController extends Controller
         ]);
 
         $pageId = $request->get('uploaded_to');
-        $page = $this->entityRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
         $attachment = $this->attachment->findOrFail($attachmentId);
 
         $this->checkOwnablePermission('page-update', $page);
@@ -95,10 +93,8 @@ class AttachmentController extends Controller
 
     /**
      * Update the details of an existing file.
-     * @param Request $request
-     * @param $attachmentId
-     * @return Attachment|mixed
-     * @throws \Illuminate\Validation\ValidationException
+     * @throws ValidationException
+     * @throws NotFoundException
      */
     public function update(Request $request, $attachmentId)
     {
@@ -109,7 +105,7 @@ class AttachmentController extends Controller
         ]);
 
         $pageId = $request->get('uploaded_to');
-        $page = $this->entityRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
         $attachment = $this->attachment->findOrFail($attachmentId);
 
         $this->checkOwnablePermission('page-update', $page);
@@ -125,8 +121,8 @@ class AttachmentController extends Controller
 
     /**
      * Attach a link to a page.
-     * @param Request $request
-     * @return mixed
+     * @throws ValidationException
+     * @throws NotFoundException
      */
     public function attachLink(Request $request)
     {
@@ -137,7 +133,7 @@ class AttachmentController extends Controller
         ]);
 
         $pageId = $request->get('uploaded_to');
-        $page = $this->entityRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
 
         $this->checkPermission('attachment-create-all');
         $this->checkOwnablePermission('page-update', $page);
@@ -151,30 +147,26 @@ class AttachmentController extends Controller
 
     /**
      * Get the attachments for a specific page.
-     * @param $pageId
-     * @return mixed
      */
-    public function listForPage($pageId)
+    public function listForPage(int $pageId)
     {
-        $page = $this->entityRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-view', $page);
         return response()->json($page->attachments);
     }
 
     /**
      * Update the attachment sorting.
-     * @param Request $request
-     * @param $pageId
-     * @return mixed
-     * @throws \Illuminate\Validation\ValidationException
+     * @throws ValidationException
+     * @throws NotFoundException
      */
-    public function sortForPage(Request $request, $pageId)
+    public function sortForPage(Request $request, int $pageId)
     {
         $this->validate($request, [
             'files' => 'required|array',
             'files.*.id' => 'required|integer',
         ]);
-        $page = $this->entityRepo->getById('page', $pageId);
+        $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
 
         $attachments = $request->get('files');
@@ -184,16 +176,15 @@ class AttachmentController extends Controller
 
     /**
      * Get an attachment from storage.
-     * @param $attachmentId
-     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
-     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+     * @throws FileNotFoundException
      * @throws NotFoundException
      */
-    public function get($attachmentId)
+    public function get(int $attachmentId)
     {
         $attachment = $this->attachment->findOrFail($attachmentId);
-        $page = $this->entityRepo->getById('page', $attachment->uploaded_to);
-        if ($page === null) {
+        try {
+            $page = $this->pageRepo->getById($attachment->uploaded_to);
+        } catch (NotFoundException $exception) {
             throw new NotFoundException(trans('errors.attachment_not_found'));
         }
 
@@ -211,9 +202,9 @@ class AttachmentController extends Controller
      * Delete a specific attachment in the system.
      * @param $attachmentId
      * @return mixed
-     * @throws \Exception
+     * @throws Exception
      */
-    public function delete($attachmentId)
+    public function delete(int $attachmentId)
     {
         $attachment = $this->attachment->findOrFail($attachmentId);
         $this->checkOwnablePermission('attachment-delete', $attachment);
index 4300ab458cd9a77ddd046006e4e04c05311f1c19..099558eb77fdce133b307aaac327277bc02510f8 100644 (file)
@@ -65,14 +65,14 @@ class ConfirmEmailController extends Controller
             $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
         } catch (Exception $exception) {
             if ($exception instanceof UserTokenNotFoundException) {
-                session()->flash('error', trans('errors.email_confirmation_invalid'));
+                $this->showErrorNotification(trans('errors.email_confirmation_invalid'));
                 return redirect('/register');
             }
 
             if ($exception instanceof UserTokenExpiredException) {
                 $user = $this->userRepo->getById($exception->userId);
                 $this->emailConfirmationService->sendConfirmation($user);
-                session()->flash('error', trans('errors.email_confirmation_expired'));
+                $this->showErrorNotification(trans('errors.email_confirmation_expired'));
                 return redirect('/register/confirm');
             }
 
@@ -84,7 +84,7 @@ class ConfirmEmailController extends Controller
         $user->save();
 
         auth()->login($user);
-        session()->flash('success', trans('auth.email_confirm_success'));
+        $this->showSuccessNotification(trans('auth.email_confirm_success'));
         $this->emailConfirmationService->deleteByUser($user);
 
         return redirect('/');
@@ -106,11 +106,11 @@ class ConfirmEmailController extends Controller
         try {
             $this->emailConfirmationService->sendConfirmation($user);
         } catch (Exception $e) {
-            session()->flash('error', trans('auth.email_confirm_send_error'));
+            $this->showErrorNotification(trans('auth.email_confirm_send_error'));
             return redirect('/register/confirm');
         }
 
-        session()->flash('success', trans('auth.email_confirm_resent'));
+        $this->showSuccessNotification(trans('auth.email_confirm_resent'));
         return redirect('/register/confirm');
     }
 }
index a0cbae9c611bffeb421e64788ebca7651ef2f65e..a3c0433a56f9e4af9d574ae0446edac02dbd5c1f 100644 (file)
@@ -53,7 +53,7 @@ class ForgotPasswordController extends Controller
 
         if ($response === Password::RESET_LINK_SENT) {
             $message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
-            session()->flash('success', $message);
+            $this->showSuccessNotification($message);
             return back()->with('status', trans($response));
         }
 
index 70090fed0443facc45cb1bd22aaa2d7c0b45dea7..304d3bed2e69999b381e0a8e1b4cb6a22790f492 100644 (file)
@@ -166,14 +166,14 @@ class RegisterController extends Controller
             try {
                 $this->emailConfirmationService->sendConfirmation($newUser);
             } catch (Exception $e) {
-                session()->flash('error', trans('auth.email_confirm_send_error'));
+                $this->showErrorNotification(trans('auth.email_confirm_send_error'));
             }
 
             return redirect('/register/confirm');
         }
 
         auth()->login($newUser);
-        session()->flash('success', trans('auth.register_success'));
+        $this->showSuccessNotification(trans('auth.register_success'));
         return redirect($this->redirectPath());
     }
 
index d7005b73954d9759195121e79353384a4d3c070e..4d98eca597c3fffcece65fc79209bae646a49ab9 100644 (file)
@@ -44,7 +44,7 @@ class ResetPasswordController extends Controller
     protected function sendResetResponse(Request $request, $response)
     {
         $message = trans('auth.reset_password_success');
-        session()->flash('success', $message);
+        $this->showSuccessNotification($message);
         return redirect($this->redirectPath())
             ->with('status', trans($response));
     }
index 8799d264cacee4c6ed0b45d997df8911ae4d4563..c361b0a9b43687101e577db4f3e46f55b86ba397 100644 (file)
@@ -77,7 +77,7 @@ class UserInviteController extends Controller
         $user->save();
 
         auth()->login($user);
-        session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')]));
+        $this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
         $this->inviteService->deleteByUser($user);
 
         return redirect('/');
@@ -96,7 +96,7 @@ class UserInviteController extends Controller
         }
 
         if ($exception instanceof UserTokenExpiredException) {
-            session()->flash('error', trans('errors.invite_token_expired'));
+            $this->showErrorNotification(trans('errors.invite_token_expired'));
             return redirect('/password/email');
         }
 
index 97659ed96cb79593d909d31c192b63637222dfeb..e7d788d91dde01b78fdd158035fe07fd6cd5f54e 100644 (file)
@@ -1,23 +1,14 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Auth\UserRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\EntityContextManager;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Managers\EntityContext;
 use BookStack\Entities\Repos\BookRepo;
-use BookStack\Entities\Repos\EntityRepo;
-use BookStack\Entities\ExportService;
 use BookStack\Exceptions\ImageUploadException;
-use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\NotifyException;
-use BookStack\Uploads\ImageRepo;
-use Illuminate\Contracts\View\Factory;
-use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
-use Illuminate\Routing\Redirector;
 use Illuminate\Validation\ValidationException;
-use Illuminate\View\View;
 use Throwable;
 use Views;
 
@@ -25,49 +16,31 @@ class BookController extends Controller
 {
 
     protected $bookRepo;
-    protected $userRepo;
     protected $entityContextManager;
-    protected $imageRepo;
 
     /**
      * BookController constructor.
-     * @param BookRepo $bookRepo
-     * @param UserRepo $userRepo
-     * @param EntityContextManager $entityContextManager
-     * @param ImageRepo $imageRepo
      */
-    public function __construct(
-        BookRepo $bookRepo,
-        UserRepo $userRepo,
-        EntityContextManager $entityContextManager,
-        ImageRepo $imageRepo
-    ) {
+    public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
+    {
         $this->bookRepo = $bookRepo;
-        $this->userRepo = $userRepo;
         $this->entityContextManager = $entityContextManager;
-        $this->imageRepo = $imageRepo;
         parent::__construct();
     }
 
     /**
      * Display a listing of the book.
-     * @return Response
      */
     public function index()
     {
-        $view = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books'));
-        $sort = setting()->getUser($this->currentUser, 'books_sort', 'name');
-        $order = setting()->getUser($this->currentUser, 'books_sort_order', 'asc');
-        $sortOptions = [
-            'name' => trans('common.sort_name'),
-            'created_at' => trans('common.sort_created_at'),
-            'updated_at' => trans('common.sort_updated_at'),
-        ];
-
-        $books = $this->bookRepo->getAllPaginated('book', 18, $sort, $order);
-        $recents = $this->signedIn ? $this->bookRepo->getRecentlyViewed('book', 4, 0) : false;
-        $popular = $this->bookRepo->getPopular('book', 4, 0);
-        $new = $this->bookRepo->getRecentlyCreated('book', 4, 0);
+        $view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
+        $sort = setting()->getForCurrentUser('books_sort', 'name');
+        $order = setting()->getForCurrentUser('books_sort_order', 'asc');
+
+        $books = $this->bookRepo->getAllPaginated(18, $sort, $order);
+        $recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
+        $popular = $this->bookRepo->getPopular(4);
+        $new = $this->bookRepo->getRecentlyCreated(4);
 
         $this->entityContextManager->clearShelfContext();
 
@@ -80,25 +53,22 @@ class BookController extends Controller
             'view' => $view,
             'sort' => $sort,
             'order' => $order,
-            'sortOptions' => $sortOptions,
         ]);
     }
 
     /**
      * Show the form for creating a new book.
-     * @param string $shelfSlug
-     * @return Response
-     * @throws NotFoundException
      */
     public function create(string $shelfSlug = null)
     {
+        $this->checkPermission('book-create-all');
+
         $bookshelf = null;
         if ($shelfSlug !== null) {
-            $bookshelf = $this->bookRepo->getEntityBySlug('bookshelf', $shelfSlug);
+            $bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
             $this->checkOwnablePermission('bookshelf-update', $bookshelf);
         }
 
-        $this->checkPermission('book-create-all');
         $this->setPageTitle(trans('entities.books_create'));
         return view('books.create', [
             'bookshelf' => $bookshelf
@@ -107,11 +77,6 @@ class BookController extends Controller
 
     /**
      * Store a newly created book in storage.
-     *
-     * @param Request $request
-     * @param string $shelfSlug
-     * @return Response
-     * @throws NotFoundException
      * @throws ImageUploadException
      * @throws ValidationException
      */
@@ -121,21 +86,21 @@ class BookController extends Controller
         $this->validate($request, [
             'name' => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => $this->imageRepo->getImageValidationRules(),
+            'image' => $this->getImageValidationRules(),
         ]);
 
         $bookshelf = null;
         if ($shelfSlug !== null) {
-            $bookshelf = $this->bookRepo->getEntityBySlug('bookshelf', $shelfSlug);
+            $bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
             $this->checkOwnablePermission('bookshelf-update', $bookshelf);
         }
 
-        $book = $this->bookRepo->createFromInput('book', $request->all());
-        $this->bookUpdateActions($book, $request);
+        $book = $this->bookRepo->create($request->all());
+        $this->bookRepo->updateCoverImage($book, $request->file('image', null));
         Activity::add($book, 'book_create', $book->id);
 
         if ($bookshelf) {
-            $this->bookRepo->appendBookToShelf($bookshelf, $book);
+            $bookshelf->appendBook($book);
             Activity::add($bookshelf, 'bookshelf_update');
         }
 
@@ -144,17 +109,11 @@ class BookController extends Controller
 
     /**
      * Display the specified book.
-     * @param Request $request
-     * @param string $slug
-     * @return Response
-     * @throws NotFoundException
      */
     public function show(Request $request, string $slug)
     {
         $book = $this->bookRepo->getBySlug($slug);
-        $this->checkOwnablePermission('book-view', $book);
-
-        $bookChildren = $this->bookRepo->getBookChildren($book);
+        $bookChildren = (new BookContents($book))->getTree(true);
 
         Views::add($book);
         if ($request->has('shelf')) {
@@ -172,9 +131,6 @@ class BookController extends Controller
 
     /**
      * Show the form for editing the specified book.
-     * @param string $slug
-     * @return Response
-     * @throws NotFoundException
      */
     public function edit(string $slug)
     {
@@ -186,12 +142,9 @@ class BookController extends Controller
 
     /**
      * Update the specified book in storage.
-     * @param Request $request
-     * @param string $slug
-     * @return Response
      * @throws ImageUploadException
-     * @throws NotFoundException
      * @throws ValidationException
+     * @throws Throwable
      */
     public function update(Request $request, string $slug)
     {
@@ -200,22 +153,20 @@ class BookController extends Controller
         $this->validate($request, [
             'name' => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => $this->imageRepo->getImageValidationRules(),
+            'image' => $this->getImageValidationRules(),
         ]);
 
-         $book = $this->bookRepo->updateFromInput('book', $book, $request->all());
-         $this->bookUpdateActions($book, $request);
+        $book = $this->bookRepo->update($book, $request->all());
+        $resetCover = $request->has('image_reset');
+        $this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
 
-         Activity::add($book, 'book_update', $book->id);
+        Activity::add($book, 'book_update', $book->id);
 
-         return redirect($book->getUrl());
+        return redirect($book->getUrl());
     }
 
     /**
-     * Shows the page to confirm deletion
-     * @param string $bookSlug
-     * @return View
-     * @throws NotFoundException
+     * Shows the page to confirm deletion.
      */
     public function showDelete(string $bookSlug)
     {
@@ -226,113 +177,7 @@ class BookController extends Controller
     }
 
     /**
-     * Shows the view which allows pages to be re-ordered and sorted.
-     * @param string $bookSlug
-     * @return View
-     * @throws NotFoundException
-     */
-    public function sort(string $bookSlug)
-    {
-        $book = $this->bookRepo->getBySlug($bookSlug);
-        $this->checkOwnablePermission('book-update', $book);
-
-        $bookChildren = $this->bookRepo->getBookChildren($book, true);
-
-        $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
-        return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
-    }
-
-    /**
-     * Shows the sort box for a single book.
-     * Used via AJAX when loading in extra books to a sort.
-     * @param string $bookSlug
-     * @return Factory|View
-     * @throws NotFoundException
-     */
-    public function getSortItem(string $bookSlug)
-    {
-        $book = $this->bookRepo->getBySlug($bookSlug);
-        $bookChildren = $this->bookRepo->getBookChildren($book);
-        return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
-    }
-
-    /**
-     * Saves an array of sort mapping to pages and chapters.
-     * @param Request $request
-     * @param string $bookSlug
-     * @return RedirectResponse|Redirector
-     * @throws NotFoundException
-     */
-    public function saveSort(Request $request, string $bookSlug)
-    {
-        $book = $this->bookRepo->getBySlug($bookSlug);
-        $this->checkOwnablePermission('book-update', $book);
-
-        // Return if no map sent
-        if (!$request->filled('sort-tree')) {
-            return redirect($book->getUrl());
-        }
-
-        // Sort pages and chapters
-        $sortMap = collect(json_decode($request->get('sort-tree')));
-        $bookIdsInvolved = collect([$book->id]);
-
-        // Load models into map
-        $sortMap->each(function ($mapItem) use ($bookIdsInvolved) {
-            $mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter');
-            $mapItem->model = $this->bookRepo->getById($mapItem->type, $mapItem->id);
-            // Store source and target books
-            $bookIdsInvolved->push(intval($mapItem->model->book_id));
-            $bookIdsInvolved->push(intval($mapItem->book));
-        });
-
-        // Get the books involved in the sort
-        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
-        $booksInvolved = $this->bookRepo->getManyById('book', $bookIdsInvolved, false, true);
-        // Throw permission error if invalid ids or inaccessible books given.
-        if (count($bookIdsInvolved) !== count($booksInvolved)) {
-            $this->showPermissionError();
-        }
-        // Check permissions of involved books
-        $booksInvolved->each(function (Book $book) {
-             $this->checkOwnablePermission('book-update', $book);
-        });
-
-        // Perform the sort
-        $sortMap->each(function ($mapItem) {
-            $model = $mapItem->model;
-
-            $priorityChanged = intval($model->priority) !== intval($mapItem->sort);
-            $bookChanged = intval($model->book_id) !== intval($mapItem->book);
-            $chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter;
-
-            if ($bookChanged) {
-                $this->bookRepo->changeBook($mapItem->type, $mapItem->book, $model);
-            }
-            if ($chapterChanged) {
-                $model->chapter_id = intval($mapItem->parentChapter);
-                $model->save();
-            }
-            if ($priorityChanged) {
-                $model->priority = intval($mapItem->sort);
-                $model->save();
-            }
-        });
-
-        // Rebuild permissions and add activity for involved books.
-        $booksInvolved->each(function (Book $book) {
-            $this->bookRepo->buildJointPermissionsForBook($book);
-            Activity::add($book, 'book_sort', $book->id);
-        });
-
-        return redirect($book->getUrl());
-    }
-
-    /**
-     * Remove the specified book from storage.
-     * @param string $bookSlug
-     * @return Response
-     * @throws NotFoundException
+     * Remove the specified book from the system.
      * @throws Throwable
      * @throws NotifyException
      */
@@ -340,72 +185,40 @@ class BookController extends Controller
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('book-delete', $book);
-        Activity::addMessage('book_delete', 0, $book->name);
 
-        if ($book->cover) {
-            $this->imageRepo->destroyImage($book->cover);
-        }
-        $this->bookRepo->destroyBook($book);
+        Activity::addMessage('book_delete', $book->name);
+        $this->bookRepo->destroy($book);
 
         return redirect('/books');
     }
 
     /**
-     * Show the Restrictions view.
-     * @param string $bookSlug
-     * @return Factory|View
-     * @throws NotFoundException
+     * Show the permissions view.
      */
     public function showPermissions(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('restrictions-manage', $book);
-        $roles = $this->userRepo->getRestrictableRoles();
+
         return view('books.permissions', [
             'book' => $book,
-            'roles' => $roles
         ]);
     }
 
     /**
      * Set the restrictions for this book.
-     * @param Request $request
-     * @param string $bookSlug
-     * @return RedirectResponse|Redirector
-     * @throws NotFoundException
      * @throws Throwable
      */
     public function permissions(Request $request, string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('restrictions-manage', $book);
-        $this->bookRepo->updateEntityPermissionsFromRequest($request, $book);
-        session()->flash('success', trans('entities.books_permissions_updated'));
-        return redirect($book->getUrl());
-    }
 
-    /**
-     * Common actions to run on book update.
-     * Handles updating the cover image.
-     * @param Book $book
-     * @param Request $request
-     * @throws ImageUploadException
-     */
-    protected function bookUpdateActions(Book $book, Request $request)
-    {
-        // Update the cover image if in request
-        if ($request->has('image')) {
-            $this->imageRepo->destroyImage($book->cover);
-            $newImage = $request->file('image');
-            $image = $this->imageRepo->saveNew($newImage, 'cover_book', $book->id, 512, 512, true);
-            $book->image_id = $image->id;
-            $book->save();
-        }
+        $restricted = $request->get('restricted') === 'true';
+        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
+        $this->bookRepo->updatePermissions($book, $restricted, $permissions);
 
-        if ($request->has('image_reset')) {
-            $this->imageRepo->destroyImage($book->cover);
-            $book->image_id = 0;
-            $book->save();
-        }
+        $this->showSuccessNotification(trans('entities.books_permissions_updated'));
+        return redirect($book->getUrl());
     }
 }
index ae3f56b816f5dc4368ec9a4242c6ed03346f2b25..cfa3d6a3a3d162e9eb5d3bf19afa5eb4a4f6b7b8 100644 (file)
@@ -4,25 +4,16 @@ namespace BookStack\Http\Controllers;
 
 use BookStack\Entities\ExportService;
 use BookStack\Entities\Repos\BookRepo;
-use BookStack\Exceptions\NotFoundException;
 use Throwable;
 
 class BookExportController extends Controller
 {
-    /**
-     * @var BookRepo
-     */
-    protected $bookRepo;
 
-    /**
-     * @var ExportService
-     */
+    protected $bookRepo;
     protected $exportService;
 
     /**
      * BookExportController constructor.
-     * @param BookRepo $bookRepo
-     * @param ExportService $exportService
      */
     public function __construct(BookRepo $bookRepo, ExportService $exportService)
     {
@@ -33,9 +24,6 @@ class BookExportController extends Controller
 
     /**
      * Export a book as a PDF file.
-     * @param string $bookSlug
-     * @return mixed
-     * @throws NotFoundException
      * @throws Throwable
      */
     public function pdf(string $bookSlug)
@@ -47,9 +35,6 @@ class BookExportController extends Controller
 
     /**
      * Export a book as a contained HTML file.
-     * @param string $bookSlug
-     * @return mixed
-     * @throws NotFoundException
      * @throws Throwable
      */
     public function html(string $bookSlug)
@@ -61,9 +46,6 @@ class BookExportController extends Controller
 
     /**
      * Export a book as a plain text file.
-     * @param $bookSlug
-     * @return mixed
-     * @throws NotFoundException
      */
     public function plainText(string $bookSlug)
     {
diff --git a/app/Http/Controllers/BookSortController.php b/app/Http/Controllers/BookSortController.php
new file mode 100644 (file)
index 0000000..f5fb6f2
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Book;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Exceptions\SortOperationException;
+use BookStack\Facades\Activity;
+use Illuminate\Http\Request;
+
+class BookSortController extends Controller
+{
+
+    protected $bookRepo;
+
+    /**
+     * BookSortController constructor.
+     * @param $bookRepo
+     */
+    public function __construct(BookRepo $bookRepo)
+    {
+        $this->bookRepo = $bookRepo;
+        parent::__construct();
+    }
+
+    /**
+     * Shows the view which allows pages to be re-ordered and sorted.
+     */
+    public function show(string $bookSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $this->checkOwnablePermission('book-update', $book);
+
+        $bookChildren = (new BookContents($book))->getTree(false);
+
+        $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
+        return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
+    }
+
+    /**
+     * Shows the sort box for a single book.
+     * Used via AJAX when loading in extra books to a sort.
+     */
+    public function showItem(string $bookSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $bookChildren = (new BookContents($book))->getTree();
+        return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
+    }
+
+    /**
+     * Sorts a book using a given mapping array.
+     */
+    public function update(Request $request, string $bookSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $this->checkOwnablePermission('book-update', $book);
+
+        // Return if no map sent
+        if (!$request->filled('sort-tree')) {
+            return redirect($book->getUrl());
+        }
+
+        $sortMap = collect(json_decode($request->get('sort-tree')));
+        $bookContents = new BookContents($book);
+        $booksInvolved = collect();
+
+        try {
+            $booksInvolved = $bookContents->sortUsingMap($sortMap);
+        } catch (SortOperationException $exception) {
+            $this->showPermissionError();
+        }
+
+        // Rebuild permissions and add activity for involved books.
+        $booksInvolved->each(function (Book $book) {
+            Activity::add($book, 'book_sort', $book->id);
+        });
+
+        return redirect($book->getUrl());
+    }
+}
index 32d08dc77da28d6cfb99f385b90a0d44673bd137..57e67dc00e618b53fec9b16eec134242422dd8af 100644 (file)
@@ -1,34 +1,30 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Auth\UserRepo;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\EntityContextManager;
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Book;
+use BookStack\Entities\Managers\EntityContext;
+use BookStack\Entities\Repos\BookshelfRepo;
+use BookStack\Exceptions\ImageUploadException;
+use BookStack\Exceptions\NotFoundException;
 use BookStack\Uploads\ImageRepo;
+use Exception;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
+use Illuminate\Validation\ValidationException;
 use Views;
 
 class BookshelfController extends Controller
 {
 
-    protected $entityRepo;
-    protected $userRepo;
+    protected $bookshelfRepo;
     protected $entityContextManager;
     protected $imageRepo;
 
     /**
      * BookController constructor.
-     * @param EntityRepo $entityRepo
-     * @param UserRepo $userRepo
-     * @param EntityContextManager $entityContextManager
-     * @param ImageRepo $imageRepo
      */
-    public function __construct(EntityRepo $entityRepo, UserRepo $userRepo, EntityContextManager $entityContextManager, ImageRepo $imageRepo)
+    public function __construct(BookshelfRepo $bookshelfRepo, EntityContext $entityContextManager, ImageRepo $imageRepo)
     {
-        $this->entityRepo = $entityRepo;
-        $this->userRepo = $userRepo;
+        $this->bookshelfRepo = $bookshelfRepo;
         $this->entityContextManager = $entityContextManager;
         $this->imageRepo = $imageRepo;
         parent::__construct();
@@ -36,27 +32,22 @@ class BookshelfController extends Controller
 
     /**
      * Display a listing of the book.
-     * @return Response
      */
     public function index()
     {
-        $view = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid'));
-        $sort = setting()->getUser($this->currentUser, 'bookshelves_sort', 'name');
-        $order = setting()->getUser($this->currentUser, 'bookshelves_sort_order', 'asc');
+        $view = setting()->getForCurrentUser('bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+        $sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
+        $order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
         $sortOptions = [
             'name' => trans('common.sort_name'),
             'created_at' => trans('common.sort_created_at'),
             'updated_at' => trans('common.sort_updated_at'),
         ];
 
-        $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18, $sort, $order);
-        foreach ($shelves as $shelf) {
-            $shelf->books = $this->entityRepo->getBookshelfChildren($shelf);
-        }
-
-        $recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('bookshelf', 4, 0) : false;
-        $popular = $this->entityRepo->getPopular('bookshelf', 4, 0);
-        $new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0);
+        $shelves = $this->bookshelfRepo->getAllPaginated(18, $sort, $order);
+        $recents = $this->isSignedIn() ? $this->bookshelfRepo->getRecentlyViewed(4) : false;
+        $popular = $this->bookshelfRepo->getPopular(4);
+        $new = $this->bookshelfRepo->getRecentlyCreated(4);
 
         $this->entityContextManager->clearShelfContext();
         $this->setPageTitle(trans('entities.shelves'));
@@ -74,21 +65,19 @@ class BookshelfController extends Controller
 
     /**
      * Show the form for creating a new bookshelf.
-     * @return Response
      */
     public function create()
     {
         $this->checkPermission('bookshelf-create-all');
-        $books = $this->entityRepo->getAll('book', false, 'update');
+        $books = Book::hasPermission('update')->get();
         $this->setPageTitle(trans('entities.shelves_create'));
         return view('shelves.create', ['books' => $books]);
     }
 
     /**
      * Store a newly created bookshelf in storage.
-     * @param Request $request
-     * @return Response
-     * @throws \BookStack\Exceptions\ImageUploadException
+     * @throws ValidationException
+     * @throws ImageUploadException
      */
     public function store(Request $request)
     {
@@ -96,80 +85,63 @@ class BookshelfController extends Controller
         $this->validate($request, [
             'name' => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => $this->imageRepo->getImageValidationRules(),
+            'image' => $this->getImageValidationRules(),
         ]);
 
-        $shelf = $this->entityRepo->createFromInput('bookshelf', $request->all());
-        $this->shelfUpdateActions($shelf, $request);
+        $bookIds = explode(',', $request->get('books', ''));
+        $shelf = $this->bookshelfRepo->create($request->all(), $bookIds);
+        $this->bookshelfRepo->updateCoverImage($shelf);
 
         Activity::add($shelf, 'bookshelf_create');
         return redirect($shelf->getUrl());
     }
 
-
     /**
-     * Display the specified bookshelf.
-     * @param String $slug
-     * @return Response
-     * @throws \BookStack\Exceptions\NotFoundException
+     * Display the bookshelf of the given slug.
+     * @throws NotFoundException
      */
     public function show(string $slug)
     {
-        /** @var Bookshelf $shelf */
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('book-view', $shelf);
 
-        $books = $this->entityRepo->getBookshelfChildren($shelf);
         Views::add($shelf);
         $this->entityContextManager->setShelfContext($shelf->id);
 
         $this->setPageTitle($shelf->getShortName());
-
         return view('shelves.show', [
             'shelf' => $shelf,
-            'books' => $books,
             'activity' => Activity::entityActivity($shelf, 20, 1)
         ]);
     }
 
     /**
      * Show the form for editing the specified bookshelf.
-     * @param $slug
-     * @return Response
-     * @throws \BookStack\Exceptions\NotFoundException
      */
     public function edit(string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-update', $shelf);
 
-        $shelfBooks = $this->entityRepo->getBookshelfChildren($shelf);
-        $shelfBookIds = $shelfBooks->pluck('id');
-        $books = $this->entityRepo->getAll('book', false, 'update');
-        $books = $books->filter(function ($book) use ($shelfBookIds) {
-             return !$shelfBookIds->contains($book->id);
-        });
+        $shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
+        $books = Book::hasPermission('update')->whereNotIn('id', $shelfBookIds)->get();
 
         $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
         return view('shelves.edit', [
             'shelf' => $shelf,
             'books' => $books,
-            'shelfBooks' => $shelfBooks,
         ]);
     }
 
-
     /**
      * Update the specified bookshelf in storage.
-     * @param Request $request
-     * @param string $slug
-     * @return Response
-     * @throws \BookStack\Exceptions\NotFoundException
-     * @throws \BookStack\Exceptions\ImageUploadException
+     * @throws ValidationException
+     * @throws ImageUploadException
+     * @throws NotFoundException
      */
     public function update(Request $request, string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-update', $shelf);
         $this->validate($request, [
             'name' => 'required|string|max:255',
@@ -177,24 +149,22 @@ class BookshelfController extends Controller
             'image' => $this->imageRepo->getImageValidationRules(),
         ]);
 
-         $shelf = $this->entityRepo->updateFromInput('bookshelf', $shelf, $request->all());
-         $this->shelfUpdateActions($shelf, $request);
 
-         Activity::add($shelf, 'bookshelf_update');
+        $bookIds = explode(',', $request->get('books', ''));
+        $shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds);
+        $resetCover = $request->has('image_reset');
+        $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover);
+        Activity::add($shelf, 'bookshelf_update');
 
-         return redirect($shelf->getUrl());
+        return redirect($shelf->getUrl());
     }
 
-
     /**
      * Shows the page to confirm deletion
-     * @param $slug
-     * @return \Illuminate\View\View
-     * @throws \BookStack\Exceptions\NotFoundException
      */
     public function showDelete(string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
         $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@@ -203,101 +173,58 @@ class BookshelfController extends Controller
 
     /**
      * Remove the specified bookshelf from storage.
-     * @param string $slug
-     * @return Response
-     * @throws \BookStack\Exceptions\NotFoundException
-     * @throws \Throwable
+     * @throws Exception
      */
     public function destroy(string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
-        Activity::addMessage('bookshelf_delete', 0, $shelf->name);
 
-        if ($shelf->cover) {
-            $this->imageRepo->destroyImage($shelf->cover);
-        }
-        $this->entityRepo->destroyBookshelf($shelf);
+        Activity::addMessage('bookshelf_delete', $shelf->name);
+        $this->bookshelfRepo->destroy($shelf);
 
         return redirect('/shelves');
     }
 
     /**
      * Show the permissions view.
-     * @param string $slug
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
-     * @throws \BookStack\Exceptions\NotFoundException
      */
     public function showPermissions(string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $roles = $this->userRepo->getRestrictableRoles();
         return view('shelves.permissions', [
             'shelf' => $shelf,
-            'roles' => $roles
         ]);
     }
 
     /**
      * Set the permissions for this bookshelf.
-     * @param Request $request
-     * @param string $slug
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-     * @throws \BookStack\Exceptions\NotFoundException
-     * @throws \Throwable
      */
     public function permissions(Request $request, string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $this->entityRepo->updateEntityPermissionsFromRequest($request, $shelf);
-        session()->flash('success', trans('entities.shelves_permissions_updated'));
+        $restricted = $request->get('restricted') === 'true';
+        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
+        $this->bookshelfRepo->updatePermissions($shelf, $restricted, $permissions);
+
+        $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
         return redirect($shelf->getUrl());
     }
 
     /**
      * Copy the permissions of a bookshelf to the child books.
-     * @param string $slug
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-     * @throws \BookStack\Exceptions\NotFoundException
      */
     public function copyPermissions(string $slug)
     {
-        $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
+        $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $updateCount = $this->entityRepo->copyBookshelfPermissions($shelf);
-        session()->flash('success', trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
+        $updateCount = $this->bookshelfRepo->copyDownPermissions($shelf);
+        $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
         return redirect($shelf->getUrl());
     }
-
-    /**
-     * Common actions to run on bookshelf update.
-     * @param Bookshelf $shelf
-     * @param Request $request
-     * @throws \BookStack\Exceptions\ImageUploadException
-     */
-    protected function shelfUpdateActions(Bookshelf $shelf, Request $request)
-    {
-        // Update the books that the shelf references
-        $this->entityRepo->updateShelfBooks($shelf, $request->get('books', ''));
-
-        // Update the cover image if in request
-        if ($request->has('image')) {
-            $newImage = $request->file('image');
-            $this->imageRepo->destroyImage($shelf->cover);
-            $image = $this->imageRepo->saveNew($newImage, 'cover_shelf', $shelf->id, 512, 512, true);
-            $shelf->image_id = $image->id;
-            $shelf->save();
-        }
-
-        if ($request->has('image_reset')) {
-            $this->imageRepo->destroyImage($shelf->cover);
-            $shelf->image_id = 0;
-            $shelf->save();
-        }
-    }
 }
index 796689c66fd88fffa24371087425bfcc8a3cb108..1355979107eb0181d272e3610511688d5772b7b7 100644 (file)
@@ -1,51 +1,45 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Auth\UserRepo;
-use BookStack\Entities\Repos\EntityRepo;
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Book;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Exceptions\MoveOperationException;
+use BookStack\Exceptions\NotFoundException;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
+use Illuminate\Validation\ValidationException;
+use Throwable;
 use Views;
 
 class ChapterController extends Controller
 {
 
-    protected $userRepo;
-    protected $entityRepo;
+    protected $chapterRepo;
 
     /**
      * ChapterController constructor.
-     * @param EntityRepo $entityRepo
-     * @param UserRepo $userRepo
      */
-    public function __construct(EntityRepo $entityRepo, UserRepo $userRepo)
+    public function __construct(ChapterRepo $chapterRepo)
     {
-        $this->entityRepo = $entityRepo;
-        $this->userRepo = $userRepo;
+        $this->chapterRepo = $chapterRepo;
         parent::__construct();
     }
 
     /**
      * Show the form for creating a new chapter.
-     * @param $bookSlug
-     * @return Response
      */
-    public function create($bookSlug)
+    public function create(string $bookSlug)
     {
-        $book = $this->entityRepo->getEntityBySlug('book', $bookSlug);
+        $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
         $this->checkOwnablePermission('chapter-create', $book);
+
         $this->setPageTitle(trans('entities.chapters_create'));
         return view('chapters.create', ['book' => $book, 'current' => $book]);
     }
 
     /**
      * Store a newly created chapter in storage.
-     * @param Request $request
-     * @param string $bookSlug
-     * @return Response
-     * @throws \BookStack\Exceptions\NotFoundException
-     * @throws \Illuminate\Validation\ValidationException
+     * @throws ValidationException
      */
     public function store(Request $request, string $bookSlug)
     {
@@ -53,30 +47,28 @@ class ChapterController extends Controller
             'name' => 'required|string|max:255'
         ]);
 
-        $book = $this->entityRepo->getEntityBySlug('book', $bookSlug);
+        $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
         $this->checkOwnablePermission('chapter-create', $book);
 
-        $input = $request->all();
-        $input['priority'] = $this->entityRepo->getNewBookPriority($book);
-        $chapter = $this->entityRepo->createFromInput('chapter', $input, $book);
+        $chapter = $this->chapterRepo->create($request->all(), $book);
         Activity::add($chapter, 'chapter_create', $book->id);
+
         return redirect($chapter->getUrl());
     }
 
     /**
      * Display the specified chapter.
-     * @param $bookSlug
-     * @param $chapterSlug
-     * @return Response
      */
-    public function show($bookSlug, $chapterSlug)
+    public function show(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-view', $chapter);
-        $sidebarTree = $this->entityRepo->getBookChildren($chapter->book);
+
+        $sidebarTree = (new BookContents($chapter->book))->getTree();
+        $pages = $chapter->getVisiblePages();
         Views::add($chapter);
+
         $this->setPageTitle($chapter->getShortName());
-        $pages = $this->entityRepo->getChapterChildren($chapter);
         return view('chapters.show', [
             'book' => $chapter->book,
             'chapter' => $chapter,
@@ -88,79 +80,71 @@ class ChapterController extends Controller
 
     /**
      * Show the form for editing the specified chapter.
-     * @param $bookSlug
-     * @param $chapterSlug
-     * @return Response
      */
-    public function edit($bookSlug, $chapterSlug)
+    public function edit(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-update', $chapter);
+
         $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
         return view('chapters.edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
     }
 
     /**
      * Update the specified chapter in storage.
-     * @param Request $request
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return Response
-     * @throws \BookStack\Exceptions\NotFoundException
+     * @throws NotFoundException
      */
     public function update(Request $request, string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-update', $chapter);
 
-        $this->entityRepo->updateFromInput('chapter', $chapter, $request->all());
+        $this->chapterRepo->update($chapter, $request->all());
         Activity::add($chapter, 'chapter_update', $chapter->book->id);
+
         return redirect($chapter->getUrl());
     }
 
     /**
      * Shows the page to confirm deletion of this chapter.
-     * @param $bookSlug
-     * @param $chapterSlug
-     * @return \Illuminate\View\View
+     * @throws NotFoundException
      */
-    public function showDelete($bookSlug, $chapterSlug)
+    public function showDelete(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-delete', $chapter);
+
         $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
         return view('chapters.delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
     }
 
     /**
      * Remove the specified chapter from storage.
-     * @param $bookSlug
-     * @param $chapterSlug
-     * @return Response
+     * @throws NotFoundException
+     * @throws Throwable
      */
-    public function destroy($bookSlug, $chapterSlug)
+    public function destroy(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
-        $book = $chapter->book;
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-delete', $chapter);
-        Activity::addMessage('chapter_delete', $book->id, $chapter->name);
-        $this->entityRepo->destroyChapter($chapter);
-        return redirect($book->getUrl());
+
+        Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
+        $this->chapterRepo->destroy($chapter);
+
+        return redirect($chapter->book->getUrl());
     }
 
     /**
      * Show the page for moving a chapter.
-     * @param $bookSlug
-     * @param $chapterSlug
-     * @return mixed
-     * @throws \BookStack\Exceptions\NotFoundException
+     * @throws NotFoundException
      */
-    public function showMove($bookSlug, $chapterSlug)
+    public function showMove(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
         $this->checkOwnablePermission('chapter-update', $chapter);
         $this->checkOwnablePermission('chapter-delete', $chapter);
+
         return view('chapters.move', [
             'chapter' => $chapter,
             'book' => $chapter->book
@@ -169,15 +153,11 @@ class ChapterController extends Controller
 
     /**
      * Perform the move action for a chapter.
-     * @param Request $request
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return mixed
-     * @throws \BookStack\Exceptions\NotFoundException
+     * @throws NotFoundException
      */
     public function move(Request $request, string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-update', $chapter);
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
@@ -186,61 +166,47 @@ class ChapterController extends Controller
             return redirect($chapter->getUrl());
         }
 
-        $stringExploded = explode(':', $entitySelection);
-        $entityType = $stringExploded[0];
-        $entityId = intval($stringExploded[1]);
-
-        $parent = false;
-
-        if ($entityType == 'book') {
-            $parent = $this->entityRepo->getById('book', $entityId);
-        }
-
-        if ($parent === false || $parent === null) {
-            session()->flash('error', trans('errors.selected_book_not_found'));
+        try {
+            $newBook = $this->chapterRepo->move($chapter, $entitySelection);
+        } catch (MoveOperationException $exception) {
+            $this->showErrorNotification(trans('errors.selected_book_not_found'));
             return redirect()->back();
         }
 
-        $this->entityRepo->changeBook('chapter', $parent->id, $chapter, true);
-        Activity::add($chapter, 'chapter_move', $chapter->book->id);
-        session()->flash('success', trans('entities.chapter_move_success', ['bookName' => $parent->name]));
+        Activity::add($chapter, 'chapter_move', $newBook->id);
 
+        $this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
         return redirect($chapter->getUrl());
     }
 
     /**
      * Show the Restrictions view.
-     * @param $bookSlug
-     * @param $chapterSlug
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
-     * @throws \BookStack\Exceptions\NotFoundException
+     * @throws NotFoundException
      */
-    public function showPermissions($bookSlug, $chapterSlug)
+    public function showPermissions(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('restrictions-manage', $chapter);
-        $roles = $this->userRepo->getRestrictableRoles();
+
         return view('chapters.permissions', [
             'chapter' => $chapter,
-            'roles' => $roles
         ]);
     }
 
     /**
      * Set the restrictions for this chapter.
-     * @param Request $request
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-     * @throws \BookStack\Exceptions\NotFoundException
-     * @throws \Throwable
+     * @throws NotFoundException
      */
     public function permissions(Request $request, string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('restrictions-manage', $chapter);
-        $this->entityRepo->updateEntityPermissionsFromRequest($request, $chapter);
-        session()->flash('success', trans('entities.chapters_permissions_success'));
+
+        $restricted = $request->get('restricted') === 'true';
+        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
+        $this->chapterRepo->updatePermissions($chapter, $restricted, $permissions);
+
+        $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
         return redirect($chapter->getUrl());
     }
 }
index 15d67d53927b1c41afa5e3e7c876315d67a90e3d..0c86f854828b70dad5418a9b475c7262aef16612 100644 (file)
@@ -1,77 +1,57 @@
-<?php
-
-namespace BookStack\Http\Controllers;
+<?php namespace BookStack\Http\Controllers;
 
 use BookStack\Entities\ExportService;
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Exceptions\NotFoundException;
-use Illuminate\Http\Response;
 use Throwable;
 
 class ChapterExportController extends Controller
 {
-    /**
-     * @var EntityRepo
-     */
-    protected $entityRepo;
 
-    /**
-     * @var ExportService
-     */
+    protected $chapterRepo;
     protected $exportService;
 
     /**
      * ChapterExportController constructor.
-     * @param EntityRepo $entityRepo
-     * @param ExportService $exportService
      */
-    public function __construct(EntityRepo $entityRepo, ExportService $exportService)
+    public function __construct(ChapterRepo $chapterRepo, ExportService $exportService)
     {
-        $this->entityRepo = $entityRepo;
+        $this->chapterRepo = $chapterRepo;
         $this->exportService = $exportService;
         parent::__construct();
     }
 
     /**
-     * Exports a chapter to pdf .
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return Response
+     * Exports a chapter to pdf.
      * @throws NotFoundException
      * @throws Throwable
      */
     public function pdf(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $pdfContent = $this->exportService->chapterToPdf($chapter);
         return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
     }
 
     /**
      * Export a chapter to a self-contained HTML file.
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return Response
      * @throws NotFoundException
      * @throws Throwable
      */
     public function html(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $containedHtml = $this->exportService->chapterToContainedHtml($chapter);
         return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
     }
 
     /**
      * Export a chapter to a simple plaintext .txt file.
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return Response
      * @throws NotFoundException
      */
     public function plainText(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $chapterText = $this->exportService->chapterToPlainText($chapter);
         return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
     }
index 860b507623fb6b5d09cd6a2a1f1a4aea5021fc35..068358d72d10af92de45327e0b207093c9c501fd 100644 (file)
@@ -2,44 +2,36 @@
 
 use Activity;
 use BookStack\Actions\CommentRepo;
-use BookStack\Entities\Repos\EntityRepo;
-use Illuminate\Database\Eloquent\ModelNotFoundException;
+use BookStack\Entities\Page;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
 class CommentController extends Controller
 {
-    protected $entityRepo;
     protected $commentRepo;
 
     /**
      * CommentController constructor.
-     * @param \BookStack\Entities\Repos\EntityRepo $entityRepo
-     * @param \BookStack\Actions\CommentRepo $commentRepo
      */
-    public function __construct(EntityRepo $entityRepo, CommentRepo $commentRepo)
+    public function __construct(CommentRepo $commentRepo)
     {
-        $this->entityRepo = $entityRepo;
         $this->commentRepo = $commentRepo;
         parent::__construct();
     }
 
     /**
      * Save a new comment for a Page
-     * @param Request $request
-     * @param integer $pageId
-     * @param null|integer $commentId
-     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
+     * @throws ValidationException
      */
-    public function savePageComment(Request $request, $pageId, $commentId = null)
+    public function savePageComment(Request $request, int $pageId, int $commentId = null)
     {
         $this->validate($request, [
             'text' => 'required|string',
             'html' => 'required|string',
         ]);
 
-        try {
-            $page = $this->entityRepo->getById('page', $pageId, true);
-        } catch (ModelNotFoundException $e) {
+        $page = Page::visible()->find($pageId);
+        if ($page === null) {
             return response('Not found', 404);
         }
 
@@ -59,11 +51,9 @@ class CommentController extends Controller
 
     /**
      * Update an existing comment.
-     * @param Request $request
-     * @param integer $commentId
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+     * @throws ValidationException
      */
-    public function update(Request $request, $commentId)
+    public function update(Request $request, int $commentId)
     {
         $this->validate($request, [
             'text' => 'required|string',
@@ -80,13 +70,12 @@ class CommentController extends Controller
 
     /**
      * Delete a comment from the system.
-     * @param integer $id
-     * @return \Illuminate\Http\JsonResponse
      */
-    public function destroy($id)
+    public function destroy(int $id)
     {
         $comment = $this->commentRepo->getById($id);
         $this->checkOwnablePermission('comment-delete', $comment);
+
         $this->commentRepo->delete($comment);
         return response()->json(['message' => trans('entities.comment_deleted')]);
     }
index 5bc62c601a73bcf63913a2bac3bd63c25dbab2e1..b9576f2febd7923293b8eacb105bab6130f118c1 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Auth\User;
 use BookStack\Ownable;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Foundation\Validation\ValidatesRequests;
@@ -14,42 +13,27 @@ abstract class Controller extends BaseController
 {
     use DispatchesJobs, ValidatesRequests;
 
-    /**
-     * @var User static
-     */
-    protected $currentUser;
-    /**
-     * @var bool
-     */
-    protected $signedIn;
-
     /**
      * Controller constructor.
      */
     public function __construct()
     {
-        $this->middleware(function ($request, $next) {
-
-            // Get a user instance for the current user
-            $user = user();
-
-            // Share variables with controllers
-            $this->currentUser = $user;
-            $this->signedIn = auth()->check();
-
-            // Share variables with views
-            view()->share('signedIn', $this->signedIn);
-            view()->share('currentUser', $user);
+        //
+    }
 
-            return $next($request);
-        });
+    /**
+     * Check if the current user is signed in.
+     */
+    protected function isSignedIn(): bool
+    {
+        return auth()->check();
     }
 
     /**
      * Stops the application and shows a permission error if
      * the application is in demo mode.
      */
-    protected function preventAccessForDemoUsers()
+    protected function preventAccessInDemoMode()
     {
         if (config('app.env') === 'demo') {
             $this->showPermissionError();
@@ -75,7 +59,7 @@ abstract class Controller extends BaseController
             $response = response()->json(['error' => trans('errors.permissionJson')], 403);
         } else {
             $response = redirect('/');
-            session()->flash('error', trans('errors.permission'));
+            $this->showErrorNotification(trans('errors.permission'));
         }
 
         throw new HttpResponseException($response);
@@ -133,7 +117,7 @@ abstract class Controller extends BaseController
     protected function checkPermissionOrCurrentUser(string $permissionName, int $userId)
     {
         return $this->checkPermissionOr($permissionName, function () use ($userId) {
-            return $userId === $this->currentUser->id;
+            return $userId === user()->id;
         });
     }
 
@@ -145,7 +129,7 @@ abstract class Controller extends BaseController
      */
     protected function jsonError($messageText = "", $statusCode = 500)
     {
-        return response()->json(['message' => $messageText], $statusCode);
+        return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
     }
 
     /**
@@ -178,4 +162,39 @@ abstract class Controller extends BaseController
             'Content-Disposition' => 'attachment; filename="' . $fileName . '"'
         ]);
     }
+
+    /**
+     * Show a positive, successful notification to the user on next view load.
+     * @param string $message
+     */
+    protected function showSuccessNotification(string $message)
+    {
+        session()->flash('success', $message);
+    }
+
+    /**
+     * Show a warning notification to the user on next view load.
+     * @param string $message
+     */
+    protected function showWarningNotification(string $message)
+    {
+        session()->flash('warning', $message);
+    }
+
+    /**
+     * Show an error notification to the user on next view load.
+     * @param string $message
+     */
+    protected function showErrorNotification(string $message)
+    {
+        session()->flash('error', $message);
+    }
+
+    /**
+     * Get the validation rules for image files.
+     */
+    protected function getImageValidationRules(): string
+    {
+        return 'image_extension|no_double_extension|mimes:jpeg,png,gif,bmp,webp,tiff';
+    }
 }
index d2c75f95622fee3d8278629dd75b3390268dcb38..260952fd16eb8e18962bc962ac3db1acc3f13445 100644 (file)
@@ -1,23 +1,16 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Book;
+use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Page;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Repos\BookshelfRepo;
 use Illuminate\Http\Response;
 use Views;
 
 class HomeController extends Controller
 {
-    protected $entityRepo;
-
-    /**
-     * HomeController constructor.
-     * @param EntityRepo $entityRepo
-     */
-    public function __construct(EntityRepo $entityRepo)
-    {
-        $this->entityRepo = $entityRepo;
-        parent::__construct();
-    }
 
     /**
      * Display the homepage.
@@ -26,10 +19,20 @@ class HomeController extends Controller
     public function index()
     {
         $activity = Activity::latest(10);
-        $draftPages = $this->signedIn ? $this->entityRepo->getUserDraftPages(6) : [];
+        $draftPages = [];
+
+        if ($this->isSignedIn()) {
+            $draftPages = Page::visible()->where('draft', '=', true)
+                ->where('created_by', '=', user()->id)
+                ->orderBy('updated_at', 'desc')->take(6)->get();
+        }
+
         $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
-        $recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 12*$recentFactor);
-        $recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 12);
+        $recents = $this->isSignedIn() ?
+              Views::getUserRecentlyViewed(12*$recentFactor, 0)
+            : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
+        $recentlyUpdatedPages = Page::visible()->where('draft', false)
+            ->orderBy('updated_at', 'desc')->take(12)->get();
 
         $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
         $homepageOption = setting('app-homepage-type', 'default');
@@ -47,9 +50,9 @@ class HomeController extends Controller
         // Add required list ordering & sorting for books & shelves views.
         if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
             $key = $homepageOption;
-            $view = setting()->getUser($this->currentUser, $key . '_view_type', config('app.views.' . $key));
-            $sort = setting()->getUser($this->currentUser, $key . '_sort', 'name');
-            $order = setting()->getUser($this->currentUser, $key . '_sort_order', 'asc');
+            $view = setting()->getForCurrentUser($key . '_view_type', config('app.views.' . $key));
+            $sort = setting()->getForCurrentUser($key . '_sort', 'name');
+            $order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
 
             $sortOptions = [
                 'name' => trans('common.sort_name'),
@@ -66,16 +69,18 @@ class HomeController extends Controller
         }
 
         if ($homepageOption === 'bookshelves') {
-            $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18, $commonData['sort'], $commonData['order']);
+            $shelfRepo = app(BookshelfRepo::class);
+            $shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
             foreach ($shelves as $shelf) {
-                $shelf->books = $this->entityRepo->getBookshelfChildren($shelf);
+                $shelf->books = $shelf->visibleBooks;
             }
             $data = array_merge($commonData, ['shelves' => $shelves]);
             return view('common.home-shelves', $data);
         }
 
         if ($homepageOption === 'books') {
-            $books = $this->entityRepo->getAllPaginated('book', 18, $commonData['sort'], $commonData['order']);
+            $bookRepo = app(BookRepo::class);
+            $books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
             $data = array_merge($commonData, ['books' => $books]);
             return view('common.home-book', $data);
         }
@@ -83,8 +88,9 @@ class HomeController extends Controller
         if ($homepageOption === 'page') {
             $homepageSetting = setting('app-homepage', '0:');
             $id = intval(explode(':', $homepageSetting)[0]);
-            $customHomepage = $this->entityRepo->getById('page', $id, false, true);
-            $this->entityRepo->renderPage($customHomepage, true);
+            $customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
+            $pageContent = new PageContent($customHomepage);
+            $customHomepage->html = $pageContent->render(true);
             return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage]));
         }
 
index 79a23df27f00b6dcb64be57f5485e839f053ebbd..9c67704dd339bd0b21d3471b751206625386869b 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace BookStack\Http\Controllers\Images;
 
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Page;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controllers\Controller;
 use BookStack\Repos\PageRepo;
@@ -69,16 +69,21 @@ class ImageController extends Controller
 
     /**
      * Show the usage of an image on pages.
-     * @param \BookStack\Entities\Repos\EntityRepo $entityRepo
-     * @param $id
-     * @return \Illuminate\Http\JsonResponse
      */
-    public function usage(EntityRepo $entityRepo, $id)
+    public function usage(int $id)
     {
         $image = $this->imageRepo->getById($id);
         $this->checkImagePermission($image);
-        $pageSearch = $entityRepo->searchForImage($image->url);
-        return response()->json($pageSearch);
+
+        $pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
+        foreach ($pages as $page) {
+            $page->url = $page->getUrl();
+            $page->html = '';
+            $page->text = '';
+        }
+        $result = count($pages) > 0 ? $pages : false;
+
+        return response()->json($result);
     }
 
     /**
index 45aa8f9347554d2de3cc2f951e3b3b19a2e533a0..630f888ed3e9dc0d16da96b622a03278b527cfb9 100644 (file)
@@ -1,18 +1,17 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Auth\UserRepo;
+use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Managers\PageEditActivity;
+use BookStack\Entities\Page;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Exceptions\PermissionsException;
 use Exception;
-use GatherContent\Htmldiff\Htmldiff;
-use Illuminate\Contracts\View\Factory;
-use Illuminate\Http\JsonResponse;
-use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
-use Illuminate\Routing\Redirector;
-use Illuminate\View\View;
+use Illuminate\Validation\ValidationException;
 use Throwable;
 use Views;
 
@@ -20,44 +19,28 @@ class PageController extends Controller
 {
 
     protected $pageRepo;
-    protected $userRepo;
 
     /**
      * PageController constructor.
-     * @param PageRepo $pageRepo
-     * @param UserRepo $userRepo
      */
-    public function __construct(PageRepo $pageRepo, UserRepo $userRepo)
+    public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        $this->userRepo = $userRepo;
         parent::__construct();
     }
 
     /**
      * Show the form for creating a new page.
-     * @param string $bookSlug
-     * @param string $chapterSlug
-     * @return Response
-     * @internal param bool $pageSlug
-     * @throws NotFoundException
+     * @throws Throwable
      */
-    public function create($bookSlug, $chapterSlug = null)
+    public function create(string $bookSlug, string $chapterSlug = null)
     {
-        if ($chapterSlug !== null) {
-            $chapter = $this->pageRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
-            $book = $chapter->book;
-        } else {
-            $chapter = null;
-            $book = $this->pageRepo->getEntityBySlug('book', $bookSlug);
-        }
-
-        $parent = $chapter ? $chapter : $book;
+        $parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('page-create', $parent);
 
         // Redirect to draft edit screen if signed in
-        if ($this->signedIn) {
-            $draft = $this->pageRepo->getDraftPage($book, $chapter);
+        if ($this->isSignedIn()) {
+            $draft = $this->pageRepo->getNewDraftPage($parent);
             return redirect($draft->getUrl());
         }
 
@@ -68,51 +51,38 @@ class PageController extends Controller
 
     /**
      * Create a new page as a guest user.
-     * @param Request $request
-     * @param string $bookSlug
-     * @param string|null $chapterSlug
-     * @return mixed
-     * @throws NotFoundException
+     * @throws ValidationException
      */
-    public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null)
+    public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
     {
         $this->validate($request, [
             'name' => 'required|string|max:255'
         ]);
 
-        if ($chapterSlug !== null) {
-            $chapter = $this->pageRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
-            $book = $chapter->book;
-        } else {
-            $chapter = null;
-            $book = $this->pageRepo->getEntityBySlug('book', $bookSlug);
-        }
-
-        $parent = $chapter ? $chapter : $book;
+        $parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('page-create', $parent);
 
-        $page = $this->pageRepo->getDraftPage($book, $chapter);
-        $this->pageRepo->publishPageDraft($page, [
+        $page = $this->pageRepo->getNewDraftPage($parent);
+        $this->pageRepo->publishDraft($page, [
             'name' => $request->get('name'),
             'html' => ''
         ]);
+
         return redirect($page->getUrl('/edit'));
     }
 
     /**
      * Show form to continue editing a draft page.
-     * @param string $bookSlug
-     * @param int $pageId
-     * @return Factory|View
+     * @throws NotFoundException
      */
-    public function editDraft($bookSlug, $pageId)
+    public function editDraft(string $bookSlug, int $pageId)
     {
-        $draft = $this->pageRepo->getById('page', $pageId, true);
-        $this->checkOwnablePermission('page-create', $draft->parent);
+        $draft = $this->pageRepo->getById($pageId);
+        $this->checkOwnablePermission('page-create', $draft->parent());
         $this->setPageTitle(trans('entities.pages_edit_draft'));
 
-        $draftsEnabled = $this->signedIn;
-        $templates = $this->pageRepo->getPageTemplates(10);
+        $draftsEnabled = $this->isSignedIn();
+        $templates = $this->pageRepo->getTemplates(10);
 
         return view('pages.edit', [
             'page' => $draft,
@@ -125,63 +95,50 @@ class PageController extends Controller
 
     /**
      * Store a new page by changing a draft into a page.
-     * @param  Request $request
-     * @param  string $bookSlug
-     * @param  int $pageId
-     * @return Response
+     * @throws NotFoundException
+     * @throws ValidationException
      */
-    public function store(Request $request, $bookSlug, $pageId)
+    public function store(Request $request, string $bookSlug, int $pageId)
     {
         $this->validate($request, [
             'name' => 'required|string|max:255'
         ]);
+        $draftPage = $this->pageRepo->getById($pageId);
+        $this->checkOwnablePermission('page-create', $draftPage->parent());
 
-        $input = $request->all();
-        $draftPage = $this->pageRepo->getById('page', $pageId, true);
-        $book = $draftPage->book;
-
-        $parent = $draftPage->parent;
-        $this->checkOwnablePermission('page-create', $parent);
-
-        if ($parent->isA('chapter')) {
-            $input['priority'] = $this->pageRepo->getNewChapterPriority($parent);
-        } else {
-            $input['priority'] = $this->pageRepo->getNewBookPriority($parent);
-        }
-
-        $page = $this->pageRepo->publishPageDraft($draftPage, $input);
+        $page = $this->pageRepo->publishDraft($draftPage, $request->all());
+        Activity::add($page, 'page_create', $draftPage->book->id);
 
-        Activity::add($page, 'page_create', $book->id);
         return redirect($page->getUrl());
     }
 
     /**
      * Display the specified page.
      * If the page is not found via the slug the revisions are searched for a match.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Response
      * @throws NotFoundException
      */
-    public function show($bookSlug, $pageSlug)
+    public function show(string $bookSlug, string $pageSlug)
     {
         try {
-            $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+            $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         } catch (NotFoundException $e) {
-            $page = $this->pageRepo->getPageByOldSlug($pageSlug, $bookSlug);
+            $page = $this->pageRepo->getByOldSlug($bookSlug, $pageSlug);
+
             if ($page === null) {
                 throw $e;
             }
+
             return redirect($page->getUrl());
         }
 
         $this->checkOwnablePermission('page-view', $page);
 
-        $page->html = $this->pageRepo->renderPage($page);
-        $sidebarTree = $this->pageRepo->getBookChildren($page->book);
-        $pageNav = $this->pageRepo->getPageNav($page->html);
+        $pageContent = (new PageContent($page));
+        $page->html = $pageContent->render();
+        $sidebarTree = (new BookContents($page->book))->getTree();
+        $pageNav = $pageContent->getNavigation($page->html);
 
-        // check if the comment's are enabled
+        // Check if page comments are enabled
         $commentsEnabled = !setting('app-disable-comments');
         if ($commentsEnabled) {
             $page->load(['comments.createdBy']);
@@ -190,7 +147,8 @@ class PageController extends Controller
         Views::add($page);
         $this->setPageTitle($page->getShortName());
         return view('pages.show', [
-            'page' => $page,'book' => $page->book,
+            'page' => $page,
+            'book' => $page->book,
             'current' => $page,
             'sidebarTree' => $sidebarTree,
             'commentsEnabled' => $commentsEnabled,
@@ -200,52 +158,47 @@ class PageController extends Controller
 
     /**
      * Get page from an ajax request.
-     * @param int $pageId
-     * @return JsonResponse
+     * @throws NotFoundException
      */
-    public function getPageAjax($pageId)
+    public function getPageAjax(int $pageId)
     {
-        $page = $this->pageRepo->getById('page', $pageId);
+        $page = $this->pageRepo->getById($pageId);
         return response()->json($page);
     }
 
     /**
      * Show the form for editing the specified page.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Response
      * @throws NotFoundException
      */
-    public function edit($bookSlug, $pageSlug)
+    public function edit(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-update', $page);
-        $this->setPageTitle(trans('entities.pages_editing_named', ['pageName'=>$page->getShortName()]));
+
         $page->isDraft = false;
+        $editActivity = new PageEditActivity($page);
 
         // Check for active editing
         $warnings = [];
-        if ($this->pageRepo->isPageEditingActive($page, 60)) {
-            $warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60);
+        if ($editActivity->hasActiveEditing()) {
+            $warnings[] = $editActivity->activeEditingMessage();
         }
 
         // Check for a current draft version for this user
-        $userPageDraft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
-        if ($userPageDraft !== null) {
-            $page->name = $userPageDraft->name;
-            $page->html = $userPageDraft->html;
-            $page->markdown = $userPageDraft->markdown;
+        $userDraft = $this->pageRepo->getUserDraft($page);
+        if ($userDraft !== null) {
+            $page->forceFill($userDraft->only(['name', 'html', 'markdown']));
             $page->isDraft = true;
-            $warnings [] = $this->pageRepo->getUserPageDraftMessage($userPageDraft);
+            $warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
         }
 
         if (count($warnings) > 0) {
-            session()->flash('warning', implode("\n", $warnings));
+            $this->showWarningNotification(implode("\n", $warnings));
         }
 
-        $draftsEnabled = $this->signedIn;
-        $templates = $this->pageRepo->getPageTemplates(10);
-
+        $templates = $this->pageRepo->getTemplates(10);
+        $draftsEnabled = $this->isSignedIn();
+        $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()]));
         return view('pages.edit', [
             'page' => $page,
             'book' => $page->book,
@@ -257,39 +210,34 @@ class PageController extends Controller
 
     /**
      * Update the specified page in storage.
-     * @param  Request $request
-     * @param  string $bookSlug
-     * @param  string $pageSlug
-     * @return Response
+     * @throws ValidationException
+     * @throws NotFoundException
      */
-    public function update(Request $request, $bookSlug, $pageSlug)
+    public function update(Request $request, string $bookSlug, string $pageSlug)
     {
         $this->validate($request, [
             'name' => 'required|string|max:255'
         ]);
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-update', $page);
-        $this->pageRepo->updatePage($page, $page->book->id, $request->all());
+
+        $this->pageRepo->update($page, $request->all());
         Activity::add($page, 'page_update', $page->book->id);
+
         return redirect($page->getUrl());
     }
 
     /**
      * Save a draft update as a revision.
-     * @param Request $request
-     * @param int $pageId
-     * @return JsonResponse
+     * @throws NotFoundException
      */
-    public function saveDraft(Request $request, $pageId)
+    public function saveDraft(Request $request, int $pageId)
     {
-        $page = $this->pageRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
 
-        if (!$this->signedIn) {
-            return response()->json([
-                'status' => 'error',
-                'message' => trans('errors.guests_cannot_save_drafts'),
-            ], 500);
+        if (!$this->isSignedIn()) {
+            return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
         }
 
         $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
@@ -303,211 +251,98 @@ class PageController extends Controller
     }
 
     /**
-     * Redirect from a special link url which
-     * uses the page id rather than the name.
-     * @param int $pageId
-     * @return RedirectResponse|Redirector
+     * Redirect from a special link url which uses the page id rather than the name.
+     * @throws NotFoundException
      */
-    public function redirectFromLink($pageId)
+    public function redirectFromLink(int $pageId)
     {
-        $page = $this->pageRepo->getById('page', $pageId);
+        $page = $this->pageRepo->getById($pageId);
         return redirect($page->getUrl());
     }
 
     /**
      * Show the deletion page for the specified page.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return View
+     * @throws NotFoundException
      */
-    public function showDelete($bookSlug, $pageSlug)
+    public function showDelete(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-delete', $page);
         $this->setPageTitle(trans('entities.pages_delete_named', ['pageName'=>$page->getShortName()]));
-        return view('pages.delete', ['book' => $page->book, 'page' => $page, 'current' => $page]);
+        return view('pages.delete', [
+            'book' => $page->book,
+            'page' => $page,
+            'current' => $page
+        ]);
     }
 
-
     /**
      * Show the deletion page for the specified page.
-     * @param string $bookSlug
-     * @param int $pageId
-     * @return View
      * @throws NotFoundException
      */
-    public function showDeleteDraft($bookSlug, $pageId)
+    public function showDeleteDraft(string $bookSlug, int $pageId)
     {
-        $page = $this->pageRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
         $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName'=>$page->getShortName()]));
-        return view('pages.delete', ['book' => $page->book, 'page' => $page, 'current' => $page]);
+        return view('pages.delete', [
+            'book' => $page->book,
+            'page' => $page,
+            'current' => $page
+        ]);
     }
 
     /**
      * Remove the specified page from storage.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Response
-     * @internal param int $id
+     * @throws NotFoundException
+     * @throws Throwable
+     * @throws NotifyException
      */
-    public function destroy($bookSlug, $pageSlug)
+    public function destroy(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $book = $page->book;
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-delete', $page);
-        $this->pageRepo->destroyPage($page);
 
-        Activity::addMessage('page_delete', $book->id, $page->name);
-        session()->flash('success', trans('entities.pages_delete_success'));
+        $book = $page->book;
+        $this->pageRepo->destroy($page);
+        Activity::addMessage('page_delete', $page->name, $book->id);
+
+        $this->showSuccessNotification(trans('entities.pages_delete_success'));
         return redirect($book->getUrl());
     }
 
     /**
      * Remove the specified draft page from storage.
-     * @param string $bookSlug
-     * @param int $pageId
-     * @return Response
      * @throws NotFoundException
+     * @throws NotifyException
+     * @throws Throwable
      */
-    public function destroyDraft($bookSlug, $pageId)
+    public function destroyDraft(string $bookSlug, int $pageId)
     {
-        $page = $this->pageRepo->getById('page', $pageId, true);
+        $page = $this->pageRepo->getById($pageId);
         $book = $page->book;
+        $chapter = $page->chapter;
         $this->checkOwnablePermission('page-update', $page);
-        session()->flash('success', trans('entities.pages_delete_draft_success'));
-        $this->pageRepo->destroyPage($page);
-        return redirect($book->getUrl());
-    }
 
-    /**
-     * Shows the last revisions for this page.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return View
-     * @throws NotFoundException
-     */
-    public function showRevisions($bookSlug, $pageSlug)
-    {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
-        return view('pages.revisions', ['page' => $page, 'current' => $page]);
-    }
+        $this->pageRepo->destroy($page);
 
-    /**
-     * Shows a preview of a single revision
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @param int $revisionId
-     * @return View
-     */
-    public function showRevision($bookSlug, $pageSlug, $revisionId)
-    {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
-        if ($revision === null) {
-            abort(404);
-        }
+        $this->showSuccessNotification(trans('entities.pages_delete_draft_success'));
 
-        $page->fill($revision->toArray());
-        $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
-
-        return view('pages.revision', [
-            'page' => $page,
-            'book' => $page->book,
-            'diff' => null,
-            'revision' => $revision
-        ]);
-    }
-
-    /**
-     * Shows the changes of a single revision
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @param int $revisionId
-     * @return View
-     */
-    public function showRevisionChanges($bookSlug, $pageSlug, $revisionId)
-    {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
-        if ($revision === null) {
-            abort(404);
-        }
-
-        $prev = $revision->getPrevious();
-        $prevContent = ($prev === null) ? '' : $prev->html;
-        $diff = (new Htmldiff)->diff($prevContent, $revision->html);
-
-        $page->fill($revision->toArray());
-        $this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
-
-        return view('pages.revision', [
-            'page' => $page,
-            'book' => $page->book,
-            'diff' => $diff,
-            'revision' => $revision
-        ]);
-    }
-
-    /**
-     * Restores a page using the content of the specified revision.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @param int $revisionId
-     * @return RedirectResponse|Redirector
-     */
-    public function restoreRevision($bookSlug, $pageSlug, $revisionId)
-    {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $this->checkOwnablePermission('page-update', $page);
-        $page = $this->pageRepo->restorePageRevision($page, $page->book, $revisionId);
-        Activity::add($page, 'page_restore', $page->book->id);
-        return redirect($page->getUrl());
-    }
-
-
-    /**
-     * Deletes a revision using the id of the specified revision.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @param int $revId
-     * @return RedirectResponse|Redirector
-     *@throws BadRequestException
-     * @throws NotFoundException
-     */
-    public function destroyRevision($bookSlug, $pageSlug, $revId)
-    {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $this->checkOwnablePermission('page-delete', $page);
-
-        $revision = $page->revisions()->where('id', '=', $revId)->first();
-        if ($revision === null) {
-            throw new NotFoundException("Revision #{$revId} not found");
+        if ($chapter && userCan('view', $chapter)) {
+            return redirect($chapter->getUrl());
         }
-
-        // Get the current revision for the page
-        $currentRevision = $page->getCurrentRevision();
-
-        // Check if its the latest revision, cannot delete latest revision.
-        if (intval($currentRevision->id) === intval($revId)) {
-            session()->flash('error', trans('entities.revision_cannot_delete_latest'));
-            return response()->view('pages.revisions', ['page' => $page, 'book' => $page->book, 'current' => $page], 400);
-        }
-
-        $revision->delete();
-        session()->flash('success', trans('entities.revision_delete_success'));
-        return redirect($page->getUrl('/revisions'));
+        return redirect($book->getUrl());
     }
 
     /**
-     * Show a listing of recently created pages
-     * @return Factory|View
+     * Show a listing of recently created pages.
      */
     public function showRecentlyUpdated()
     {
-        // TODO - Still exist?
-        $pages = $this->pageRepo->getRecentlyUpdatedPaginated('page', 20)->setPath(url('/pages/recently-updated'));
+        $pages = Page::visible()->orderBy('updated_at', 'desc')
+            ->paginate(20)
+            ->setPath(url('/pages/recently-updated'));
+
         return view('pages.detailed-listing', [
             'title' => trans('entities.recently_updated_pages'),
             'pages' => $pages
@@ -516,14 +351,11 @@ class PageController extends Controller
 
     /**
      * Show the view to choose a new parent to move a page into.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return mixed
      * @throws NotFoundException
      */
-    public function showMove($bookSlug, $pageSlug)
+    public function showMove(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-update', $page);
         $this->checkOwnablePermission('page-delete', $page);
         return view('pages.move', [
@@ -533,17 +365,13 @@ class PageController extends Controller
     }
 
     /**
-     * Does the action of moving the location of a page
-     * @param Request $request
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return mixed
+     * Does the action of moving the location of a page.
      * @throws NotFoundException
      * @throws Throwable
      */
     public function move(Request $request, string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-update', $page);
         $this->checkOwnablePermission('page-delete', $page);
 
@@ -552,37 +380,29 @@ class PageController extends Controller
             return redirect($page->getUrl());
         }
 
-        $stringExploded = explode(':', $entitySelection);
-        $entityType = $stringExploded[0];
-        $entityId = intval($stringExploded[1]);
-
-
         try {
-            $parent = $this->pageRepo->getById($entityType, $entityId);
-        } catch (Exception $e) {
-            session()->flash(trans('entities.selected_book_chapter_not_found'));
+            $parent = $this->pageRepo->move($page, $entitySelection);
+        } catch (Exception $exception) {
+            if ($exception instanceof  PermissionsException) {
+                $this->showPermissionError();
+            }
+
+            $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
             return redirect()->back();
         }
 
-        $this->checkOwnablePermission('page-create', $parent);
-
-        $this->pageRepo->changePageParent($page, $parent);
         Activity::add($page, 'page_move', $page->book->id);
-        session()->flash('success', trans('entities.pages_move_success', ['parentName' => $parent->name]));
-
+        $this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name]));
         return redirect($page->getUrl());
     }
 
     /**
      * Show the view to copy a page.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return mixed
      * @throws NotFoundException
      */
-    public function showCopy($bookSlug, $pageSlug)
+    public function showCopy(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-view', $page);
         session()->flashInput(['name' => $page->name]);
         return view('pages.copy', [
@@ -591,79 +411,65 @@ class PageController extends Controller
         ]);
     }
 
+
     /**
      * Create a copy of a page within the requested target destination.
-     * @param Request $request
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return mixed
      * @throws NotFoundException
      * @throws Throwable
      */
     public function copy(Request $request, string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-view', $page);
 
-        $entitySelection = $request->get('entity_selection', null);
-        if ($entitySelection === null || $entitySelection === '') {
-            $parent = $page->chapter ? $page->chapter : $page->book;
-        } else {
-            $stringExploded = explode(':', $entitySelection);
-            $entityType = $stringExploded[0];
-            $entityId = intval($stringExploded[1]);
-
-            try {
-                $parent = $this->pageRepo->getById($entityType, $entityId);
-            } catch (Exception $e) {
-                session()->flash(trans('entities.selected_book_chapter_not_found'));
-                return redirect()->back();
-            }
-        }
+        $entitySelection = $request->get('entity_selection', null) ?? null;
+        $newName = $request->get('name', null);
 
-        $this->checkOwnablePermission('page-create', $parent);
+        try {
+            $pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
+        } catch (Exception $exception) {
+            if ($exception instanceof  PermissionsException) {
+                $this->showPermissionError();
+            }
 
-        $pageCopy = $this->pageRepo->copyPage($page, $parent, $request->get('name', ''));
+            $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
+            return redirect()->back();
+        }
 
         Activity::add($pageCopy, 'page_create', $pageCopy->book->id);
-        session()->flash('success', trans('entities.pages_copy_success'));
 
+        $this->showSuccessNotification(trans('entities.pages_copy_success'));
         return redirect($pageCopy->getUrl());
     }
 
     /**
      * Show the Permissions view.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Factory|View
      * @throws NotFoundException
      */
-    public function showPermissions($bookSlug, $pageSlug)
+    public function showPermissions(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('restrictions-manage', $page);
-        $roles = $this->userRepo->getRestrictableRoles();
         return view('pages.permissions', [
             'page'  => $page,
-            'roles' => $roles
         ]);
     }
 
     /**
      * Set the permissions for this page.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @param Request $request
-     * @return RedirectResponse|Redirector
      * @throws NotFoundException
      * @throws Throwable
      */
     public function permissions(Request $request, string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('restrictions-manage', $page);
-        $this->pageRepo->updateEntityPermissionsFromRequest($request, $page);
-        session()->flash('success', trans('entities.pages_permissions_success'));
+
+        $restricted = $request->get('restricted') === 'true';
+        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
+        $this->pageRepo->updatePermissions($page, $restricted, $permissions);
+
+        $this->showSuccessNotification(trans('entities.pages_permissions_success'));
         return redirect($page->getUrl());
     }
 }
index addcc55132c218078ab903bc85777bf8805ba330..3b02ea224716c4f01bc1ceffdd043d5e88702ba4 100644 (file)
@@ -3,21 +3,15 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Entities\ExportService;
+use BookStack\Entities\Managers\PageContent;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\NotFoundException;
-use Illuminate\Http\Response;
 use Throwable;
 
 class PageExportController extends Controller
 {
-    /**
-     * @var PageRepo
-     */
-    protected $pageRepo;
 
-    /**
-     * @var ExportService
-     */
+    protected $pageRepo;
     protected $exportService;
 
     /**
@@ -35,46 +29,37 @@ class PageExportController extends Controller
     /**
      * Exports a page to a PDF.
      * https://github.com/barryvdh/laravel-dompdf
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Response
      * @throws NotFoundException
      * @throws Throwable
      */
     public function pdf(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $page->html = $this->pageRepo->renderPage($page);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $page->html = (new PageContent($page))->render();
         $pdfContent = $this->exportService->pageToPdf($page);
         return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
     }
 
     /**
      * Export a page to a self-contained HTML file.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Response
      * @throws NotFoundException
      * @throws Throwable
      */
     public function html(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
-        $page->html = $this->pageRepo->renderPage($page);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $page->html = (new PageContent($page))->render();
         $containedHtml = $this->exportService->pageToContainedHtml($page);
         return $this->downloadResponse($containedHtml, $pageSlug . '.html');
     }
 
     /**
      * Export a page to a simple plaintext .txt file.
-     * @param string $bookSlug
-     * @param string $pageSlug
-     * @return Response
      * @throws NotFoundException
      */
     public function plainText(string $bookSlug, string $pageSlug)
     {
-        $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $pageText = $this->exportService->pageToPlainText($page);
         return $this->downloadResponse($pageText, $pageSlug . '.txt');
     }
diff --git a/app/Http/Controllers/PageRevisionController.php b/app/Http/Controllers/PageRevisionController.php
new file mode 100644 (file)
index 0000000..3c65b50
--- /dev/null
@@ -0,0 +1,128 @@
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Facades\Activity;
+use GatherContent\Htmldiff\Htmldiff;
+
+class PageRevisionController extends Controller
+{
+
+    protected $pageRepo;
+
+    /**
+     * PageRevisionController constructor.
+     */
+    public function __construct(PageRepo $pageRepo)
+    {
+        $this->pageRepo = $pageRepo;
+        parent::__construct();
+    }
+
+    /**
+     * Shows the last revisions for this page.
+     * @throws NotFoundException
+     */
+    public function index(string $bookSlug, string $pageSlug)
+    {
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
+        return view('pages.revisions', [
+            'page' => $page,
+            'current' => $page
+        ]);
+    }
+
+    /**
+     * Shows a preview of a single revision.
+     * @throws NotFoundException
+     */
+    public function show(string $bookSlug, string $pageSlug, int $revisionId)
+    {
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
+        if ($revision === null) {
+            throw new NotFoundException();
+        }
+
+        $page->fill($revision->toArray());
+
+        $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
+        return view('pages.revision', [
+            'page' => $page,
+            'book' => $page->book,
+            'diff' => null,
+            'revision' => $revision
+        ]);
+    }
+
+    /**
+     * Shows the changes of a single revision.
+     * @throws NotFoundException
+     */
+    public function changes(string $bookSlug, string $pageSlug, int $revisionId)
+    {
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
+        if ($revision === null) {
+            throw new NotFoundException();
+        }
+
+        $prev = $revision->getPrevious();
+        $prevContent = $prev->html ?? '';
+        $diff = (new Htmldiff)->diff($prevContent, $revision->html);
+
+        $page->fill($revision->toArray());
+        $this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
+
+        return view('pages.revision', [
+            'page' => $page,
+            'book' => $page->book,
+            'diff' => $diff,
+            'revision' => $revision
+        ]);
+    }
+
+    /**
+     * Restores a page using the content of the specified revision.
+     * @throws NotFoundException
+     */
+    public function restore(string $bookSlug, string $pageSlug, int $revisionId)
+    {
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $this->checkOwnablePermission('page-update', $page);
+
+        $page = $this->pageRepo->restoreRevision($page, $revisionId);
+
+        Activity::add($page, 'page_restore', $page->book->id);
+        return redirect($page->getUrl());
+    }
+
+    /**
+     * Deletes a revision using the id of the specified revision.
+     * @throws NotFoundException
+     */
+    public function destroy(string $bookSlug, string $pageSlug, int $revId)
+    {
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $this->checkOwnablePermission('page-delete', $page);
+
+        $revision = $page->revisions()->where('id', '=', $revId)->first();
+        if ($revision === null) {
+            throw new NotFoundException("Revision #{$revId} not found");
+        }
+
+        // Get the current revision for the page
+        $currentRevision = $page->getCurrentRevision();
+
+        // Check if its the latest revision, cannot delete latest revision.
+        if (intval($currentRevision->id) === intval($revId)) {
+            $this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
+            return redirect($page->getUrl('/revisions'));
+        }
+
+        $revision->delete();
+        $this->showSuccessNotification(trans('entities.revision_delete_success'));
+        return redirect($page->getUrl('/revisions'));
+    }
+}
index b47205a1ba3ad0ddfc8c6984b0e8b8c8f65f7a0f..eaa1a8ae26ae18f28473c6796062dfb5065dba97 100644 (file)
@@ -11,8 +11,7 @@ class PageTemplateController extends Controller
     protected $pageRepo;
 
     /**
-     * PageTemplateController constructor.
-     * @param $pageRepo
+     * PageTemplateController constructor
      */
     public function __construct(PageRepo $pageRepo)
     {
@@ -22,14 +21,12 @@ class PageTemplateController extends Controller
 
     /**
      * Fetch a list of templates from the system.
-     * @param Request $request
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
      */
     public function list(Request $request)
     {
         $page = $request->get('page', 1);
         $search = $request->get('search', '');
-        $templates = $this->pageRepo->getPageTemplates(10, $page, $search);
+        $templates = $this->pageRepo->getTemplates(10, $page, $search);
 
         if ($search) {
             $templates->appends(['search' => $search]);
@@ -42,13 +39,11 @@ class PageTemplateController extends Controller
 
     /**
      * Get the content of a template.
-     * @param $templateId
-     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
      * @throws NotFoundException
      */
-    public function get($templateId)
+    public function get(int $templateId)
     {
-        $page = $this->pageRepo->getById('page', $templateId);
+        $page = $this->pageRepo->getById($templateId);
 
         if (!$page->template) {
             throw new NotFoundException();
index f19e7e763510b6c66246af5d4031a8c1bfecce54..148ae5cd65a3049e421ea2b331f3bfa34f31ed4d 100644 (file)
@@ -53,7 +53,7 @@ class PermissionController extends Controller
         ]);
 
         $this->permissionsRepo->saveNewRole($request->all());
-        session()->flash('success', trans('settings.role_create_success'));
+        $this->showSuccessNotification(trans('settings.role_create_success'));
         return redirect('/settings/roles');
     }
 
@@ -90,7 +90,7 @@ class PermissionController extends Controller
         ]);
 
         $this->permissionsRepo->updateRole($id, $request->all());
-        session()->flash('success', trans('settings.role_update_success'));
+        $this->showSuccessNotification(trans('settings.role_update_success'));
         return redirect('/settings/roles');
     }
 
@@ -124,11 +124,11 @@ class PermissionController extends Controller
         try {
             $this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id'));
         } catch (PermissionsException $e) {
-            session()->flash('error', $e->getMessage());
+            $this->showErrorNotification($e->getMessage());
             return redirect()->back();
         }
 
-        session()->flash('success', trans('settings.role_delete_success'));
+        $this->showSuccessNotification(trans('settings.role_delete_success'));
         return redirect('/settings/roles');
     }
 }
index 1691ee9b0bf937f338436305baa8b9beea3bb24f..a5cd7ad6b82224f094173f1d76b2d4a864b48174 100644 (file)
@@ -1,35 +1,27 @@
 <?php namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ViewService;
-use BookStack\Entities\EntityContextManager;
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Book;
+use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Entity;
+use BookStack\Entities\Managers\EntityContext;
 use BookStack\Entities\SearchService;
-use BookStack\Exceptions\NotFoundException;
-use Illuminate\Contracts\View\Factory;
 use Illuminate\Http\Request;
-use Illuminate\View\View;
 
 class SearchController extends Controller
 {
-    protected $entityRepo;
     protected $viewService;
     protected $searchService;
     protected $entityContextManager;
 
     /**
      * SearchController constructor.
-     * @param EntityRepo $entityRepo
-     * @param ViewService $viewService
-     * @param SearchService $searchService
-     * @param EntityContextManager $entityContextManager
      */
     public function __construct(
-        EntityRepo $entityRepo,
         ViewService $viewService,
         SearchService $searchService,
-        EntityContextManager $entityContextManager
+        EntityContext $entityContextManager
     ) {
-        $this->entityRepo = $entityRepo;
         $this->viewService = $viewService;
         $this->searchService = $searchService;
         $this->entityContextManager = $entityContextManager;
@@ -38,9 +30,6 @@ class SearchController extends Controller
 
     /**
      * Searches all entities.
-     * @param Request $request
-     * @return View
-     * @internal param string $searchTerm
      */
     public function search(Request $request)
     {
@@ -64,12 +53,8 @@ class SearchController extends Controller
 
     /**
      * Searches all entities within a book.
-     * @param Request $request
-     * @param integer $bookId
-     * @return View
-     * @internal param string $searchTerm
      */
-    public function searchBook(Request $request, $bookId)
+    public function searchBook(Request $request, int $bookId)
     {
         $term = $request->get('term', '');
         $results = $this->searchService->searchBook($bookId, $term);
@@ -78,12 +63,8 @@ class SearchController extends Controller
 
     /**
      * Searches all entities within a chapter.
-     * @param Request $request
-     * @param integer $chapterId
-     * @return View
-     * @internal param string $searchTerm
      */
-    public function searchChapter(Request $request, $chapterId)
+    public function searchChapter(Request $request, int $chapterId)
     {
         $term = $request->get('term', '');
         $results = $this->searchService->searchChapter($chapterId, $term);
@@ -93,8 +74,6 @@ class SearchController extends Controller
     /**
      * Search for a list of entities and return a partial HTML response of matching entities.
      * Returns the most popular entities if no search is provided.
-     * @param Request $request
-     * @return mixed
      */
     public function searchEntitiesAjax(Request $request)
     {
@@ -115,15 +94,13 @@ class SearchController extends Controller
 
     /**
      * Search siblings items in the system.
-     * @param Request $request
-     * @return Factory|View|mixed
      */
     public function searchSiblings(Request $request)
     {
         $type = $request->get('entity_type', null);
         $id = $request->get('entity_id', null);
 
-        $entity = $this->entityRepo->getById($type, $id);
+        $entity = Entity::getEntityInstance($type)->newQuery()->visible()->find($id);
         if (!$entity) {
             return $this->jsonError(trans('errors.entity_not_found'), 404);
         }
@@ -132,12 +109,12 @@ class SearchController extends Controller
 
         // Page in chapter
         if ($entity->isA('page') && $entity->chapter) {
-            $entities = $this->entityRepo->getChapterChildren($entity->chapter);
+            $entities = $entity->chapter->visiblePages();
         }
 
         // Page in book or chapter
         if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
-            $entities = $this->entityRepo->getBookDirectChildren($entity->book);
+            $entities = $entity->book->getDirectChildren();
         }
 
         // Book
@@ -145,15 +122,15 @@ class SearchController extends Controller
         if ($entity->isA('book')) {
             $contextShelf = $this->entityContextManager->getContextualShelfForBook($entity);
             if ($contextShelf) {
-                $entities = $this->entityRepo->getBookshelfChildren($contextShelf);
+                $entities = $contextShelf->visibleBooks()->get();
             } else {
-                $entities = $this->entityRepo->getAll('book');
+                $entities = Book::visible()->get();
             }
         }
 
         // Shelve
         if ($entity->isA('bookshelf')) {
-            $entities = $this->entityRepo->getAll('bookshelf');
+            $entities = Bookshelf::visible()->get();
         }
 
         return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']);
index 650833c7f33bd7a9a5d5e6e97a4446688c22b775..1146f22c749ec8df442dfe6a278b3922a3a1fdea 100644 (file)
@@ -47,7 +47,7 @@ class SettingController extends Controller
      */
     public function update(Request $request)
     {
-        $this->preventAccessForDemoUsers();
+        $this->preventAccessInDemoMode();
         $this->checkPermission('settings-manage');
         $this->validate($request, [
             'app_logo' => $this->imageRepo->getImageValidationRules(),
@@ -76,7 +76,7 @@ class SettingController extends Controller
             setting()->remove('app-logo');
         }
 
-        session()->flash('success', trans('settings.settings_save_success'));
+        $this->showSuccessNotification(trans('settings.settings_save_success'));
         return redirect('/settings');
     }
 
@@ -111,14 +111,14 @@ class SettingController extends Controller
         $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
         $deleteCount = count($imagesToDelete);
         if ($deleteCount === 0) {
-            session()->flash('warning', trans('settings.maint_image_cleanup_nothing_found'));
+            $this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
             return redirect('/settings/maintenance')->withInput();
         }
 
         if ($dryRun) {
             session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
         } else {
-            session()->flash('success', trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
+            $this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
         }
 
         return redirect('/settings/maintenance#image-cleanup')->withInput();
index 4f4f98104216df44f21ea598a8fdcb3503dd274e..b55398d2f6f644cb35cec75383df75be9dd40b71 100644 (file)
@@ -144,7 +144,7 @@ class UserController extends Controller
      */
     public function update(Request $request, $id)
     {
-        $this->preventAccessForDemoUsers();
+        $this->preventAccessInDemoMode();
         $this->checkPermissionOrCurrentUser('users-manage', $id);
 
         $this->validate($request, [
@@ -177,7 +177,7 @@ class UserController extends Controller
         }
 
         // External auth id updates
-        if ($this->currentUser->can('users-manage') && $request->filled('external_auth_id')) {
+        if (user()->can('users-manage') && $request->filled('external_auth_id')) {
             $user->external_auth_id = $request->get('external_auth_id');
         }
 
@@ -202,7 +202,7 @@ class UserController extends Controller
         }
 
         $user->save();
-        session()->flash('success', trans('settings.users_edit_success'));
+        $this->showSuccessNotification(trans('settings.users_edit_success'));
 
         $redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
         return redirect($redirectUrl);
@@ -230,23 +230,23 @@ class UserController extends Controller
      */
     public function destroy($id)
     {
-        $this->preventAccessForDemoUsers();
+        $this->preventAccessInDemoMode();
         $this->checkPermissionOrCurrentUser('users-manage', $id);
 
         $user = $this->userRepo->getById($id);
 
         if ($this->userRepo->isOnlyAdmin($user)) {
-            session()->flash('error', trans('errors.users_cannot_delete_only_admin'));
+            $this->showErrorNotification(trans('errors.users_cannot_delete_only_admin'));
             return redirect($user->getEditUrl());
         }
 
         if ($user->system_name === 'public') {
-            session()->flash('error', trans('errors.users_cannot_delete_guest'));
+            $this->showErrorNotification(trans('errors.users_cannot_delete_guest'));
             return redirect($user->getEditUrl());
         }
 
         $this->userRepo->destroy($user);
-        session()->flash('success', trans('settings.users_delete_success'));
+        $this->showSuccessNotification(trans('settings.users_delete_success'));
 
         return redirect('/settings/users');
     }
@@ -261,7 +261,7 @@ class UserController extends Controller
         $user = $this->userRepo->getById($id);
 
         $userActivity = $this->userRepo->getActivity($user);
-        $recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5, 0);
+        $recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5);
         $assetCounts = $this->userRepo->getAssetCounts($user);
 
         return view('users.profile', [
index 004f301396528db78ee9c4d4a0b1995cf126a9da..f9752da09d6492430dd2fe2f4c131dcac301aedf 100644 (file)
@@ -32,7 +32,8 @@ class Kernel extends HttpKernel
             \Illuminate\Routing\Middleware\ThrottleRequests::class,
             \BookStack\Http\Middleware\VerifyCsrfToken::class,
             \Illuminate\Routing\Middleware\SubstituteBindings::class,
-            \BookStack\Http\Middleware\Localization::class
+            \BookStack\Http\Middleware\Localization::class,
+            \BookStack\Http\Middleware\GlobalViewData::class,
         ],
         'api' => [
             'throttle:60,1',
diff --git a/app/Http/Middleware/GlobalViewData.php b/app/Http/Middleware/GlobalViewData.php
new file mode 100644 (file)
index 0000000..bc132df
--- /dev/null
@@ -0,0 +1,27 @@
+<?php namespace BookStack\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+/**
+ * Class GlobalViewData
+ * Sets up data that is accessible to any view rendered by the web routes.
+ */
+class GlobalViewData
+{
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param Request $request
+     * @param Closure $next
+     * @return mixed
+     */
+    public function handle(Request $request, Closure $next)
+    {
+        view()->share('signedIn', auth()->check());
+        view()->share('currentUser', user());
+
+        return $next($request);
+    }
+}
index b5e702781ef53b6d7bd28b76cbe5ca42728a283b..8fe4ca9906c7f62ab61ece1df2f18ce5d0150f08 100644 (file)
@@ -37,6 +37,7 @@ class Localization
         'uk' => 'uk_UA',
         'zh_CN' => 'zh_CN',
         'zh_TW' => 'zh_TW',
+        'tr' => 'tr_TR',
     ];
 
     /**
index e7bde5290490fc5d1fc06bf01c0c91c6de2afb55..b4158187cd5fe5225b68aa013d033d67d05edb30 100644 (file)
@@ -4,6 +4,7 @@ namespace BookStack\Providers;
 
 use BookStack\Actions\ActivityService;
 use BookStack\Actions\ViewService;
+use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Settings\SettingService;
 use BookStack\Uploads\ImageService;
 use Illuminate\Support\ServiceProvider;
@@ -27,20 +28,24 @@ class CustomFacadeProvider extends ServiceProvider
      */
     public function register()
     {
-        $this->app->bind('activity', function () {
+        $this->app->singleton('activity', function () {
             return $this->app->make(ActivityService::class);
         });
 
-        $this->app->bind('views', function () {
+        $this->app->singleton('views', function () {
             return $this->app->make(ViewService::class);
         });
 
-        $this->app->bind('setting', function () {
+        $this->app->singleton('setting', function () {
             return $this->app->make(SettingService::class);
         });
 
-        $this->app->bind('images', function () {
+        $this->app->singleton('images', function () {
             return $this->app->make(ImageService::class);
         });
+
+        $this->app->singleton('permissions', function () {
+            return $this->app->make(PermissionService::class);
+        });
     }
 }
index 82ea2b6f6db642d4c2e41e16a2b91ee82359b368..8816615cf69986b75a3713d02e80b5a283221d13 100644 (file)
@@ -4,7 +4,7 @@ ENV APACHE_DOCUMENT_ROOT /app/public
 WORKDIR /app
 
 RUN apt-get update -y \
-    && apt-get install -y libtidy-dev libpng-dev libldap2-dev libxml++2.6-dev wait-for-it \
+    && apt-get install -y git zip unzip libtidy-dev libpng-dev libldap2-dev libxml++2.6-dev wait-for-it \
     && docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
     && docker-php-ext-install pdo pdo_mysql tidy dom xml mbstring gd ldap \
     && a2enmod rewrite \
index 7de7233af02ee72c4eb2dc099a2c3e4cbe4880b3..9f83e95ff238ea30a9672077b9fef6add7a885ab 100644 (file)
@@ -29,7 +29,7 @@
         <server name="DB_CONNECTION" value="mysql_testing"/>
         <server name="BCRYPT_ROUNDS" value="4"/>
         <server name="MAIL_DRIVER" value="array"/>
-        <server name="LOG_CHANNEL" value="null"/>
+        <server name="LOG_CHANNEL" value="single"/>
         <server name="AUTH_METHOD" value="standard"/>
         <server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
         <server name="AVATAR_URL" value=""/>
index c89a7ad8be87c41673c5e998bdef200f8bbe8f19..de256a8466a727add893f5370167f3db4ef34320 100644 (file)
@@ -30,6 +30,12 @@ class MarkdownEditor {
             this.displayDoc = this.display.contentDocument;
             this.init();
         });
+
+        window.$events.emitPublic(elem, 'editor-markdown::setup', {
+            markdownIt: this.markdown,
+            displayEl: this.display,
+            codeMirrorInstance: this.cm,
+        });
     }
 
     init() {
index cabce91396082cf1944153e274d590643ed25742..5d8d169589804a5c92fd355281353bdcdaf6ed88 100644 (file)
@@ -26,10 +26,12 @@ class PageComments {
 
     handleAction(event) {
         let actionElem = event.target.closest('[action]');
+
         if (event.target.matches('a[href^="#"]')) {
             const id = event.target.href.split('#')[1];
             scrollAndHighlightElement(document.querySelector('#' + id));
         }
+
         if (actionElem === null) return;
         event.preventDefault();
 
index c03c0d2aa4e8bba75b8206e7060d50bb4dc26f06..60a6743ea7679bdebb7100bd2c047e1b26101166 100644 (file)
@@ -412,6 +412,7 @@ class WysiwygEditor {
         this.loadPlugins();
 
         this.tinyMceConfig = this.getTinyMceConfig();
+        window.$events.emitPublic(elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
         window.tinymce.init(this.tinyMceConfig);
     }
 
@@ -654,6 +655,8 @@ class WysiwygEditor {
                 // Paste image-uploads
                 editor.on('paste', event => editorPaste(event, editor, context));
 
+                // Custom handler hook
+                window.$events.emitPublic(context.elem, 'editor-tinymce::setup', {editor});
             }
         };
     }
index 8a3e9a57b4ac1a22a932981296570468f4a960f9..b6158ea5f8fbee97872d6350f0a8fa72e9954c13 100644 (file)
@@ -1,3 +1,10 @@
+/**
+ * Used in the function below to store references of clean-up functions.
+ * Used to ensure only one transitionend function exists at any time.
+ * @type {WeakMap<object, any>}
+ */
+const animateStylesCleanupMap = new WeakMap();
+
 /**
  * Fade out the given element.
  * @param {Element} element
@@ -5,6 +12,7 @@
  * @param {Function|null} onComplete
  */
 export function fadeOut(element, animTime = 400, onComplete = null) {
+    cleanupExistingElementAnimation(element);
     animateStyles(element, {
         opacity: ['1', '0']
     }, animTime, () => {
@@ -19,6 +27,7 @@ export function fadeOut(element, animTime = 400, onComplete = null) {
  * @param {Number} animTime
  */
 export function slideUp(element, animTime = 400) {
+    cleanupExistingElementAnimation(element);
     const currentHeight = element.getBoundingClientRect().height;
     const computedStyles = getComputedStyle(element);
     const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
@@ -41,6 +50,7 @@ export function slideUp(element, animTime = 400) {
  * @param {Number} animTime - Animation time in ms
  */
 export function slideDown(element, animTime = 400) {
+    cleanupExistingElementAnimation(element);
     element.style.display = 'block';
     const targetHeight = element.getBoundingClientRect().height;
     const computedStyles = getComputedStyle(element);
@@ -56,13 +66,6 @@ export function slideDown(element, animTime = 400) {
     animateStyles(element, animStyles, animTime);
 }
 
-/**
- * Used in the function below to store references of clean-up functions.
- * Used to ensure only one transitionend function exists at any time.
- * @type {WeakMap<object, any>}
- */
-const animateStylesCleanupMap = new WeakMap();
-
 /**
  * Animate the css styles of an element using FLIP animation techniques.
  * Styles must be an object where the keys are style properties, camelcase, and the values
@@ -84,23 +87,28 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) {
         }
         element.style.transition = null;
         element.removeEventListener('transitionend', cleanup);
+        animateStylesCleanupMap.delete(element);
         if (onComplete) onComplete();
     };
 
     setTimeout(() => {
-        requestAnimationFrame(() => {
-            element.style.transition = `all ease-in-out ${animTime}ms`;
-            for (let style of styleNames) {
-                element.style[style] = styles[style][1];
-            }
+        element.style.transition = `all ease-in-out ${animTime}ms`;
+        for (let style of styleNames) {
+            element.style[style] = styles[style][1];
+        }
 
-            if (animateStylesCleanupMap.has(element)) {
-                const oldCleanup = animateStylesCleanupMap.get(element);
-                element.removeEventListener('transitionend', oldCleanup);
-            }
+        element.addEventListener('transitionend', cleanup);
+        animateStylesCleanupMap.set(element, cleanup);
+    }, 15);
+}
 
-            element.addEventListener('transitionend', cleanup);
-            animateStylesCleanupMap.set(element, cleanup);
-        });
-    }, 10);
+/**
+ * Run the active cleanup action for the given element.
+ * @param {Element} element
+ */
+function cleanupExistingElementAnimation(element) {
+    if (animateStylesCleanupMap.has(element)) {
+        const oldCleanup = animateStylesCleanupMap.get(element);
+        oldCleanup();
+    }
 }
\ No newline at end of file
index f92752109ccda2f386ebd7956d1c6f6174fb7545..70355643d51069eccfa4f261f66dcf58aacd3c72 100644 (file)
@@ -105,6 +105,7 @@ function highlightElem(elem) {
         value: content,
         mode:  mode,
         lineNumbers: true,
+        lineWrapping: false,
         theme: getTheme(),
         readOnly: true
     });
@@ -191,6 +192,7 @@ function wysiwygView(elem) {
         value: content,
         mode:  getMode(lang),
         lineNumbers: true,
+        lineWrapping: false,
         theme: getTheme(),
         readOnly: true
     });
@@ -216,8 +218,8 @@ function popupEditor(elem, modeSuggestion) {
         value: content,
         mode:  getMode(modeSuggestion),
         lineNumbers: true,
-        theme: getTheme(),
-        lineWrapping: true
+        lineWrapping: false,
+        theme: getTheme()
     });
 }
 
@@ -243,24 +245,27 @@ function setContent(cmInstance, codeContent) {
 }
 
 /**
- * Get a CodeMirror instace to use for the markdown editor.
+ * Get a CodeMirror instance to use for the markdown editor.
  * @param {HTMLElement} elem
  * @returns {*}
  */
 function markdownEditor(elem) {
-    let content = elem.textContent;
-
-    return CodeMirror(function (elt) {
-        elem.parentNode.insertBefore(elt, elem);
-        elem.style.display = 'none';
-    }, {
+    const content = elem.textContent;
+    const config = {
         value: content,
         mode: "markdown",
         lineNumbers: true,
-        theme: getTheme(),
         lineWrapping: true,
+        theme: getTheme(),
         scrollPastEnd: true,
-    });
+    };
+
+    window.$events.emitPublic(elem, 'editor-markdown-cm::pre-init', {config});
+
+    return CodeMirror(function (elt) {
+        elem.parentNode.insertBefore(elt, elem);
+        elem.style.display = 'none';
+    }, config);
 }
 
 /**
index 1f97d0cb8295ccb02774b6c4b8c4faf189fdffd7..fa3ed7fdfcb55ebd341ee44543eafd58b697d14d 100644 (file)
@@ -7,6 +7,12 @@ class Events {
         this.stack = [];
     }
 
+    /**
+     * Emit a custom event for any handlers to pick-up.
+     * @param {String} eventName
+     * @param {*} eventData
+     * @returns {Events}
+     */
     emit(eventName, eventData) {
         this.stack.push({name: eventName, data: eventData});
         if (typeof this.listeners[eventName] === 'undefined') return this;
@@ -18,11 +24,32 @@ class Events {
         return this;
     }
 
+    /**
+     * Listen to a custom event and run the given callback when that event occurs.
+     * @param {String} eventName
+     * @param {Function} callback
+     * @returns {Events}
+     */
     listen(eventName, callback) {
         if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
         this.listeners[eventName].push(callback);
         return this;
     }
+
+    /**
+     * Emit an event for public use.
+     * Sends the event via the native DOM event handling system.
+     * @param {Element} targetElement
+     * @param {String} eventName
+     * @param {Object} eventData
+     */
+    emitPublic(targetElement, eventName, eventData) {
+        const event = new CustomEvent(eventName, {
+            detail: eventData,
+            bubbles: true
+        });
+        targetElement.dispatchEvent(event);
+    }
 }
 
 export default Events;
\ No newline at end of file
index bb542a588895b9cbedc1e2b2900d3883f64869f3..026308a3451603809d7487bbf2a3bd6734a5ff92 100755 (executable)
@@ -159,8 +159,9 @@ return [
         'ru' => 'Русский',
         'uk' => 'Українська',
         'zh_CN' => '简体中文',
-       'zh_TW' => '繁體中文',
-       'hu' => 'Magyar'
+        'zh_TW' => '繁體中文',
+        'hu' => 'Magyar',
+        'tr' => 'Türkçe',
     ]
     //!////////////////////////////////
 ];
index 556903cb94c9fe4b2b1b0e276408fb1e0359ce6a..1df55cdaebae60cf01438deb06095d15ad7de5be 100644 (file)
@@ -36,13 +36,41 @@ return [
     'digits'               => ':attribute debe ser de :digits dígitos.',
     'digits_between'       => ':attribute debe ser un valor entre :min y :max dígios.',
     'email'                => ':attribute debe ser una dirección álida.',
+    'ends_with'            => 'El :attribute debe terminar con uno de los siguientes: :values',
     'filled'               => 'El campo :attribute es requerido.',
+    'gt'                   => [
+        'numeric' => 'El :attribute debe ser mayor que :value.',
+        'file'    => 'El :attribute debe ser mayor que :value kilobytes.',
+        'string'  => 'El :attribute debe ser mayor que :value caracteres.',
+        'array'   => 'El :attribute debe tener más de :value objetos.',
+    ],
+    'gte'                  => [
+        'numeric' => 'El :attribute debe ser mayor o igual a :value.',
+        'file'    => 'El :attribute debe ser mayor o igual a :value kilobytes.',
+        'string'  => 'El :attribute debe ser mayor o igual a :value caracteres.',
+        'array'   => 'El :attribute debe tener :value objetos o más.',
+    ],
     'exists'               => 'El :attribute seleccionado es inválido.',
     'image'                => 'El :attribute debe ser una imagen.',
     'in'                   => 'El selected :attribute es inválio.',
     'image_extension'      => 'El :attribute debe tener una extensión de imagen válida y soportada.',
     'integer'              => 'El :attribute debe ser un entero.',
     'ip'                   => 'El :attribute debe ser una dirección IP álida.',
+    'ipv4'                 => 'El :attribute debe ser una dirección IPv4 válida.',
+    'ipv6'                 => 'El :attribute debe ser una dirección IPv6 válida.',
+    'json'                 => 'El :attribute debe ser una cadena JSON válida.',
+    'lt'                   => [
+        'numeric' => 'El :attribute debe ser menor que :value.',
+        'file'    => 'El :attribute debe ser menor que :value kilobytes.',
+        'string'  => 'El :attribute debe ser menor que :value caracteres.',
+        'array'   => 'El :attribute debe tener menos de :value objetos.',
+    ],
+    'lte'                  => [
+        'numeric' => 'El :attribute debe ser menor o igual a :value.',
+        'file'    => 'El :attribute debe ser menor o igual a :value kilobytes.',
+        'string'  => 'El :attribute debe ser menor o igual a :value caracteres.',
+        'array'   => 'El :attribute no debe tener más de :value objetos.',
+    ],
     'max'                  => [
         'numeric' => ':attribute no puede ser mayor que :max.',
         'file'    => ':attribute no puede ser mayor que :max kilobytes.',
@@ -57,7 +85,8 @@ return [
         'array'   => ':attribute debe tener como mínimo :min items.',
     ],
     'no_double_extension'  => 'El :attribute debe tener una única extensión de archivo.',
-    'not_in'               => ':attribute seleccionado es inválio.',
+    'not_in'               => ':attribute seleccionado es inválido.',
+    'not_regex'            => 'El formato de :attribute es inválido.',
     'numeric'              => ':attribute debe ser numérico.',
     'regex'                => ':attribute con formato inválido',
     'required'             => ':attribute es requerido.',
index 89908c8c462efd62695c1c0e3f4b9899630869d5..8b4bb1f52d2e78c794db9998aef1590928654b7b 100644 (file)
@@ -28,19 +28,19 @@ return [
     'password' => 'Mot de passe',
     'password_confirm' => 'Confirmez le mot de passe',
     'password_hint' => 'Doit faire plus de 7 caractères',
-    'forgot_password' => 'Mot de passe oublié ?',
+    'forgot_password' => 'Mot de passe oublié ?',
     'remember_me' => 'Se souvenir de moi',
-    'ldap_email_hint' => "Merci d'entrer une adresse e-mail pour ce compte",
+    'ldap_email_hint' => "Merci d'entrer une adresse e-mail pour ce compte.",
     'create_account' => 'Créer un compte',
-    'already_have_account' => 'Vous avez déjà un compte?',
-    'dont_have_account' => 'Vous n\'avez pas de compte?',
-    'social_login' => 'Social Login',
-    'social_registration' => 'Enregistrement Social',
-    'social_registration_text' => "S'inscrire et se connecter avec un réseau social",
+    'already_have_account' => 'Vous avez déjà un compte ?',
+    'dont_have_account' => 'Vous n\'avez pas de compte ?',
+    'social_login' => 'Connexion avec un réseau social',
+    'social_registration' => 'Inscription avec un réseau social',
+    'social_registration_text' => "S'inscrire et se connecter avec un réseau social.",
 
-    'register_thanks' => 'Merci pour votre enregistrement',
+    'register_thanks' => 'Merci pour votre inscription !',
     'register_confirm' => 'Vérifiez vos e-mails et cliquez sur le lien de confirmation pour rejoindre :appName.',
-    'registrations_disabled' => "L'inscription est désactivée pour le moment",
+    'registrations_disabled' => "Les inscriptions sont désactivées pour le moment",
     'registration_email_domain_invalid' => 'Cette adresse e-mail ne peut pas accéder à l\'application',
     'register_success' => 'Merci pour votre inscription. Vous êtes maintenant inscrit(e) et connecté(e)',
 
@@ -48,14 +48,14 @@ return [
     /**
      * Password Reset
      */
-    'reset_password' => 'Reset Password',
-    'reset_password_send_instructions' => 'Entrez votre adresse e-mail ci-dessous et un e-mail avec un lien de réinitialisation de mot de passe vous sera envoyé',
+    'reset_password' => 'Réinitialiser le mot de passe',
+    'reset_password_send_instructions' => 'Entrez votre adresse e-mail ci-dessous et un e-mail avec un lien de réinitialisation de mot de passe vous sera envoyé.',
     'reset_password_send_button' => 'Envoyer un lien de réinitialisation',
     'reset_password_sent_success' => 'Un lien de réinitialisation a été envoyé à :email.',
     'reset_password_success' => 'Votre mot de passe a été réinitialisé avec succès.',
 
     'email_reset_subject' => 'Réinitialisez votre mot de passe pour :appName',
-    'email_reset_text' => 'Vous recevez cet e-mail parce que nous avons reçu une demande de réinitialisation pour votre compte',
+    'email_reset_text' => 'Vous recevez cet e-mail parce que nous avons reçu une demande de réinitialisation pour votre compte.',
     'email_reset_not_requested' => 'Si vous n\'avez pas effectué cette demande, vous pouvez ignorer cet e-mail.',
 
 
@@ -63,11 +63,11 @@ return [
      * Email Confirmation
      */
     'email_confirm_subject' => 'Confirmez votre adresse e-mail pour :appName',
-    'email_confirm_greeting' => 'Merci d\'avoir rejoint :appName !',
-    'email_confirm_text' => 'Merci de confirmer en cliquant sur le lien ci-dessous :',
+    'email_confirm_greeting' => 'Merci d\'avoir rejoint :appName !',
+    'email_confirm_text' => 'Merci de confirmer en cliquant sur le lien ci-dessous :',
     'email_confirm_action' => 'Confirmez votre adresse e-mail',
     'email_confirm_send_error' => 'La confirmation par e-mail est requise mais le système n\'a pas pu envoyer l\'e-mail. Contactez l\'administrateur système.',
-    'email_confirm_success' => 'Votre adresse e-mail a été confirmée !',
+    'email_confirm_success' => 'Votre adresse e-mail a été confirmée !',
     'email_confirm_resent' => 'L\'e-mail de confirmation a été ré-envoyé. Vérifiez votre boîte de récéption.',
 
     'email_not_confirmed' => 'Adresse e-mail non confirmée',
@@ -75,4 +75,14 @@ return [
     'email_not_confirmed_click_link' => 'Merci de cliquer sur le lien dans l\'e-mail qui vous a été envoyé après l\'enregistrement.',
     'email_not_confirmed_resend' => 'Si vous ne retrouvez plus l\'e-mail, vous pouvez renvoyer un e-mail de confirmation en utilisant le formulaire ci-dessous.',
     'email_not_confirmed_resend_button' => 'Renvoyez l\'e-mail de confirmation',
+
+    // User Invite
+    'user_invite_email_subject' => 'Vous avez été invité(e) à rejoindre :appName !',
+    'user_invite_email_greeting' => 'Un compte vous a été créé sur :appName.',
+    'user_invite_email_text' => 'Cliquez sur le bouton ci-dessous pour renseigner le mot de passe et récupérer l\'accès :',
+    'user_invite_email_action' => 'Renseignez le mot de passe de votre compte',
+    'user_invite_page_welcome' => 'Bienvenue dans :appName !',
+    'user_invite_page_text' => 'Pour finaliser votre compte et recevoir l\'accès, vous devez renseigner le mot de passe qui sera utilisé pour la connexion à :appName les prochaines fois.',
+    'user_invite_page_confirm_button' => 'Confirmez le mot de passe',
+    'user_invite_success' => 'Mot de passe renseigné, vous avez maintenant accès à :appName !'
 ];
index 1cf6e716f87f3e3962adae680ed868e96528ace6..ae33ef3eb5e428e3a4a761a96a7c47bb01a36bff 100644 (file)
@@ -20,7 +20,7 @@ return [
     'description' => 'Description',
     'role' => 'Rôle',
     'cover_image' => 'Image de couverture',
-    'cover_image_description' => 'Cette image doit être environ 440x250px.',
+    'cover_image_description' => 'Cette image doit faire environ 440x250 px.',
     
     /**
      * Actions
@@ -45,6 +45,10 @@ return [
     /**
      * Sort Options
      */
+    'sort_options' => 'Options de tri',
+    'sort_direction_toggle' => 'Inverser la direction du tri',
+    'sort_ascending' => 'Tri ascendant',
+    'sort_descending' => 'Tri descendant',
     'sort_name' => 'Nom',
     'sort_created_at' => 'Date de création',
     'sort_updated_at' => 'Date de mise à jour',
@@ -62,16 +66,22 @@ return [
     'grid_view' => 'Vue en grille',
     'list_view' => 'Vue en liste',
     'default' => 'Défaut',
+    'breadcrumb' => 'Fil d\'Ariane',
 
     /**
      * Header
      */
+    'profile_menu' => 'Menu du profil',
     'view_profile' => 'Voir le profil',
     'edit_profile' => 'Modifier le profil',
 
+    // Layout tabs
+    'tab_info' => 'Info',
+    'tab_content' => 'Contenu',
+
     /**
      * Email Content
      */
-    'email_action_help' => 'Si vous rencontrez des problèmes pour cliquer sur le bouton ":actionText", copiez et collez l\'adresse ci-dessous dans votre navigateur :',
+    'email_action_help' => 'Si vous rencontrez des problèmes pour cliquer sur le bouton ":actionText", copiez et collez l\'adresse ci-dessous dans votre navigateur :',
     'email_rights' => 'Tous droits réservés',
 ];
index 4ba1a36e3df9ceb8b059e61c16b43bff91f85e9c..958a162da04f6a9726c792d3e93d1eff5dd3b9f6 100644 (file)
@@ -92,7 +92,7 @@ return [
     'shelves_delete' => 'Supprimer l\'étagère',
     'shelves_delete_named' => 'Supprimer l\'étagère :name',
     'shelves_delete_explain' => "Ceci va supprimer l\'étagère nommée \':bookName\'. Les livres contenus dans cette étagère ne seront pas supprimés.",
-    'shelves_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer cette étagère ?',
+    'shelves_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer cette étagère ?',
     'shelves_permissions' => 'Permissions de l\'étagère',
     'shelves_permissions_updated' => 'Permissions de l\'étagère mises à jour',
     'shelves_permissions_active' => 'Permissions de l\'étagère activées',
@@ -118,7 +118,7 @@ return [
     'books_delete' => 'Supprimer un livre',
     'books_delete_named' => 'Supprimer le livre :bookName',
     'books_delete_explain' => 'Ceci va supprimer le livre nommé \':bookName\', tous les chapitres et pages seront supprimés.',
-    'books_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer ce livre ?',
+    'books_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer ce livre ?',
     'books_edit' => 'Modifier le livre',
     'books_edit_named' => 'Modifier le livre :bookName',
     'books_form_book_name' => 'Nom du livre',
@@ -155,7 +155,7 @@ return [
     'chapters_delete' => 'Supprimer le chapitre',
     'chapters_delete_named' => 'Supprimer le chapitre :chapterName',
     'chapters_delete_explain' => 'Ceci va supprimer le chapitre \':chapterName\', toutes les pages seront déplacées dans le livre parent.',
-    'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre ?',
+    'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre ?',
     'chapters_edit' => 'Modifier le chapitre',
     'chapters_edit_named' => 'Modifier le chapitre :chapterName',
     'chapters_save' => 'Enregistrer le chapitre',
@@ -184,8 +184,8 @@ return [
     'pages_delete_draft' => 'Supprimer le brouillon',
     'pages_delete_success' => 'Page supprimée',
     'pages_delete_draft_success' => 'Brouillon supprimé',
-    'pages_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cette page ?',
-    'pages_delete_draft_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce brouillon ?',
+    'pages_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cette page ?',
+    'pages_delete_draft_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce brouillon ?',
     'pages_editing_named' => 'Modification de la page :pageName',
     'pages_edit_toggle_header' => 'Afficher/cacher l\'en-tête',
     'pages_edit_save_draft' => 'Enregistrer le brouillon',
@@ -239,10 +239,11 @@ return [
         'start_b' => ':userName a commencé à éditer cette page',
         'time_a' => 'depuis la dernière sauvegarde',
         'time_b' => 'dans les :minCount dernières minutes',
-        'message' => ':start :time. Attention à ne pas écraser les mises à jour de quelqu\'un d\'autre !',
+        'message' => ':start :time. Attention à ne pas écraser les mises à jour de quelqu\'un d\'autre !',
     ],
     'pages_draft_discarded' => 'Brouillon écarté, la page est dans sa version actuelle.',
     'pages_specific' => 'Page Spécifique',
+    'pages_is_template' => 'Modèle de page',
 
     /**
      * Editor sidebar
@@ -281,6 +282,12 @@ return [
     'attachments_file_uploaded' => 'Fichier ajouté avec succès',
     'attachments_file_updated' => 'Fichier mis à jour avec succès',
     'attachments_link_attached' => 'Lien attaché à la page avec succès',
+    'templates' => 'Modèles',
+    'templates_set_as_template' => 'La page est un modèle',
+    'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
+    'templates_replace_content' => 'Remplacer le contenu de la page',
+    'templates_append_content' => 'Ajouter après le contenu de la page',
+    'templates_prepend_content' => 'Ajouter devant le contenu de la page',
 
     /**
      * Profile View
@@ -299,23 +306,24 @@ return [
     'comments' => 'Commentaires',
     'comment_add' => 'Ajouter un commentaire',
     'comment_placeholder' => 'Entrez vos commentaires ici',
-    'comment_count' => '{0} Pas de commentaires|{1} 1 Commentaire|[2,*] :count Commentaires',
+    'comment_count' => '{0} Pas de commentaires|{1} Un commentaire|[2,*] :count commentaires',
     'comment_save' => 'Enregistrer le commentaire',
-    'comment_saving' => 'Enregistrement du commentaire...',
-    'comment_deleting' => 'Suppression du commentaire...',
+    'comment_saving' => 'Enregistrement du commentaire',
+    'comment_deleting' => 'Suppression du commentaire',
     'comment_new' => 'Nouveau commentaire',
     'comment_created' => 'commenté :createDiff',
     'comment_updated' => 'Mis à jour :updateDiff par :username',
     'comment_deleted_success' => 'Commentaire supprimé',
     'comment_created_success' => 'Commentaire ajouté',
     'comment_updated_success' => 'Commentaire mis à jour',
-    'comment_delete_confirm' => 'Etes-vous sûr de vouloir supprimer ce commentaire ?',
+    'comment_delete_confirm' => 'Etes-vous sûr de vouloir supprimer ce commentaire ?',
     'comment_in_reply_to' => 'En réponse à :commentId',
 
      /**
      * Revision
      */
-    'revision_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer cette révision?',
+    'revision_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer cette révision ?',
+    'revision_restore_confirm' => 'Êtes-vous sûr de vouloir restaurer cette révision ? Le contenu courant de la page va être remplacé.',
     'revision_delete_success' => 'Révision supprimée',
     'revision_cannot_delete_latest' => 'Impossible de supprimer la dernière révision.'
 ];
index e758f6e5e5c7da99c4a3d663dc6430efac561b6c..ecb751f25c0681fc89cc8047d700cd5b37196c7f 100644 (file)
@@ -20,7 +20,7 @@ return [
     'ldap_extension_not_installed' => 'L\'extension LDAP PHP n\'est pas installée',
     'ldap_cannot_connect' => 'Impossible de se connecter au serveur LDAP, la connexion initiale a échoué',
     'social_no_action_defined' => 'Pas d\'action définie',
-    'social_login_bad_response' => "Erreur pendant la tentative de connexion à :socialAccount : \n:error",
+    'social_login_bad_response' => "Erreur pendant la tentative de connexion à :socialAccount : \n:error",
     'social_account_in_use' => 'Ce compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
     'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
     'social_account_existing' => 'Ce compte :socialAccount est déjà rattaché à votre profil.',
@@ -29,6 +29,7 @@ return [
     'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez le lier avec l\'option :socialAccount.',
     'social_driver_not_found' => 'Pilote de compte social absent',
     'social_driver_not_configured' => 'Vos préférences pour le compte :socialAccount sont incorrectes.',
+    'invite_token_expired' => 'Le lien de cette invitation a expiré. Vous pouvez essayer de réinitiliser votre mot de passe.',
 
     // System
     'path_not_writable' => 'Impossible d\'écrire dans :filePath. Assurez-vous d\'avoir les droits d\'écriture sur le serveur',
@@ -66,6 +67,7 @@ return [
     'role_cannot_be_edited' => 'Ce rôle ne peut pas être modifié',
     'role_system_cannot_be_deleted' => 'Ceci est un rôle du système et ne peut pas être supprimé',
     'role_registration_default_cannot_delete' => 'Ce rôle ne peut pas être supprimé tant qu\'il est le rôle par défaut',
+    'role_cannot_remove_only_admin' => 'Ceci est le seul compte administrateur. Assignez un nouvel administrateur avant de le supprimer ici.',
 
     // Error pages
     '404_page_not_found' => 'Page non trouvée',
index 484b4b20c295c6f7862e72cfff6cf3a22e2ee172..16d5e4d2c58597d45e4bd55e54086dc679b22423 100644 (file)
@@ -16,7 +16,7 @@ return [
     'password' => 'Les mots de passe doivent faire au moins 6 caractères et correspondre à la confirmation.',
     'user' => "Nous n'avons pas trouvé d'utilisateur avec cette adresse.",
     'token' => 'Le jeton de réinitialisation est invalide.',
-    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe !',
-    'reset' => 'Votre mot de passe a été réinitialisé !',
+    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe !',
+    'reset' => 'Votre mot de passe a été réinitialisé !',
 
 ];
index f978114c5671ab558f03554bad8ed597429e3c58..1f32d746ad53895b3d4dbcace9d0e528b0d82599 100644 (file)
@@ -20,13 +20,13 @@ return [
     'app_features_security' => 'Fonctionnalités et sécurité',
     'app_name' => 'Nom de l\'application',
     'app_name_desc' => 'Ce nom est affiché dans l\'en-tête et les e-mails.',
-    'app_name_header' => 'Afficher le nom dans l\'en-tête ?',
+    'app_name_header' => 'Afficher le nom dans l\'en-tête ?',
     'app_public_access' => 'Accès public',
     'app_public_access_desc' => 'L\'activation de cette option permettra aux visiteurs, qui ne sont pas connectés, d\'accéder au contenu de votre instance BookStack.',
     'app_public_access_desc_guest' => 'L\'accès pour les visiteurs publics peut être contrôlé par l\'utilisateur "Guest".',
     'app_public_access_toggle' => 'Autoriser l\'accès public',
-    'app_public_viewing' => 'Accepter le visionnage public des pages ?',
-    'app_secure_images' => 'Activer l\'ajout d\'image sécurisé ?',
+    'app_public_viewing' => 'Accepter le visionnage public des pages ?',
+    'app_secure_images' => 'Activer l\'ajout d\'image sécurisé ?',
     'app_secure_images_toggle' => 'Activer l\'ajout d\'image sécurisé',
     'app_secure_images_desc' => 'Pour des questions de performances, toutes les images sont publiques. Cette option ajoute une chaîne aléatoire difficile à deviner dans les URLs des images.',
     'app_editor' => 'Editeur des pages',
@@ -54,7 +54,7 @@ return [
     'reg_enable_desc' => 'Lorsque l\'inscription est activée, l\'utilisateur pourra s\'enregistrer en tant qu\'utilisateur de l\'application. Lors de l\'inscription, ils se voient attribuer un rôle par défaut.',
     'reg_default_role' => 'Rôle par défaut lors de l\'inscription',
     'reg_email_confirmation' => 'Confirmation de l\'e-mail',
-    'reg_email_confirmation_toggle' => 'Obliger la confirmation par e-mail ?',
+    'reg_email_confirmation_toggle' => 'Obliger la confirmation par e-mail ?',
     'reg_confirm_email_desc' => 'Si la restriction de domaine est activée, la confirmation sera automatiquement obligatoire et cette valeur sera ignorée.',
     'reg_confirm_restrict_domain' => 'Restreindre l\'inscription à un domaine',
     'reg_confirm_restrict_domain_desc' => 'Entrez une liste de domaines acceptés lors de l\'inscription, séparés par une virgule. Les utilisateurs recevront un e-mail de confirmation à cette adresse. <br> Les utilisateurs pourront changer leur adresse après inscription s\'ils le souhaitent.',
@@ -69,9 +69,9 @@ return [
     'maint_image_cleanup_desc' => "Scan le contenu des pages et des révisions pour vérifier les images et les dessins en cours d'utilisation et lesquels sont redondant. Veuillez à faire une sauvegarde de la base de données et des images avant de lancer ceci.",
     'maint_image_cleanup_ignore_revisions' => 'Ignorer les images dans les révisions',
     'maint_image_cleanup_run' => 'Lancer le nettoyage',
-    'maint_image_cleanup_warning' => ':count images potentiellement inutilisées trouvées. Etes-vous sûr de vouloir supprimer ces images ?',
-    'maint_image_cleanup_success' => ':count images potentiellement inutilisées trouvées et supprimées !',
-    'maint_image_cleanup_nothing_found' => 'Aucune image inutilisée trouvée, rien à supprimer !',
+    'maint_image_cleanup_warning' => ':count images potentiellement inutilisées trouvées. Etes-vous sûr de vouloir supprimer ces images ?',
+    'maint_image_cleanup_success' => ':count images potentiellement inutilisées trouvées et supprimées !',
+    'maint_image_cleanup_nothing_found' => 'Aucune image inutilisée trouvée, rien à supprimer !',
     
     /**
      * Role settings
@@ -85,7 +85,7 @@ return [
     'role_delete_confirm' => 'Ceci va supprimer le rôle \':roleName\'.',
     'role_delete_users_assigned' => 'Ce rôle a :userCount utilisateurs assignés. Vous pouvez choisir un rôle de remplacement pour ces utilisateurs.',
     'role_delete_no_migration' => "Ne pas assigner de nouveau rôle",
-    'role_delete_sure' => 'Êtes-vous sûr de vouloir supprimer ce rôle ?',
+    'role_delete_sure' => 'Êtes-vous sûr de vouloir supprimer ce rôle ?',
     'role_delete_success' => 'Le rôle a été supprimé avec succès',
     'role_edit' => 'Modifier le rôle',
     'role_details' => 'Détails du rôle',
@@ -96,7 +96,7 @@ return [
     'role_manage_users' => 'Gérer les utilisateurs',
     'role_manage_roles' => 'Gérer les rôles et permissions',
     'role_manage_entity_permissions' => 'Gérer les permissions sur les livres, chapitres et pages',
-    'role_manage_own_entity_permissions' => 'Gérer les permissions de ses propres livres, chapitres, et pages',
+    'role_manage_own_entity_permissions' => 'Gérer les permissions de ses propres livres, chapitres et pages',
     'role_manage_settings' => 'Gérer les préférences de l\'application',
     'role_asset' => 'Permissions des ressources',
     'role_asset_desc' => 'Ces permissions contrôlent l\'accès par défaut des ressources dans le système. Les permissions dans les livres, les chapitres et les pages ignoreront ces permissions',
@@ -131,13 +131,13 @@ return [
     'users_delete' => 'Supprimer un utilisateur',
     'users_delete_named' => 'Supprimer l\'utilisateur :userName',
     'users_delete_warning' => 'Ceci va supprimer \':userName\' du système.',
-    'users_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?',
+    'users_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?',
     'users_delete_success' => 'Utilisateurs supprimés avec succès',
     'users_edit' => 'Modifier l\'utilisateur',
     'users_edit_profile' => 'Modifier le profil',
     'users_edit_success' => 'Utilisateur mis à jour avec succès',
     'users_avatar' => 'Avatar de l\'utilisateur',
-    'users_avatar_desc' => 'Cette image doit être un carré d\'environ 256px.',
+    'users_avatar_desc' => 'Cette image doit être un carré d\'environ 256 px.',
     'users_preferred_language' => 'Langue préférée',
     'users_preferred_language_desc' => 'Cette option changera la langue utilisée pour l\'interface utilisateur de l\'application. Ceci n\'affectera aucun contenu créé par l\'utilisateur.',
     'users_social_accounts' => 'Comptes sociaux',
index 4be55df4ff35b240cb446ef700bea86d5989b57c..04993f249fb09729512261f00cd70b538edbb39b 100644 (file)
@@ -35,13 +35,41 @@ return [
     'digits'               => ':attribute doit être de longueur :digits.',
     'digits_between'       => ':attribute doit avoir une longueur entre :min et :max.',
     'email'                => ':attribute doit être une adresse e-mail valide.',
+    'ends_with'            => ':attribute doit se terminer par une des valeurs suivantes : :values',
     'filled'               => ':attribute est un champ requis.',
+    'gt'                   => [
+        'numeric' => ':attribute doit être plus grand que :value.',
+        'file'    => ':attribute doit être plus grand que :value kilobytes.',
+        'string'  => ':attribute doit être plus grand que :value caractères.',
+        'array'   => ':attribute doit avoir plus que :value éléments.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute doit être plus grand ou égal à :value.',
+        'file'    => ':attribute doit être plus grand ou égal à :value kilobytes.',
+        'string'  => ':attribute doit être plus grand ou égal à :value caractères.',
+        'array'   => ':attribute doit avoir :value éléments ou plus.',
+    ],
     'exists'               => 'L\'attribut :attribute est invalide.',
     'image'                => ':attribute doit être une image.',
     'image_extension'      => ':attribute doit avoir une extension d\'image valide et supportée.',
     'in'                   => 'L\'attribut :attribute est invalide.',
     'integer'              => ':attribute doit être un chiffre entier.',
     'ip'                   => ':attribute doit être une adresse IP valide.',
+    'ipv4'                 => ':attribute doit être une adresse IPv4 valide.',
+    'ipv6'                 => ':attribute doit être une adresse IPv6 valide.',
+    'json'                 => ':attribute doit être une chaine JSON valide.',
+    'lt'                   => [
+        'numeric' => ':attribute doit être plus petit que :value.',
+        'file'    => ':attribute doit être plus petit que :value kilobytes.',
+        'string'  => ':attribute doit être plus petit que :value caractères.',
+        'array'   => ':attribute doit avoir moins de :value éléments.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute doit être plus petit ou égal à :value.',
+        'file'    => ':attribute doit être plus petit ou égal à :value kilobytes.',
+        'string'  => ':attribute doit être plus petit ou égal à :value caractères.',
+        'array'   => ':attribute ne doit pas avoir plus de :value éléments.',
+    ],
     'max'                  => [
         'numeric' => ':attribute ne doit pas excéder :max.',
         'file'    => ':attribute ne doit pas excéder :max kilobytes.',
@@ -57,6 +85,7 @@ return [
     ],
     'no_double_extension'  => ':attribute ne doit avoir qu\'une seule extension de fichier.',
     'not_in'               => 'L\'attribut sélectionné :attribute est invalide.',
+    'not_regex'            => ':attribute a un format invalide.',
     'numeric'              => ':attribute doit être un nombre.',
     'regex'                => ':attribute a un format invalide.',
     'required'             => ':attribute est un champ requis.',
index c182cf22ed32057190dccc8685000ab63b809385..95f453a27a9eff2fe154cef950000276e8536c24 100755 (executable)
@@ -13,7 +13,7 @@ return [
 
     // App Settings
     'app_customization' => 'Настройки',
-    'app_features_security' => 'ФÑ\83нкÑ\86ии & Безопасность',
+    'app_features_security' => 'ФÑ\83нкÑ\86ионал & Безопасность',
     'app_name' => 'Имя приложения',
     'app_name_desc' => 'Имя отображается в заголовке email отправленных системой.',
     'app_name_header' => 'Отображать имя приложения в заголовке',
@@ -43,13 +43,13 @@ return [
 
     // Registration Settings
     'reg_settings' => 'Настройки регистрации',
-    'reg_enable' => 'РазÑ\80еÑ\88иÑ\82Ñ\8c Ñ\80егиÑ\81Ñ\82Ñ\80аÑ\86иÑ\8fÑ\8e',
+    'reg_enable' => 'Разрешить регистрацию',
     'reg_enable_toggle' => 'Разрешить регистрацию',
-    'reg_enable_desc' => 'Если регистрация разрешена, пользователь сможет зарегистрироваться в системе самомтоятельно. При регистрации назначается роль пользователя по умолчанию',
+    'reg_enable_desc' => 'Если регистрация разрешена, пользователь сможет зарегистрироваться в системе самостоятельно. При регистрации назначается роль пользователя по умолчанию',
     'reg_default_role' => 'Роль пользователя по умолчанию после регистрации',
     'reg_email_confirmation' => 'Подтверждение электонной почты',
     'reg_email_confirmation_toggle' => 'Требовать подтверждение по электронной почте',
-    'reg_confirm_email_desc' => 'Ð\95Ñ\81ли Ð¸Ñ\81полÑ\8cзÑ\83еÑ\82Ñ\81Ñ\8f Ð¾Ð³Ñ\80аниÑ\87ение Ð¿Ð¾ Ð´Ð¾Ð¼ÐµÐ½Ñ\83, Ð¿Ð¾Ð´Ñ\82веÑ\80ждение Ð±Ñ\83деÑ\82 Ð¾Ð±Ñ\8fзаÑ\82елÑ\8cно, Ð° Ñ\8dÑ\82оÑ\82 Ð¿Ñ\83нкÑ\82 Ð¿Ñ\80оигноÑ\80иÑ\80ован.',
+    'reg_confirm_email_desc' => 'Ð\9fÑ\80и Ð¸Ñ\81полÑ\8cзовании Ð¾Ð³Ñ\80аниÑ\87ениÑ\8f Ð¿Ð¾ Ð´Ð¾Ð¼ÐµÐ½Ñ\83 - Ð¿Ð¾Ð´Ñ\82веÑ\80ждение Ð¾Ð±Ñ\8fзаÑ\82елÑ\8cно, Ñ\8dÑ\82оÑ\82 Ð¿Ñ\83нкÑ\82 Ð¸Ð³Ð½Ð¾Ñ\80иÑ\80Ñ\83еÑ\82Ñ\81Ñ\8f.',
     'reg_confirm_restrict_domain' => 'Ограничить регистрацию по домену',
     'reg_confirm_restrict_domain_desc' => 'Введите список доменов почты через запятую, для которых разрешена регистрация. Пользователям будет отправлено письмо для подтверждения адреса перед входом в приложение. <br> Обратите внимание, что пользователи смогут изменить свои адреса уже после регистрации.',
     'reg_confirm_restrict_domain_placeholder' => 'Без ограничений',
diff --git a/resources/lang/tr/activities.php b/resources/lang/tr/activities.php
new file mode 100644 (file)
index 0000000..ccc55e1
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'saya oluşturuldu',
+    'page_create_notification'    => 'Sayfa Başarıyla Oluşturuldu',
+    'page_update'                 => 'sayfa güncellendi',
+    'page_update_notification'    => 'Sayfa Başarıyla Güncellendi',
+    'page_delete'                 => 'sayfa silindi',
+    'page_delete_notification'    => 'Sayfa Başarıyla Silindi',
+    'page_restore'                => 'sayfa kurtarıldı',
+    'page_restore_notification'   => 'Sayfa Başarıyla Kurtarıldı',
+    'page_move'                   => 'sayfa taşındı',
+
+    // Chapters
+    'chapter_create'              => 'bölüm oluşturuldu',
+    'chapter_create_notification' => 'Bölüm Başarıyla Oluşturuldu',
+    'chapter_update'              => 'bölüm güncellendi',
+    'chapter_update_notification' => 'Bölüm Başarıyla Güncellendi',
+    'chapter_delete'              => 'bölüm silindi',
+    'chapter_delete_notification' => 'Bölüm Başarıyla Silindi',
+    'chapter_move'                => 'bölüm taşındı',
+
+    // Books
+    'book_create'                 => 'kitap oluşturuldu',
+    'book_create_notification'    => 'Kitap Başarıyla Oluşturuldu',
+    'book_update'                 => 'kitap güncellendi',
+    'book_update_notification'    => 'Kitap Başarıyla Güncellendi',
+    'book_delete'                 => 'kitap silindi',
+    'book_delete_notification'    => 'Kitap Başarıyla Silindi',
+    'book_sort'                   => 'kitap düzenlendi',
+    'book_sort_notification'      => 'Kitap Başarıyla Yeniden Sıralandı',
+
+    // Bookshelves
+    'bookshelf_create'            => 'kitaplık oluşturuldu',
+    'bookshelf_create_notification'    => 'Kitaplık Başarıyla Oluşturuldu',
+    'bookshelf_update'                 => 'kitaplık güncellendi',
+    'bookshelf_update_notification'    => 'Kitaplık Başarıyla Güncellendi',
+    'bookshelf_delete'                 => 'kitaplık silindi',
+    'bookshelf_delete_notification'    => 'Kitaplık Başarıyla Silindi',
+
+    // Other
+    'commented_on'                => 'yorum yaptı',
+];
diff --git a/resources/lang/tr/auth.php b/resources/lang/tr/auth.php
new file mode 100644 (file)
index 0000000..1988c01
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Authentication Language Lines
+ * The following language lines are used during authentication for various
+ * messages that we need to display to the user.
+ */
+return [
+
+    'failed' => 'Girilen bilgiler bizdeki kayıtlarla uyuşmuyor.',
+    'throttle' => 'Çok fazla giriş yapmaya çalıştınız. Lütfen :seconds saniye içinde tekrar deneyin.',
+
+    // Login & Register
+    'sign_up' => 'Kayıt Ol',
+    'log_in' => 'Giriş Yap',
+    'log_in_with' => ':socialDriver ile giriş yap',
+    'sign_up_with' => ':socialDriver ile kayıt ol',
+    'logout' => 'Çıkış Yap',
+
+    'name' => 'İsim',
+    'username' => 'Kullanıcı Adı',
+    'email' => 'Email',
+    'password' => 'Şifre',
+    'password_confirm' => 'Şifreyi onayla',
+    'password_hint' => 'En az 5 karakter olmalı',
+    'forgot_password' => 'Şifrenizi mi unuttunuz?',
+    'remember_me' => 'Beni Hatırla',
+    'ldap_email_hint' => 'Hesabı kullanmak istediğiniz e-mail adresinizi giriniz.',
+    'create_account' => 'Hesap Oluştur',
+    'already_have_account' => 'Zaten bir hesabınız var mı?',
+    'dont_have_account' => 'Hesabınız yok mu?',
+    'social_login' => 'Diğer Servisler ile Giriş Yap',
+    'social_registration' => 'Diğer Servisler ile Kayıt Ol',
+    'social_registration_text' => 'Diğer servisler ile kayıt ol ve giriş yap.',
+
+    'register_thanks' => 'Kayıt olduğunuz için teşekkürler!',
+    'register_confirm' => 'Lütfen e-posta adresinizi kontrol edin ve gelen doğrulama bağlantısına tıklayınız. :appName.',
+    'registrations_disabled' => 'Kayıt olma özelliği geçici olarak kısıtlanmıştır',
+    'registration_email_domain_invalid' => 'Bu e-mail sağlayıcısının bu uygulamaya erişim izni yoktur.',
+    'register_success' => 'Artık kayıtlı bir kullanıcı olarak giriş yaptınız.',
+
+
+    // Password Reset
+    'reset_password' => 'Parolayı Sıfırla',
+    'reset_password_send_instructions' => 'Aşağıya e-mail adresinizi girdiğinizde parola yenileme bağlantısı mail adresinize gönderilecektir.',
+    'reset_password_send_button' => '>Sıfırlama Bağlantısını Gönder',
+    'reset_password_sent_success' => 'Sıfırlama bağlantısı :email adresinize gönderildi.',
+    'reset_password_success' => 'Parolanız başarıyla sıfırlandı.',
+    'email_reset_subject' => ':appName şifrenizi sıfırlayın.',
+    'email_reset_text' => ' Parola sıfırlama isteğinde bulunduğunuz için bu maili görüntülüyorsunuz.',
+    'email_reset_not_requested' => 'Eğer bu parola sıfırlama isteğinde bulunmadıysanız herhangi bir işlem yapmanıza gerek yoktur.',
+
+
+    // Email Confirmation
+    'email_confirm_subject' => ':appName için girdiğiniz mail adresiniz onaylayınız',
+    'email_confirm_greeting' => ':appName\'e katıldığınız için teşekkürler!',
+    'email_confirm_text' => 'Lütfen e-mail adresinizi aşağıda bulunan butona tıklayarak onaylayınız:',
+    'email_confirm_action' => 'E-Maili Onayla',
+    'email_confirm_send_error' => 'e-mail onayı gerekli fakat sistem mail göndermeyi başaramadı. Yöneticiniz ile görüşüp kurulumlarda bir sorun olmadığını doğrulayın.',
+    'email_confirm_success' => 'e-mail adresiniz onaylandı!',
+    'email_confirm_resent' => 'Doğrulama maili gönderildi, lütfen gelen kutunuzu kontrol ediniz...',
+
+    'email_not_confirmed' => 'E-mail Adresi Doğrulanmadı',
+    'email_not_confirmed_text' => 'Sağlamış olduğunuz e-mail adresi henüz doğrulanmadı.',
+    'email_not_confirmed_click_link' => 'Lütfen kayıt olduktan kısa süre sonra size gönderilen maildeki bağlantıya tıklayın ve mail adresinizi onaylayın.',
+    'email_not_confirmed_resend' => 'Eğer gelen maili bulamadıysanız aşağıdaki formu tekrar doldurarak onay mailini kendinize tekrar gönderebilirsiniz.',
+    'email_not_confirmed_resend_button' => 'Doğrulama Mailini Yeniden Yolla',
+];
\ No newline at end of file
diff --git a/resources/lang/tr/common.php b/resources/lang/tr/common.php
new file mode 100644 (file)
index 0000000..54425ae
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'İptal',
+    'confirm' => 'Onayla',
+    'back' => 'Geri',
+    'save' => 'Kaydet',
+    'continue' => 'Devam',
+    'select' => 'Seç',
+    'toggle_all' => 'Hepsini Değiştir',
+    'more' => 'Daha Fazla',
+
+    // Form Labels
+    'name' => 'İsim',
+    'description' => 'Açıklama',
+    'role' => 'Rol',
+    'cover_image' => 'Kapak resmi',
+    'cover_image_description' => 'Bu resim yaklaşık 440x250px boyutlarında olmalıdır.',
+    
+    // Actions
+    'actions' => 'Aksiyonlar',
+    'view' => 'Görüntüle',
+    'view_all' => 'Hepsini Görüntüle',
+    'create' => 'Oluştur',
+    'update' => 'Güncelle',
+    'edit' => 'Düzenle',
+    'sort' => 'Sırala',
+    'move' => 'Taşı',
+    'copy' => 'Kopyala',
+    'reply' => 'Yanıtla',
+    'delete' => 'Sil',
+    'search' => 'Ara',
+    'search_clear' => 'Aramayı Temizle',
+    'reset' => 'Sıfırla',
+    'remove' => 'Kaldır',
+    'add' => 'Ekle',
+
+    // Sort Options
+    'sort_name' => 'İsim',
+    'sort_created_at' => 'Oluşturulma Tarihi',
+    'sort_updated_at' => 'Güncellenme Tarihi',
+
+    // Misc
+    'deleted_user' => 'Silinmiş Kullanıcı',
+    'no_activity' => 'Gösterilecek aktivite yok',
+    'no_items' => 'Kullanılabilir öge yok',
+    'back_to_top' => 'Başa dön',
+    'toggle_details' => 'Detayları değiştir',
+    'toggle_thumbnails' => 'Küçük resimleri değiştir',
+    'details' => 'Detaylar',
+    'grid_view' => 'Grid görünümü',
+    'list_view' => 'Liste görünümü',
+    'default' => 'Varsayılan',
+
+    // Header
+    'view_profile' => 'Profili Görüntüle',
+    'edit_profile' => 'Profili Düzenle',
+
+    // Layout tabs
+    'tab_info' => 'Bilgi',
+    'tab_content' => 'İçerik',
+
+    // Email Content
+    'email_action_help' => 'Eğer ":actionText" butonuna tıklamakta zorluk çekiyorsanız, aşağıda bulunan linki kopyalayıp tarayıcınıza yapıştırabilirsiniz.',
+    'email_rights' => 'Bütün hakları saklıdır',
+];
diff --git a/resources/lang/tr/components.php b/resources/lang/tr/components.php
new file mode 100644 (file)
index 0000000..d52f2d0
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Görsel Seç',
+    'image_all' => 'Tümü',
+    'image_all_title' => 'Tüm görselleri temizle',
+    'image_book_title' => 'Bu kitaba ait görselleri görüntüle',
+    'image_page_title' => 'Bu sayfaya ait görselleri görüntüle',
+    'image_search_hint' => 'Görsel adı ile ara',
+    'image_uploaded' => ':uploadedDate tarihinde yüklendi',
+    'image_load_more' => 'Daha Fazla ',
+    'image_image_name' => 'Görsel Adı',
+    'image_delete_used' => 'Bu görsel aşağıda bulunan görsellerde kullanılmış.',
+    'image_delete_confirm' => 'Gerçekten bu görseli silmek istiyorsanız sil tuşuna basınız.',
+    'image_select_image' => 'Görsel Seç',
+    'image_dropzone' => 'Görselleri buraya sürükle veya seçmek için buraya tıkla',
+    'images_deleted' => 'Görseller Silindi',
+    'image_preview' => 'Görsel Önizleme',
+    'image_upload_success' => 'Görsel başarıyla yüklendi',
+    'image_update_success' => 'Görsel başarıyla güncellendi',
+    'image_delete_success' => 'Görsel başarıyla silindi',
+    'image_upload_remove' => 'Kaldır',
+
+    // Code Editor
+    'code_editor' => 'Kodu Güncelle',
+    'code_language' => 'Kod Dil',
+    'code_content' => 'Kod İçeriği',
+    'code_save' => 'Kodu Kaydet',
+];
diff --git a/resources/lang/tr/entities.php b/resources/lang/tr/entities.php
new file mode 100644 (file)
index 0000000..698355a
--- /dev/null
@@ -0,0 +1,304 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Yakın Zamanda Oluşturuldu',
+    'recently_created_pages' => 'Yakın Zamanda Oluşturulmuş Sayfalar',
+    'recently_updated_pages' => 'Yakın Zamanda Güncellenmiş Sayfalar',
+    'recently_created_chapters' => 'Yakın Zamanda Oluşturulmuş Bölümler',
+    'recently_created_books' => 'Yakın Zamanda Olşturulmuş Kitaplar',
+    'recently_created_shelves' => 'Yakın Zamanda Oluşturulmuş Kitaplıklar',
+    'recently_update' => 'Yakın Zamanda Güncellenmiş',
+    'recently_viewed' => 'Yakın Zamanda Görüntülenmiş',
+    'recent_activity' => 'Son Hareketler',
+    'create_now' => 'Hemen bir tane oluştur',
+    'revisions' => 'Revizyonlar',
+    'meta_revision' => 'Revizyon #:revisionCount',
+    'meta_created' => 'Oluşturuldu :timeLength',
+    'meta_created_name' => ':user tarafından :timeLength tarihinde oluşturuldu',
+    'meta_updated' => 'Güncellendi :timeLength',
+    'meta_updated_name' => ':user tarafından :timeLength tarihinde güncellendi',
+    'entity_select' => 'Öğe Seçme',
+    'images' => 'Görseller',
+    'my_recent_drafts' => 'Son Taslaklarım',
+    'my_recently_viewed' => 'Son Görüntülemelerim',
+    'no_pages_viewed' => 'Herhangi bir sayfa görüntülemediniz',
+    'no_pages_recently_created' => 'Yakın zamanda bir sayfa oluşturulmadı',
+    'no_pages_recently_updated' => 'Yakın zamanda bir sayfa güncellenmedi',
+    'export' => 'Dışa Aktar',
+    'export_html' => 'Contained Web Dosyası',
+    'export_pdf' => 'PDF Dosyası',
+    'export_text' => 'Düz Metin Dosyası',
+
+    // Permissions and restrictions
+    'permissions' => 'İzinler',
+    'permissions_intro' => 'Etkinleştirildikten sonra bu izinler diğer diğer bütün izinlerden öncelikli olacaktır.',
+    'permissions_enable' => 'Özelleştirilmiş Yetkileri Etkinleştir',
+    'permissions_save' => 'İzinleri Kaydet',
+
+    // Search
+    'search_results' => 'Arama Sonuçları',
+    'search_total_results_found' => ':count sonuç bulundu |:count toplam sonuç bulundu',
+    'search_clear' => 'Aramaları Temizle',
+    'search_no_pages' => 'Bu aramayla herhangi bir sonuç eşleşmedi',
+    'search_for_term' => ':term için ara',
+    'search_more' => 'Daha Fazla Sonuç',
+    'search_filters' => 'Arama Filtreleri',
+    'search_content_type' => 'İçerik Türü',
+    'search_exact_matches' => 'Tam Eşleşmeler',
+    'search_tags' => 'Etiket Aramaları',
+    'search_options' => 'Ayarlar',
+    'search_viewed_by_me' => 'Benim tarafımdan görüntülendi',
+    'search_not_viewed_by_me' => 'Benim tarafımdan görüntülenmedi',
+    'search_permissions_set' => 'İzinler ayarlandı',
+    'search_created_by_me' => 'Benim tarafımdan oluşturuldu',
+    'search_updated_by_me' => 'Benim tarafımdan güncellendi',
+    'search_date_options' => 'Tarih Seçenekleri',
+    'search_updated_before' => 'Önce güncellendi',
+    'search_updated_after' => 'Sonra güncellendi',
+    'search_created_before' => 'Önce oluşturuldu',
+    'search_created_after' => 'Sonra oluşturuldu',
+    'search_set_date' => 'Tarih Ayarla',
+    'search_update' => 'Aramayı Güncelle',
+
+    // Shelves
+    'shelf' => 'Kitaplık',
+    'shelves' => 'Kitaplıklar',
+    'x_shelves' => ':count Kitaplık|:count Kitaplıklar',
+    'shelves_long' => 'Kitaplıklar',
+    'shelves_empty' => 'Hiç kitaplık oluşturulmadı',
+    'shelves_create' => 'Yeni Kitaplık Oluştur',
+    'shelves_popular' => 'Popüler Kitaplıklar',
+    'shelves_new' => 'Yeni Kitaplıklar',
+    'shelves_new_action' => 'Yeni Kitaplık',
+    'shelves_popular_empty' => 'En popüler kitaplıklar burada görüntülenecek.',
+    'shelves_new_empty' => 'En son oluşturulmuş kitaplıklar burada görüntülenecek.',
+    'shelves_save' => 'Kitaplığı Kaydet',
+    'shelves_books' => 'Bu kitaplıktaki kitaplar',
+    'shelves_add_books' => 'Bu kitaplığa kitap ekle',
+    'shelves_drag_books' => 'Bu kitaplığa kitap eklemek için kitapları buraya sürükle',
+    'shelves_empty_contents' => 'Bu kitaplığa henüz hiç bir kitap atanmamış',
+    'shelves_edit_and_assign' => 'Kitaplığa kitap eklemek için güncelle',
+    'shelves_edit_named' => ':name Kitaplığını Güncelle',
+    'shelves_edit' => 'Kitaplığı Güncelle',
+    'shelves_delete' => 'Kitaplığı Sil',
+    'shelves_delete_named' => ':name Kitaplığını Sil',
+    'shelves_delete_explain' => "Bu işlem :name kitaplığını silecektir. İçerdiği kitaplar silinmeyecektir.",
+    'shelves_delete_confirmation' => 'Bu kitaplığı silmek istediğinizden emin misiniz?',
+    'shelves_permissions' => 'Kitaplık İzinleri',
+    'shelves_permissions_updated' => 'Kitaplık İzinleri Güncellendi',
+    'shelves_permissions_active' => 'Kitaplık İzinleri Aktif',
+    'shelves_copy_permissions_to_books' => 'İzinleri Kitaplara Kopyala',
+    'shelves_copy_permissions' => 'İzinleri Kopyala',
+    'shelves_copy_permissions_explain' => 'Bu işlem sonucunda kitaplığınızın izinleri içerdiği kitaplara da aynen uygulanır. Aktifleştirmeden bu kitaplığa ait izinleri kaydettiğinizden emin olun.',
+    'shelves_copy_permission_success' => 'Kitaplık izinleri :count adet kitaba kopyalandı',
+
+    // Books
+    'book' => 'Kitap',
+    'books' => 'Kitaplar',
+    'x_books' => ':count Kitap|:count Kitaplar',
+    'books_empty' => 'Hiç kitap oluşturulmadı',
+    'books_popular' => 'Popüler Kitaplar',
+    'books_recent' => 'En Son Kitaplar',
+    'books_new' => 'Yeni Kitaplar',
+    'books_new_action' => 'Yeni Kitap',
+    'books_popular_empty' => 'En popüler kitaplar burada görüntülenecek.',
+    'books_new_empty' => 'En yeni kitaplar burada görüntülenecek.',
+    'books_create' => 'Yeni Kitap Oluştur',
+    'books_delete' => 'Kitabı Sil',
+    'books_delete_named' => ':bookName kitabını sil',
+    'books_delete_explain' => 'Bu işlem \':bookName\' kitabını silecek. Bütün sayfa ve bölümler silinecektir.',
+    'books_delete_confirmation' => 'Bu kitabı silmek istediğinizden emin misiniz?',
+    'books_edit' => 'Kitabı Güncelle',
+    'books_edit_named' => ':bookName Kitabını Güncelle',
+    'books_form_book_name' => 'Kitap Adı',
+    'books_save' => 'Kitabı Kaydet',
+    'books_permissions' => 'Kitap İzinleri',
+    'books_permissions_updated' => 'Kitap İzinleri Güncellendi',
+    'books_empty_contents' => 'Bu kitaba ait sayfa veya bölüm oluşturulmadı.',
+    'books_empty_create_page' => 'Yeni sayfa oluştur',
+    'books_empty_sort_current_book' => 'Mevcut kitabı sırala',
+    'books_empty_add_chapter' => 'Yeni bölüm ekle',
+    'books_permissions_active' => 'Kitap İzinleri Aktif',
+    'books_search_this' => 'Bu kitapta ara',
+    'books_navigation' => 'Kitap Navigasyonu',
+    'books_sort' => 'Kitap İçeriklerini Sırala',
+    'books_sort_named' => ':bookName Kitabını Sırala',
+    'books_sort_name' => 'İsme Göre Sırala',
+    'books_sort_created' => 'Oluşturulma Tarihine Göre Sırala',
+    'books_sort_updated' => 'Güncellenme Tarihine Göre Sırala',
+    'books_sort_chapters_first' => 'Önce Bölümler',
+    'books_sort_chapters_last' => 'En Son Bölümler',
+    'books_sort_show_other' => 'Diğer Kitapları Göster',
+    'books_sort_save' => 'Yeni Düzeni Kaydet',
+
+    // Chapters
+    'chapter' => 'Bölüm',
+    'chapters' => 'Bölümler',
+    'x_chapters' => ':count Bölüm|:count Bölümler',
+    'chapters_popular' => 'Popüler Bölümler',
+    'chapters_new' => 'Yeni Bölüm',
+    'chapters_create' => 'Yeni Bölüm Oluştur',
+    'chapters_delete' => 'Bölümü Sil',
+    'chapters_delete_named' => ':chapterName Bölümünü Sil',
+    'chapters_delete_explain' => 'Bu işlem \':chapterName\' kitabını silecek. Bütün sayfalar silinecek ve direkt olarak ana kitab eklenecektir.',
+    'chapters_delete_confirm' => 'Bölümü silmek istediğinizden emin misiniz?',
+    'chapters_edit' => 'Bölümü Güncelle',
+    'chapters_edit_named' => ':chapterName Bölümünü Güncelle',
+    'chapters_save' => 'Bölümü Kaydet',
+    'chapters_move' => 'Bölümü Taşı',
+    'chapters_move_named' => ':chapterName Bölümünü Taşı',
+    'chapter_move_success' => 'Bölüm :bookName Kitabına Taşındı',
+    'chapters_permissions' => 'Bölüm İzinleri',
+    'chapters_empty' => 'Bu bölümde henüz bir sayfa yok.',
+    'chapters_permissions_active' => 'Bölüm İzinleri Aktif',
+    'chapters_permissions_success' => 'Bölüm İzinleri Güncellendi',
+    'chapters_search_this' => 'Bu bölümü ara',
+
+    // Pages
+    'page' => 'Sayfa',
+    'pages' => 'Sayfalar',
+    'x_pages' => ':count Sayfa|:count Sayfalar',
+    'pages_popular' => 'Popüler Sayfalar',
+    'pages_new' => 'Yeni Sayfa',
+    'pages_attachments' => 'Ekler',
+    'pages_navigation' => 'Sayfa Navigasyonu',
+    'pages_delete' => 'Sayfayı Sil',
+    'pages_delete_named' => ':pageName Sayfasını Sil',
+    'pages_delete_draft_named' => ':pageName Taslak Sayfasını Sil',
+    'pages_delete_draft' => 'Taslak Sayfayı Sil',
+    'pages_delete_success' => 'Sayfa silindi',
+    'pages_delete_draft_success' => 'Taslak sayfa silindi',
+    'pages_delete_confirm' => 'Bu sayfayı silmek istediğinizden emin misiniz?',
+    'pages_delete_draft_confirm' => 'Bu taslak sayfayı silmek istediğinizden emin misiniz?',
+    'pages_editing_named' => ':pageName Sayfası Düzenleniyor',
+    'pages_edit_save_draft' => 'Taslağı Kaydet',
+    'pages_edit_draft' => 'Taslak Sayfasını Düzenle',
+    'pages_editing_draft' => 'Taslak Düzenleniyor',
+    'pages_editing_page' => 'Sayfa Düzenleniyor',
+    'pages_edit_draft_save_at' => 'Taslak kaydedildi ',
+    'pages_edit_delete_draft' => 'Taslağı Sl',
+    'pages_edit_discard_draft' => 'Taslağı Yoksay',
+    'pages_edit_set_changelog' => 'Değişiklik Logunu Kaydet',
+    'pages_edit_enter_changelog_desc' => 'Yaptığınız değişiklikler hakkında kısa bir bilgilendirme ekleyin',
+    'pages_edit_enter_changelog' => 'Değişim Günlüğü Ekleyin',
+    'pages_save' => 'Sayfayı Kaydet',
+    'pages_title' => 'Sayfa Başlığı',
+    'pages_name' => 'Sayfa İsmi',
+    'pages_md_editor' => 'Editör',
+    'pages_md_preview' => 'Önizleme',
+    'pages_md_insert_image' => 'Görsel Ekle',
+    'pages_md_insert_link' => 'Öge Linki Ekle',
+    'pages_md_insert_drawing' => 'Çizim Ekle',
+    'pages_not_in_chapter' => 'Sayfa Bu Bölümde Değil',
+    'pages_move' => 'Sayfayı Taşı',
+    'pages_move_success' => 'Sayfa ":parentName"\'a taşındı',
+    'pages_copy' => 'Sayfayı Kopyala',
+    'pages_copy_desination' => 'Kopyalanacak Hedef',
+    'pages_copy_success' => 'Sayfa başarıyla kopyalandı',
+    'pages_permissions' => 'Sayfa İzinleri',
+    'pages_permissions_success' => 'Sayfa izinleri güncellendi',
+    'pages_revision' => 'Revizyon',
+    'pages_revisions' => 'Sayfa Revizyonları',
+    'pages_revisions_named' => ':pageName için Sayfa Revizyonları',
+    'pages_revision_named' => ':pageName için Sayfa Revizyonu',
+    'pages_revisions_created_by' => 'Oluşturan',
+    'pages_revisions_date' => 'Revizyon Tarihi',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Revizyon #:id',
+    'pages_revisions_numbered_changes' => 'Revizyon #:id Değişiklikleri',
+    'pages_revisions_changelog' => 'Değişim Günlüğü',
+    'pages_revisions_changes' => 'Değişiklikler',
+    'pages_revisions_current' => 'Mevcut Versiyon',
+    'pages_revisions_preview' => 'Önizleme',
+    'pages_revisions_restore' => 'Kurtar',
+    'pages_revisions_none' => 'Bu sayfaya ait revizyon yok',
+    'pages_copy_link' => 'Linki kopyala',
+    'pages_edit_content_link' => 'İçeriği Düzenle',
+    'pages_permissions_active' => 'Sayfa İzinleri Aktif',
+    'pages_initial_revision' => 'İlk Yayın',
+    'pages_initial_name' => 'Yeni Sayfa',
+    'pages_editing_draft_notification' => 'Şu anda :timeDiff tarhinde kaydedilmiş olan taslağı düzenlemektesiniz.',
+    'pages_draft_edited_notification' => 'Bu sayfa son girişinizden bu yana güncellendi. Değişiklikleri yoksayıp, kaydetmeden çıkmanız önerilir.',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count kullanıcı bu sayfayı düzenlemeye başladı',
+        'start_b' => ':userName kullanıcısı bu sayfayı düzenlemeye başladı',
+        'time_a' => 'sayfa son güncellendiğinden beri',
+        'time_b' => 'son :minCount dakikada',
+        'message' => ':start :time. Birbirinizin düzenlemelerinin çakışmamasına dikkat edin!',
+    ],
+    'pages_draft_discarded' => 'Taslak yok sayıldı, editör mevcut sayfa içeriği ile güncellendi',
+    'pages_specific' => 'Özel Sayfa',
+
+    // Editor Sidebar
+    'page_tags' => 'Sayfa Etiketleri',
+    'chapter_tags' => 'Bölüm Etiketleri',
+    'book_tags' => 'Kitap Etiketleri',
+    'shelf_tags' => 'Kitaplık Etiketleri',
+    'tag' => 'Etiket',
+    'tags' =>  'Etiketler',
+    'tag_value' => 'Etiket İçeriği (Opsiyonel)',
+    'tags_explain' => "İçeriğini daha iyi kategorize etmek için bazı etiketler ekle. Etiketlere değer atayarak daha derin bir organizasyon yapısına sahip olabilirsin.",
+    'tags_add' => 'Başka etiket ekle',
+    'attachments' => 'Ekler',
+    'attachments_explain' => 'Sayfanızda göstermek için bazı dosyalar yükleyin veya bazı bağlantılar ekleyin. Bunlar sayfanın sidebarında görülebilir.',
+    'attachments_explain_instant_save' => 'Burada yapılan değişiklikler anında kaydedilir.',
+    'attachments_items' => 'Eklenmiş Ögeler',
+    'attachments_upload' => 'Dosya Yükle',
+    'attachments_link' => 'Link Ekle',
+    'attachments_set_link' => 'Link Düzenle',
+    'attachments_delete_confirm' => 'Eki gerçekten silmek istiyor musunuz?',
+    'attachments_dropzone' => 'Dosyaları buraya sürükle veya  eklemek için buraya tıkla',
+    'attachments_no_files' => 'Hiç bir dosya yüklenmedi',
+    'attachments_explain_link' => 'Eğer dosya yüklememeyi tercih ederseniz link ekleyebilirsiniz. Bu başka bir sayfaya veya buluttaki bir dosyanın linki olabilir.',
+    'attachments_link_name' => 'Bağlantı Adı',
+    'attachment_link' => 'Ek linki',
+    'attachments_link_url' => 'Dosya linki',
+    'attachments_link_url_hint' => 'Dosyanın veya sitenin url adres',
+    'attach' => 'Ekle',
+    'attachments_edit_file' => 'Dosyayı Düzenle',
+    'attachments_edit_file_name' => 'Dosya Adı',
+    'attachments_edit_drop_upload' => 'Dosyaları sürükle veya yüklemek için buraya tıkla',
+    'attachments_order_updated' => 'Ek sırası güncellendi',
+    'attachments_updated_success' => 'Ek detayları güncellendi',
+    'attachments_deleted' => 'Ek silindi',
+    'attachments_file_uploaded' => 'Dosya başarıyla yüklendi',
+    'attachments_file_updated' => 'Dosya başarıyla güncellendi',
+    'attachments_link_attached' => 'Link sayfaya başarıyla eklendi',
+
+    // Profile View
+    'profile_user_for_x' => 'Kullanıcı :time',
+    'profile_created_content' => 'Oluşturulan İçerik',
+    'profile_not_created_pages' => ':userName herhangi bir sayfa oluşturmadı',
+    'profile_not_created_chapters' => ':userName herhangi bir bölüm oluşturmadı',
+    'profile_not_created_books' => ':userName herhangi bir kitap oluşturmadı',
+    'profile_not_created_shelves' => ':userName herhangi bir kitaplık oluşturmadı',
+
+    // Comments
+    'comment' => 'Yorum',
+    'comments' => 'Yorumlar',
+    'comment_add' => 'Yorum Ekle',
+    'comment_placeholder' => 'Buraya yorum ekle',
+    'comment_count' => '{0} Yorum Yok|{1} 1 Yorum|[2,*] :count Yorun',
+    'comment_save' => 'Yorum Kaydet',
+    'comment_saving' => 'Yorum kaydediliyor...',
+    'comment_deleting' => 'Yorum siliniyor...',
+    'comment_new' => 'Yeni Yorum',
+    'comment_created' => 'yorum yaptı :createDiff',
+    'comment_updated' => ':username tarafından :updateDiff önce güncellendi',
+    'comment_deleted_success' => 'Yorum silindi',
+    'comment_created_success' => 'Yorum eklendi',
+    'comment_updated_success' => 'Yorum güncellendi',
+    'comment_delete_confirm' => 'Bu yorumu silmek istediğinizden emin misiniz?',
+    'comment_in_reply_to' => ':commentId yorumuna yanıt olarak',
+
+    // Revision
+    'revision_delete_confirm' => 'Bu revizyonu silmek istediğinizden emin misiniz?',
+    'revision_restore_confirm' => 'Bu revizyonu yeniden yüklemek istediğinizden emin misiniz? Mevcut sayfa içeriği değiştirilecektir.',
+    'revision_delete_success' => 'Revizyon silindi',
+    'revision_cannot_delete_latest' => 'Son revizyon silinemez.'
+];
\ No newline at end of file
diff --git a/resources/lang/tr/errors.php b/resources/lang/tr/errors.php
new file mode 100644 (file)
index 0000000..962057e
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Bu sayfaya erişme yetkiniz yok.',
+    'permissionJson' => 'Bu işlemi yapmak için yetkiniz yo.',
+
+    // Auth
+    'error_user_exists_different_creds' => ':email adresi farklı kullanıcı bilgileri ile zaten kullanımda.',
+    'email_already_confirmed' => 'E-mail halihazırda onaylanmış, giriş yapmayı dene.',
+    'email_confirmation_invalid' => 'Bu doğrulama tokenı daha önce kullanılmış veya geçerli değil, lütfen tekrar kayıt olmayı deneyin.',
+    'email_confirmation_expired' => 'Doğrulama token\'ının süresi geçmiş, yeni bir mail gönderildi.',
+    'ldap_fail_anonymous' => 'Anonim LDAP girişi başarısız oldu',
+    'ldap_fail_authed' => 'Verdiğiniz bilgiler ile LDAP girişi başarısız oldu.',
+    'ldap_extension_not_installed' => 'LDAP PHP eklentisi yüklenmedi',
+    'ldap_cannot_connect' => 'LDAP sunucusuna bağlanılamadı, ilk bağlantı başarısız oldu',
+    'social_no_action_defined' => 'Bir aksiyon tanımlanmadı',
+    'social_login_bad_response' => ":socialAccount girişi sırasında hata oluştu: \n:error",
+    'social_account_in_use' => 'Bu :socialAccount zaten kullanımda, :socialAccount hesabıyla giriş yapmayı deneyin.',
+    'social_account_email_in_use' => ':email adresi zaten kullanımda. Eğer zaten bir hesabınız varsa :socialAccount hesabınızı profil ayarları kısmından bağlayabilirsiniz.',
+    'social_account_existing' => 'Bu :socialAccount zaten profilinize eklenmiş.',
+    'social_account_already_used_existing' => 'Bu :socialAccount başka bir kullanıcı tarafından kullanılıyor.',
+    'social_account_not_used' => 'Bu :socialAccount hesabı hiç bir kullanıcıya bağlı değil. Lütfen profil ayarlarına gidiniz ve bağlayınız. ',
+    'social_account_register_instructions' => 'Hala bir hesabınız yoksa :socialAccount ile kayıt olabilirsiniz.',
+    'social_driver_not_found' => 'Social driver bulunamadı',
+    'social_driver_not_configured' => ':socialAccount ayarlarınız doğru bir şekilde ayarlanmadı.',
+
+    // System
+    'path_not_writable' => ':filePath dosya yolu yüklenemedi. Sunucuya yazılabilir olduğundan emin olun.',
+    'cannot_get_image_from_url' => ':url\'den görsel alınamadı',
+    'cannot_create_thumbs' => 'Sunucu küçük resimleri oluşturamadı. Lütfen GD PHP eklentisinin yüklü olduğundan emin olun.',
+    'server_upload_limit' => 'Sunucu bu boyutta dosya yüklemenize izin vermiyor. Lütfen daha küçük boyutta dosya yüklemeyi deneyiniz.',
+    'uploaded'  => 'Sunucu bu boyutta dosya yüklemenize izin vermiyor. Lütfen daha küçük boyutta dosya yüklemeyi deneyiniz.',
+    'image_upload_error' => 'Görsel yüklenirken bir hata oluştu',
+    'image_upload_type_error' => 'Yüklemeye çalıştığınız dosya türü geçerli değildir',
+    'file_upload_timeout' => 'Dosya yüklemesi zaman aşımına uğradı',
+
+    // Attachments
+    'attachment_page_mismatch' => 'Ek güncellemesi sırasında sayfa uyuşmazlığı yaşandı',
+    'attachment_not_found' => 'Ek bulunamadı',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Taslak kaydetme başarısız. Sayfanızı kaydetmeden önce internet bağlantınız olduğundan emin olun',
+    'page_custom_home_deletion' => 'Bu sayfa anasayfa olarak ayarlandığı için silinemez',
+
+    // Entities
+    'entity_not_found' => 'Eleman bulunamadı',
+    'bookshelf_not_found' => 'Kitaplık bulunamadı',
+    'book_not_found' => 'Kitap bulunamadı',
+    'page_not_found' => 'Sayfa bulunamadı',
+    'chapter_not_found' => 'Bölüm bulunamadı',
+    'selected_book_not_found' => 'Seçilen kitap bulunamadı',
+    'selected_book_chapter_not_found' => 'Seçilen kitap veya bölüm bulunamadı',
+    'guests_cannot_save_drafts' => 'Misafirler taslak kaydedemezler',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Tek olan yöneticiyi silemezsiniz',
+    'users_cannot_delete_guest' => 'Misafir kullanıyıcıyı silemezsiniz',
+
+    // Roles
+    'role_cannot_be_edited' => 'Bu rol düzenlenemez',
+    'role_system_cannot_be_deleted' => 'Bu bir yönetici rolüdür ve silinemez',
+    'role_registration_default_cannot_delete' => 'Bu rol varsayılan yönetici rolü olarak atandığı için silinemez ',
+    'role_cannot_remove_only_admin' => 'Bu kullanıcı yönetici rolü olan tek kullanıcı olduğu için silinemez. Bu kullanıcıyı silmek için önce başka bir kullanıcıya yönetici rolü atayın.',
+
+    // Comments
+    'comment_list' => 'Yorumlar yüklenirken bir hata oluştu.',
+    'cannot_add_comment_to_draft' => 'Taslaklara yorum ekleyemezsiniz.',
+    'comment_add' => 'Yorum eklerken/güncellerken bir hata olıuştu.',
+    'comment_delete' => 'Yorum silinirken bir hata oluştu.',
+    'empty_comment' => 'Boş bir yorum eklenemez.',
+
+    // Error pages
+    '404_page_not_found' => 'Sayfa Bulunamadı',
+    'sorry_page_not_found' => 'Üzgünüz, aradığınız sayfa bulunamıyor.',
+    'return_home' => 'Anasayfaya dön',
+    'error_occurred' => 'Bir Hata Oluştu',
+    'app_down' => ':appName şu anda inaktif',
+    'back_soon' => 'En kısa zamanda aktif hale gelecek.',
+
+];
diff --git a/resources/lang/tr/pagination.php b/resources/lang/tr/pagination.php
new file mode 100644 (file)
index 0000000..8550a18
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Pagination Language Lines
+ * The following language lines are used by the paginator library to build
+ * the simple pagination links.
+ */
+return [
+
+    'previous' => '&laquo; Önceki',
+    'next'     => 'Sonraki &raquo;',
+
+];
diff --git a/resources/lang/tr/passwords.php b/resources/lang/tr/passwords.php
new file mode 100644 (file)
index 0000000..42e9ef2
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Password Reminder Language Lines
+ * The following language lines are the default lines which match reasons
+ * that are given by the password broker for a password update attempt has failed.
+ */
+return [
+
+    'password' => 'Parolanız en az 6 karakterden oluşmalı ve doğrulama parolası ile eşleşmelidir. ',
+    'user' => "Bu e-mail adresi ile ilişkilendirilmiş bir kullanıcı bulamadık.",
+    'token' => 'Parola yenileme tokeni geçerli değil.',
+    'sent' => 'Parola sıfırlanma bağlantısını e-mail adresinize gönderdik!',
+    'reset' => 'Parolanız sıfırlandı!',
+
+];
diff --git a/resources/lang/tr/settings.php b/resources/lang/tr/settings.php
new file mode 100755 (executable)
index 0000000..cf20b06
--- /dev/null
@@ -0,0 +1,134 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Ayarlar',
+    'settings_save' => 'Ayarları Kaydet',
+    'settings_save_success' => 'Ayarlar Kaydedildi',
+
+    // App Settings
+    'app_customization' => 'Özelleştirme',
+    'app_features_security' => 'Özellikler & Güvenlik',
+    'app_name' => 'Uygulama Adı',
+    'app_name_desc' => 'Bu isim başlıkta ve sistem tarafında gönderilen tüm mesajlarda gösterilecektir.',
+    'app_name_header' => 'İsmi başlıkta göster',
+    'app_public_access' => 'Açık Erişim',
+    'app_public_access_desc' => 'Bu özelliği aktif etmek giriş yapmamış misafir kullanıcıların sizin BookStack uygulamanıza erişmesini sağlar',
+    'app_public_access_desc_guest' => 'Kayıtlı olmayan kullanıcılar için erişim yetkisi "Guest" kullanıcısı üzerinden düzenlenebilir.',
+    'app_public_access_toggle' => 'Açık erişime izin ver',
+    'app_public_viewing' => 'Herkese açık görüntülenmeye izin verilsin mi?',
+    'app_secure_images' => 'Daha Yüksek Güvenlikli Görsel Yüklemeleri',
+    'app_secure_images_toggle' => 'Daha yüksek güveblikli görsel yüklemelerine izin ver',
+    'app_secure_images_desc' => 'Performans sebepleri nedeniyle bütün görseller halka açık. Bu opsiyon rastgele ve tahmin edilmesi zor dizileri görsel linklerinin önüne ekler. Dizin indexlerinin kapalı olduğundan emin olun.',
+    'app_editor' => 'Sayfa Editörü',
+    'app_editor_desc' => 'Sayfa düzenlemesi yapılırken hangi editörün kullanılacağını seçin.',
+    'app_custom_html' => 'Özel HTML Head İçeriği',
+    'app_custom_html_desc' => 'Buraya eklenecek olan içerik <head> taginin en sonuna eklenecektir. Bu stilleri override ederken veya analytics eklerken faydalı bir kullanım şeklidir.',
+    'app_custom_html_disabled_notice' => 'Yapılan hatalı değişikliklerin geriye alınabilmesi için bu sayfada özel HTML head içeriği kapalı.',
+    'app_logo' => 'Uygulama Logosu',
+    'app_logo_desc' => 'Bu görsel 43px yüksekliğinde olmalı. <br>Büyük görseller ölçeklenecektir.',
+    'app_primary_color' => 'Uygulamanın Birincil Rengi',
+    'app_primary_color_desc' => 'Bu bir hex değeri olmalıdır. <br>Varsayılan rengi seçmek için boş bırakın.',
+    'app_homepage' => 'Uygulama Anasayfası',
+    'app_homepage_desc' => 'Anasayfada görünmesi için bir view seçin. Sayfa izinleri seçili sayfalar için yok sayılacaktır.',
+    'app_homepage_select' => 'Sayfa seçiniz',
+    'app_disable_comments' => 'Yorumları Engelle',
+    'app_disable_comments_toggle' => 'Yorumları engelle',
+    'app_disable_comments_desc' => 'Yorumları uygulamadaki bütün sayfalar için engelle. <br> Mevcut yorumlar gösterilmeyecektir.',
+
+    // Registration Settings
+    'reg_settings' => 'Kayıt',
+    'reg_enable' => 'Kaydolmaya İzin Ver',
+    'reg_enable_toggle' => 'Kaydolmaya izin ver',
+    'reg_enable_desc' => 'Kayıt olmaya izin verdiğinizde kullanıcılar kendilerini uygulamaya kaydedebilecekler. Kayıt olduktan sonra kendilerine varsayılan kullanıcı rolü atanacaktır.',
+    'reg_default_role' => 'Kayıt olduktan sonra varsayılan kullanıcı rolü',
+    'reg_email_confirmation' => 'Email Doğrulama',
+    'reg_email_confirmation_toggle' => 'E-mail onayı gerektir',
+    'reg_confirm_email_desc' => 'Eğer domain kısıtlaması kullanılıyorsa o zaman email doğrulaması gereklidir ve bu seçenek yok sayılacaktır.',
+    'reg_confirm_restrict_domain' => 'Domain Kısıtlaması',
+    'reg_confirm_restrict_domain_desc' => 'Kısıtlamak istediğiniz email domainlerini vigül ile ayırarak yazınız. Kullanıcılara uygulamaya erişmeden önce adreslerini doğrulamak için bir mail gönderilecektir. <br> Kullanıcılar başarıyla kaydolduktan sonra email adreslerini değiştiremeyeceklerdir.',
+    'reg_confirm_restrict_domain_placeholder' => 'Hiçbir kısıtlama tanımlanmamış',
+
+    // Maintenance settings
+    'maint' => 'Bakım',
+    'maint_image_cleanup' => 'Görsel Temizliği',
+    'maint_image_cleanup_desc' => "Sayfaları ve revizyon içeriklerini tarayarak hangi gösel ve çizimlerin kullanımda olduğunu ve hangilerinin gereksiz olduğunu tespit eder. Bunu başlatmadan veritabanı ve görsellerin tam bir yedeğinin alındığından emin olun.",
+    'maint_image_cleanup_ignore_revisions' => 'Revizyonlardaki görselleri yoksay',
+    'maint_image_cleanup_run' => 'Temizliği Başlat',
+    'maint_image_cleanup_warning' => ':count potansiyel kullanılmayan görsel bulundu. Bu görselleri silmek istediğinizden emin misiniz?',
+    'maint_image_cleanup_success' => ':count potanisyel kullanılmayan görsel bulundu ve silindi!',
+    'maint_image_cleanup_nothing_found' => 'Kullanılmayan görsel bulunamadı ve birşey silinmedi!',
+
+    // Role Settings
+    'roles' => 'Roller',
+    'role_user_roles' => 'Kullanıcı Rolleri',
+    'role_create' => 'Yeni Rol Oluştur',
+    'role_create_success' => 'Rol Başarıyla Oluşturuldu',
+    'role_delete' => 'Rolü Sil',
+    'role_delete_confirm' => 'Bu işlem \':roleName\' rolünü silecektir.',
+    'role_delete_users_assigned' => 'Bu role atanmış :userCount adet kullanıcı var. Eğer bu kullanıcıların rollerini değiştirmek istiyorsanız aşağıdan yeni bir rol seçin.',
+    'role_delete_no_migration' => "Kullanıcıları taşıma",
+    'role_delete_sure' => 'Bu rolü silmek istediğinizden emin misiniz?',
+    'role_delete_success' => 'Rol başarıyla silindi',
+    'role_edit' => 'Rolü Düzenle',
+    'role_details' => 'Rol Detayları',
+    'role_name' => 'Rol Adı',
+    'role_desc' => 'Rolün Kısa Tanımı',
+    'role_external_auth_id' => 'Harici Authentication ID\'leri',
+    'role_system' => 'Sistem Yetkileri',
+    'role_manage_users' => 'Kullanıcıları yönet',
+    'role_manage_roles' => 'Rolleri ve rol izinlerini yönet',
+    'role_manage_entity_permissions' => 'Bütün kitap, bölüm ve sayfa izinlerini yönet',
+    'role_manage_own_entity_permissions' => 'Sahip olunan kitap, bölüm ve sayfaların izinlerini yönet',
+    'role_manage_settings' => 'Uygulama ayarlarını yönet',
+    'role_asset' => 'Asset Yetkileri',
+    'role_asset_desc' => 'Bu izinleri assetlere sistem içinden varsayılan erişimi kontrol eder. Kitaplar, bölümler ve sayfaların izinleri bu izinleri override eder.',
+    'role_asset_admins' => 'Yöneticilere otomatik olarak bütün içeriğe erişim yetkisi verilir fakat bu opsiyonlar UI özelliklerini gösterir veya gizler.',
+    'role_all' => 'Hepsi',
+    'role_own' => 'Sahip Olunan',
+    'role_controlled_by_asset' => 'Yükledikleri asset tarafından kontrol ediliyor',
+    'role_save' => 'Rolü Kaydet',
+    'role_update_success' => 'Rol başarıyla güncellendi',
+    'role_users' => 'Bu roldeki kullanıcılar',
+    'role_users_none' => 'Bu role henüz bir kullanıcı atanmadı',
+
+    // Users
+    'users' => 'Kullanıcılar',
+    'user_profile' => 'Kullanıcı Profili',
+    'users_add_new' => 'Yeni Kullanıcı Ekle',
+    'users_search' => 'Kullanıcıları Ara',
+    'users_details' => 'Kullanıcı Detayları',
+    'users_details_desc' => 'Bu kullanıcı için gösterilecek bir isim ve mail adresi belirleyin. Bu e-mail adresi kullanıcı tarafından giriş yaparken kullanılacak.',
+    'users_details_desc_no_email' => 'Diğer kullanıcılar tarafından tanınabilmesi için bir isim belirleyin.',
+    'users_role' => 'Kullanıcı Rolleri',
+    'users_role_desc' => 'Bu kullanıcının hangi rollere atanabileceğini belirleyin. Eğer bir kullanıcıya birden fazla rol atanırsa, kullanıcı bütün rollerin özelliklerini kullanabilir.',
+    'users_password' => 'Kullanıcı Parolası',
+    'users_password_desc' => 'Kullanıcının giriş yaparken kullanacağı bir parola belirleyin. Parola en az 5 karakter olmalıdır.',
+    'users_external_auth_id' => 'Harici Authentication ID\'si',
+    'users_external_auth_id_desc' => 'Bu ID kullanıcı LDAP sunucu ile bağlantı kurarken kullanılır.',
+    'users_password_warning' => 'Sadece parolanızı değiştirmek istiyorsanız aşağıyı doldurunuz.',
+    'users_system_public' => 'Bu kullanıcı sizin uygulamanızı ziyaret eden bütün misafir kullanıcıları temsil eder. Giriş yapmak için kullanılamaz, otomatik olarak atanır.',
+    'users_delete' => 'Kullanıcı Sil',
+    'users_delete_named' => ':userName kullanıcısını sil ',
+    'users_delete_warning' => 'Bu işlem \':userName\' kullanıcısını sistemden tamamen silecektir.',
+    'users_delete_confirm' => 'Bu kullanıcıyı tamamen silmek istediğinize emin misiniz?',
+    'users_delete_success' => 'Kullanıcılar başarıyla silindi.',
+    'users_edit' => 'Kullanıcıyı Güncelle',
+    'users_edit_profile' => 'Profili Düzenle',
+    'users_edit_success' => 'Kullanıcı başarıyla güncellendi',
+    'users_avatar' => 'Kullanıcı Avatarı',
+    'users_avatar_desc' => 'Bu kullanıcıyı temsil eden bir görsel seçin. Yaklaşık 256px kare olmalıdır.',
+    'users_preferred_language' => 'Tercih Edilen Dil',
+    'users_preferred_language_desc' => 'Bu seçenek kullanıcı arayüzünün dilini değiştirecektir. Herhangi bir kullanıcı içeriğini etkilemeyecektir.',
+    'users_social_accounts' => 'Sosyal Hesaplar',
+    'users_social_accounts_info' => 'Burada diğer hesaplarınızı ekleyerek daha hızlı ve kolay giriş sağlayabilirsiniz. Bir hesabın bağlantısını kesmek daha önce edilnilen erişiminizi kaldırmaz. Profil ayarlarınızdan bağlı sosyal hesabınızın erişimini kaldırınız.',
+    'users_social_connect' => 'Hesap Bağla',
+    'users_social_disconnect' => 'Hesabın Bağlantısını Kes',
+    'users_social_connected' => ':socialAccount hesabı profilinize başarıyla bağlandı.',
+    'users_social_disconnected' => ':socialAccount hesabınızın profilinizle ilişiği başarıyla kesildi.',
+];
diff --git a/resources/lang/tr/validation.php b/resources/lang/tr/validation.php
new file mode 100644 (file)
index 0000000..e7683a0
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Validation Lines
+ * The following language lines contain the default error messages used by
+ * the validator class. Some of these rules have multiple versions such
+ * as the size rules. Feel free to tweak each of these messages here.
+ */
+return [
+
+    // Standard laravel validation lines
+    'accepted'             => ':attribute kabul edilmelidir.',
+    'active_url'           => ':attribute geçerli bir URL adresi değildir.',
+    'after'                => ':attribute :date tarihinden sonra bir tarih olmalıdır.',
+    'alpha'                => ':attribute sadece harflerden oluşabilir.',
+    'alpha_dash'           => ':attribute sadece harf, rakam ve tirelerden oluşabilir.',
+    'alpha_num'            => ':attribute sadece harf ve rakam oluşabilir.',
+    'array'                => ':attribute array olmalıdır..',
+    'before'               => ':attribute :date tarihinden önce bir tarih olmalıdır.',
+    'between'              => [
+        'numeric' => ':attribute, :min ve :max değerleri arasında olmalıdır.',
+        'file'    => ':attribute, :min ve :max kilobyte boyutları arasında olmalıdır.',
+        'string'  => ':attribute, :min ve :max karakter arasında olmalıdır.',
+        'array'   => ':attribute :min ve :max öge arasında olmalıdır.',
+    ],
+    'boolean'              => ':attribute true veya false olmalıdır.',
+    'confirmed'            => ':attribute doğrulaması eşleşmiyor.',
+    'date'                 => ':attribute geçerli bir tarih değil.',
+    'date_format'          => ':attribute formatı :format\'ına uymuyor.',
+    'different'            => ':attribute be :other birbirinden farklı olmalıdır.',
+    'digits'               => ':attribute :digits basamaklı olmalıdır.',
+    'digits_between'       => ':attribute :min ve :max basamaklı olmalıdır.',
+    'email'                => ':attribute geçerli bir e-mail adresi olmalıdır.',
+    'filled'               => ':attribute gerekli bir alandır.',
+    'exists'               => 'Seçilen :attribute geçerli bir alan değildir.',
+    'image'                => ':attribute bir görsel olmalıdır.',
+    'image_extension'      => ':attribute geçerli ve desteklenen bir görsel uzantısı değildir.',
+    'in'                   => 'Seçilen :attribute geçerli değildir.',
+    'integer'              => ':attribute bir integer değeri olmalıdır.',
+    'ip'                   => ':attribute geçerli bir IP adresi olmalıdır.',
+    'max'                  => [
+        'numeric' => ':attribute, :max değerinden büyük olmamalıdır.',
+        'file'    => ':attribute, :max kilobyte boyutundan büyük olmamalıdır.',
+        'string'  => ':attribute, :max karakter boyutundan büyük olmamalıdır.',
+        'array'   => ':attribute, en fazla :max öge içermelidir.',
+    ],
+    'mimes'                => ':attribute :values dosya tipinde olmalıdır.',
+    'min'                  => [
+        'numeric' => ':attribute, :min değerinden az olmamalıdır.',
+        'file'    => ':attribute, :min kilobyte boyutundan küçük olmamalıdır.',
+        'string'  => ':attribute, :min karakter boyutundan küçük olmamalıdır.',
+        'array'   => ':attribute, en az :min öge içermelidir.',
+    ],
+    'no_double_extension'  => ':attribute sadece tek bir dosya tipinde olmalıdır.',
+    'not_in'               => 'Seçili :attribute geçerli değildir.',
+    'numeric'              => ':attribute rakam olmalıdır.',
+    'regex'                => ':attribute formatı geçerli değildir.',
+    'required'             => 'The :attribute field is required. :attribute alanı gereklidir.',
+    'required_if'          => ':other alanı :value değerinde ise :attribute alanı gereklidir.',
+    'required_with'        => 'Eğer :values değeri geçerli ise :attribute alanı gereklidir.',
+    'required_with_all'    => 'Eğer :values değeri geçerli ise :attribute alanı gereklidir. ',
+    'required_without'     => 'Eğer :values değeri geçerli değil ise :attribute alanı gereklidir.',
+    'required_without_all' => 'Eğer :values değerlerinden hiçbiri geçerli değil ise :attribute alanı gereklidir.',
+    'same'                 => ':attribute ve :other eşleşmelidir.',
+    'size'                 => [
+        'numeric' => ':attribute, :size boyutunda olmalıdır.',
+        'file'    => ':attribute, :size kilobyte boyutunda olmalıdır.',
+        'string'  => ':attribute, :size karakter uzunluğunda olmalıdır.',
+        'array'   => ':attribute, :size sayıda öge içermelidir.',
+    ],
+    'string'               => ':attribute string olmalıdır.',
+    'timezone'             => ':attribute geçerli bir alan olmalıdır.',
+    'unique'               => ':attribute daha önce alınmış.',
+    'url'                  => ':attribute formatı geçerli değil.',
+    'uploaded'             => 'Dosya yüklemesi başarısız oldu. Server bu boyutta dosyaları kabul etmiyor olabilir.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Parola onayı gereklidir.',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index 315f08c34b77261ce18ab6c1c7560441f8788dfb..cf78c162b95c72fc66e9643982bbc4c5aec90ec3 100644 (file)
@@ -224,7 +224,7 @@ code {
   @extend .code-base;
   display: inline;
   padding: 1px 3px;
-  white-space:pre;
+  white-space:pre-wrap;
   line-height: 1.2em;
   margin-bottom: 1.2em;
 }
@@ -369,4 +369,3 @@ span.sep {
   margin-right: $-xs;
   pointer-events: none;
 }
-
index 8f1fef70c9c512b0629dcef8545304d9779f3b16..1f4d00f6b9606b1ebe15d94b0c5b8788dd747b6e 100644 (file)
@@ -253,7 +253,8 @@ $btt-size: 40px;
   .list-sort {
     display: inline-grid;
     margin-left: $-s;
-    grid-template-columns: 120px 40px;
+    grid-template-columns: minmax(120px, max-content) 40px;
+    font-size: 0.9rem;
     border: 2px solid #DDD;
     border-radius: 4px;
   }
index f7903e18c72de72fd979356726142b91afff1a92..2699fda00ac59801e454f748dd339eb8b6c7fac8 100644 (file)
@@ -1,12 +1,12 @@
 <div class="form-group">
     <label for="username">{{ trans('auth.username') }}</label>
-    @include('form.text', ['name' => 'username', 'tabindex' => 1])
+    @include('form.text', ['name' => 'username', 'autofocus' => true])
 </div>
 
 @if(session('request-email', false) === true)
     <div class="form-group">
         <label for="email">{{ trans('auth.email') }}</label>
-        @include('form.text', ['name' => 'email', 'tabindex' => 1])
+        @include('form.text', ['name' => 'email'])
         <span class="text-neg">
             {{ trans('auth.ldap_email_hint') }}
         </span>
@@ -15,5 +15,5 @@
 
 <div class="form-group">
     <label for="password">{{ trans('auth.password') }}</label>
-    @include('form.password', ['name' => 'password', 'tabindex' => 1])
+    @include('form.password', ['name' => 'password'])
 </div>
\ No newline at end of file
index dc6081637f784f9a7823d9443eca91d51a0e7f85..52fae3750ad38dbd9cccacedeea82328d63fd0b1 100644 (file)
@@ -1,11 +1,11 @@
 <div class="form-group">
     <label for="email">{{ trans('auth.email') }}</label>
-    @include('form.text', ['name' => 'email', 'tabindex' => 1])
+    @include('form.text', ['name' => 'email', 'autofocus' => true])
 </div>
 
 <div class="form-group">
     <label for="password">{{ trans('auth.password') }}</label>
-    @include('form.password', ['name' => 'password', 'tabindex' => 1])
+    @include('form.password', ['name' => 'password'])
     <span class="block small mt-s">
         <a href="{{ url('/password/email') }}">{{ trans('auth.forgot_password') }}</a>
     </span>
index 2f3cc1f73c9b6a4756aff363977ed44bfb3a887d..fbf540d7105fd6f0969956d7291413471d820b53 100644 (file)
                             'name' => 'remember',
                             'checked' => false,
                             'value' => 'on',
-                            'tabindex' => 1,
                             'label' => trans('auth.remember_me'),
                         ])
                     </div>
 
                     <div class="text-right">
-                        <button class="button" tabindex="1">{{ Str::title(trans('auth.log_in')) }}</button>
+                        <button class="button">{{ Str::title(trans('auth.log_in')) }}</button>
                     </div>
                 </div>
 
index 4a7c45e0b81ce1b1cedcd431e94e269a19d95739..1cf91046df1b5ab2043c5e7072ed265c9a9c5a89 100644 (file)
@@ -58,7 +58,7 @@
         <h1 id="{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</h1>
 
         @if($bookChild->isA('chapter'))
-            <p>{{ $bookChild->text }}</p>
+            <p>{{ $bookChild->description }}</p>
 
             @if(count($bookChild->pages) > 0)
                 @foreach($bookChild->pages as $page)
index 871d931f17953496478a7f456e910c5ec3d6ee36..42a2757f94e0e949d536791f6dd79f4daa5ea38a 100644 (file)
@@ -4,7 +4,11 @@
         <h1 class="list-heading">{{ trans('entities.books') }}</h1>
         <div class="text-m-right my-m">
 
-            @include('partials.sort', ['options' => $sortOptions, 'order' => $order, 'sort' => $sort, 'type' => 'books'])
+            @include('partials.sort', ['options' => [
+                'name' => trans('common.sort_name'),
+                'created_at' => trans('common.sort_created_at'),
+                'updated_at' => trans('common.sort_updated_at'),
+            ], 'order' => $order, 'sort' => $sort, 'type' => 'books'])
 
         </div>
     </div>
index 5fbdfa500c6f10d14ddc64db887eb5ee2c8a824f..ea96a9250dd24a22f2bf274fc093f52edcd6454e 100644 (file)
@@ -31,7 +31,7 @@
                         <button type="button" dropdown-toggle aria-haspopup="true" aria-expanded="false" class="text-button" title="{{ trans('common.delete') }}">@icon('delete')</button>
                         <ul class="dropdown-menu" role="menu">
                             <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
-                            <li><a action="delete" href="#" class="text-button text-neg" >@icon('delete'){{ trans('common.delete') }}</a></li>
+                            <li><button action="delete" type="button" class="text-button text-neg" >@icon('delete'){{ trans('common.delete') }}</button></li>
                         </ul>
                     </div>
                 @endif
index 6ba2f457f88925f6b4dad718fbd176e6bdc9aed7..2bf4e223201a5e04b8accd48b575f7b887f378c5 100644 (file)
@@ -3,12 +3,10 @@ $name
 $value
 $checked
 $label
-$tabindex
 --}}
 <label custom-checkbox class="toggle-switch @if($errors->has($name)) text-neg @endif">
     <input type="checkbox" name="{{$name}}" value="{{ $value }}" @if($checked) checked="checked" @endif>
-    <span tabindex="{{ $tabindex ?? '0' }}"
-          role="checkbox"
+    <span tabindex="0" role="checkbox"
           aria-checked="{{ $checked ? 'true' : 'false' }}"
           class="custom-checkbox text-primary">@icon('check')</span>
     <span class="label">{{$label}}</span>
index f61c076707eb5f1277a8a7d725654e30c4a03816..a5eec30051b32a9bcd9e983c0fd98a40c4356ca9 100644 (file)
@@ -1,8 +1,7 @@
 <label toggle-switch="{{$name}}" custom-checkbox class="toggle-switch">
     <input type="hidden" name="{{$name}}" value="{{$value?'true':'false'}}"/>
     <input type="checkbox" @if($value) checked="checked" @endif>
-    <span tabindex="{{ $tabindex ?? '0' }}"
-          role="checkbox"
+    <span tabindex="0" role="checkbox"
           aria-checked="{{ $value ? 'true' : 'false' }}"
           class="custom-checkbox text-primary">@icon('check')</span>
     <span class="label">{{ $label }}</span>
index c1937ff23e5ce0311ac7247e8bb7a4e57813625f..9c599307ed0279c22de797ea8790717aa91cadf8 100644 (file)
@@ -22,7 +22,7 @@
                 <div class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'page'), 'style' => 'compact'])
+                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact'])
                     </div>
                 </div>
             </div>
@@ -30,7 +30,7 @@
                 <div class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'book'), 'style' => 'compact'])
+                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact'])
                     </div>
                 </div>
             </div>
@@ -38,7 +38,7 @@
                 <div class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'chapter'), 'style' => 'compact'])
+                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact'])
                     </div>
                 </div>
             </div>
index f27209c484f7e0def4786df72bc32397200dd5e4..3581a545b1c96f6b082b11c3a379747251f13ad1 100644 (file)
@@ -19,7 +19,7 @@
                 <a href="#" permissions-table-toggle-all class="text-small ml-m text-primary">{{ trans('common.toggle_all') }}</a>
             </th>
         </tr>
-        @foreach($roles as $role)
+        @foreach(\BookStack\Auth\Role::restrictable() as $role)
             <tr>
                 <td width="33%" class="pt-m">
                     {{ $role->display_name }}
index 8f7c4ee01e5c8384596884f7e390f9d306c293c0..a3c868a8dc08ba293e624f27aa8f1229aa5aa039 100644 (file)
@@ -1,7 +1,6 @@
 <input type="password" id="{{ $name }}" name="{{ $name }}"
        @if($errors->has($name)) class="text-neg" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
-       @if(isset($tabindex)) tabindex="{{$tabindex}}" @endif
        @if(old($name)) value="{{ old($name)}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
index 909e87286247daaba449ebfc7ae320ba24346660..4b3631a06566158d2286df4f0e662b0b4dfd376a 100644 (file)
@@ -1,9 +1,9 @@
 <input type="text" id="{{ $name }}" name="{{ $name }}"
        @if($errors->has($name)) class="text-neg" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
-       @if(isset($disabled) && $disabled) disabled="disabled" @endif
-       @if(isset($tabindex)) tabindex="{{$tabindex}}" @endif
+       @if($autofocus ?? false) autofocus @endif
+       @if($disabled ?? false) disabled="disabled" @endif
        @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
-@endif
\ No newline at end of file
+@endif
index ba2b92fe7006fd211809c2e50bdec02afe2882b9..a5277418661ea29af619b3f9fb58a0abadd13de8 100644 (file)
@@ -7,9 +7,10 @@
             <div class="py-m">
                 @include('settings.navbar', ['selected' => 'settings'])
             </div>
-            <div class="text-right mb-l px-m">
-                <br>
-                BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
+            <div class="text-right p-m">
+                <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
+                    BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
+                </a>
             </div>
         </div>
 
index 6be49cdf2c94479d077ef2258f36bcdd08d1183c..2522599cfdf98b7f12a858b16e466a2a0c3b04b6 100644 (file)
@@ -7,9 +7,10 @@
         <div class="py-m">
             @include('settings.navbar', ['selected' => 'maintenance'])
         </div>
-        <div class="text-right mb-l px-m">
-            <br>
+        <div class="text-right p-m">
+            <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
             BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
+            </a>
         </div>
     </div>
 
index 5125e7e194ce250f53bbc6a4a0fd61b5a5837132..19c5bbecd69ae95c70bd782703cfe377ddd32b30 100644 (file)
     <div class="form-group">
         <label for="books">{{ trans('entities.shelves_books') }}</label>
         <input type="hidden" id="books-input" name="books"
-               value="{{ isset($shelf) ? $shelf->books->implode('id', ',') : '' }}">
+               value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}">
         <div class="scroll-box" shelf-sort-assigned-books data-instruction="{{ trans('entities.shelves_drag_books') }}">
-            @if (isset($shelfBooks) && count($shelfBooks) > 0)
-                @foreach ($shelfBooks as $book)
+            @if (count($shelf->visibleBooks ?? []) > 0)
+                @foreach ($shelf->visibleBooks as $book)
                     <div data-id="{{ $book->id }}" class="scroll-box-item">
                         <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
                     </div>
index 6bfc525a5dc458852ce750984891862857f9b010..2212e1c1e32ae3014b3f839f802d1dc534542c3a 100644 (file)
@@ -12,9 +12,9 @@
         <h1 class="break-text">{{$shelf->name}}</h1>
         <div class="book-content">
             <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
-            @if(count($books) > 0)
+            @if(count($shelf->visibleBooks) > 0)
                 <div class="entity-list">
-                    @foreach($books as $book)
+                    @foreach($shelf->visibleBooks as $book)
                         @include('books.list-item', ['book' => $book])
                     @endforeach
                 </div>
index 0277c1bb435fc5ac8a9f5ec48d51db57e5a43103..5dee447a4c7abd56d67a495dcdd68336ac6b9026 100644 (file)
@@ -9,9 +9,7 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
         ->where('path', '.*$');
 
-    Route::group(['prefix' => 'pages'], function() {
-        Route::get('/recently-updated', 'PageController@showRecentlyUpdated');
-    });
+    Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
 
     // Shelves
     Route::get('/create-shelf', 'BookshelfController@create');
@@ -40,13 +38,13 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/{slug}/edit', 'BookController@edit');
         Route::put('/{slug}', 'BookController@update');
         Route::delete('/{id}', 'BookController@destroy');
-        Route::get('/{slug}/sort-item', 'BookController@getSortItem');
+        Route::get('/{slug}/sort-item', 'BookSortController@showItem');
         Route::get('/{slug}', 'BookController@show');
         Route::get('/{bookSlug}/permissions', 'BookController@showPermissions');
         Route::put('/{bookSlug}/permissions', 'BookController@permissions');
         Route::get('/{slug}/delete', 'BookController@showDelete');
-        Route::get('/{bookSlug}/sort', 'BookController@sort');
-        Route::put('/{bookSlug}/sort', 'BookController@saveSort');
+        Route::get('/{bookSlug}/sort', 'BookSortController@show');
+        Route::put('/{bookSlug}/sort', 'BookSortController@update');
         Route::get('/{bookSlug}/export/html', 'BookExportController@html');
         Route::get('/{bookSlug}/export/pdf', 'BookExportController@pdf');
         Route::get('/{bookSlug}/export/plaintext', 'BookExportController@plainText');
@@ -74,11 +72,11 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/{bookSlug}/draft/{pageId}', 'PageController@destroyDraft');
 
         // Revisions
-        Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions');
-        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision');
-        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges');
-        Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
-        Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageController@destroyRevision');
+        Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageRevisionController@index');
+        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageRevisionController@show');
+        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageRevisionController@changes');
+        Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageRevisionController@restore');
+        Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageRevisionController@destroy');
 
         // Chapters
         Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create');
index a884809696d32cd4e4126f7c7c68121738ee905f..4aef0ed2658861b2fe8a6f326c12ea131e3888ee 100644 (file)
@@ -2,7 +2,6 @@
 
 use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Entities\Page;
-use BookStack\Entities\Repos\EntityRepo;
 use BookStack\Auth\User;
 use BookStack\Entities\Repos\PageRepo;
 
@@ -56,7 +55,7 @@ class CommandsTest extends TestCase
         $this->asEditor();
         $pageRepo = app(PageRepo::class);
         $page = Page::first();
-        $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+        $pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
         $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '<p>new content in draft</p>', 'summary' => 'page revision testing']);
 
         $this->assertDatabaseHas('page_revisions', [
index a3fb1cfe11833591a5d1282aeda43fdce23f9eb9..b506da2aa62387126f3b5c69ffb678b0b6548fe6 100644 (file)
@@ -3,7 +3,6 @@
 use BookStack\Entities\Book;
 use BookStack\Entities\Chapter;
 use BookStack\Entities\Page;
-use BookStack\Entities\Repos\EntityRepo;
 use BookStack\Auth\UserRepo;
 use BookStack\Entities\Repos\PageRepo;
 use Carbon\Carbon;
@@ -192,7 +191,7 @@ class EntityTest extends BrowserKitTest
         $entities = $this->createEntityChainBelongingToUser($creator, $updater);
         $this->actingAs($creator);
         app(UserRepo::class)->destroy($creator);
-        app(PageRepo::class)->savePageRevision($entities['page']);
+        app(PageRepo::class)->update($entities['page'], ['html' => '<p>hello!</p>>']);
 
         $this->checkEntitiesViewable($entities);
     }
@@ -205,7 +204,7 @@ class EntityTest extends BrowserKitTest
         $entities = $this->createEntityChainBelongingToUser($creator, $updater);
         $this->actingAs($updater);
         app(UserRepo::class)->destroy($updater);
-        app(PageRepo::class)->savePageRevision($entities['page']);
+        app(PageRepo::class)->update($entities['page'], ['html' => '<p>Hello there!</p>']);
 
         $this->checkEntitiesViewable($entities);
     }
@@ -273,8 +272,7 @@ class EntityTest extends BrowserKitTest
 
     public function test_slug_multi_byte_lower_casing()
     {
-        $entityRepo = app(EntityRepo::class);
-        $book = $entityRepo->createFromInput('book', [
+        $book = $this->newBook([
             'name' => 'КНИГА'
         ]);
 
@@ -284,8 +282,7 @@ class EntityTest extends BrowserKitTest
 
     public function test_slug_format()
     {
-        $entityRepo = app(EntityRepo::class);
-        $book = $entityRepo->createFromInput('book', [
+        $book = $this->newBook([
             'name' => 'PartA / PartB / PartC'
         ]);
 
index a0fcb5ca8ab8a505b9fe5bafec962d587ebeeb42..8a78c8ac019fd08de3cc37d50e7df50954c83f6e 100644 (file)
@@ -1,8 +1,7 @@
 <?php namespace Tests;
 
+use BookStack\Entities\Managers\PageContent;
 use BookStack\Entities\Page;
-use BookStack\Entities\Repos\EntityRepo;
-use BookStack\Entities\Repos\PageRepo;
 
 class PageContentTest extends TestCase
 {
@@ -242,4 +241,66 @@ class PageContentTest extends TestCase
         $updatedPage = Page::where('id', '=', $page->id)->first();
         $this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
     }
+
+    public function test_get_page_nav_sets_correct_properties()
+    {
+        $content = '<h1 id="testa">Hello</h1><h2 id="testb">There</h2><h3 id="testc">Donkey</h3>';
+        $pageContent = new PageContent(new Page(['html' => $content]));
+        $navMap = $pageContent->getNavigation($content);
+
+        $this->assertCount(3, $navMap);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h1',
+            'link' => '#testa',
+            'text' => 'Hello',
+            'level' => 1,
+        ], $navMap[0]);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h2',
+            'link' => '#testb',
+            'text' => 'There',
+            'level' => 2,
+        ], $navMap[1]);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h3',
+            'link' => '#testc',
+            'text' => 'Donkey',
+            'level' => 3,
+        ], $navMap[2]);
+    }
+
+    public function test_get_page_nav_does_not_show_empty_titles()
+    {
+        $content = '<h1 id="testa">Hello</h1><h2 id="testb">&nbsp;</h2><h3 id="testc"></h3>';
+        $pageContent = new PageContent(new Page(['html' => $content]));
+        $navMap = $pageContent->getNavigation($content);
+
+        $this->assertCount(1, $navMap);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h1',
+            'link' => '#testa',
+            'text' => 'Hello'
+        ], $navMap[0]);
+    }
+
+    public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used()
+    {
+        $content = '<h4 id="testa">Hello</h4><h5 id="testb">There</h5><h6 id="testc">Donkey</h6>';
+        $pageContent = new PageContent(new Page(['html' => $content]));
+        $navMap = $pageContent->getNavigation($content);
+
+        $this->assertCount(3, $navMap);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h4',
+            'level' => 1,
+        ], $navMap[0]);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h5',
+            'level' => 2,
+        ], $navMap[1]);
+        $this->assertArrayMapIncludes([
+            'nodeName' => 'h6',
+            'level' => 3,
+        ], $navMap[2]);
+    }
 }
index f29231c3966c8d29f014f4e7999a4db58cd221a7..e83f78a10a8a8f2d566ece9586a115dd24d4c323 100644 (file)
@@ -1,11 +1,14 @@
 <?php namespace Tests;
 
-
 use BookStack\Entities\Repos\PageRepo;
 
 class PageDraftTest extends BrowserKitTest
 {
     protected $page;
+
+    /**
+     * @var PageRepo
+     */
     protected $pageRepo;
 
     public function setUp(): void
@@ -18,25 +21,26 @@ class PageDraftTest extends BrowserKitTest
     public function test_draft_content_shows_if_available()
     {
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
+        $this->asAdmin()->visit($this->page->getUrl('/edit'))
             ->dontSeeInField('html', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
+        $this->asAdmin()->visit($this->page->getUrl('/edit'))
             ->seeInField('html', $newContent);
     }
 
     public function test_draft_not_visible_by_others()
     {
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
+        $this->asAdmin()->visit($this->page->getUrl('/edit'))
             ->dontSeeInField('html', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $newUser = $this->getEditor();
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
-        $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit')
+
+        $this->actingAs($newUser)->visit($this->page->getUrl('/edit'))
             ->dontSeeInField('html', $newContent);
     }
 
@@ -44,7 +48,7 @@ class PageDraftTest extends BrowserKitTest
     {
         $this->asAdmin();
         $this->pageRepo->updatePageDraft($this->page, ['html' => 'test content']);
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
+        $this->asAdmin()->visit($this->page->getUrl('/edit'))
             ->see('You are currently editing a draft');
     }
 
@@ -52,7 +56,7 @@ class PageDraftTest extends BrowserKitTest
     {
         $nonEditedPage = \BookStack\Entities\Page::take(10)->get()->last();
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
+        $this->asAdmin()->visit($this->page->getUrl('/edit'))
             ->dontSeeInField('html', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
@@ -60,7 +64,7 @@ class PageDraftTest extends BrowserKitTest
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
 
         $this->actingAs($newUser)
-            ->visit($this->page->getUrl() . '/edit')
+            ->visit($this->page->getUrl('/edit'))
             ->see('Admin has started editing this page');
             $this->flushSession();
         $this->visit($nonEditedPage->getUrl() . '/edit')
@@ -84,11 +88,11 @@ class PageDraftTest extends BrowserKitTest
         $newUser = $this->getEditor();
 
         $this->actingAs($newUser)->visit('/')
-            ->visit($book->getUrl() . '/create-page')
-            ->visit($chapter->getUrl() . '/create-page')
+            ->visit($book->getUrl('/create-page'))
+            ->visit($chapter->getUrl('/create-page'))
             ->visit($book->getUrl())
             ->seeInElement('.book-contents', 'New Page');
-        
+
         $this->asAdmin()
             ->visit($book->getUrl())
             ->dontSeeInElement('.book-contents', 'New Page')
index 140f67fe81abb339614978e9fd913531ac5b47e4..38193ec1a6b7bd09cadbe04e6a8bcb3ea8765f98 100644 (file)
@@ -12,7 +12,7 @@ class PageRevisionTest extends TestCase
 
         $pageRepo = app(PageRepo::class);
         $page = Page::first();
-        $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+        $pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
         $pageRevision = $page->revisions->last();
 
         $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id);
@@ -30,8 +30,8 @@ class PageRevisionTest extends TestCase
 
         $pageRepo = app(PageRepo::class);
         $page = Page::first();
-        $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
-        $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+        $pageRepo->update($page, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
+        $pageRepo->update($page, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
         $page =  Page::find($page->id);
 
 
@@ -98,7 +98,7 @@ class PageRevisionTest extends TestCase
         $beforeRevisionCount = $page->revisions->count();
         $currentRevision = $page->getCurrentRevision();
         $resp = $this->asEditor()->delete($currentRevision->getUrl('/delete/'));
-        $resp->assertStatus(400);
+        $resp->assertRedirect($page->getUrl('/revisions'));
 
         $page = Page::find($page->id);
         $afterRevisionCount = $page->revisions->count();
index cad6d3c01e1d2aa3febca6ce2bfbf08151a21ba2..3c83d626aea73632dc93a5d08274a1a4d6e420da 100644 (file)
@@ -1,6 +1,5 @@
 <?php namespace Tests;
 
-use BookStack\Auth\Role;
 use BookStack\Entities\Book;
 use BookStack\Entities\Chapter;
 use BookStack\Entities\Page;
@@ -20,7 +19,7 @@ class SortTest extends TestCase
     {
         $this->asAdmin();
         $pageRepo = app(PageRepo::class);
-        $draft = $pageRepo->getDraftPage($this->book);
+        $draft = $pageRepo->getNewDraftPage($this->book);
 
         $resp = $this->get($this->book->getUrl());
         $resp->assertSee($draft->name);
@@ -214,7 +213,6 @@ class SortTest extends TestCase
             'entity_selection' => 'book:' . $newBook->id,
             'name' => 'My copied test page'
         ]);
-
         $pageCopy = Page::where('name', '=', 'My copied test page')->first();
 
         $movePageResp->assertRedirect($pageCopy->getUrl());
index 70b8c960b73980544b74356d2666c0cd89fee067..13876410a0cddccaf4e3d04783e5735ccfd78697 100644 (file)
@@ -3,6 +3,7 @@
 use BookStack\Entities\Book;
 use BookStack\Entities\Chapter;
 use BookStack\Actions\Tag;
+use BookStack\Entities\Entity;
 use BookStack\Entities\Page;
 use BookStack\Auth\Permissions\PermissionService;
 
@@ -14,9 +15,9 @@ class TagTest extends BrowserKitTest
     /**
      * Get an instance of a page that has many tags.
      * @param \BookStack\Actions\Tag[]|bool $tags
-     * @return mixed
+     * @return Entity
      */
-    protected function getEntityWithTags($class, $tags = false)
+    protected function getEntityWithTags($class, $tags = false): Entity
     {
         $entity = $class::first();
 
@@ -122,7 +123,7 @@ class TagTest extends BrowserKitTest
         // Set restricted permission the page
         $page->restricted = true;
         $page->save();
-        $permissionService->buildJointPermissionsForEntity($page);
+        $page->rebuildPermissions();
 
         $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']);
         $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals([]);
index f6e07c0f1efe72b64e2ccdb34b80ab0448f88058..d899c6396041a9d8a46d695efff0599ce0159a83 100644 (file)
@@ -5,14 +5,13 @@ use BookStack\Entities\Bookshelf;
 use BookStack\Entities\Chapter;
 use BookStack\Entities\Entity;
 use BookStack\Auth\User;
-use BookStack\Entities\Repos\EntityRepo;
 use BookStack\Entities\Page;
 
 class RestrictionsTest extends BrowserKitTest
 {
 
     /**
-     * @var \BookStack\Auth\User
+     * @var User
      */
     protected $user;
 
@@ -327,7 +326,7 @@ class RestrictionsTest extends BrowserKitTest
 
     public function test_page_view_restriction()
     {
-        $page = \BookStack\Entities\Page::first();
+        $page = Page::first();
 
         $pageUrl = $page->getUrl();
         $this->actingAs($this->user)
@@ -367,7 +366,7 @@ class RestrictionsTest extends BrowserKitTest
 
     public function test_page_delete_restriction()
     {
-        $page = \BookStack\Entities\Page::first();
+        $page = Page::first();
 
         $pageUrl = $page->getUrl();
         $this->actingAs($this->user)
@@ -438,7 +437,7 @@ class RestrictionsTest extends BrowserKitTest
 
     public function test_page_restriction_form()
     {
-        $page = \BookStack\Entities\Page::first();
+        $page = Page::first();
         $this->asAdmin()->visit($page->getUrl() . '/permissions')
             ->see('Page Permissions')
             ->check('restricted')
@@ -665,10 +664,8 @@ class RestrictionsTest extends BrowserKitTest
         $this->setEntityRestrictions($firstBook, ['view', 'update']);
         $this->setEntityRestrictions($secondBook, ['view']);
 
-        $firstBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter',
-                ['name' => 'first book chapter'], $firstBook);
-        $secondBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter',
-                ['name' => 'second book chapter'], $secondBook);
+        $firstBookChapter = $this->newChapter(['name' => 'first book chapter'], $firstBook);
+        $secondBookChapter = $this->newChapter(['name' => 'second book chapter'], $secondBook);
 
         // Create request data
         $reqData = [
index ed92fa59f45edffeb2e8bf96132d613c1fa731a0..3433f3b832f86d61411729ea23983905596d57d0 100644 (file)
@@ -1,9 +1,14 @@
 <?php namespace Tests;
 
+use BookStack\Auth\User;
 use BookStack\Entities\Book;
+use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Chapter;
 use BookStack\Entities\Entity;
 use BookStack\Entities\Page;
-use BookStack\Entities\Repos\EntityRepo;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Repos\BookshelfRepo;
+use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Auth\Permissions\PermissionsRepo;
 use BookStack\Auth\Role;
 use BookStack\Auth\Permissions\PermissionService;
@@ -11,6 +16,8 @@ use BookStack\Entities\Repos\PageRepo;
 use BookStack\Settings\SettingService;
 use BookStack\Uploads\HttpFetcher;
 use Illuminate\Support\Env;
+use Mockery;
+use Throwable;
 
 trait SharedTestHelpers
 {
@@ -68,7 +75,7 @@ trait SharedTestHelpers
      */
     protected function getViewer($attributes = [])
     {
-        $user = \BookStack\Auth\Role::getRole('viewer')->users()->first();
+        $user = Role::getRole('viewer')->users()->first();
         if (!empty($attributes)) $user->forceFill($attributes)->save();
         return $user;
     }
@@ -76,21 +83,21 @@ trait SharedTestHelpers
     /**
      * Regenerate the permission for an entity.
      * @param Entity $entity
-     * @throws \Throwable
+     * @throws Throwable
      */
     protected function regenEntityPermissions(Entity $entity)
     {
-        app(PermissionService::class)->buildJointPermissionsForEntity($entity);
+        $entity->rebuildPermissions();
         $entity->load('jointPermissions');
     }
 
     /**
      * Create and return a new bookshelf.
      * @param array $input
-     * @return \BookStack\Entities\Bookshelf
+     * @return Bookshelf
      */
     public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) {
-        return app(EntityRepo::class)->createFromInput('bookshelf', $input);
+        return app(BookshelfRepo::class)->create($input, []);
     }
 
     /**
@@ -99,30 +106,30 @@ trait SharedTestHelpers
      * @return Book
      */
     public function newBook($input = ['name' => 'test book', 'description' => 'My new test book']) {
-        return app(EntityRepo::class)->createFromInput('book', $input);
+        return app(BookRepo::class)->create($input);
     }
 
     /**
      * Create and return a new test chapter
      * @param array $input
      * @param Book $book
-     * @return \BookStack\Entities\Chapter
+     * @return Chapter
      */
     public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) {
-        return app(EntityRepo::class)->createFromInput('chapter', $input, $book);
+        return app(ChapterRepo::class)->create($input, $book);
     }
 
     /**
      * Create and return a new test page
      * @param array $input
      * @return Page
-     * @throws \Throwable
+     * @throws Throwable
      */
     public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) {
         $book = Book::first();
         $pageRepo = app(PageRepo::class);
-        $draftPage = $pageRepo->getDraftPage($book);
-        return $pageRepo->publishPageDraft($draftPage, $input);
+        $draftPage = $pageRepo->getNewDraftPage($book);
+        return $pageRepo->publishDraft($draftPage, $input);
     }
 
     /**
@@ -167,10 +174,10 @@ trait SharedTestHelpers
 
     /**
      * Give the given user some permissions.
-     * @param \BookStack\Auth\User $user
+     * @param User $user
      * @param array $permissions
      */
-    protected function giveUserPermissions(\BookStack\Auth\User $user, $permissions = [])
+    protected function giveUserPermissions(User $user, $permissions = [])
     {
         $newRole = $this->createNewRole($permissions);
         $user->attachRole($newRole);
@@ -198,7 +205,7 @@ trait SharedTestHelpers
      */
     protected function mockHttpFetch($returnData, int $times = 1)
     {
-        $mockHttp = \Mockery::mock(HttpFetcher::class);
+        $mockHttp = Mockery::mock(HttpFetcher::class);
         $this->app[HttpFetcher::class] = $mockHttp;
         $mockHttp->shouldReceive('fetch')
             ->times($times)
@@ -215,13 +222,11 @@ trait SharedTestHelpers
     protected function runWithEnv(string $name, $value, callable $callback)
     {
         Env::disablePutenv();
-        $originalVal = $_ENV[$name] ?? null;
+        $originalVal = $_SERVER[$name] ?? null;
 
         if (is_null($value)) {
-            unset($_ENV[$name]);
             unset($_SERVER[$name]);
         } else {
-            $_ENV[$name] = $value;
             $_SERVER[$name] = $value;
         }
 
@@ -230,10 +235,8 @@ trait SharedTestHelpers
 
         if (is_null($originalVal)) {
             unset($_SERVER[$name]);
-            unset($_ENV[$name]);
         } else {
             $_SERVER[$name] = $originalVal;
-            $_ENV[$name] = $originalVal;
         }
     }
 
index 7b9f64e6a7fa3062c5c7ad80ac6b61c98a9c3eae..c84305ad88112354a418f6e9c01a58cded82b9eb 100644 (file)
@@ -20,7 +20,6 @@ class ConfigTest extends TestCase
 
     public function test_filesystem_attachments_falls_back_to_storage_type_var()
     {
-        putenv('STORAGE_TYPE=local_secure');
         $this->runWithEnv('STORAGE_TYPE', 'local_secure', function() {
             $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', 's3', 'filesystems.attachments', 's3');
             $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', null, 'filesystems.attachments', 'local_secure');
diff --git a/tests/Unit/PageRepoTest.php b/tests/Unit/PageRepoTest.php
deleted file mode 100644 (file)
index 38ffbf6..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-<?php
-namespace Tests;
-
-use BookStack\Entities\Repos\PageRepo;
-
-class PageRepoTest extends TestCase
-{
-    /**
-     * @var PageRepo $pageRepo
-     */
-    protected $pageRepo;
-
-    protected function setUp(): void
-    {
-        parent::setUp();
-        $this->pageRepo = app()->make(PageRepo::class);
-    }
-
-    public function test_get_page_nav_sets_correct_properties()
-    {
-        $content = '<h1 id="testa">Hello</h1><h2 id="testb">There</h2><h3 id="testc">Donkey</h3>';
-        $navMap = $this->pageRepo->getPageNav($content);
-
-        $this->assertCount(3, $navMap);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h1',
-            'link' => '#testa',
-            'text' => 'Hello',
-            'level' => 1,
-        ], $navMap[0]);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h2',
-            'link' => '#testb',
-            'text' => 'There',
-            'level' => 2,
-        ], $navMap[1]);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h3',
-            'link' => '#testc',
-            'text' => 'Donkey',
-            'level' => 3,
-        ], $navMap[2]);
-    }
-
-    public function test_get_page_nav_does_not_show_empty_titles()
-    {
-        $content = '<h1 id="testa">Hello</h1><h2 id="testb">&nbsp;</h2><h3 id="testc"></h3>';
-        $navMap = $this->pageRepo->getPageNav($content);
-
-        $this->assertCount(1, $navMap);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h1',
-            'link' => '#testa',
-            'text' => 'Hello'
-        ], $navMap[0]);
-    }
-
-    public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used()
-    {
-        $content = '<h4 id="testa">Hello</h4><h5 id="testb">There</h5><h6 id="testc">Donkey</h6>';
-        $navMap = $this->pageRepo->getPageNav($content);
-
-        $this->assertCount(3, $navMap);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h4',
-            'level' => 1,
-        ], $navMap[0]);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h5',
-            'level' => 2,
-        ], $navMap[1]);
-        $this->assertArrayMapIncludes([
-            'nodeName' => 'h6',
-            'level' => 3,
-        ], $navMap[2]);
-    }
-
-}
\ No newline at end of file
index 0d51e050fa6c421c4d6593e94570687055f42a49..12b254d00a4c84ac87877d8e3e49917163f7c071 100644 (file)
@@ -223,7 +223,7 @@ class AttachmentTest extends TestCase
     {
         $admin = $this->getAdmin();
         $viewer = $this->getViewer();
-        $page = Page::first();
+        $page = Page::first(); /** @var Page $page */
 
         $this->actingAs($admin);
         $fileName = 'permission_test.txt';
@@ -233,7 +233,7 @@ class AttachmentTest extends TestCase
         $page->restricted = true;
         $page->permissions()->delete();
         $page->save();
-        $this->app[PermissionService::class]->buildJointPermissionsForEntity($page);
+        $page->rebuildPermissions();
         $page->load('jointPermissions');
 
         $this->actingAs($viewer);
index 4d3e8a498db4f66367eb57877876d3693c4021f8..0615a95ce52641cff442ce913d59bb65f812d2b3 100644 (file)
@@ -367,7 +367,7 @@ class ImageTest extends TestCase
         $image = Image::where('type', '=', 'gallery')->first();
 
         $pageRepo = app(PageRepo::class);
-        $pageRepo->updatePage($page, $page->book_id, [
+        $pageRepo->update($page, [
             'name' => $page->name,
             'html' => $page->html . "<img src=\"{$image->url}\">",
             'summary' => ''
@@ -379,7 +379,7 @@ class ImageTest extends TestCase
         $this->assertCount(0, $toDelete);
 
         // Save a revision of our page without the image;
-        $pageRepo->updatePage($page, $page->book_id, [
+        $pageRepo->update($page, [
             'name' => $page->name,
             'html' => "<p>Hello</p>",
             'summary' => ''