Merge branch 'master' into release
This commit is contained in:
commit
e22c9cae91
15
.env.example
15
.env.example
|
@ -1,3 +1,11 @@
|
|||
# This file, when named as ".env" in the root of your BookStack install
|
||||
# folder, is used for the core configuration of the application.
|
||||
# By default this file contains the most common required options but
|
||||
# a full list of options can be found in the '.env.example.complete' file.
|
||||
|
||||
# NOTE: If any of your values contain a space or a hash you will need to
|
||||
# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
|
||||
|
||||
# Application key
|
||||
# Used for encryption where needed.
|
||||
# Run `php artisan key:generate` to generate a valid key.
|
||||
|
@ -5,7 +13,7 @@ APP_KEY=SomeRandomString
|
|||
|
||||
# Application URL
|
||||
# Remove the hash below and set a URL if using BookStack behind
|
||||
# a proxy, if using a third-party authentication option.
|
||||
# a proxy or if using a third-party authentication option.
|
||||
# This must be the root URL that you want to host BookStack on.
|
||||
# All URL's in BookStack will be generated using this value.
|
||||
#APP_URL=https://example.com
|
||||
|
@ -25,11 +33,10 @@ MAIL_FROM_NAME=BookStack
|
|||
MAIL_FROM=bookstack@example.com
|
||||
|
||||
# SMTP mail options
|
||||
# These settings can be checked using the "Send a Test Email"
|
||||
# feature found in the "Settings > Maintenance" area of the system.
|
||||
MAIL_HOST=localhost
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
|
||||
|
||||
# A full list of options can be found in the '.env.example.complete' file.
|
|
@ -271,3 +271,11 @@ API_MAX_ITEM_COUNT=500
|
|||
|
||||
# The number of API requests that can be made per minute by a single user.
|
||||
API_REQUESTS_PER_MIN=180
|
||||
|
||||
# Enable the logging of failed email+password logins with the given message.
|
||||
# The default log channel below uses the php 'error_log' function which commonly
|
||||
# results in messages being output to the webserver error logs.
|
||||
# The message can contain a %u parameter which will be replaced with the login
|
||||
# user identifier (Username or email).
|
||||
LOG_FAILED_LOGIN_MESSAGE=false
|
||||
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
||||
|
|
|
@ -61,7 +61,7 @@ Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
|
|||
aekramer :: Dutch
|
||||
JachuPL :: Polish
|
||||
milesteg :: Hungarian
|
||||
Beenbag :: German
|
||||
Beenbag :: German; German Informal
|
||||
Lett3rs :: Danish
|
||||
Julian (julian.henneberg) :: German; German Informal
|
||||
3GNWn :: Danish
|
||||
|
@ -98,3 +98,25 @@ Thinkverse (thinkverse) :: Swedish
|
|||
alef (toishoki) :: Turkish
|
||||
Robbert Feunekes (Muukuro) :: Dutch
|
||||
seohyeon.joo :: Korean
|
||||
Orenda (OREDNA) :: Bulgarian
|
||||
Marek Pavelka (marapavelka) :: Czech
|
||||
Venkinovec :: Czech
|
||||
Tommy Ku (tommyku) :: Chinese Traditional; Japanese
|
||||
Michał Bielejewski (bielej) :: Polish
|
||||
jozefrebjak :: Slovak
|
||||
Ikhwan Koo (Ikhwan.Koo) :: Korean
|
||||
Whay (remkovdhoef) :: Dutch
|
||||
jc7115 :: Chinese Traditional
|
||||
주서현 (seohyeon.joo) :: Korean
|
||||
ReadySystems :: Arabic
|
||||
HFinch :: German; German Informal
|
||||
brechtgijsens :: Dutch
|
||||
Lowkey (v587ygq) :: Chinese Simplified
|
||||
sdl-blue :: German Informal
|
||||
sqlik :: Polish
|
||||
Roy van Schaijk (royvanschaijk) :: Dutch
|
||||
Simsimpicpic :: French
|
||||
Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
|
||||
tatsuya.info :: Japanese
|
||||
fadiapp :: Arabic
|
||||
Jakub “Jéžiš” Bouček (jakubboucek) :: Czech
|
||||
|
|
|
@ -4,6 +4,7 @@ use BookStack\Auth\Permissions\PermissionService;
|
|||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Entity;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
|
@ -159,4 +160,20 @@ class ActivityService
|
|||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace("%u", $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use BookStack\Model;
|
|||
class Tag extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'value', 'order'];
|
||||
protected $hidden = ['id', 'entity_id', 'entity_type'];
|
||||
|
||||
/**
|
||||
* Get the entity that this tag belongs to
|
||||
|
|
|
@ -2,71 +2,31 @@
|
|||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Entity;
|
||||
use DB;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Class TagRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class TagRepo
|
||||
{
|
||||
|
||||
protected $tag;
|
||||
protected $entity;
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* TagRepo constructor.
|
||||
* @param \BookStack\Actions\Tag $attr
|
||||
* @param \BookStack\Entities\Entity $ent
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $ps
|
||||
*/
|
||||
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
|
||||
public function __construct(Tag $tag, PermissionService $ps)
|
||||
{
|
||||
$this->tag = $attr;
|
||||
$this->entity = $ent;
|
||||
$this->tag = $tag;
|
||||
$this->permissionService = $ps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an entity instance of its particular type.
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @param string $action
|
||||
* @return \Illuminate\Database\Eloquent\Model|null|static
|
||||
*/
|
||||
public function getEntity($entityType, $entityId, $action = 'view')
|
||||
{
|
||||
$entityInstance = $this->entity->getEntityInstance($entityType);
|
||||
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
|
||||
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
|
||||
return $searchQuery->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a particular entity.
|
||||
* @param string $entityType
|
||||
* @param int $entityId
|
||||
* @return mixed
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$entity = $this->getEntity($entityType, $entityId);
|
||||
if ($entity === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $entity->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
* @param $searchTerm
|
||||
* @return array
|
||||
*/
|
||||
public function getNameSuggestions($searchTerm = false)
|
||||
public function getNameSuggestions(?string $searchTerm): Collection
|
||||
{
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
|
||||
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
|
@ -82,13 +42,10 @@ class TagRepo
|
|||
* Get tag value suggestions from scanning existing tag values.
|
||||
* If no search is given the 50 most popular values are provided.
|
||||
* Passing a tagName will only find values for a tags with a particular name.
|
||||
* @param $searchTerm
|
||||
* @param $tagName
|
||||
* @return array
|
||||
*/
|
||||
public function getValueSuggestions($searchTerm = false, $tagName = false)
|
||||
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
|
||||
{
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
|
||||
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||
|
@ -96,7 +53,7 @@ class TagRepo
|
|||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
if ($tagName !== false) {
|
||||
if ($tagName) {
|
||||
$query = $query->where('name', '=', $tagName);
|
||||
}
|
||||
|
||||
|
@ -106,35 +63,28 @@ class TagRepo
|
|||
|
||||
/**
|
||||
* Save an array of tags to an entity
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @param array $tags
|
||||
* @return array|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function saveTagsToEntity(Entity $entity, $tags = [])
|
||||
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
|
||||
{
|
||||
$entity->tags()->delete();
|
||||
$newTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (trim($tag['name']) === '') {
|
||||
continue;
|
||||
}
|
||||
$newTags[] = $this->newInstanceFromInput($tag);
|
||||
}
|
||||
|
||||
$newTags = collect($tags)->filter(function ($tag) {
|
||||
return boolval(trim($tag['name']));
|
||||
})->map(function ($tag) {
|
||||
return $this->newInstanceFromInput($tag);
|
||||
})->all();
|
||||
|
||||
return $entity->tags()->saveMany($newTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Tag instance from user input.
|
||||
* @param $input
|
||||
* @return \BookStack\Actions\Tag
|
||||
* Input must be an array with a 'name' and an optional 'value' key.
|
||||
*/
|
||||
protected function newInstanceFromInput($input)
|
||||
protected function newInstanceFromInput(array $input): Tag
|
||||
{
|
||||
$name = trim($input['name']);
|
||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||
// Any other modification or cleanup required can go here
|
||||
$values = ['name' => $name, 'value' => $value];
|
||||
return $this->tag->newInstance($values);
|
||||
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExternalAuthService
|
||||
{
|
||||
|
@ -39,22 +41,14 @@ class ExternalAuthService
|
|||
/**
|
||||
* Match an array of group names to BookStack system roles.
|
||||
* Formats group names to be lower-case and hyphenated.
|
||||
* @param array $groupNames
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames)
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
|
||||
{
|
||||
foreach ($groupNames as $i => $groupName) {
|
||||
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
|
||||
}
|
||||
|
||||
$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
|
||||
$query->whereIn('name', $groupNames);
|
||||
foreach ($groupNames as $groupName) {
|
||||
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
|
||||
}
|
||||
})->get();
|
||||
|
||||
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
|
||||
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
|
||||
return $this->roleMatchesGroupNames($role, $groupNames);
|
||||
});
|
||||
|
|
|
@ -71,15 +71,15 @@ class RegistrationService
|
|||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
$newUser->save();
|
||||
$message = '';
|
||||
|
||||
try {
|
||||
$this->emailConfirmationService->sendConfirmation($newUser);
|
||||
session()->flash('sent-email-confirmation', true);
|
||||
} catch (Exception $e) {
|
||||
$message = trans('auth.email_confirm_send_error');
|
||||
throw new UserRegistrationException($message, '/register/confirm');
|
||||
}
|
||||
|
||||
throw new UserRegistrationException($message, '/register/confirm');
|
||||
}
|
||||
|
||||
return $newUser;
|
||||
|
|
|
@ -311,7 +311,6 @@ class Saml2Service extends ExternalAuthService
|
|||
|
||||
/**
|
||||
* Get the user from the database for the specified details.
|
||||
* @throws SamlException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function getOrRegisterUser(array $userDetails): ?User
|
||||
|
|
|
@ -3,25 +3,26 @@
|
|||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
|
||||
class JointPermission extends Model
|
||||
{
|
||||
protected $primaryKey = null;
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role that this points to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function role()
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity this points to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
|
||||
*/
|
||||
public function entity()
|
||||
public function entity(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Entity::class, 'entity');
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Permissions;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PermissionsRepo
|
||||
|
@ -16,11 +17,8 @@ class PermissionsRepo
|
|||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
* @param RolePermission $permission
|
||||
* @param Role $role
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
|
||||
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
|
||||
{
|
||||
$this->permission = $permission;
|
||||
$this->role = $role;
|
||||
|
@ -29,46 +27,34 @@ class PermissionsRepo
|
|||
|
||||
/**
|
||||
* Get all the user roles from the system.
|
||||
* @return \Illuminate\Database\Eloquent\Collection|static[]
|
||||
*/
|
||||
public function getAllRoles()
|
||||
public function getAllRoles(): Collection
|
||||
{
|
||||
return $this->role->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the roles except for the provided one.
|
||||
* @param Role $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAllRolesExcept(Role $role)
|
||||
public function getAllRolesExcept(Role $role): Collection
|
||||
{
|
||||
return $this->role->where('id', '!=', $role->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a role via its ID.
|
||||
* @param $id
|
||||
* @return mixed
|
||||
*/
|
||||
public function getRoleById($id)
|
||||
public function getRoleById($id): Role
|
||||
{
|
||||
return $this->role->findOrFail($id);
|
||||
return $this->role->newQuery()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new role into the system.
|
||||
* @param array $roleData
|
||||
* @return Role
|
||||
*/
|
||||
public function saveNewRole($roleData)
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
$role = $this->role->newInstance($roleData);
|
||||
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
|
||||
// Prevent duplicate names
|
||||
while ($this->role->where('name', '=', $role->name)->count() > 0) {
|
||||
$role->name .= strtolower(Str::random(2));
|
||||
}
|
||||
$role->save();
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
|
@ -80,13 +66,11 @@ class PermissionsRepo
|
|||
/**
|
||||
* Updates an existing role.
|
||||
* Ensure Admin role always have core permissions.
|
||||
* @param $roleId
|
||||
* @param $roleData
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function updateRole($roleId, $roleData)
|
||||
public function updateRole($roleId, array $roleData)
|
||||
{
|
||||
$role = $this->role->findOrFail($roleId);
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
if ($role->system_name === 'admin') {
|
||||
|
@ -108,16 +92,19 @@ class PermissionsRepo
|
|||
|
||||
/**
|
||||
* Assign an list of permission names to an role.
|
||||
* @param Role $role
|
||||
* @param array $permissionNameArray
|
||||
*/
|
||||
public function assignRolePermissions(Role $role, $permissionNameArray = [])
|
||||
public function assignRolePermissions(Role $role, array $permissionNameArray = [])
|
||||
{
|
||||
$permissions = [];
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
if ($permissionNameArray && count($permissionNameArray) > 0) {
|
||||
$permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
|
||||
|
||||
if ($permissionNameArray) {
|
||||
$permissions = $this->permission->newQuery()
|
||||
->whereIn('name', $permissionNameArray)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$role->permissions()->sync($permissions);
|
||||
}
|
||||
|
||||
|
@ -126,13 +113,13 @@ class PermissionsRepo
|
|||
* Check it's not an admin role or set as default before deleting.
|
||||
* If an migration Role ID is specified the users assign to the current role
|
||||
* will be added to the role of the specified id.
|
||||
* @param $roleId
|
||||
* @param $migrateRoleId
|
||||
* @throws PermissionsException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteRole($roleId, $migrateRoleId)
|
||||
{
|
||||
$role = $this->role->findOrFail($roleId);
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
|
||||
// Prevent deleting admin role or default registration role.
|
||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||
|
@ -142,9 +129,9 @@ class PermissionsRepo
|
|||
}
|
||||
|
||||
if ($migrateRoleId) {
|
||||
$newRole = $this->role->find($migrateRoleId);
|
||||
$newRole = $this->role->newQuery()->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
$users = $role->users->pluck('id')->toArray();
|
||||
$users = $role->users()->pluck('id')->toArray();
|
||||
$newRole->users()->sync($users);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
*/
|
||||
class RolePermission extends Model
|
||||
{
|
||||
/**
|
||||
|
|
|
@ -3,13 +3,16 @@
|
|||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Class Role
|
||||
* @property int $id
|
||||
* @property string $display_name
|
||||
* @property string $description
|
||||
* @property string $external_auth_id
|
||||
* @package BookStack\Auth
|
||||
* @property string $system_name
|
||||
*/
|
||||
class Role extends Model
|
||||
{
|
||||
|
@ -26,9 +29,8 @@ class Role extends Model
|
|||
|
||||
/**
|
||||
* Get all related JointPermissions.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function jointPermissions()
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class);
|
||||
}
|
||||
|
@ -43,10 +45,8 @@ class Role extends Model
|
|||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
* @param $permissionName
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPermission($permissionName)
|
||||
public function hasPermission(string $permissionName): bool
|
||||
{
|
||||
$permissions = $this->getRelationValue('permissions');
|
||||
foreach ($permissions as $permission) {
|
||||
|
@ -59,7 +59,6 @@ class Role extends Model
|
|||
|
||||
/**
|
||||
* Add a permission to this role.
|
||||
* @param RolePermission $permission
|
||||
*/
|
||||
public function attachPermission(RolePermission $permission)
|
||||
{
|
||||
|
@ -68,7 +67,6 @@ class Role extends Model
|
|||
|
||||
/**
|
||||
* Detach a single permission from this role.
|
||||
* @param RolePermission $permission
|
||||
*/
|
||||
public function detachPermission(RolePermission $permission)
|
||||
{
|
||||
|
@ -76,39 +74,33 @@ class Role extends Model
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the role object for the specified role.
|
||||
* @param $roleName
|
||||
* @return Role
|
||||
* Get the role of the specified display name.
|
||||
*/
|
||||
public static function getRole($roleName)
|
||||
public static function getRole(string $displayName): ?Role
|
||||
{
|
||||
return static::query()->where('name', '=', $roleName)->first();
|
||||
return static::query()->where('display_name', '=', $displayName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role object for the specified system role.
|
||||
* @param $roleName
|
||||
* @return Role
|
||||
*/
|
||||
public static function getSystemRole($roleName)
|
||||
public static function getSystemRole(string $systemName): ?Role
|
||||
{
|
||||
return static::query()->where('system_name', '=', $roleName)->first();
|
||||
return static::query()->where('system_name', '=', $systemName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all visible roles
|
||||
* @return mixed
|
||||
*/
|
||||
public static function visible()
|
||||
public static function visible(): Collection
|
||||
{
|
||||
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()
|
||||
public static function restrictable(): Collection
|
||||
{
|
||||
return static::query()->where('system_name', '!=', 'admin')->get();
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
|||
*/
|
||||
protected $hidden = [
|
||||
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
|
||||
'created_at', 'updated_at',
|
||||
'created_at', 'updated_at', 'image_id',
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -101,12 +101,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
|||
|
||||
/**
|
||||
* Check if the user has a role.
|
||||
* @param $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function hasRole($role)
|
||||
public function hasRole($roleId): bool
|
||||
{
|
||||
return $this->roles->pluck('name')->contains($role);
|
||||
return $this->roles->pluck('id')->contains($roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -163,7 +161,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
|||
|
||||
/**
|
||||
* Attach a role to this user.
|
||||
* @param Role $role
|
||||
*/
|
||||
public function attachRole(Role $role)
|
||||
{
|
||||
|
|
|
@ -238,7 +238,7 @@ class UserRepo
|
|||
*/
|
||||
public function getAllRoles()
|
||||
{
|
||||
return $this->role->newQuery()->orderBy('name', 'asc')->get();
|
||||
return $this->role->newQuery()->orderBy('display_name', 'asc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -52,7 +52,7 @@ return [
|
|||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
|
||||
'locales' => ['en', 'ar', 'bg', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Handler\ErrorLogHandler;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
|
||||
|
@ -73,10 +75,38 @@ return [
|
|||
'level' => 'debug',
|
||||
],
|
||||
|
||||
// Custom errorlog implementation that logs out a plain,
|
||||
// non-formatted message intended for the webserver log.
|
||||
'errorlog_plain_webserver' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => 'debug',
|
||||
'handler' => ErrorLogHandler::class,
|
||||
'handler_with' => [4],
|
||||
'formatter' => LineFormatter::class,
|
||||
'formatter_with' => [
|
||||
'format' => "%message%",
|
||||
],
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
// Testing channel
|
||||
// Uses a shared testing instance during tests
|
||||
// so that logs can be checked against.
|
||||
'testing' => [
|
||||
'driver' => 'testing',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
// Failed Login Message
|
||||
// Allows a configurable message to be logged when a login request fails.
|
||||
'failed_login' => [
|
||||
'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
|
||||
'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -101,7 +101,7 @@ return [
|
|||
'url' => env('SAML2_IDP_SLO', null),
|
||||
// URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
|
||||
// if not set, url for the SLO Request will be used
|
||||
'responseUrl' => '',
|
||||
'responseUrl' => null,
|
||||
// SAML protocol binding to be used when returning the <Response>
|
||||
// message. Onelogin Toolkit supports for this endpoint the
|
||||
// HTTP-Redirect binding only
|
||||
|
|
|
@ -19,7 +19,7 @@ class Book extends Entity implements HasCoverImage
|
|||
public $searchFactor = 2;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
protected $hidden = ['restricted', 'pivot'];
|
||||
protected $hidden = ['restricted', 'pivot', 'image_id'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
|
|
|
@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
|||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['restricted'];
|
||||
protected $hidden = ['restricted', 'image_id'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
|
|
|
@ -12,6 +12,7 @@ class Chapter extends BookChild
|
|||
public $searchFactor = 1.3;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
||||
protected $hidden = ['restricted', 'pivot'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
|
|
|
@ -238,10 +238,8 @@ class Entity extends Ownable
|
|||
|
||||
/**
|
||||
* Gets a limited-length version of the entities name.
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
public function getShortName($length = 25)
|
||||
public function getShortName(int $length = 25): string
|
||||
{
|
||||
if (mb_strlen($this->name) <= $length) {
|
||||
return $this->name;
|
||||
|
@ -288,7 +286,7 @@ class Entity extends Ownable
|
|||
public function rebuildPermissions()
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
Permissions::buildJointPermissionsForEntity($this);
|
||||
Permissions::buildJointPermissionsForEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -297,7 +295,7 @@ class Entity extends Ownable
|
|||
public function indexForSearch()
|
||||
{
|
||||
$searchService = app()->make(SearchService::class);
|
||||
$searchService->indexEntity($this);
|
||||
$searchService->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -108,7 +108,7 @@ class PageContent
|
|||
protected function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
return strip_tags($html);
|
||||
return html_entity_decode(strip_tags($html));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -21,12 +21,14 @@ use Permissions;
|
|||
*/
|
||||
class Page extends BookChild
|
||||
{
|
||||
protected $fillable = ['name', 'html', 'priority', 'markdown'];
|
||||
protected $fillable = ['name', 'priority', 'markdown'];
|
||||
|
||||
protected $simpleAttributes = ['name', 'id', 'slug'];
|
||||
|
||||
public $textField = 'text';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
|
|
|
@ -180,12 +180,11 @@ class PageRepo
|
|||
$page->template = ($input['template'] === 'true');
|
||||
}
|
||||
|
||||
$pageContent = new PageContent($page);
|
||||
$pageContent->setNewHTML($input['html']);
|
||||
$this->baseRepo->update($page, $input);
|
||||
|
||||
// Update with new details
|
||||
$page->fill($input);
|
||||
$pageContent = new PageContent($page);
|
||||
$pageContent->setNewHTML($input['html']);
|
||||
$page->revision_count++;
|
||||
|
||||
if (setting('app-editor') !== 'markdown') {
|
||||
|
@ -211,7 +210,7 @@ class PageRepo
|
|||
*/
|
||||
protected function savePageRevision(Page $page, string $summary = null)
|
||||
{
|
||||
$revision = new PageRevision($page->toArray());
|
||||
$revision = new PageRevision($page->getAttributes());
|
||||
|
||||
if (setting('app-editor') !== 'markdown') {
|
||||
$revision->markdown = '';
|
||||
|
@ -279,7 +278,7 @@ class PageRepo
|
|||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
$page->fill($revision->toArray());
|
||||
$content = new PageContent($page);
|
||||
$content->setNewHTML($page->html);
|
||||
$content->setNewHTML($revision->html);
|
||||
$page->updated_by = user()->id;
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
<?php namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchOptions
|
||||
{
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $searches = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $exacts = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $tags = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $filters = [];
|
||||
|
||||
/**
|
||||
* Create a new instance from a search string.
|
||||
*/
|
||||
public static function fromString(string $search): SearchOptions
|
||||
{
|
||||
$decoded = static::decode($search);
|
||||
$instance = new static();
|
||||
foreach ($decoded as $type => $value) {
|
||||
$instance->$type = $value;
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from a request.
|
||||
* Will look for a classic string term and use that
|
||||
* Otherwise we'll use the details from an advanced search form.
|
||||
*/
|
||||
public static function fromRequest(Request $request): SearchOptions
|
||||
{
|
||||
if (!$request->has('search') && !$request->has('term')) {
|
||||
return static::fromString('');
|
||||
}
|
||||
|
||||
if ($request->has('term')) {
|
||||
return static::fromString($request->get('term'));
|
||||
}
|
||||
|
||||
$instance = new static();
|
||||
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
|
||||
$instance->searches = explode(' ', $inputs['search'] ?? []);
|
||||
$instance->exacts = array_filter($inputs['exact'] ?? []);
|
||||
$instance->tags = array_filter($inputs['tags'] ?? []);
|
||||
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
|
||||
if (empty($filterVal)) {
|
||||
continue;
|
||||
}
|
||||
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
|
||||
}
|
||||
if (isset($inputs['types']) && count($inputs['types']) < 4) {
|
||||
$instance->filters['type'] = implode('|', $inputs['types']);
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a search string into an array of terms.
|
||||
*/
|
||||
protected static function decode(string $searchString): array
|
||||
{
|
||||
$terms = [
|
||||
'searches' => [],
|
||||
'exacts' => [],
|
||||
'tags' => [],
|
||||
'filters' => []
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'exacts' => '/"(.*?)"/',
|
||||
'tags' => '/\[(.*?)\]/',
|
||||
'filters' => '/\{(.*?)\}/'
|
||||
];
|
||||
|
||||
// Parse special terms
|
||||
foreach ($patterns as $termType => $pattern) {
|
||||
$matches = [];
|
||||
preg_match_all($pattern, $searchString, $matches);
|
||||
if (count($matches) > 0) {
|
||||
$terms[$termType] = $matches[1];
|
||||
$searchString = preg_replace($pattern, '', $searchString);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse standard terms
|
||||
foreach (explode(' ', trim($searchString)) as $searchTerm) {
|
||||
if ($searchTerm !== '') {
|
||||
$terms['searches'][] = $searchTerm;
|
||||
}
|
||||
}
|
||||
|
||||
// Split filter values out
|
||||
$splitFilters = [];
|
||||
foreach ($terms['filters'] as $filter) {
|
||||
$explodedFilter = explode(':', $filter, 2);
|
||||
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
|
||||
}
|
||||
$terms['filters'] = $splitFilters;
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode this instance to a search string.
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
$string = implode(' ', $this->searches ?? []);
|
||||
|
||||
foreach ($this->exacts as $term) {
|
||||
$string .= ' "' . $term . '"';
|
||||
}
|
||||
|
||||
foreach ($this->tags as $term) {
|
||||
$string .= " [{$term}]";
|
||||
}
|
||||
|
||||
foreach ($this->filters as $filterName => $filterVal) {
|
||||
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
}
|
|
@ -39,10 +39,6 @@ class SearchService
|
|||
|
||||
/**
|
||||
* SearchService constructor.
|
||||
* @param SearchTerm $searchTerm
|
||||
* @param EntityProvider $entityProvider
|
||||
* @param Connection $db
|
||||
* @param PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
|
||||
{
|
||||
|
@ -54,7 +50,6 @@ class SearchService
|
|||
|
||||
/**
|
||||
* Set the database connection
|
||||
* @param Connection $connection
|
||||
*/
|
||||
public function setConnection(Connection $connection)
|
||||
{
|
||||
|
@ -63,23 +58,18 @@ class SearchService
|
|||
|
||||
/**
|
||||
* Search all entities in the system.
|
||||
* @param string $searchString
|
||||
* @param string $entityType
|
||||
* @param int $page
|
||||
* @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
|
||||
* @param string $action
|
||||
* @return array[int, Collection];
|
||||
* The provided count is for each entity to search,
|
||||
* Total returned could can be larger and not guaranteed.
|
||||
*/
|
||||
public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
|
||||
{
|
||||
$terms = $this->parseSearchString($searchString);
|
||||
$entityTypes = array_keys($this->entityProvider->all());
|
||||
$entityTypesToSearch = $entityTypes;
|
||||
|
||||
if ($entityType !== 'all') {
|
||||
$entityTypesToSearch = $entityType;
|
||||
} else if (isset($terms['filters']['type'])) {
|
||||
$entityTypesToSearch = explode('|', $terms['filters']['type']);
|
||||
} else if (isset($searchOpts->filters['type'])) {
|
||||
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
|
||||
}
|
||||
|
||||
$results = collect();
|
||||
|
@ -90,8 +80,8 @@ class SearchService
|
|||
if (!in_array($entityType, $entityTypes)) {
|
||||
continue;
|
||||
}
|
||||
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
|
||||
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
|
||||
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
|
||||
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
|
||||
if ($entityTotal > $page * $count) {
|
||||
$hasMore = true;
|
||||
}
|
||||
|
@ -103,29 +93,26 @@ class SearchService
|
|||
'total' => $total,
|
||||
'count' => count($results),
|
||||
'has_more' => $hasMore,
|
||||
'results' => $results->sortByDesc('score')->values()
|
||||
'results' => $results->sortByDesc('score')->values(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Search a book for entities
|
||||
* @param integer $bookId
|
||||
* @param string $searchString
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchBook($bookId, $searchString)
|
||||
public function searchBook(int $bookId, string $searchString): Collection
|
||||
{
|
||||
$terms = $this->parseSearchString($searchString);
|
||||
$opts = SearchOptions::fromString($searchString);
|
||||
$entityTypes = ['page', 'chapter'];
|
||||
$entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
|
||||
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
|
||||
|
||||
$results = collect();
|
||||
foreach ($entityTypesToSearch as $entityType) {
|
||||
if (!in_array($entityType, $entityTypes)) {
|
||||
continue;
|
||||
}
|
||||
$search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
|
||||
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
|
||||
$results = $results->merge($search);
|
||||
}
|
||||
return $results->sortByDesc('score')->take(20);
|
||||
|
@ -133,30 +120,23 @@ class SearchService
|
|||
|
||||
/**
|
||||
* Search a book for entities
|
||||
* @param integer $chapterId
|
||||
* @param string $searchString
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchChapter($chapterId, $searchString)
|
||||
public function searchChapter(int $chapterId, string $searchString): Collection
|
||||
{
|
||||
$terms = $this->parseSearchString($searchString);
|
||||
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
||||
$opts = SearchOptions::fromString($searchString);
|
||||
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
||||
return $pages->sortByDesc('score');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across a particular entity type.
|
||||
* @param array $terms
|
||||
* @param string $entityType
|
||||
* @param int $page
|
||||
* @param int $count
|
||||
* @param string $action
|
||||
* @param bool $getCount Return the total count of the search
|
||||
* Setting getCount = true will return the total
|
||||
* matching instead of the items themselves.
|
||||
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
|
||||
*/
|
||||
public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
|
||||
public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
|
||||
{
|
||||
$query = $this->buildEntitySearchQuery($terms, $entityType, $action);
|
||||
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
|
||||
if ($getCount) {
|
||||
return $query->count();
|
||||
}
|
||||
|
@ -167,22 +147,18 @@ class SearchService
|
|||
|
||||
/**
|
||||
* Create a search query for an entity
|
||||
* @param array $terms
|
||||
* @param string $entityType
|
||||
* @param string $action
|
||||
* @return EloquentBuilder
|
||||
*/
|
||||
protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
|
||||
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
|
||||
{
|
||||
$entity = $this->entityProvider->get($entityType);
|
||||
$entitySelect = $entity->newQuery();
|
||||
|
||||
// Handle normal search terms
|
||||
if (count($terms['search']) > 0) {
|
||||
if (count($searchOpts->searches) > 0) {
|
||||
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
|
||||
$subQuery->where('entity_type', '=', $entity->getMorphClass());
|
||||
$subQuery->where(function (Builder $query) use ($terms) {
|
||||
foreach ($terms['search'] as $inputTerm) {
|
||||
$subQuery->where(function (Builder $query) use ($searchOpts) {
|
||||
foreach ($searchOpts->searches as $inputTerm) {
|
||||
$query->orWhere('term', 'like', $inputTerm .'%');
|
||||
}
|
||||
})->groupBy('entity_type', 'entity_id');
|
||||
|
@ -193,9 +169,9 @@ class SearchService
|
|||
}
|
||||
|
||||
// Handle exact term matching
|
||||
if (count($terms['exact']) > 0) {
|
||||
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
|
||||
foreach ($terms['exact'] as $inputTerm) {
|
||||
if (count($searchOpts->exacts) > 0) {
|
||||
$entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
|
||||
foreach ($searchOpts->exacts as $inputTerm) {
|
||||
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
|
||||
$query->where('name', 'like', '%'.$inputTerm .'%')
|
||||
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
|
||||
|
@ -205,12 +181,12 @@ class SearchService
|
|||
}
|
||||
|
||||
// Handle tag searches
|
||||
foreach ($terms['tags'] as $inputTerm) {
|
||||
foreach ($searchOpts->tags as $inputTerm) {
|
||||
$this->applyTagSearch($entitySelect, $inputTerm);
|
||||
}
|
||||
|
||||
// Handle filters
|
||||
foreach ($terms['filters'] as $filterTerm => $filterValue) {
|
||||
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
|
||||
$functionName = Str::camel('filter_' . $filterTerm);
|
||||
if (method_exists($this, $functionName)) {
|
||||
$this->$functionName($entitySelect, $entity, $filterValue);
|
||||
|
@ -220,60 +196,10 @@ class SearchService
|
|||
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse a search string into components.
|
||||
* @param $searchString
|
||||
* @return array
|
||||
*/
|
||||
protected function parseSearchString($searchString)
|
||||
{
|
||||
$terms = [
|
||||
'search' => [],
|
||||
'exact' => [],
|
||||
'tags' => [],
|
||||
'filters' => []
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'exact' => '/"(.*?)"/',
|
||||
'tags' => '/\[(.*?)\]/',
|
||||
'filters' => '/\{(.*?)\}/'
|
||||
];
|
||||
|
||||
// Parse special terms
|
||||
foreach ($patterns as $termType => $pattern) {
|
||||
$matches = [];
|
||||
preg_match_all($pattern, $searchString, $matches);
|
||||
if (count($matches) > 0) {
|
||||
$terms[$termType] = $matches[1];
|
||||
$searchString = preg_replace($pattern, '', $searchString);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse standard terms
|
||||
foreach (explode(' ', trim($searchString)) as $searchTerm) {
|
||||
if ($searchTerm !== '') {
|
||||
$terms['search'][] = $searchTerm;
|
||||
}
|
||||
}
|
||||
|
||||
// Split filter values out
|
||||
$splitFilters = [];
|
||||
foreach ($terms['filters'] as $filter) {
|
||||
$explodedFilter = explode(':', $filter, 2);
|
||||
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
|
||||
}
|
||||
$terms['filters'] = $splitFilters;
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available query operators as a regex escaped list.
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getRegexEscapedOperators()
|
||||
protected function getRegexEscapedOperators(): string
|
||||
{
|
||||
$escapedOperators = [];
|
||||
foreach ($this->queryOperators as $operator) {
|
||||
|
@ -284,11 +210,8 @@ class SearchService
|
|||
|
||||
/**
|
||||
* Apply a tag search term onto a entity query.
|
||||
* @param EloquentBuilder $query
|
||||
* @param string $tagTerm
|
||||
* @return mixed
|
||||
*/
|
||||
protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
|
||||
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
|
||||
{
|
||||
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
|
||||
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
|
||||
|
@ -318,7 +241,6 @@ class SearchService
|
|||
|
||||
/**
|
||||
* Index the given entity.
|
||||
* @param Entity $entity
|
||||
*/
|
||||
public function indexEntity(Entity $entity)
|
||||
{
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<?php namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SlugGenerator
|
||||
{
|
||||
|
||||
|
@ -32,9 +34,7 @@ class SlugGenerator
|
|||
*/
|
||||
protected function formatNameAsSlug(string $name): string
|
||||
{
|
||||
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
|
||||
$slug = preg_replace('/\s{2,}/', ' ', $slug);
|
||||
$slug = str_replace(' ', '-', $slug);
|
||||
$slug = Str::slug($name);
|
||||
if ($slug === "") {
|
||||
$slug = substr(md5(rand(1, 500)), 0, 5);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
@ -26,6 +25,7 @@ class Handler extends ExceptionHandler
|
|||
HttpException::class,
|
||||
ModelNotFoundException::class,
|
||||
ValidationException::class,
|
||||
NotFoundException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -8,7 +8,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BooksApiController extends ApiController
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
|
||||
protected $bookRepo;
|
||||
|
@ -17,10 +17,12 @@ class BooksApiController extends ApiController
|
|||
'create' => [
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
'update' => [
|
||||
'name' => 'string|min:1|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
];
|
||||
|
|
@ -5,9 +5,8 @@ use BookStack\Entities\ExportService;
|
|||
use BookStack\Entities\Repos\BookRepo;
|
||||
use Throwable;
|
||||
|
||||
class BooksExportApiController extends ApiController
|
||||
class BookExportApiController extends ApiController
|
||||
{
|
||||
|
||||
protected $bookRepo;
|
||||
protected $exportService;
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChapterApiController extends ApiController
|
||||
{
|
||||
protected $chapterRepo;
|
||||
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'book_id' => 'required|integer',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
'update' => [
|
||||
'book_id' => 'integer',
|
||||
'name' => 'string|min:1|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* ChapterController constructor.
|
||||
*/
|
||||
public function __construct(ChapterRepo $chapterRepo)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of chapters visible to the user.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$chapters = Chapter::visible();
|
||||
return $this->apiListingResponse($chapters, [
|
||||
'id', 'book_id', 'name', 'slug', 'description', 'priority',
|
||||
'created_at', 'updated_at', 'created_by', 'updated_by',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chapter in the system.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->get('book_id');
|
||||
$book = Book::visible()->findOrFail($bookId);
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$chapter = $this->chapterRepo->create($request->all(), $book);
|
||||
Activity::add($chapter, 'chapter_create', $book->id);
|
||||
|
||||
return response()->json($chapter->load(['tags']));
|
||||
}
|
||||
|
||||
/**
|
||||
* View the details of a single chapter.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'pages' => function (HasMany $query) {
|
||||
$query->visible()->get(['id', 'name', 'slug']);
|
||||
}])->findOrFail($id);
|
||||
return response()->json($chapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of a single chapter.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
$updatedChapter = $this->chapterRepo->update($chapter, $request->all());
|
||||
Activity::add($chapter, 'chapter_update', $chapter->book->id);
|
||||
|
||||
return response()->json($updatedChapter->load(['tags']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chapter from the system.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
$this->chapterRepo->destroy($chapter);
|
||||
Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
<?php namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\ExportService;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use Throwable;
|
||||
|
||||
class ChapterExportApiController extends ApiController
|
||||
{
|
||||
protected $chapterRepo;
|
||||
protected $exportService;
|
||||
|
||||
/**
|
||||
* ChapterExportController constructor.
|
||||
*/
|
||||
public function __construct(BookRepo $chapterRepo, ExportService $exportService)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->exportService = $exportService;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chapter as a PDF file.
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function exportPdf(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$pdfContent = $this->exportService->chapterToPdf($chapter);
|
||||
return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chapter as a contained HTML file.
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function exportHtml(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$htmlContent = $this->exportService->chapterToContainedHtml($chapter);
|
||||
return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chapter as a plain text file.
|
||||
*/
|
||||
public function exportPlainText(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$textContent = $this->exportService->chapterToPlainText($chapter);
|
||||
return $this->downloadResponse($textContent, $chapter->slug . '.txt');
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ use BookStack\Uploads\AttachmentService;
|
|||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
|
@ -60,26 +61,18 @@ class AttachmentController extends Controller
|
|||
/**
|
||||
* Update an uploaded attachment.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function uploadUpdate(Request $request, $attachmentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'file' => 'required|file'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
|
||||
$this->checkOwnablePermission('view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
|
||||
if (intval($pageId) !== intval($attachment->uploaded_to)) {
|
||||
return $this->jsonError(trans('errors.attachment_page_mismatch'));
|
||||
}
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
|
||||
try {
|
||||
|
@ -92,57 +85,87 @@ class AttachmentController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the details of an existing file.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
* Get the update form for an attachment.
|
||||
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function update(Request $request, $attachmentId)
|
||||
public function getUpdateForm(string $attachmentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'name' => 'required|string|min:1|max:255',
|
||||
'link' => 'string|min:1|max:255'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
|
||||
if (intval($pageId) !== intval($attachment->uploaded_to)) {
|
||||
return $this->jsonError(trans('errors.attachment_page_mismatch'));
|
||||
return view('attachments.manager-edit-form', [
|
||||
'attachment' => $attachment,
|
||||
]);
|
||||
}
|
||||
|
||||
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
|
||||
return response()->json($attachment);
|
||||
/**
|
||||
* Update the details of an existing file.
|
||||
*/
|
||||
public function update(Request $request, string $attachmentId)
|
||||
{
|
||||
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_edit_name' => 'required|string|min:1|max:255',
|
||||
'attachment_edit_url' => 'string|min:1|max:255'
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
|
||||
'attachment' => $attachment,
|
||||
'errors' => new MessageBag($exception->errors()),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
|
||||
$attachment = $this->attachmentService->updateFile($attachment, [
|
||||
'name' => $request->get('attachment_edit_name'),
|
||||
'link' => $request->get('attachment_edit_url'),
|
||||
]);
|
||||
|
||||
return view('attachments.manager-edit-form', [
|
||||
'attachment' => $attachment,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a link to a page.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function attachLink(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'name' => 'required|string|min:1|max:255',
|
||||
'link' => 'required|string|min:1|max:255'
|
||||
]);
|
||||
$pageId = $request->get('attachment_link_uploaded_to');
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'attachment_link_name' => 'required|string|min:1|max:255',
|
||||
'attachment_link_url' => 'required|string|min:1|max:255'
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
|
||||
'pageId' => $pageId,
|
||||
'errors' => new MessageBag($exception->errors()),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
|
||||
$this->checkPermission('attachment-create-all');
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$attachmentName = $request->get('name');
|
||||
$link = $request->get('link');
|
||||
$attachmentName = $request->get('attachment_link_name');
|
||||
$link = $request->get('attachment_link_url');
|
||||
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
|
||||
|
||||
return response()->json($attachment);
|
||||
return view('attachments.manager-link-form', [
|
||||
'pageId' => $pageId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -152,7 +175,9 @@ class AttachmentController extends Controller
|
|||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
return response()->json($page->attachments);
|
||||
return view('attachments.manager-list', [
|
||||
'attachments' => $page->attachments->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -163,14 +188,13 @@ class AttachmentController extends Controller
|
|||
public function sortForPage(Request $request, int $pageId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'files' => 'required|array',
|
||||
'files.*.id' => 'required|integer',
|
||||
'order' => 'required|array',
|
||||
]);
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$attachments = $request->get('files');
|
||||
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
|
||||
$attachmentOrder = $request->get('order');
|
||||
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
|
||||
return response()->json(['message' => trans('entities.attachments_order_updated')]);
|
||||
}
|
||||
|
||||
|
@ -179,7 +203,7 @@ class AttachmentController extends Controller
|
|||
* @throws FileNotFoundException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function get(int $attachmentId)
|
||||
public function get(string $attachmentId)
|
||||
{
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
try {
|
||||
|
@ -200,11 +224,9 @@ class AttachmentController extends Controller
|
|||
|
||||
/**
|
||||
* Delete a specific attachment in the system.
|
||||
* @param $attachmentId
|
||||
* @return mixed
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete(int $attachmentId)
|
||||
public function delete(string $attachmentId)
|
||||
{
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
$this->checkOwnablePermission('attachment-delete', $attachment);
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'desc'),
|
||||
'event' => $request->get('event', ''),
|
||||
'sort' => $request->get('sort', 'created_at'),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
->with(['entity', 'user'])
|
||||
->orderBy($listDetails['sort'], $listDetails['order']);
|
||||
|
||||
if ($listDetails['event']) {
|
||||
$query->where('key', '=', $listDetails['event']);
|
||||
}
|
||||
|
||||
if ($listDetails['date_from']) {
|
||||
$query->where('created_at', '>=', $listDetails['date_from']);
|
||||
}
|
||||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
|
||||
$keys = DB::table('activities')->select('key')->distinct()->pluck('key');
|
||||
$this->setPageTitle(trans('settings.audit'));
|
||||
return view('settings.audit', [
|
||||
'activities' => $activities,
|
||||
'listDetails' => $listDetails,
|
||||
'activityKeys' => $keys,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
|
@ -76,10 +77,14 @@ class LoginController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
// Store the previous location for redirect after login
|
||||
$previous = url()->previous('');
|
||||
if (setting('app-public') && $previous && $previous !== url('/login')) {
|
||||
if ($previous && $previous !== url('/login') && setting('app-public')) {
|
||||
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
|
||||
if ($isPreviousFromInstance) {
|
||||
redirect()->setIntendedUrl($previous);
|
||||
}
|
||||
}
|
||||
|
||||
return view('auth.login', [
|
||||
'socialDrivers' => $socialDrivers,
|
||||
|
@ -98,6 +103,7 @@ class LoginController extends Controller
|
|||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
|
||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
||||
// the login attempts for this application. We'll key this by the username and
|
||||
|
@ -106,6 +112,7 @@ class LoginController extends Controller
|
|||
$this->hasTooManyLoginAttempts($request)) {
|
||||
$this->fireLockoutEvent($request);
|
||||
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
|
@ -114,6 +121,7 @@ class LoginController extends Controller
|
|||
return $this->sendLoginResponse($request);
|
||||
}
|
||||
} catch (LoginAttemptException $exception) {
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
||||
}
|
||||
|
||||
|
@ -122,6 +130,7 @@ class LoginController extends Controller
|
|||
// user surpasses their maximum number of attempts they will get locked out.
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
|
|||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
|
@ -132,23 +133,6 @@ abstract class Controller extends BaseController
|
|||
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the response for when a request fails validation.
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param array $errors
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function buildFailedValidationResponse(Request $request, array $errors)
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['validation' => $errors], 422);
|
||||
}
|
||||
|
||||
return redirect()->to($this->getRedirectUrl())
|
||||
->withInput($request->input())
|
||||
->withErrors($errors, $this->errorBag());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a response that forces a download in the browser.
|
||||
* @param string $content
|
||||
|
|
|
@ -30,7 +30,10 @@ class DrawioImageController extends Controller
|
|||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
||||
return response()->json($imgData);
|
||||
return view('components.image-manager-list', [
|
||||
'images' => $imgData['images'],
|
||||
'hasMore' => $imgData['has_more'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -72,6 +75,7 @@ class DrawioImageController extends Controller
|
|||
if ($imageData === null) {
|
||||
return $this->jsonError("Image data could not be found");
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'content' => base64_encode($imageData)
|
||||
]);
|
||||
|
|
|
@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
|
|||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GalleryImageController extends Controller
|
||||
{
|
||||
|
@ -13,7 +14,6 @@ class GalleryImageController extends Controller
|
|||
|
||||
/**
|
||||
* GalleryImageController constructor.
|
||||
* @param ImageRepo $imageRepo
|
||||
*/
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
|
@ -24,8 +24,6 @@ class GalleryImageController extends Controller
|
|||
/**
|
||||
* Get a list of gallery images, in a list.
|
||||
* Can be paged and filtered by entity.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
|
@ -35,14 +33,15 @@ class GalleryImageController extends Controller
|
|||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
||||
return response()->json($imgData);
|
||||
return view('components.image-manager-list', [
|
||||
'images' => $imgData['images'],
|
||||
'hasMore' => $imgData['has_more'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new gallery image in the system.
|
||||
* @param Request $request
|
||||
* @return Illuminate\Http\JsonResponse
|
||||
* @throws \Exception
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
|
|
|
@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller;
|
|||
use BookStack\Repos\PageRepo;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Filesystem\Filesystem as File;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
|
@ -17,9 +20,6 @@ class ImageController extends Controller
|
|||
|
||||
/**
|
||||
* ImageController constructor.
|
||||
* @param Image $image
|
||||
* @param File $file
|
||||
* @param ImageRepo $imageRepo
|
||||
*/
|
||||
public function __construct(Image $image, File $file, ImageRepo $imageRepo)
|
||||
{
|
||||
|
@ -31,8 +31,6 @@ class ImageController extends Controller
|
|||
|
||||
/**
|
||||
* Provide an image file from storage.
|
||||
* @param string $path
|
||||
* @return mixed
|
||||
*/
|
||||
public function showImage(string $path)
|
||||
{
|
||||
|
@ -47,13 +45,10 @@ class ImageController extends Controller
|
|||
|
||||
/**
|
||||
* Update image details
|
||||
* @param Request $request
|
||||
* @param integer $id
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @throws ImageUploadException
|
||||
* @throws \Exception
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'name' => 'required|min:2|string'
|
||||
|
@ -64,47 +59,50 @@ class ImageController extends Controller
|
|||
$this->checkOwnablePermission('image-update', $image);
|
||||
|
||||
$image = $this->imageRepo->updateImageDetails($image, $request->all());
|
||||
return response()->json($image);
|
||||
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
return view('components.image-manager-form', [
|
||||
'image' => $image,
|
||||
'dependantPages' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the usage of an image on pages.
|
||||
* Get the form for editing the given image.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function usage(int $id)
|
||||
public function edit(Request $request, string $id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkImagePermission($image);
|
||||
|
||||
$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 = '';
|
||||
if ($request->has('delete')) {
|
||||
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
|
||||
}
|
||||
$result = count($pages) > 0 ? $pages : false;
|
||||
|
||||
return response()->json($result);
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
return view('components.image-manager-form', [
|
||||
'image' => $image,
|
||||
'dependantPages' => $dependantPages ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image and all thumbnail/image files
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy($id)
|
||||
public function destroy(string $id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkOwnablePermission('image-delete', $image);
|
||||
$this->checkImagePermission($image);
|
||||
|
||||
$this->imageRepo->destroyImage($image);
|
||||
return response()->json(trans('components.images_deleted'));
|
||||
return response('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check related page permission and ensure type is drawio or gallery.
|
||||
* @param Image $image
|
||||
*/
|
||||
protected function checkImagePermission(Image $image)
|
||||
{
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Notifications\TestEmail;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MaintenanceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the page for application maintenance.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.maint'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.maintenance', ['version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to clean-up images in the system.
|
||||
*/
|
||||
public function cleanupImages(Request $request, ImageService $imageService)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
|
||||
$dryRun = !($request->has('confirm'));
|
||||
|
||||
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
$deleteCount = count($imagesToDelete);
|
||||
if ($deleteCount === 0) {
|
||||
$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 {
|
||||
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to send a test e-mail to the current user.
|
||||
*/
|
||||
public function sendTestEmail()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
$this->showErrorNotification($errorMessage);
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
}
|
|
@ -163,6 +163,8 @@ class PageController extends Controller
|
|||
public function getPageAjax(int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->addHidden(['book']);
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Entities\Managers\PageContent;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
|
@ -46,6 +47,9 @@ class PageRevisionController extends Controller
|
|||
}
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||
return view('pages.revision', [
|
||||
|
@ -73,6 +77,9 @@ class PageRevisionController extends Controller
|
|||
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
|
||||
|
||||
return view('pages.revision', [
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
|
@ -11,7 +13,6 @@ class PermissionController extends Controller
|
|||
|
||||
/**
|
||||
* PermissionController constructor.
|
||||
* @param \BookStack\Auth\Permissions\PermissionsRepo $permissionsRepo
|
||||
*/
|
||||
public function __construct(PermissionsRepo $permissionsRepo)
|
||||
{
|
||||
|
@ -31,7 +32,6 @@ class PermissionController extends Controller
|
|||
|
||||
/**
|
||||
* Show the form to create a new role
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function createRole()
|
||||
{
|
||||
|
@ -41,15 +41,13 @@ class PermissionController extends Controller
|
|||
|
||||
/**
|
||||
* Store a new role in the system.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
*/
|
||||
public function storeRole(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => 'required|min:3|max:200',
|
||||
'description' => 'max:250'
|
||||
'display_name' => 'required|min:3|max:180',
|
||||
'description' => 'max:180'
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->saveNewRole($request->all());
|
||||
|
@ -59,11 +57,9 @@ class PermissionController extends Controller
|
|||
|
||||
/**
|
||||
* Show the form for editing a user role.
|
||||
* @param $id
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function editRole($id)
|
||||
public function editRole(string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
|
@ -75,18 +71,14 @@ class PermissionController extends Controller
|
|||
|
||||
/**
|
||||
* Updates a user role.
|
||||
* @param Request $request
|
||||
* @param $id
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
* @throws PermissionsException
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function updateRole(Request $request, $id)
|
||||
public function updateRole(Request $request, string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => 'required|min:3|max:200',
|
||||
'description' => 'max:250'
|
||||
'display_name' => 'required|min:3|max:180',
|
||||
'description' => 'max:180'
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->updateRole($id, $request->all());
|
||||
|
@ -97,10 +89,8 @@ class PermissionController extends Controller
|
|||
/**
|
||||
* Show the view to delete a role.
|
||||
* Offers the chance to migrate users.
|
||||
* @param $id
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function showDeleteRole($id)
|
||||
public function showDeleteRole(string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
|
@ -113,11 +103,9 @@ class PermissionController extends Controller
|
|||
/**
|
||||
* Delete a role from the system,
|
||||
* Migrate from a previous role if set.
|
||||
* @param Request $request
|
||||
* @param $id
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteRole(Request $request, $id)
|
||||
public function deleteRole(Request $request, string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
|
|||
use BookStack\Entities\Entity;
|
||||
use BookStack\Entities\Managers\EntityContext;
|
||||
use BookStack\Entities\SearchService;
|
||||
use BookStack\Entities\SearchOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
|
@ -33,20 +34,22 @@ class SearchController extends Controller
|
|||
*/
|
||||
public function search(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term');
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
|
||||
$searchOpts = SearchOptions::fromRequest($request);
|
||||
$fullSearchString = $searchOpts->toString();
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
|
||||
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
|
||||
|
||||
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
|
||||
$results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
|
||||
|
||||
return view('search.all', [
|
||||
'entities' => $results['results'],
|
||||
'totalResults' => $results['total'],
|
||||
'searchTerm' => $searchTerm,
|
||||
'searchTerm' => $fullSearchString,
|
||||
'hasNextPage' => $results['has_more'],
|
||||
'nextPageLink' => $nextPageLink
|
||||
'nextPageLink' => $nextPageLink,
|
||||
'options' => $searchOpts,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -84,7 +87,7 @@ class SearchController extends Controller
|
|||
// Search for entities otherwise show most popular
|
||||
if ($searchTerm !== false) {
|
||||
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
|
||||
$entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results'];
|
||||
$entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
|
||||
} else {
|
||||
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Notifications\TestEmail;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
|
@ -74,63 +72,4 @@ class SettingController extends Controller
|
|||
$redirectLocation = '/settings#' . $request->get('section', '');
|
||||
return redirect(rtrim($redirectLocation, '#'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page for application maintenance.
|
||||
*/
|
||||
public function showMaintenance()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.maint'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.maintenance', ['version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to clean-up images in the system.
|
||||
*/
|
||||
public function cleanupImages(Request $request, ImageService $imageService)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
|
||||
$dryRun = !($request->has('confirm'));
|
||||
|
||||
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
$deleteCount = count($imagesToDelete);
|
||||
if ($deleteCount === 0) {
|
||||
$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 {
|
||||
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to send a test e-mail to the current user.
|
||||
*/
|
||||
public function sendTestEmail()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
$this->showErrorNotification($errorMessage);
|
||||
}
|
||||
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ class TagController extends Controller
|
|||
|
||||
/**
|
||||
* TagController constructor.
|
||||
* @param $tagRepo
|
||||
*/
|
||||
public function __construct(TagRepo $tagRepo)
|
||||
{
|
||||
|
@ -18,39 +17,23 @@ class TagController extends Controller
|
|||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the Tags for a particular entity
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$tags = $this->tagRepo->getForEntity($entityType, $entityId);
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from a given search term.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', false);
|
||||
$searchTerm = $request->get('search', null);
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from a given search term.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', false);
|
||||
$tagName = $request->get('name', false);
|
||||
$searchTerm = $request->get('search', null);
|
||||
$tagName = $request->get('name', null);
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
|
|
@ -35,9 +35,9 @@ class ApiAuthenticate
|
|||
{
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (signedInUser()) {
|
||||
if (signedInUser() || session()->isStarted()) {
|
||||
$this->ensureEmailConfirmedIfRequested();
|
||||
if (!auth()->user()->can('access-api')) {
|
||||
if (!user()->can('access-api')) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
return;
|
||||
|
|
|
@ -44,6 +44,10 @@ class Authenticate
|
|||
], 401);
|
||||
}
|
||||
|
||||
if (session()->get('sent-email-confirmation') === true) {
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
return redirect('/register/confirm/awaiting');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ class Localization
|
|||
*/
|
||||
protected $localeMap = [
|
||||
'ar' => 'ar',
|
||||
'bg' => 'bg_BG',
|
||||
'da' => 'da_DK',
|
||||
'de' => 'de_DE',
|
||||
'de_informal' => 'de_DE',
|
||||
|
|
|
@ -3,6 +3,13 @@
|
|||
use BookStack\Entities\Page;
|
||||
use BookStack\Ownable;
|
||||
|
||||
/**
|
||||
* @property int id
|
||||
* @property string name
|
||||
* @property string path
|
||||
* @property string extension
|
||||
* @property bool external
|
||||
*/
|
||||
class Attachment extends Ownable
|
||||
{
|
||||
protected $fillable = ['name', 'order'];
|
||||
|
@ -30,13 +37,28 @@ class Attachment extends Ownable
|
|||
|
||||
/**
|
||||
* Get the url of this file.
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl()
|
||||
public function getUrl(): string
|
||||
{
|
||||
if ($this->external && strpos($this->path, 'http') !== 0) {
|
||||
return $this->path;
|
||||
}
|
||||
return url('/attachments/' . $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a HTML link to this attachment.
|
||||
*/
|
||||
public function htmlLink(): string
|
||||
{
|
||||
return '<a target="_blank" href="'.e($this->getUrl()).'">'.e($this->name).'</a>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a markdown link to this attachment.
|
||||
*/
|
||||
public function markdownLink(): string
|
||||
{
|
||||
return '['. $this->name .']('. $this->getUrl() .')';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,14 +109,14 @@ class AttachmentService extends UploadService
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates the file ordering for a listing of attached files.
|
||||
* @param array $attachmentList
|
||||
* @param $pageId
|
||||
* Updates the ordering for a listing of attached files.
|
||||
*/
|
||||
public function updateFileOrderWithinPage($attachmentList, $pageId)
|
||||
public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
|
||||
{
|
||||
foreach ($attachmentList as $index => $attachment) {
|
||||
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
|
||||
foreach ($attachmentOrder as $index => $attachmentId) {
|
||||
Attachment::query()->where('uploaded_to', '=', $pageId)
|
||||
->where('id', '=', $attachmentId)
|
||||
->update(['order' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -185,7 +185,7 @@ class ImageRepo
|
|||
* Load thumbnails onto an image object.
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function loadThumbs(Image $image)
|
||||
public function loadThumbs(Image $image)
|
||||
{
|
||||
$image->thumbs = [
|
||||
'gallery' => $this->getThumbnail($image, 150, 150, false),
|
||||
|
@ -219,4 +219,20 @@ class ImageRepo
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user visible pages using the given image.
|
||||
*/
|
||||
public function getPagesUsingImage(Image $image): array
|
||||
{
|
||||
$pages = Page::visible()
|
||||
->where('html', 'like', '%' . $image->url . '%')
|
||||
->get(['id', 'name', 'slug', 'book_id']);
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$page->url = $page->getUrl();
|
||||
}
|
||||
|
||||
return $pages->all();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,29 +124,24 @@ class ImageService extends UploadService
|
|||
}
|
||||
|
||||
/**
|
||||
* Saves a new image
|
||||
* @param string $imageName
|
||||
* @param string $imageData
|
||||
* @param string $type
|
||||
* @param int $uploadedTo
|
||||
* @return Image
|
||||
* Save a new image into storage.
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
private function saveNew($imageName, $imageData, $type, $uploadedTo = 0)
|
||||
private function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
|
||||
{
|
||||
$storage = $this->getStorage($type);
|
||||
$secureUploads = setting('app-secure-images');
|
||||
$imageName = str_replace(' ', '-', $imageName);
|
||||
$fileName = $this->cleanImageFileName($imageName);
|
||||
|
||||
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
|
||||
|
||||
while ($storage->exists($imagePath . $imageName)) {
|
||||
$imageName = Str::random(3) . $imageName;
|
||||
while ($storage->exists($imagePath . $fileName)) {
|
||||
$fileName = Str::random(3) . $fileName;
|
||||
}
|
||||
|
||||
$fullPath = $imagePath . $imageName;
|
||||
$fullPath = $imagePath . $fileName;
|
||||
if ($secureUploads) {
|
||||
$fullPath = $imagePath . Str::random(16) . '-' . $imageName;
|
||||
$fullPath = $imagePath . Str::random(16) . '-' . $fileName;
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -175,6 +170,23 @@ class ImageService extends UploadService
|
|||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an image file name to be both URL and storage safe.
|
||||
*/
|
||||
protected function cleanImageFileName(string $name): string
|
||||
{
|
||||
$name = str_replace(' ', '-', $name);
|
||||
$nameParts = explode('.', $name);
|
||||
$extension = array_pop($nameParts);
|
||||
$name = implode('.', $nameParts);
|
||||
$name = Str::slug($name);
|
||||
|
||||
if (strlen($name) === 0) {
|
||||
$name = Str::random(10);
|
||||
}
|
||||
|
||||
return $name . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the image is a gif. Returns true if it is, else false.
|
||||
|
@ -223,6 +235,7 @@ class ImageService extends UploadService
|
|||
$storage->setVisibility($thumbFilePath, 'public');
|
||||
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
|
||||
|
||||
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
|
@ -292,11 +305,9 @@ class ImageService extends UploadService
|
|||
|
||||
/**
|
||||
* Destroys an image at the given path.
|
||||
* Searches for image thumbnails in addition to main provided path..
|
||||
* @param string $path
|
||||
* @return bool
|
||||
* Searches for image thumbnails in addition to main provided path.
|
||||
*/
|
||||
protected function destroyImagesFromPath(string $path)
|
||||
protected function destroyImagesFromPath(string $path): bool
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
|
||||
|
@ -306,8 +317,7 @@ class ImageService extends UploadService
|
|||
|
||||
// Delete image files
|
||||
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
||||
$expectedIndex = strlen($imagePath) - strlen($imageFileName);
|
||||
return strpos($imagePath, $imageFileName) === $expectedIndex;
|
||||
return basename($imagePath) === $imageFileName;
|
||||
});
|
||||
$storage->delete($imagesToDelete->all());
|
||||
|
||||
|
|
|
@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string
|
|||
* Generate a url with multiple parameters for sorting purposes.
|
||||
* Works out the logic to set the correct sorting direction
|
||||
* Discards empty parameters and allows overriding.
|
||||
* @param string $path
|
||||
* @param array $data
|
||||
* @param array $overrideData
|
||||
* @return string
|
||||
*/
|
||||
function sortUrl(string $path, array $data, array $overrideData = []): string
|
||||
{
|
||||
|
@ -166,7 +162,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string
|
|||
// Change sorting direction is already sorted on current attribute
|
||||
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
|
||||
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
|
||||
} else {
|
||||
} elseif (isset($overrideData['sort'])) {
|
||||
$queryData['order'] = 'asc';
|
||||
}
|
||||
|
||||
|
|
|
@ -13,14 +13,14 @@
|
|||
"ext-mbstring": "*",
|
||||
"ext-tidy": "*",
|
||||
"ext-xml": "*",
|
||||
"barryvdh/laravel-dompdf": "^0.8.5",
|
||||
"barryvdh/laravel-snappy": "^0.4.5",
|
||||
"barryvdh/laravel-dompdf": "^0.8.6",
|
||||
"barryvdh/laravel-snappy": "^0.4.7",
|
||||
"doctrine/dbal": "^2.9",
|
||||
"facade/ignition": "^1.4",
|
||||
"fideloper/proxy": "^4.0",
|
||||
"gathercontent/htmldiff": "^0.2.1",
|
||||
"intervention/image": "^2.5",
|
||||
"laravel/framework": "^6.12",
|
||||
"laravel/framework": "^6.18",
|
||||
"laravel/socialite": "^4.3.2",
|
||||
"league/commonmark": "^1.4",
|
||||
"league/flysystem-aws-s3-v3": "^1.0",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DropJointPermissionsId extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->dropColumn('id');
|
||||
$table->primary(['role_id', 'entity_type', 'entity_id', 'action'], 'joint_primary');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->dropPrimary(['role_id', 'entity_type', 'entity_id', 'action']);
|
||||
});
|
||||
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->increments('id')->unsigned();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RemoveRoleNameField extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->string('name')->index();
|
||||
});
|
||||
|
||||
DB::table('roles')->update([
|
||||
"name" => DB::raw("lower(replace(`display_name`, ' ', '-'))"),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddActivityIndexes extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('activities', function(Blueprint $table) {
|
||||
$table->index('key');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('activities', function(Blueprint $table) {
|
||||
$table->dropIndex('key');
|
||||
$table->dropIndex('created_at');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"book_id": 1,
|
||||
"name": "My fantastic new chapter",
|
||||
"description": "This is a great new chapter that I've created via the API",
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Top Content"},
|
||||
{"name": "Rating", "value": "Highest"}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"book_id": 1,
|
||||
"name": "My fantastic updated chapter",
|
||||
"description": "This is an updated chapter that I've altered via the API",
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Kinda Good Content"},
|
||||
{"name": "Rating", "value": "Medium"}
|
||||
]
|
||||
}
|
|
@ -7,15 +7,12 @@
|
|||
"updated_at": "2020-01-12 14:11:51",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"image_id": 452,
|
||||
"tags": [
|
||||
{
|
||||
"id": 13,
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"book_id": 1,
|
||||
"priority": 6,
|
||||
"name": "My fantastic new chapter",
|
||||
"description": "This is a great new chapter that I've created via the API",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"slug": "my-fantastic-new-chapter",
|
||||
"updated_at": "2020-05-22 22:59:55",
|
||||
"created_at": "2020-05-22 22:59:55",
|
||||
"id": 74,
|
||||
"book": {
|
||||
"id": 1,
|
||||
"name": "BookStack User Guide",
|
||||
"slug": "bookstack-user-guide",
|
||||
"description": "This is a general guide on using BookStack on a day-to-day basis.",
|
||||
"created_at": "2019-05-05 21:48:46",
|
||||
"updated_at": "2019-12-11 20:57:31",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Top Content",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 22:59:55",
|
||||
"updated_at": "2020-05-22 22:59:55"
|
||||
},
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Highest",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 22:59:55",
|
||||
"updated_at": "2020-05-22 22:59:55"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"book_id": 1,
|
||||
"name": "Content Creation",
|
||||
"slug": "content-creation",
|
||||
"description": "How to create documentation on whatever subject you need to write about.",
|
||||
"priority": 3,
|
||||
"created_at": "2019-05-05 21:49:56",
|
||||
"updated_at": "2019-09-28 11:24:23",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"book_id": 1,
|
||||
"name": "Managing Content",
|
||||
"slug": "managing-content",
|
||||
"description": "How to keep things organised and orderly in the system for easier navigation and better user experience.",
|
||||
"priority": 5,
|
||||
"created_at": "2019-05-05 21:58:07",
|
||||
"updated_at": "2019-10-17 15:05:34",
|
||||
"created_by": 3,
|
||||
"updated_by": 3
|
||||
}
|
||||
],
|
||||
"total": 40
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"id": 1,
|
||||
"book_id": 1,
|
||||
"slug": "content-creation",
|
||||
"name": "Content Creation",
|
||||
"description": "How to create documentation on whatever subject you need to write about.",
|
||||
"priority": 3,
|
||||
"created_at": "2019-05-05 21:49:56",
|
||||
"updated_at": "2019-09-28 11:24:23",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Guide",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 22:51:51",
|
||||
"updated_at": "2020-05-22 22:51:51"
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"book_id": 1,
|
||||
"chapter_id": 1,
|
||||
"name": "How to create page content",
|
||||
"slug": "how-to-create-page-content",
|
||||
"priority": 0,
|
||||
"created_at": "2019-05-05 21:49:58",
|
||||
"updated_at": "2019-08-26 14:32:59",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"draft": 0,
|
||||
"revision_count": 2,
|
||||
"template": 0
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"book_id": 1,
|
||||
"chapter_id": 1,
|
||||
"name": "Good book structure",
|
||||
"slug": "good-book-structure",
|
||||
"priority": 1,
|
||||
"created_at": "2019-05-05 22:01:55",
|
||||
"updated_at": "2019-06-06 12:03:04",
|
||||
"created_by": 3,
|
||||
"updated_by": 3,
|
||||
"draft": 0,
|
||||
"revision_count": 1,
|
||||
"template": 0
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"id": 75,
|
||||
"book_id": 1,
|
||||
"slug": "my-fantastic-updated-chapter",
|
||||
"name": "My fantastic updated chapter",
|
||||
"description": "This is an updated chapter that I've altered via the API",
|
||||
"priority": 7,
|
||||
"created_at": "2020-05-22 23:03:35",
|
||||
"updated_at": "2020-05-22 23:07:20",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"book": {
|
||||
"id": 1,
|
||||
"name": "BookStack User Guide",
|
||||
"slug": "bookstack-user-guide",
|
||||
"description": "This is a general guide on using BookStack on a day-to-day basis.",
|
||||
"created_at": "2019-05-05 21:48:46",
|
||||
"updated_at": "2019-12-11 20:57:31",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Kinda Good Content",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 23:07:20",
|
||||
"updated_at": "2020-05-22 23:07:20"
|
||||
},
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Medium",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 23:07:20",
|
||||
"updated_at": "2020-05-22 23:07:20"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -5,15 +5,12 @@
|
|||
"description": "This is my shelf with some books",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"image_id": 501,
|
||||
"created_at": "2020-04-10 13:24:09",
|
||||
"updated_at": "2020-04-10 13:31:04",
|
||||
"tags": [
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
# JavaScript Components
|
||||
|
||||
This document details the format for JavaScript components in BookStack. This is a really simple class-based setup with a few helpers provided.
|
||||
|
||||
#### Defining a Component in JS
|
||||
|
||||
```js
|
||||
class Dropdown {
|
||||
setup() {
|
||||
this.toggle = this.$refs.toggle;
|
||||
this.menu = this.$refs.menu;
|
||||
|
||||
this.speed = parseInt(this.$opts.speed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
|
||||
|
||||
#### Using a Component in HTML
|
||||
|
||||
A component is used like so:
|
||||
|
||||
```html
|
||||
<div component="dropdown"></div>
|
||||
|
||||
<!-- or, for multiple -->
|
||||
|
||||
<div components="dropdown image-picker"></div>
|
||||
```
|
||||
|
||||
The names will be parsed and new component instance will be created if a matching name is found in the `components/index.js` componentMapping.
|
||||
|
||||
#### Element References
|
||||
|
||||
Within a component you'll often need to refer to other element instances. This can be done like so:
|
||||
|
||||
```html
|
||||
<div component="dropdown">
|
||||
<span refs="dropdown@toggle othercomponent@handle">View more</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
You can then access the span element as `this.$refs.toggle` in your component.
|
||||
|
||||
#### Component Options
|
||||
|
||||
```html
|
||||
<div component="dropdown"
|
||||
option:dropdown:delay="500"
|
||||
option:dropdown:show>
|
||||
</div>
|
||||
```
|
||||
|
||||
Will result with `this.$opts` being:
|
||||
|
||||
```json
|
||||
{
|
||||
"delay": "500",
|
||||
"show": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### Global Helpers
|
||||
|
||||
There are various global helper libraries which can be used in components:
|
||||
|
||||
```js
|
||||
// HTTP service
|
||||
window.$http.get(url, params);
|
||||
window.$http.post(url, data);
|
||||
window.$http.put(url, data);
|
||||
window.$http.delete(url, data);
|
||||
window.$http.patch(url, data);
|
||||
|
||||
// Global event system
|
||||
// Emit a global event
|
||||
window.$events.emit(eventName, eventData);
|
||||
// Listen to a global event
|
||||
window.$events.listen(eventName, callback);
|
||||
// Show a success message
|
||||
window.$events.success(message);
|
||||
// Show an error message
|
||||
window.$events.error(message);
|
||||
// Show validation errors, if existing, as an error notification
|
||||
window.$events.showValidationErrors(error);
|
||||
|
||||
// Translator
|
||||
// Take the given plural text and count to decide on what plural option
|
||||
// to use, Similar to laravel's trans_choice function but instead
|
||||
// takes the direction directly instead of a translation key.
|
||||
window.trans_plural(translationString, count, replacements);
|
||||
|
||||
// Component System
|
||||
// Parse and initialise any components from the given root el down.
|
||||
window.components.init(rootEl);
|
||||
// Get the first active component of the given name
|
||||
window.components.first(name);
|
||||
```
|
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
|
@ -1,36 +1,33 @@
|
|||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"production": "NODE_ENV=production webpack && rm -f ./public/dist/*styles.js",
|
||||
"build-profile": "NODE_ENV=production webpack --profile --json > webpack-stats.json && rm -f ./public/dist/*styles.js",
|
||||
"build:css:dev": "sass ./resources/sass:./public/dist",
|
||||
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
|
||||
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
|
||||
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2020",
|
||||
"build:js:watch": "chokidar \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
|
||||
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --minify",
|
||||
"build": "npm-run-all --parallel build:*:dev",
|
||||
"production": "npm-run-all --parallel build:*:production",
|
||||
"dev": "npm-run-all --parallel watch livereload",
|
||||
"watch": "webpack --watch",
|
||||
"watch": "npm-run-all --parallel build:*:watch",
|
||||
"livereload": "livereload ./public/dist/",
|
||||
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "^3.4.2",
|
||||
"chokidar-cli": "^2.1.0",
|
||||
"esbuild": "0.6.30",
|
||||
"livereload": "^0.9.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"node-sass": "^4.13.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.1.3",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11"
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.26.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.6",
|
||||
"codemirror": "^5.52.2",
|
||||
"dropzone": "^5.7.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"codemirror": "^5.57.0",
|
||||
"dropzone": "^5.7.2",
|
||||
"markdown-it": "^11.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"sortablejs": "^1.10.2",
|
||||
"vue": "^2.6.11",
|
||||
"vuedraggable": "^2.23.2"
|
||||
},
|
||||
"browser": {
|
||||
"vue": "vue/dist/vue.common.js"
|
||||
"sortablejs": "^1.10.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<description>The coding standard for BookStack.</description>
|
||||
<file>app</file>
|
||||
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||
<exclude-pattern>*/tests/*</exclude-pattern>
|
||||
<arg value="np"/>
|
||||
<rule ref="PSR2"/>
|
||||
</ruleset>
|
|
@ -51,5 +51,7 @@
|
|||
<server name="DEBUGBAR_ENABLED" value="false"/>
|
||||
<server name="SAML2_ENABLED" value="false"/>
|
||||
<server name="API_REQUESTS_PER_MIN" value="180"/>
|
||||
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
|
||||
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
@ -51,7 +51,7 @@ All development on BookStack is currently done on the master branch. When it's t
|
|||
|
||||
* [Node.js](https://nodejs.org/en/) v10.0+
|
||||
|
||||
This project uses SASS for CSS development and this is built, along with the JavaScript, using webpack. The below npm commands can be used to install the dependencies & run the build tasks:
|
||||
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
|
||||
|
||||
``` bash
|
||||
# Install NPM Dependencies
|
||||
|
@ -157,8 +157,7 @@ These are the great open-source projects used to help build BookStack:
|
|||
* [Laravel](http://laravel.com/)
|
||||
* [TinyMCE](https://www.tinymce.com/)
|
||||
* [CodeMirror](https://codemirror.net)
|
||||
* [Vue.js](http://vuejs.org/)
|
||||
* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
|
||||
* [Sortable](https://github.com/SortableJS/Sortable)
|
||||
* [Google Material Icons](https://material.io/icons/)
|
||||
* [Dropzone.js](http://www.dropzonejs.com/)
|
||||
* [clipboard.js](https://clipboardjs.com/)
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import {onChildEvent} from "../services/dom";
|
||||
import {uniqueId} from "../services/util";
|
||||
|
||||
/**
|
||||
* AddRemoveRows
|
||||
* Allows easy row add/remove controls onto a table.
|
||||
* Needs a model row to use when adding a new row.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AddRemoveRows {
|
||||
setup() {
|
||||
this.modelRow = this.$refs.model;
|
||||
this.addButton = this.$refs.add;
|
||||
this.removeSelector = this.$opts.removeSelector;
|
||||
this.rowSelector = this.$opts.rowSelector;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.addButton.addEventListener('click', this.add.bind(this));
|
||||
|
||||
onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
|
||||
const row = e.target.closest(this.rowSelector);
|
||||
row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// For external use
|
||||
add() {
|
||||
const clone = this.modelRow.cloneNode(true);
|
||||
clone.classList.remove('hidden');
|
||||
this.setClonedInputNames(clone);
|
||||
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
|
||||
window.components.init(clone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the HTML names of a clone to be unique if required.
|
||||
* Names can use placeholder values. For exmaple, a model row
|
||||
* may have name="tags[randrowid][name]".
|
||||
* These are the available placeholder values:
|
||||
* - randrowid - An random string ID, applied the same across the row.
|
||||
* @param {HTMLElement} clone
|
||||
*/
|
||||
setClonedInputNames(clone) {
|
||||
const rowId = uniqueId();
|
||||
const randRowIdElems = clone.querySelectorAll(`[name*="randrowid"]`);
|
||||
for (const elem of randRowIdElems) {
|
||||
elem.name = elem.name.split('randrowid').join(rowId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AddRemoveRows;
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* AjaxDelete
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {onSelect} from "../services/dom";
|
||||
|
||||
class AjaxDeleteRow {
|
||||
setup() {
|
||||
this.row = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
this.deleteButtons = this.$manyRefs.delete;
|
||||
|
||||
onSelect(this.deleteButtons, this.runDelete.bind(this));
|
||||
}
|
||||
|
||||
runDelete() {
|
||||
this.row.style.opacity = '0.7';
|
||||
this.row.style.pointerEvents = 'none';
|
||||
|
||||
window.$http.delete(this.url).then(resp => {
|
||||
if (typeof resp.data === 'object' && resp.data.message) {
|
||||
window.$events.emit('success', resp.data.message);
|
||||
}
|
||||
this.row.remove();
|
||||
}).catch(err => {
|
||||
this.row.style.opacity = null;
|
||||
this.row.style.pointerEvents = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AjaxDeleteRow;
|
|
@ -0,0 +1,82 @@
|
|||
import {onEnterPress, onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Ajax Form
|
||||
* Will handle button clicks or input enter press events and submit
|
||||
* the data over ajax. Will always expect a partial HTML view to be returned.
|
||||
* Fires an 'ajax-form-success' event when submitted successfully.
|
||||
*
|
||||
* Will handle a real form if that's what the component is added to
|
||||
* otherwise will act as a fake form element.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AjaxForm {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.responseContainer = this.container;
|
||||
this.url = this.$opts.url;
|
||||
this.method = this.$opts.method || 'post';
|
||||
this.successMessage = this.$opts.successMessage;
|
||||
this.submitButtons = this.$manyRefs.submit || [];
|
||||
|
||||
if (this.$opts.responseContainer) {
|
||||
this.responseContainer = this.container.closest(this.$opts.responseContainer);
|
||||
}
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
|
||||
if (this.container.tagName === 'FORM') {
|
||||
this.container.addEventListener('submit', this.submitRealForm.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
onEnterPress(this.container, event => {
|
||||
this.submitFakeForm();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
this.submitButtons.forEach(button => onSelect(button, this.submitFakeForm.bind(this)));
|
||||
}
|
||||
|
||||
submitFakeForm() {
|
||||
const fd = new FormData();
|
||||
const inputs = this.container.querySelectorAll(`[name]`);
|
||||
for (const input of inputs) {
|
||||
fd.append(input.getAttribute('name'), input.value);
|
||||
}
|
||||
this.submit(fd);
|
||||
}
|
||||
|
||||
submitRealForm(event) {
|
||||
event.preventDefault();
|
||||
const fd = new FormData(this.container);
|
||||
this.submit(fd);
|
||||
}
|
||||
|
||||
async submit(formData) {
|
||||
this.responseContainer.style.opacity = '0.7';
|
||||
this.responseContainer.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
const resp = await window.$http[this.method.toLowerCase()](this.url, formData);
|
||||
this.$emit('success', {formData});
|
||||
this.responseContainer.innerHTML = resp.data;
|
||||
if (this.successMessage) {
|
||||
window.$events.emit('success', this.successMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
this.responseContainer.innerHTML = err.data;
|
||||
}
|
||||
|
||||
window.components.init(this.responseContainer);
|
||||
this.responseContainer.style.opacity = null;
|
||||
this.responseContainer.style.pointerEvents = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default AjaxForm;
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Attachments
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {showLoading} from "../services/dom";
|
||||
|
||||
class Attachments {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.pageId = this.$opts.pageId;
|
||||
this.editContainer = this.$refs.editContainer;
|
||||
this.listContainer = this.$refs.listContainer;
|
||||
this.mainTabs = this.$refs.mainTabs;
|
||||
this.list = this.$refs.list;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const reloadListBound = this.reloadList.bind(this);
|
||||
this.container.addEventListener('dropzone-success', reloadListBound);
|
||||
this.container.addEventListener('ajax-form-success', reloadListBound);
|
||||
|
||||
this.container.addEventListener('sortable-list-sort', event => {
|
||||
this.updateOrder(event.detail.ids);
|
||||
});
|
||||
|
||||
this.container.addEventListener('event-emit-select-edit', event => {
|
||||
this.startEdit(event.detail.id);
|
||||
});
|
||||
|
||||
this.container.addEventListener('event-emit-select-edit-back', event => {
|
||||
this.stopEdit();
|
||||
});
|
||||
|
||||
this.container.addEventListener('event-emit-select-insert', event => {
|
||||
const insertContent = event.target.closest('[data-drag-content]').getAttribute('data-drag-content');
|
||||
const contentTypes = JSON.parse(insertContent);
|
||||
window.$events.emit('editor::insert', {
|
||||
html: contentTypes['text/html'],
|
||||
markdown: contentTypes['text/plain'],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reloadList() {
|
||||
this.stopEdit();
|
||||
this.mainTabs.components.tabs.show('items');
|
||||
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
|
||||
this.list.innerHTML = resp.data;
|
||||
window.components.init(this.list);
|
||||
});
|
||||
}
|
||||
|
||||
updateOrder(idOrder) {
|
||||
window.$http.put(`/attachments/sort/page/${this.pageId}`, {order: idOrder}).then(resp => {
|
||||
window.$events.emit('success', resp.data.message);
|
||||
});
|
||||
}
|
||||
|
||||
async startEdit(id) {
|
||||
this.editContainer.classList.remove('hidden');
|
||||
this.listContainer.classList.add('hidden');
|
||||
|
||||
showLoading(this.editContainer);
|
||||
const resp = await window.$http.get(`/attachments/edit/${id}`);
|
||||
this.editContainer.innerHTML = resp.data;
|
||||
window.components.init(this.editContainer);
|
||||
}
|
||||
|
||||
stopEdit() {
|
||||
this.editContainer.classList.add('hidden');
|
||||
this.listContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Attachments;
|
|
@ -0,0 +1,152 @@
|
|||
import {escapeHtml} from "../services/util";
|
||||
import {onChildEvent} from "../services/dom";
|
||||
|
||||
const ajaxCache = {};
|
||||
|
||||
/**
|
||||
* AutoSuggest
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AutoSuggest {
|
||||
setup() {
|
||||
this.parent = this.$el.parentElement;
|
||||
this.container = this.$el;
|
||||
this.type = this.$opts.type;
|
||||
this.url = this.$opts.url;
|
||||
this.input = this.$refs.input;
|
||||
this.list = this.$refs.list;
|
||||
|
||||
this.lastPopulated = 0;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.input.addEventListener('input', this.requestSuggestions.bind(this));
|
||||
this.input.addEventListener('focus', this.requestSuggestions.bind(this));
|
||||
this.input.addEventListener('keydown', event => {
|
||||
if (event.key === 'Tab') {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
|
||||
this.container.addEventListener('keydown', this.containerKeyDown.bind(this));
|
||||
|
||||
onChildEvent(this.list, 'button', 'click', (event, el) => {
|
||||
this.selectSuggestion(el.textContent);
|
||||
});
|
||||
onChildEvent(this.list, 'button', 'keydown', (event, el) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.selectSuggestion(el.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
selectSuggestion(value) {
|
||||
this.input.value = value;
|
||||
this.lastPopulated = Date.now();
|
||||
this.input.focus();
|
||||
this.input.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
this.input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
this.hideSuggestions();
|
||||
}
|
||||
|
||||
containerKeyDown(event) {
|
||||
if (event.key === 'Enter') event.preventDefault();
|
||||
if (this.list.classList.contains('hidden')) return;
|
||||
|
||||
// Down arrow
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.moveFocus(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
// Up Arrow
|
||||
else if (event.key === 'ArrowUp') {
|
||||
this.moveFocus(false);
|
||||
event.preventDefault();
|
||||
}
|
||||
// Escape key
|
||||
else if (event.key === 'Escape') {
|
||||
this.hideSuggestions();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
moveFocus(forward = true) {
|
||||
const focusables = Array.from(this.container.querySelectorAll('input,button'));
|
||||
const index = focusables.indexOf(document.activeElement);
|
||||
const newFocus = focusables[index + (forward ? 1 : -1)];
|
||||
if (newFocus) {
|
||||
newFocus.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async requestSuggestions() {
|
||||
if (Date.now() - this.lastPopulated < 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameFilter = this.getNameFilterIfNeeded();
|
||||
const search = this.input.value.slice(0, 3).toLowerCase();
|
||||
const suggestions = await this.loadSuggestions(search, nameFilter);
|
||||
let toShow = suggestions.slice(0, 6);
|
||||
if (search.length > 0) {
|
||||
toShow = suggestions.filter(val => {
|
||||
return val.toLowerCase().includes(search);
|
||||
}).slice(0, 6);
|
||||
}
|
||||
|
||||
this.displaySuggestions(toShow);
|
||||
}
|
||||
|
||||
getNameFilterIfNeeded() {
|
||||
if (this.type !== 'value') return null;
|
||||
return this.parent.querySelector('input').value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} search
|
||||
* @param {String|null} nameFilter
|
||||
* @returns {Promise<Object|String|*>}
|
||||
*/
|
||||
async loadSuggestions(search, nameFilter = null) {
|
||||
const params = {search, name: nameFilter};
|
||||
const cacheKey = `${this.url}:${JSON.stringify(params)}`;
|
||||
|
||||
if (ajaxCache[cacheKey]) {
|
||||
return ajaxCache[cacheKey];
|
||||
}
|
||||
|
||||
const resp = await window.$http.get(this.url, params);
|
||||
ajaxCache[cacheKey] = resp.data;
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String[]} suggestions
|
||||
*/
|
||||
displaySuggestions(suggestions) {
|
||||
if (suggestions.length === 0) {
|
||||
return this.hideSuggestions();
|
||||
}
|
||||
|
||||
this.list.innerHTML = suggestions.map(value => `<li><button type="button">${escapeHtml(value)}</button></li>`).join('');
|
||||
this.list.style.display = 'block';
|
||||
for (const button of this.list.querySelectorAll('button')) {
|
||||
button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
this.list.style.display = 'none';
|
||||
}
|
||||
|
||||
hideSuggestionsIfFocusedLost(event) {
|
||||
if (!this.container.contains(event.relatedTarget)) {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoSuggest;
|
|
@ -0,0 +1,117 @@
|
|||
import Code from "../services/code";
|
||||
import {onChildEvent, onEnterPress, onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Code Editor
|
||||
* @extends {Component}
|
||||
*/
|
||||
class CodeEditor {
|
||||
|
||||
setup() {
|
||||
this.container = this.$refs.container;
|
||||
this.popup = this.$el;
|
||||
this.editorInput = this.$refs.editor;
|
||||
this.languageLinks = this.$manyRefs.languageLink;
|
||||
this.saveButton = this.$refs.saveButton;
|
||||
this.languageInput = this.$refs.languageInput;
|
||||
this.historyDropDown = this.$refs.historyDropDown;
|
||||
this.historyList = this.$refs.historyList;
|
||||
|
||||
this.callback = null;
|
||||
this.editor = null;
|
||||
this.history = {};
|
||||
this.historyKey = 'code_history';
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.container.addEventListener('keydown', event => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
this.save();
|
||||
}
|
||||
});
|
||||
|
||||
onSelect(this.languageLinks, event => {
|
||||
const language = event.target.dataset.lang;
|
||||
this.languageInput.value = language;
|
||||
this.updateEditorMode(language);
|
||||
});
|
||||
|
||||
onEnterPress(this.languageInput, e => this.save());
|
||||
onSelect(this.saveButton, e => this.save());
|
||||
|
||||
onChildEvent(this.historyList, 'button', 'click', (event, elem) => {
|
||||
event.preventDefault();
|
||||
const historyTime = elem.dataset.time;
|
||||
if (this.editor) {
|
||||
this.editor.setValue(this.history[historyTime]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.callback) {
|
||||
this.callback(this.editor.getValue(), this.languageInput.value);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
open(code, language, callback) {
|
||||
this.languageInput.value = language;
|
||||
this.callback = callback;
|
||||
|
||||
this.show();
|
||||
this.updateEditorMode(language);
|
||||
|
||||
Code.setContent(this.editor, code);
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.editor) {
|
||||
this.editor = Code.popupEditor(this.editorInput, this.languageInput.value);
|
||||
}
|
||||
this.loadHistory();
|
||||
this.popup.components.popup.show(() => {
|
||||
Code.updateLayout(this.editor);
|
||||
this.editor.focus();
|
||||
}, () => {
|
||||
this.addHistory()
|
||||
});
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popup.components.popup.hide();
|
||||
this.addHistory();
|
||||
}
|
||||
|
||||
updateEditorMode(language) {
|
||||
Code.setMode(this.editor, language, this.editor.getValue());
|
||||
}
|
||||
|
||||
loadHistory() {
|
||||
this.history = JSON.parse(window.sessionStorage.getItem(this.historyKey) || '{}');
|
||||
const historyKeys = Object.keys(this.history).reverse();
|
||||
this.historyDropDown.classList.toggle('hidden', historyKeys.length === 0);
|
||||
this.historyList.innerHTML = historyKeys.map(key => {
|
||||
const localTime = (new Date(parseInt(key))).toLocaleTimeString();
|
||||
return `<li><button type="button" data-time="${key}">${localTime}</button></li>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
addHistory() {
|
||||
if (!this.editor) return;
|
||||
const code = this.editor.getValue();
|
||||
if (!code) return;
|
||||
|
||||
// Stop if we'd be storing the same as the last item
|
||||
const lastHistoryKey = Object.keys(this.history).pop();
|
||||
if (this.history[lastHistoryKey] === code) return;
|
||||
|
||||
this.history[String(Date.now())] = code;
|
||||
const historyString = JSON.stringify(this.history);
|
||||
window.sessionStorage.setItem(this.historyKey, historyString);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
|
@ -37,7 +37,7 @@ class Collapsible {
|
|||
}
|
||||
|
||||
openIfContainsError() {
|
||||
const error = this.content.querySelector('.text-neg');
|
||||
const error = this.content.querySelector('.text-neg.text-small');
|
||||
if (error) {
|
||||
this.open();
|
||||
}
|
||||
|
|
|
@ -3,14 +3,16 @@ import {onSelect} from "../services/dom";
|
|||
/**
|
||||
* Dropdown
|
||||
* Provides some simple logic to create simple dropdown menus.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class DropDown {
|
||||
|
||||
constructor(elem) {
|
||||
this.container = elem;
|
||||
this.menu = elem.querySelector('.dropdown-menu, [dropdown-menu]');
|
||||
this.moveMenu = elem.hasAttribute('dropdown-move-menu');
|
||||
this.toggle = elem.querySelector('[dropdown-toggle]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.menu = this.$refs.menu;
|
||||
this.toggle = this.$refs.toggle;
|
||||
this.moveMenu = this.$opts.moveMenu;
|
||||
|
||||
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
||||
this.body = document.body;
|
||||
this.showing = false;
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import DropZoneLib from "dropzone";
|
||||
import {fadeOut} from "../services/animations";
|
||||
|
||||
/**
|
||||
* Dropzone
|
||||
* @extends {Component}
|
||||
*/
|
||||
class Dropzone {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
this.successMessage = this.$opts.successMessage;
|
||||
this.removeMessage = this.$opts.removeMessage;
|
||||
this.uploadLimitMessage = this.$opts.uploadLimitMessage;
|
||||
this.timeoutMessage = this.$opts.timeoutMessage;
|
||||
|
||||
const _this = this;
|
||||
this.dz = new DropZoneLib(this.container, {
|
||||
addRemoveLinks: true,
|
||||
dictRemoveFile: this.removeMessage,
|
||||
timeout: Number(window.uploadTimeout) || 60000,
|
||||
maxFilesize: Number(window.uploadLimit) || 256,
|
||||
url: this.url,
|
||||
withCredentials: true,
|
||||
init() {
|
||||
this.dz = this;
|
||||
this.dz.on('sending', _this.onSending.bind(_this));
|
||||
this.dz.on('success', _this.onSuccess.bind(_this));
|
||||
this.dz.on('error', _this.onError.bind(_this));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSending(file, xhr, data) {
|
||||
|
||||
const token = window.document.querySelector('meta[name=token]').getAttribute('content');
|
||||
data.append('_token', token);
|
||||
|
||||
xhr.ontimeout = (e) => {
|
||||
this.dz.emit('complete', file);
|
||||
this.dz.emit('error', file, this.timeoutMessage);
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess(file, data) {
|
||||
this.$emit('success', {file, data});
|
||||
|
||||
if (this.successMessage) {
|
||||
window.$events.emit('success', this.successMessage);
|
||||
}
|
||||
|
||||
fadeOut(file.previewElement, 800, () => {
|
||||
this.dz.removeFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
onError(file, errorMessage, xhr) {
|
||||
this.$emit('error', {file, errorMessage, xhr});
|
||||
|
||||
const setMessage = (message) => {
|
||||
const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
|
||||
messsageEl.textContent = message;
|
||||
}
|
||||
|
||||
if (xhr && xhr.status === 413) {
|
||||
setMessage(this.uploadLimitMessage);
|
||||
} else if (errorMessage.file) {
|
||||
setMessage(errorMessage.file);
|
||||
}
|
||||
}
|
||||
|
||||
removeAll() {
|
||||
this.dz.removeAllFiles(true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dropzone;
|
|
@ -0,0 +1,59 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Class EntitySearch
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySearch {
|
||||
setup() {
|
||||
this.entityId = this.$opts.entityId;
|
||||
this.entityType = this.$opts.entityType;
|
||||
|
||||
this.contentView = this.$refs.contentView;
|
||||
this.searchView = this.$refs.searchView;
|
||||
this.searchResults = this.$refs.searchResults;
|
||||
this.searchInput = this.$refs.searchInput;
|
||||
this.searchForm = this.$refs.searchForm;
|
||||
this.clearButton = this.$refs.clearButton;
|
||||
this.loadingBlock = this.$refs.loadingBlock;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.searchInput.addEventListener('change', this.runSearch.bind(this));
|
||||
this.searchForm.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
this.runSearch();
|
||||
});
|
||||
|
||||
onSelect(this.clearButton, this.clearSearch.bind(this));
|
||||
}
|
||||
|
||||
runSearch() {
|
||||
const term = this.searchInput.value.trim();
|
||||
if (term.length === 0) {
|
||||
return this.clearSearch();
|
||||
}
|
||||
|
||||
this.searchView.classList.remove('hidden');
|
||||
this.contentView.classList.add('hidden');
|
||||
this.loadingBlock.classList.remove('hidden');
|
||||
|
||||
const url = window.baseUrl(`/search/${this.entityType}/${this.entityId}`);
|
||||
window.$http.get(url, {term}).then(resp => {
|
||||
this.searchResults.innerHTML = resp.data;
|
||||
}).catch(console.error).then(() => {
|
||||
this.loadingBlock.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchView.classList.add('hidden');
|
||||
this.contentView.classList.remove('hidden');
|
||||
this.loadingBlock.classList.add('hidden');
|
||||
this.searchInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default EntitySearch;
|
|
@ -1,27 +1,29 @@
|
|||
|
||||
/**
|
||||
* Entity Selector Popup
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EntitySelectorPopup {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.selectButton = this.$refs.select;
|
||||
window.EntitySelectorPopup = this;
|
||||
|
||||
this.callback = null;
|
||||
this.selection = null;
|
||||
|
||||
this.selectButton = elem.querySelector('.entity-link-selector-confirm');
|
||||
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
|
||||
|
||||
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
|
||||
window.$events.listen('entity-select-confirm', this.onSelectionConfirm.bind(this));
|
||||
}
|
||||
|
||||
show(callback) {
|
||||
this.callback = callback;
|
||||
this.elem.components.overlay.show();
|
||||
this.elem.components.popup.show();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.elem.components.overlay.hide();
|
||||
this.elem.components.popup.hide();
|
||||
}
|
||||
|
||||
onSelectButtonClick() {
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* EventEmitSelect
|
||||
* Component will simply emit an event when selected.
|
||||
*
|
||||
* Has one required option: "name".
|
||||
* A name of "hello" will emit a component DOM event of
|
||||
* "event-emit-select-name"
|
||||
*
|
||||
* All options will be set as the "detail" of the event with
|
||||
* their values included.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class EventEmitSelect {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.name = this.$opts.name;
|
||||
|
||||
|
||||
onSelect(this.$el, () => {
|
||||
this.$emit(this.name, this.$opts);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EventEmitSelect;
|
|
@ -0,0 +1,208 @@
|
|||
import {onChildEvent, onSelect, removeLoading, showLoading} from "../services/dom";
|
||||
|
||||
/**
|
||||
* ImageManager
|
||||
* @extends {Component}
|
||||
*/
|
||||
class ImageManager {
|
||||
|
||||
setup() {
|
||||
|
||||
// Options
|
||||
this.uploadedTo = this.$opts.uploadedTo;
|
||||
|
||||
// Element References
|
||||
this.container = this.$el;
|
||||
this.popupEl = this.$refs.popup;
|
||||
this.searchForm = this.$refs.searchForm;
|
||||
this.searchInput = this.$refs.searchInput;
|
||||
this.cancelSearch = this.$refs.cancelSearch;
|
||||
this.listContainer = this.$refs.listContainer;
|
||||
this.filterTabs = this.$manyRefs.filterTabs;
|
||||
this.selectButton = this.$refs.selectButton;
|
||||
this.formContainer = this.$refs.formContainer;
|
||||
this.dropzoneContainer = this.$refs.dropzoneContainer;
|
||||
|
||||
// Instance data
|
||||
this.type = 'gallery';
|
||||
this.lastSelected = {};
|
||||
this.lastSelectedTime = 0;
|
||||
this.callback = null;
|
||||
this.resetState = () => {
|
||||
this.hasData = false;
|
||||
this.page = 1;
|
||||
this.filter = 'all';
|
||||
};
|
||||
this.resetState();
|
||||
|
||||
this.setupListeners();
|
||||
|
||||
window.ImageManager = this;
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
onSelect(this.filterTabs, e => {
|
||||
this.resetAll();
|
||||
this.filter = e.target.dataset.filter;
|
||||
this.setActiveFilterTab(this.filter);
|
||||
this.loadGallery();
|
||||
});
|
||||
|
||||
this.searchForm.addEventListener('submit', event => {
|
||||
this.resetListView();
|
||||
this.loadGallery();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
onSelect(this.cancelSearch, event => {
|
||||
this.resetListView();
|
||||
this.resetSearchView();
|
||||
this.loadGallery();
|
||||
this.cancelSearch.classList.remove('active');
|
||||
});
|
||||
|
||||
this.searchInput.addEventListener('input', event => {
|
||||
this.cancelSearch.classList.toggle('active', this.searchInput.value.trim());
|
||||
});
|
||||
|
||||
onChildEvent(this.listContainer, '.load-more', 'click', async event => {
|
||||
showLoading(event.target);
|
||||
this.page++;
|
||||
await this.loadGallery();
|
||||
event.target.remove();
|
||||
});
|
||||
|
||||
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
|
||||
|
||||
onSelect(this.selectButton, () => {
|
||||
if (this.callback) {
|
||||
this.callback(this.lastSelected);
|
||||
}
|
||||
this.hide();
|
||||
});
|
||||
|
||||
onChildEvent(this.formContainer, '#image-manager-delete', 'click', event => {
|
||||
if (this.lastSelected) {
|
||||
this.loadImageEditForm(this.lastSelected.id, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.formContainer.addEventListener('ajax-form-success', this.refreshGallery.bind(this));
|
||||
this.container.addEventListener('dropzone-success', this.refreshGallery.bind(this));
|
||||
}
|
||||
|
||||
show(callback, type = 'gallery') {
|
||||
this.resetAll();
|
||||
|
||||
this.callback = callback;
|
||||
this.type = type;
|
||||
this.popupEl.components.popup.show();
|
||||
this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery');
|
||||
|
||||
if (!this.hasData) {
|
||||
this.loadGallery();
|
||||
this.hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popupEl.components.popup.hide();
|
||||
}
|
||||
|
||||
async loadGallery() {
|
||||
const params = {
|
||||
page: this.page,
|
||||
search: this.searchInput.value || null,
|
||||
uploaded_to: this.uploadedTo,
|
||||
filter_type: this.filter === 'all' ? null : this.filter,
|
||||
};
|
||||
|
||||
const {data: html} = await window.$http.get(`images/${this.type}`, params);
|
||||
this.addReturnedHtmlElementsToList(html);
|
||||
removeLoading(this.listContainer);
|
||||
}
|
||||
|
||||
addReturnedHtmlElementsToList(html) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html;
|
||||
window.components.init(el);
|
||||
for (const child of [...el.children]) {
|
||||
this.listContainer.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveFilterTab(filterName) {
|
||||
this.filterTabs.forEach(t => t.classList.remove('selected'));
|
||||
const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('selected');
|
||||
}
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
this.resetState();
|
||||
this.resetListView();
|
||||
this.resetSearchView();
|
||||
this.resetEditForm();
|
||||
this.setActiveFilterTab('all');
|
||||
this.selectButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
resetSearchView() {
|
||||
this.searchInput.value = '';
|
||||
}
|
||||
|
||||
resetEditForm() {
|
||||
this.formContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
resetListView() {
|
||||
showLoading(this.listContainer);
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
refreshGallery() {
|
||||
this.resetListView();
|
||||
this.loadGallery();
|
||||
}
|
||||
|
||||
onImageSelectEvent(event) {
|
||||
const image = JSON.parse(event.detail.data);
|
||||
const isDblClick = ((image && image.id === this.lastSelected.id)
|
||||
&& Date.now() - this.lastSelectedTime < 400);
|
||||
const alreadySelected = event.target.classList.contains('selected');
|
||||
[...this.listContainer.querySelectorAll('.selected')].forEach(el => {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
|
||||
if (!alreadySelected) {
|
||||
event.target.classList.add('selected');
|
||||
this.loadImageEditForm(image.id);
|
||||
} else {
|
||||
this.resetEditForm();
|
||||
}
|
||||
this.selectButton.classList.toggle('hidden', alreadySelected);
|
||||
|
||||
if (isDblClick && this.callback) {
|
||||
this.callback(image);
|
||||
this.hide();
|
||||
}
|
||||
|
||||
this.lastSelected = image;
|
||||
this.lastSelectedTime = Date.now();
|
||||
}
|
||||
|
||||
async loadImageEditForm(imageId, requestDelete = false) {
|
||||
if (!requestDelete) {
|
||||
this.formContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
const params = requestDelete ? {delete: true} : {};
|
||||
const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
|
||||
this.formContainer.innerHTML = formHtml;
|
||||
window.components.init(this.formContainer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ImageManager;
|
|
@ -1,109 +1,266 @@
|
|||
import dropdown from "./dropdown";
|
||||
import overlay from "./overlay";
|
||||
import backToTop from "./back-to-top";
|
||||
import notification from "./notification";
|
||||
import chapterToggle from "./chapter-toggle";
|
||||
import expandToggle from "./expand-toggle";
|
||||
import entitySelectorPopup from "./entity-selector-popup";
|
||||
import entitySelector from "./entity-selector";
|
||||
import sidebar from "./sidebar";
|
||||
import pagePicker from "./page-picker";
|
||||
import pageComments from "./page-comments";
|
||||
import wysiwygEditor from "./wysiwyg-editor";
|
||||
import markdownEditor from "./markdown-editor";
|
||||
import editorToolbox from "./editor-toolbox";
|
||||
import imagePicker from "./image-picker";
|
||||
import collapsible from "./collapsible";
|
||||
import toggleSwitch from "./toggle-switch";
|
||||
import pageDisplay from "./page-display";
|
||||
import shelfSort from "./shelf-sort";
|
||||
import homepageControl from "./homepage-control";
|
||||
import headerMobileToggle from "./header-mobile-toggle";
|
||||
import listSortControl from "./list-sort-control";
|
||||
import triLayout from "./tri-layout";
|
||||
import breadcrumbListing from "./breadcrumb-listing";
|
||||
import permissionsTable from "./permissions-table";
|
||||
import customCheckbox from "./custom-checkbox";
|
||||
import bookSort from "./book-sort";
|
||||
import settingAppColorPicker from "./setting-app-color-picker";
|
||||
import settingColorPicker from "./setting-color-picker";
|
||||
import entityPermissionsEditor from "./entity-permissions-editor";
|
||||
import templateManager from "./template-manager";
|
||||
import newUserPassword from "./new-user-password";
|
||||
import detailsHighlighter from "./details-highlighter";
|
||||
import codeHighlighter from "./code-highlighter";
|
||||
import addRemoveRows from "./add-remove-rows.js"
|
||||
import ajaxDeleteRow from "./ajax-delete-row.js"
|
||||
import ajaxForm from "./ajax-form.js"
|
||||
import attachments from "./attachments.js"
|
||||
import autoSuggest from "./auto-suggest.js"
|
||||
import backToTop from "./back-to-top.js"
|
||||
import bookSort from "./book-sort.js"
|
||||
import breadcrumbListing from "./breadcrumb-listing.js"
|
||||
import chapterToggle from "./chapter-toggle.js"
|
||||
import codeEditor from "./code-editor.js"
|
||||
import codeHighlighter from "./code-highlighter.js"
|
||||
import collapsible from "./collapsible.js"
|
||||
import customCheckbox from "./custom-checkbox.js"
|
||||
import detailsHighlighter from "./details-highlighter.js"
|
||||
import dropdown from "./dropdown.js"
|
||||
import dropzone from "./dropzone.js"
|
||||
import editorToolbox from "./editor-toolbox.js"
|
||||
import entityPermissionsEditor from "./entity-permissions-editor.js"
|
||||
import entitySearch from "./entity-search.js"
|
||||
import entitySelector from "./entity-selector.js"
|
||||
import entitySelectorPopup from "./entity-selector-popup.js"
|
||||
import eventEmitSelect from "./event-emit-select.js"
|
||||
import expandToggle from "./expand-toggle.js"
|
||||
import headerMobileToggle from "./header-mobile-toggle.js"
|
||||
import homepageControl from "./homepage-control.js"
|
||||
import imageManager from "./image-manager.js"
|
||||
import imagePicker from "./image-picker.js"
|
||||
import index from "./index.js"
|
||||
import listSortControl from "./list-sort-control.js"
|
||||
import markdownEditor from "./markdown-editor.js"
|
||||
import newUserPassword from "./new-user-password.js"
|
||||
import notification from "./notification.js"
|
||||
import optionalInput from "./optional-input.js"
|
||||
import pageComments from "./page-comments.js"
|
||||
import pageDisplay from "./page-display.js"
|
||||
import pageEditor from "./page-editor.js"
|
||||
import pagePicker from "./page-picker.js"
|
||||
import permissionsTable from "./permissions-table.js"
|
||||
import popup from "./popup.js"
|
||||
import settingAppColorPicker from "./setting-app-color-picker.js"
|
||||
import settingColorPicker from "./setting-color-picker.js"
|
||||
import shelfSort from "./shelf-sort.js"
|
||||
import sidebar from "./sidebar.js"
|
||||
import sortableList from "./sortable-list.js"
|
||||
import submitOnChange from "./submit-on-change.js"
|
||||
import tabs from "./tabs.js"
|
||||
import tagManager from "./tag-manager.js"
|
||||
import templateManager from "./template-manager.js"
|
||||
import toggleSwitch from "./toggle-switch.js"
|
||||
import triLayout from "./tri-layout.js"
|
||||
import wysiwygEditor from "./wysiwyg-editor.js"
|
||||
|
||||
const componentMapping = {
|
||||
'dropdown': dropdown,
|
||||
'overlay': overlay,
|
||||
'back-to-top': backToTop,
|
||||
'notification': notification,
|
||||
'chapter-toggle': chapterToggle,
|
||||
'expand-toggle': expandToggle,
|
||||
'entity-selector-popup': entitySelectorPopup,
|
||||
'entity-selector': entitySelector,
|
||||
'sidebar': sidebar,
|
||||
'page-picker': pagePicker,
|
||||
'page-comments': pageComments,
|
||||
'wysiwyg-editor': wysiwygEditor,
|
||||
'markdown-editor': markdownEditor,
|
||||
'editor-toolbox': editorToolbox,
|
||||
'image-picker': imagePicker,
|
||||
'collapsible': collapsible,
|
||||
'toggle-switch': toggleSwitch,
|
||||
'page-display': pageDisplay,
|
||||
'shelf-sort': shelfSort,
|
||||
'homepage-control': homepageControl,
|
||||
'header-mobile-toggle': headerMobileToggle,
|
||||
'list-sort-control': listSortControl,
|
||||
'tri-layout': triLayout,
|
||||
'breadcrumb-listing': breadcrumbListing,
|
||||
'permissions-table': permissionsTable,
|
||||
'custom-checkbox': customCheckbox,
|
||||
'book-sort': bookSort,
|
||||
'setting-app-color-picker': settingAppColorPicker,
|
||||
'setting-color-picker': settingColorPicker,
|
||||
'entity-permissions-editor': entityPermissionsEditor,
|
||||
'template-manager': templateManager,
|
||||
'new-user-password': newUserPassword,
|
||||
'details-highlighter': detailsHighlighter,
|
||||
'code-highlighter': codeHighlighter,
|
||||
"add-remove-rows": addRemoveRows,
|
||||
"ajax-delete-row": ajaxDeleteRow,
|
||||
"ajax-form": ajaxForm,
|
||||
"attachments": attachments,
|
||||
"auto-suggest": autoSuggest,
|
||||
"back-to-top": backToTop,
|
||||
"book-sort": bookSort,
|
||||
"breadcrumb-listing": breadcrumbListing,
|
||||
"chapter-toggle": chapterToggle,
|
||||
"code-editor": codeEditor,
|
||||
"code-highlighter": codeHighlighter,
|
||||
"collapsible": collapsible,
|
||||
"custom-checkbox": customCheckbox,
|
||||
"details-highlighter": detailsHighlighter,
|
||||
"dropdown": dropdown,
|
||||
"dropzone": dropzone,
|
||||
"editor-toolbox": editorToolbox,
|
||||
"entity-permissions-editor": entityPermissionsEditor,
|
||||
"entity-search": entitySearch,
|
||||
"entity-selector": entitySelector,
|
||||
"entity-selector-popup": entitySelectorPopup,
|
||||
"event-emit-select": eventEmitSelect,
|
||||
"expand-toggle": expandToggle,
|
||||
"header-mobile-toggle": headerMobileToggle,
|
||||
"homepage-control": homepageControl,
|
||||
"image-manager": imageManager,
|
||||
"image-picker": imagePicker,
|
||||
"index": index,
|
||||
"list-sort-control": listSortControl,
|
||||
"markdown-editor": markdownEditor,
|
||||
"new-user-password": newUserPassword,
|
||||
"notification": notification,
|
||||
"optional-input": optionalInput,
|
||||
"page-comments": pageComments,
|
||||
"page-display": pageDisplay,
|
||||
"page-editor": pageEditor,
|
||||
"page-picker": pagePicker,
|
||||
"permissions-table": permissionsTable,
|
||||
"popup": popup,
|
||||
"setting-app-color-picker": settingAppColorPicker,
|
||||
"setting-color-picker": settingColorPicker,
|
||||
"shelf-sort": shelfSort,
|
||||
"sidebar": sidebar,
|
||||
"sortable-list": sortableList,
|
||||
"submit-on-change": submitOnChange,
|
||||
"tabs": tabs,
|
||||
"tag-manager": tagManager,
|
||||
"template-manager": templateManager,
|
||||
"toggle-switch": toggleSwitch,
|
||||
"tri-layout": triLayout,
|
||||
"wysiwyg-editor": wysiwygEditor,
|
||||
};
|
||||
|
||||
window.components = {};
|
||||
|
||||
const componentNames = Object.keys(componentMapping);
|
||||
|
||||
/**
|
||||
* Initialize components of the given name within the given element.
|
||||
* @param {String} componentName
|
||||
* @param {HTMLElement|Document} parentElement
|
||||
*/
|
||||
function initComponent(componentName, parentElement) {
|
||||
let elems = parentElement.querySelectorAll(`[${componentName}]`);
|
||||
if (elems.length === 0) return;
|
||||
|
||||
let component = componentMapping[componentName];
|
||||
if (typeof window.components[componentName] === "undefined") window.components[componentName] = [];
|
||||
function searchForComponentInParent(componentName, parentElement) {
|
||||
const elems = parentElement.querySelectorAll(`[${componentName}]`);
|
||||
for (let j = 0, jLen = elems.length; j < jLen; j++) {
|
||||
let instance = new component(elems[j]);
|
||||
if (typeof elems[j].components === 'undefined') elems[j].components = {};
|
||||
elems[j].components[componentName] = instance;
|
||||
window.components[componentName].push(instance);
|
||||
initComponent(componentName, elems[j]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a component instance on the given dom element.
|
||||
* @param {String} name
|
||||
* @param {Element} element
|
||||
*/
|
||||
function initComponent(name, element) {
|
||||
const componentModel = componentMapping[name];
|
||||
if (componentModel === undefined) return;
|
||||
|
||||
// Create our component instance
|
||||
let instance;
|
||||
try {
|
||||
instance = new componentModel(element);
|
||||
instance.$el = element;
|
||||
const allRefs = parseRefs(name, element);
|
||||
instance.$refs = allRefs.refs;
|
||||
instance.$manyRefs = allRefs.manyRefs;
|
||||
instance.$opts = parseOpts(name, element);
|
||||
instance.$emit = (eventName, data = {}) => {
|
||||
data.from = instance;
|
||||
const event = new CustomEvent(`${name}-${eventName}`, {
|
||||
bubbles: true,
|
||||
detail: data
|
||||
});
|
||||
instance.$el.dispatchEvent(event);
|
||||
};
|
||||
if (typeof instance.setup === 'function') {
|
||||
instance.setup();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create component', e, name, element);
|
||||
}
|
||||
|
||||
|
||||
// Add to global listing
|
||||
if (typeof window.components[name] === "undefined") {
|
||||
window.components[name] = [];
|
||||
}
|
||||
window.components[name].push(instance);
|
||||
|
||||
// Add to element listing
|
||||
if (typeof element.components === 'undefined') {
|
||||
element.components = {};
|
||||
}
|
||||
element.components[name] = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out the element references within the given element
|
||||
* for the given component name.
|
||||
* @param {String} name
|
||||
* @param {Element} element
|
||||
*/
|
||||
function parseRefs(name, element) {
|
||||
const refs = {};
|
||||
const manyRefs = {};
|
||||
|
||||
const prefix = `${name}@`
|
||||
const selector = `[refs*="${prefix}"]`;
|
||||
const refElems = [...element.querySelectorAll(selector)];
|
||||
if (element.matches(selector)) {
|
||||
refElems.push(element);
|
||||
}
|
||||
|
||||
for (const el of refElems) {
|
||||
const refNames = el.getAttribute('refs')
|
||||
.split(' ')
|
||||
.filter(str => str.startsWith(prefix))
|
||||
.map(str => str.replace(prefix, ''))
|
||||
.map(kebabToCamel);
|
||||
for (const ref of refNames) {
|
||||
refs[ref] = el;
|
||||
if (typeof manyRefs[ref] === 'undefined') {
|
||||
manyRefs[ref] = [];
|
||||
}
|
||||
manyRefs[ref].push(el);
|
||||
}
|
||||
}
|
||||
return {refs, manyRefs};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out the element component options.
|
||||
* @param {String} name
|
||||
* @param {Element} element
|
||||
* @return {Object<String, String>}
|
||||
*/
|
||||
function parseOpts(name, element) {
|
||||
const opts = {};
|
||||
const prefix = `option:${name}:`;
|
||||
for (const {name, value} of element.attributes) {
|
||||
if (name.startsWith(prefix)) {
|
||||
const optName = name.replace(prefix, '');
|
||||
opts[kebabToCamel(optName)] = value || '';
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a kebab-case string to camelCase
|
||||
* @param {String} kebab
|
||||
* @returns {string}
|
||||
*/
|
||||
function kebabToCamel(kebab) {
|
||||
const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
|
||||
const words = kebab.split('-');
|
||||
return words[0] + words.slice(1).map(ucFirst).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all components found within the given element.
|
||||
* @param parentElement
|
||||
*/
|
||||
function initAll(parentElement) {
|
||||
if (typeof parentElement === 'undefined') parentElement = document;
|
||||
for (let i = 0, len = componentNames.length; i < len; i++) {
|
||||
initComponent(componentNames[i], parentElement);
|
||||
|
||||
// Old attribute system
|
||||
for (const componentName of Object.keys(componentMapping)) {
|
||||
searchForComponentInParent(componentName, parentElement);
|
||||
}
|
||||
|
||||
// New component system
|
||||
const componentElems = parentElement.querySelectorAll(`[component],[components]`);
|
||||
|
||||
for (const el of componentElems) {
|
||||
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
|
||||
for (const name of componentNames) {
|
||||
initComponent(name, el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.components.init = initAll;
|
||||
window.components.first = (name) => (window.components[name] || [null])[0];
|
||||
|
||||
export default initAll;
|
||||
|
||||
/**
|
||||
* @typedef Component
|
||||
* @property {HTMLElement} $el
|
||||
* @property {Object<String, HTMLElement>} $refs
|
||||
* @property {Object<String, HTMLElement[]>} $manyRefs
|
||||
* @property {Object<String, String>} $opts
|
||||
* @property {function(string, Object)} $emit
|
||||
*/
|
|
@ -8,12 +8,11 @@ import DrawIO from "../services/drawio";
|
|||
|
||||
class MarkdownEditor {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
||||
const pageEditor = document.getElementById('page-editor');
|
||||
this.pageId = pageEditor.getAttribute('page-id');
|
||||
this.textDirection = pageEditor.getAttribute('text-direction');
|
||||
this.pageId = this.$opts.pageId;
|
||||
this.textDirection = this.$opts.textDirection;
|
||||
|
||||
this.markdown = new MarkdownIt({html: true});
|
||||
this.markdown.use(mdTasksLists, {label: true});
|
||||
|
@ -27,12 +26,18 @@ class MarkdownEditor {
|
|||
|
||||
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
|
||||
|
||||
this.display.addEventListener('load', () => {
|
||||
const displayLoad = () => {
|
||||
this.displayDoc = this.display.contentDocument;
|
||||
this.init();
|
||||
});
|
||||
};
|
||||
|
||||
window.$events.emitPublic(elem, 'editor-markdown::setup', {
|
||||
if (this.display.contentDocument.readyState === 'complete') {
|
||||
displayLoad();
|
||||
} else {
|
||||
this.display.addEventListener('load', displayLoad.bind(this));
|
||||
}
|
||||
|
||||
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
|
||||
markdownIt: this.markdown,
|
||||
displayEl: this.display,
|
||||
codeMirrorInstance: this.cm,
|
||||
|
@ -251,7 +256,7 @@ class MarkdownEditor {
|
|||
}
|
||||
|
||||
const clipboard = new Clipboard(event.dataTransfer);
|
||||
if (clipboard.hasItems()) {
|
||||
if (clipboard.hasItems() && clipboard.getImages().length > 0) {
|
||||
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
|
||||
cm.setCursor(cursorPos);
|
||||
event.stopPropagation();
|
||||
|
@ -558,6 +563,12 @@ class MarkdownEditor {
|
|||
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
|
||||
});
|
||||
|
||||
// Insert editor content at the current location
|
||||
window.$events.listen('editor::insert', (eventContent) => {
|
||||
const markdown = getContentToInsert(eventContent);
|
||||
this.cm.replaceSelection(markdown);
|
||||
});
|
||||
|
||||
// Focus on editor
|
||||
window.$events.listen('editor::focus', () => {
|
||||
this.cm.focus();
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import {onSelect} from "../services/dom";
|
||||
|
||||
class OptionalInput {
|
||||
setup() {
|
||||
this.removeButton = this.$refs.remove;
|
||||
this.showButton = this.$refs.show;
|
||||
this.input = this.$refs.input;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
onSelect(this.removeButton, () => {
|
||||
this.input.value = '';
|
||||
this.input.classList.add('hidden');
|
||||
this.removeButton.classList.add('hidden');
|
||||
this.showButton.classList.remove('hidden');
|
||||
});
|
||||
|
||||
onSelect(this.showButton, () => {
|
||||
this.input.classList.remove('hidden');
|
||||
this.removeButton.classList.remove('hidden');
|
||||
this.showButton.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default OptionalInput;
|
|
@ -1,43 +0,0 @@
|
|||
import {fadeIn, fadeOut} from "../services/animations";
|
||||
|
||||
class Overlay {
|
||||
|
||||
constructor(elem) {
|
||||
this.container = elem;
|
||||
elem.addEventListener('click', event => {
|
||||
if (event.target === elem) return this.hide();
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', event => {
|
||||
if (event.key === 'Escape') {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
let closeButtons = elem.querySelectorAll('.popup-header-close');
|
||||
for (let i=0; i < closeButtons.length; i++) {
|
||||
closeButtons[i].addEventListener('click', this.hide.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
hide(onComplete = null) { this.toggle(false, onComplete); }
|
||||
show(onComplete = null) { this.toggle(true, onComplete); }
|
||||
|
||||
toggle(show = true, onComplete) {
|
||||
if (show) {
|
||||
fadeIn(this.container, 240, onComplete);
|
||||
} else {
|
||||
fadeOut(this.container, 240, onComplete);
|
||||
}
|
||||
}
|
||||
|
||||
focusOnBody() {
|
||||
const body = this.container.querySelector('.popup-body');
|
||||
if (body) {
|
||||
body.focus();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Overlay;
|
|
@ -1,16 +1,31 @@
|
|||
import {scrollAndHighlightElement} from "../services/util";
|
||||
|
||||
/**
|
||||
* @extends {Component}
|
||||
*/
|
||||
class PageComments {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.pageId = Number(elem.getAttribute('page-id'));
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.pageId = Number(this.$opts.pageId);
|
||||
|
||||
// Element references
|
||||
this.container = this.$refs.commentContainer;
|
||||
this.formContainer = this.$refs.formContainer;
|
||||
this.commentCountBar = this.$refs.commentCountBar;
|
||||
this.addButtonContainer = this.$refs.addButtonContainer;
|
||||
this.replyToRow = this.$refs.replyToRow;
|
||||
|
||||
// Translations
|
||||
this.updatedText = this.$opts.updatedText;
|
||||
this.deletedText = this.$opts.deletedText;
|
||||
this.createdText = this.$opts.createdText;
|
||||
this.countText = this.$opts.countText;
|
||||
|
||||
// Internal State
|
||||
this.editingComment = null;
|
||||
this.parentId = null;
|
||||
|
||||
this.container = elem.querySelector('[comment-container]');
|
||||
this.formContainer = elem.querySelector('[comment-form-container]');
|
||||
|
||||
if (this.formContainer) {
|
||||
this.form = this.formContainer.querySelector('form');
|
||||
this.formInput = this.form.querySelector('textarea');
|
||||
|
@ -32,13 +47,14 @@ class PageComments {
|
|||
if (actionElem === null) return;
|
||||
event.preventDefault();
|
||||
|
||||
let action = actionElem.getAttribute('action');
|
||||
if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
|
||||
const action = actionElem.getAttribute('action');
|
||||
const comment = actionElem.closest('[comment]');
|
||||
if (action === 'edit') this.editComment(comment);
|
||||
if (action === 'closeUpdateForm') this.closeUpdateForm();
|
||||
if (action === 'delete') this.deleteComment(actionElem.closest('[comment]'));
|
||||
if (action === 'delete') this.deleteComment(comment);
|
||||
if (action === 'addComment') this.showForm();
|
||||
if (action === 'hideForm') this.hideForm();
|
||||
if (action === 'reply') this.setReply(actionElem.closest('[comment]'));
|
||||
if (action === 'reply') this.setReply(comment);
|
||||
if (action === 'remove-reply-to') this.removeReplyTo();
|
||||
}
|
||||
|
||||
|
@ -69,14 +85,15 @@ class PageComments {
|
|||
};
|
||||
this.showLoading(form);
|
||||
let commentId = this.editingComment.getAttribute('comment');
|
||||
window.$http.put(`/ajax/comment/${commentId}`, reqData).then(resp => {
|
||||
window.$http.put(`/comment/${commentId}`, reqData).then(resp => {
|
||||
let newComment = document.createElement('div');
|
||||
newComment.innerHTML = resp.data;
|
||||
this.editingComment.innerHTML = newComment.children[0].innerHTML;
|
||||
window.$events.emit('success', window.trans('entities.comment_updated_success'));
|
||||
window.$events.success(this.updatedText);
|
||||
window.components.init(this.editingComment);
|
||||
this.closeUpdateForm();
|
||||
this.editingComment = null;
|
||||
}).catch(window.$events.showValidationErrors).then(() => {
|
||||
this.hideLoading(form);
|
||||
});
|
||||
}
|
||||
|
@ -84,9 +101,9 @@ class PageComments {
|
|||
deleteComment(commentElem) {
|
||||
let id = commentElem.getAttribute('comment');
|
||||
this.showLoading(commentElem.querySelector('[comment-content]'));
|
||||
window.$http.delete(`/ajax/comment/${id}`).then(resp => {
|
||||
window.$http.delete(`/comment/${id}`).then(resp => {
|
||||
commentElem.parentNode.removeChild(commentElem);
|
||||
window.$events.emit('success', window.trans('entities.comment_deleted_success'));
|
||||
window.$events.success(this.deletedText);
|
||||
this.updateCount();
|
||||
this.hideForm();
|
||||
});
|
||||
|
@ -101,21 +118,24 @@ class PageComments {
|
|||
parent_id: this.parentId || null,
|
||||
};
|
||||
this.showLoading(this.form);
|
||||
window.$http.post(`/ajax/page/${this.pageId}/comment`, reqData).then(resp => {
|
||||
window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
|
||||
let newComment = document.createElement('div');
|
||||
newComment.innerHTML = resp.data;
|
||||
let newElem = newComment.children[0];
|
||||
this.container.appendChild(newElem);
|
||||
window.components.init(newElem);
|
||||
window.$events.emit('success', window.trans('entities.comment_created_success'));
|
||||
window.$events.success(this.createdText);
|
||||
this.resetForm();
|
||||
this.updateCount();
|
||||
}).catch(err => {
|
||||
window.$events.showValidationErrors(err);
|
||||
this.hideLoading(this.form);
|
||||
});
|
||||
}
|
||||
|
||||
updateCount() {
|
||||
let count = this.container.children.length;
|
||||
this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count});
|
||||
this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count});
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
|
@ -129,7 +149,7 @@ class PageComments {
|
|||
showForm() {
|
||||
this.formContainer.style.display = 'block';
|
||||
this.formContainer.parentNode.style.display = 'block';
|
||||
this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
|
||||
this.addButtonContainer.style.display = 'none';
|
||||
this.formInput.focus();
|
||||
this.formInput.scrollIntoView({behavior: "smooth"});
|
||||
}
|
||||
|
@ -137,14 +157,12 @@ class PageComments {
|
|||
hideForm() {
|
||||
this.formContainer.style.display = 'none';
|
||||
this.formContainer.parentNode.style.display = 'none';
|
||||
const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
|
||||
if (this.getCommentCount() > 0) {
|
||||
this.elem.appendChild(addButtonContainer)
|
||||
this.elem.appendChild(this.addButtonContainer)
|
||||
} else {
|
||||
const countBar = this.elem.querySelector('[comment-count-bar]');
|
||||
countBar.appendChild(addButtonContainer);
|
||||
this.commentCountBar.appendChild(this.addButtonContainer);
|
||||
}
|
||||
addButtonContainer.style.display = 'block';
|
||||
this.addButtonContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
getCommentCount() {
|
||||
|
@ -154,15 +172,15 @@ class PageComments {
|
|||
setReply(commentElem) {
|
||||
this.showForm();
|
||||
this.parentId = Number(commentElem.getAttribute('local-id'));
|
||||
this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
|
||||
let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
|
||||
this.replyToRow.style.display = 'block';
|
||||
const replyLink = this.replyToRow.querySelector('a');
|
||||
replyLink.textContent = `#${this.parentId}`;
|
||||
replyLink.href = `#comment${this.parentId}`;
|
||||
}
|
||||
|
||||
removeReplyTo() {
|
||||
this.parentId = null;
|
||||
this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
|
||||
this.replyToRow.style.display = 'none';
|
||||
}
|
||||
|
||||
showLoading(formElem) {
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
import * as Dates from "../services/dates";
|
||||
import {onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Page Editor
|
||||
* @extends {Component}
|
||||
*/
|
||||
class PageEditor {
|
||||
setup() {
|
||||
// Options
|
||||
this.draftsEnabled = this.$opts.draftsEnabled === 'true';
|
||||
this.editorType = this.$opts.editorType;
|
||||
this.pageId = Number(this.$opts.pageId);
|
||||
this.isNewDraft = this.$opts.pageNewDraft === 'true';
|
||||
this.hasDefaultTitle = this.$opts.isDefaultTitle || false;
|
||||
|
||||
// Elements
|
||||
this.container = this.$el;
|
||||
this.titleElem = this.$refs.titleContainer.querySelector('input');
|
||||
this.saveDraftButton = this.$refs.saveDraft;
|
||||
this.discardDraftButton = this.$refs.discardDraft;
|
||||
this.discardDraftWrap = this.$refs.discardDraftWrap;
|
||||
this.draftDisplay = this.$refs.draftDisplay;
|
||||
this.draftDisplayIcon = this.$refs.draftDisplayIcon;
|
||||
this.changelogInput = this.$refs.changelogInput;
|
||||
this.changelogDisplay = this.$refs.changelogDisplay;
|
||||
|
||||
// Translations
|
||||
this.draftText = this.$opts.draftText;
|
||||
this.autosaveFailText = this.$opts.autosaveFailText;
|
||||
this.editingPageText = this.$opts.editingPageText;
|
||||
this.draftDiscardedText = this.$opts.draftDiscardedText;
|
||||
this.setChangelogText = this.$opts.setChangelogText;
|
||||
|
||||
// State data
|
||||
this.editorHTML = '';
|
||||
this.editorMarkdown = '';
|
||||
this.autoSave = {
|
||||
interval: null,
|
||||
frequency: 30000,
|
||||
last: 0,
|
||||
};
|
||||
|
||||
if (this.pageId !== 0 && this.draftsEnabled) {
|
||||
window.setTimeout(() => {
|
||||
this.startAutoSave();
|
||||
}, 1000);
|
||||
}
|
||||
this.draftDisplay.innerHTML = this.draftText;
|
||||
|
||||
this.setupListeners();
|
||||
this.setInitialFocus();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
// Listen to save events from editor
|
||||
window.$events.listen('editor-save-draft', this.saveDraft.bind(this));
|
||||
window.$events.listen('editor-save-page', this.savePage.bind(this));
|
||||
|
||||
// Listen to content changes from the editor
|
||||
window.$events.listen('editor-html-change', html => {
|
||||
this.editorHTML = html;
|
||||
});
|
||||
window.$events.listen('editor-markdown-change', markdown => {
|
||||
this.editorMarkdown = markdown;
|
||||
});
|
||||
|
||||
// Changelog controls
|
||||
this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
|
||||
|
||||
// Draft Controls
|
||||
onSelect(this.saveDraftButton, this.saveDraft.bind(this));
|
||||
onSelect(this.discardDraftButton, this.discardDraft.bind(this));
|
||||
}
|
||||
|
||||
setInitialFocus() {
|
||||
if (this.hasDefaultTitle) {
|
||||
return this.titleElem.select();
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.$events.emit('editor::focus', '');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
startAutoSave() {
|
||||
let lastContent = this.titleElem.value.trim() + '::' + this.editorHTML;
|
||||
this.autoSaveInterval = window.setInterval(() => {
|
||||
// Stop if manually saved recently to prevent bombarding the server
|
||||
let savedRecently = (Date.now() - this.autoSave.last < (this.autoSave.frequency)/2);
|
||||
if (savedRecently) return;
|
||||
const newContent = this.titleElem.value.trim() + '::' + this.editorHTML;
|
||||
if (newContent !== lastContent) {
|
||||
lastContent = newContent;
|
||||
this.saveDraft();
|
||||
}
|
||||
|
||||
}, this.autoSave.frequency);
|
||||
}
|
||||
|
||||
savePage() {
|
||||
this.container.closest('form').submit();
|
||||
}
|
||||
|
||||
async saveDraft() {
|
||||
const data = {
|
||||
name: this.titleElem.value.trim(),
|
||||
html: this.editorHTML,
|
||||
};
|
||||
|
||||
if (this.editorType === 'markdown') {
|
||||
data.markdown = this.editorMarkdown;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
|
||||
if (!this.isNewDraft) {
|
||||
this.toggleDiscardDraftVisibility(true);
|
||||
}
|
||||
this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
|
||||
this.autoSave.last = Date.now();
|
||||
} catch (err) {
|
||||
// Save the editor content in LocalStorage as a last resort, just in case.
|
||||
try {
|
||||
const saveKey = `draft-save-fail-${(new Date()).toISOString()}`;
|
||||
window.localStorage.setItem(saveKey, JSON.stringify(data));
|
||||
} catch (err) {}
|
||||
|
||||
window.$events.emit('error', this.autosaveFailText);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
draftNotifyChange(text) {
|
||||
this.draftDisplay.innerText = text;
|
||||
this.draftDisplayIcon.classList.add('visible');
|
||||
window.setTimeout(() => {
|
||||
this.draftDisplayIcon.classList.remove('visible');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async discardDraft() {
|
||||
let response;
|
||||
try {
|
||||
response = await window.$http.get(`/ajax/page/${this.pageId}`);
|
||||
} catch (e) {
|
||||
return console.error(e);
|
||||
}
|
||||
|
||||
if (this.autoSave.interval) {
|
||||
window.clearInterval(this.autoSave.interval);
|
||||
}
|
||||
|
||||
this.draftDisplay.innerText = this.editingPageText;
|
||||
this.toggleDiscardDraftVisibility(false);
|
||||
window.$events.emit('editor-html-update', response.data.html || '');
|
||||
window.$events.emit('editor-markdown-update', response.data.markdown || response.data.html);
|
||||
|
||||
this.titleElem.value = response.data.name;
|
||||
window.setTimeout(() => {
|
||||
this.startAutoSave();
|
||||
}, 1000);
|
||||
window.$events.emit('success', this.draftDiscardedText);
|
||||
|
||||
}
|
||||
|
||||
updateChangelogDisplay() {
|
||||
let summary = this.changelogInput.value.trim();
|
||||
if (summary.length === 0) {
|
||||
summary = this.setChangelogText;
|
||||
} else if (summary.length > 16) {
|
||||
summary = summary.slice(0, 16) + '...';
|
||||
}
|
||||
this.changelogDisplay.innerText = summary;
|
||||
}
|
||||
|
||||
toggleDiscardDraftVisibility(show) {
|
||||
this.discardDraftWrap.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PageEditor;
|
|
@ -0,0 +1,61 @@
|
|||
import {fadeIn, fadeOut} from "../services/animations";
|
||||
import {onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Popup window that will contain other content.
|
||||
* This component provides the show/hide functionality
|
||||
* with the ability for popup@hide child references to close this.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class Popup {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.hideButtons = this.$manyRefs.hide || [];
|
||||
|
||||
this.onkeyup = null;
|
||||
this.onHide = null;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
let lastMouseDownTarget = null;
|
||||
this.container.addEventListener('mousedown', event => {
|
||||
lastMouseDownTarget = event.target;
|
||||
});
|
||||
|
||||
this.container.addEventListener('click', event => {
|
||||
if (event.target === this.container && lastMouseDownTarget === this.container) {
|
||||
return this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
onSelect(this.hideButtons, e => this.hide());
|
||||
}
|
||||
|
||||
hide(onComplete = null) {
|
||||
fadeOut(this.container, 240, onComplete);
|
||||
if (this.onkeyup) {
|
||||
window.removeEventListener('keyup', this.onkeyup);
|
||||
this.onkeyup = null;
|
||||
}
|
||||
if (this.onHide) {
|
||||
this.onHide();
|
||||
}
|
||||
}
|
||||
|
||||
show(onComplete = null, onHide = null) {
|
||||
fadeIn(this.container, 240, onComplete);
|
||||
|
||||
this.onkeyup = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keyup', this.onkeyup);
|
||||
this.onHide = onHide;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Popup;
|
|
@ -0,0 +1,39 @@
|
|||
import Sortable from "sortablejs";
|
||||
|
||||
/**
|
||||
* SortableList
|
||||
*
|
||||
* Can have data set on the dragged items by setting a 'data-drag-content' attribute.
|
||||
* This attribute must contain JSON where the keys are content types and the values are
|
||||
* the data to set on the data-transfer.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class SortableList {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.handleSelector = this.$opts.handleSelector;
|
||||
|
||||
const sortable = new Sortable(this.container, {
|
||||
handle: this.handleSelector,
|
||||
animation: 150,
|
||||
onSort: () => {
|
||||
this.$emit('sort', {ids: sortable.toArray()});
|
||||
},
|
||||
setData(dataTransferItem, dragEl) {
|
||||
const jsonContent = dragEl.getAttribute('data-drag-content');
|
||||
if (jsonContent) {
|
||||
const contentByType = JSON.parse(jsonContent);
|
||||
for (const [type, content] of Object.entries(contentByType)) {
|
||||
dataTransferItem.setData(type, content);
|
||||
}
|
||||
}
|
||||
},
|
||||
revertOnSpill: true,
|
||||
dropBubble: true,
|
||||
dragoverBubble: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default SortableList;
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Submit on change
|
||||
* Simply submits a parent form when this input is changed.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class SubmitOnChange {
|
||||
|
||||
setup() {
|
||||
this.$el.addEventListener('change', () => {
|
||||
const form = this.$el.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SubmitOnChange;
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Tabs
|
||||
* Works by matching 'tabToggle<Key>' with 'tabContent<Key>' sections.
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {onSelect} from "../services/dom";
|
||||
|
||||
class Tabs {
|
||||
|
||||
setup() {
|
||||
this.tabContentsByName = {};
|
||||
this.tabButtonsByName = {};
|
||||
this.allContents = [];
|
||||
this.allButtons = [];
|
||||
|
||||
for (const [key, elems] of Object.entries(this.$manyRefs || {})) {
|
||||
if (key.startsWith('toggle')) {
|
||||
const cleanKey = key.replace('toggle', '').toLowerCase();
|
||||
onSelect(elems, e => this.show(cleanKey));
|
||||
this.allButtons.push(...elems);
|
||||
this.tabButtonsByName[cleanKey] = elems;
|
||||
}
|
||||
if (key.startsWith('content')) {
|
||||
const cleanKey = key.replace('content', '').toLowerCase();
|
||||
this.tabContentsByName[cleanKey] = elems;
|
||||
this.allContents.push(...elems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show(key) {
|
||||
this.allContents.forEach(c => {
|
||||
c.classList.add('hidden');
|
||||
c.classList.remove('selected');
|
||||
});
|
||||
this.allButtons.forEach(b => b.classList.remove('selected'));
|
||||
|
||||
const contents = this.tabContentsByName[key] || [];
|
||||
const buttons = this.tabButtonsByName[key] || [];
|
||||
if (contents.length > 0) {
|
||||
contents.forEach(c => {
|
||||
c.classList.remove('hidden')
|
||||
c.classList.add('selected')
|
||||
});
|
||||
buttons.forEach(b => b.classList.add('selected'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Tabs;
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* TagManager
|
||||
* @extends {Component}
|
||||
*/
|
||||
class TagManager {
|
||||
setup() {
|
||||
this.addRemoveComponentEl = this.$refs.addRemove;
|
||||
this.container = this.$el;
|
||||
this.rowSelector = this.$opts.rowSelector;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.container.addEventListener('change', event => {
|
||||
const addRemoveComponent = this.addRemoveComponentEl.components['add-remove-rows'];
|
||||
if (!this.hasEmptyRows()) {
|
||||
addRemoveComponent.add();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hasEmptyRows() {
|
||||
const rows = this.container.querySelectorAll(this.rowSelector);
|
||||
const firstEmpty = [...rows].find(row => {
|
||||
return [...row.querySelectorAll('input')].filter(input => input.value).length === 0;
|
||||
});
|
||||
return firstEmpty !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default TagManager;
|
|
@ -137,7 +137,7 @@ function codePlugin() {
|
|||
|
||||
if (!elemIsCodeBlock(selectedNode)) {
|
||||
const providedCode = editor.selection.getNode().textContent;
|
||||
window.vues['code-editor'].open(providedCode, '', (code, lang) => {
|
||||
window.components.first('code-editor').open(providedCode, '', (code, lang) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
|
||||
wrap.querySelector('code').innerText = code;
|
||||
|
@ -155,7 +155,7 @@ function codePlugin() {
|
|||
let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
|
||||
let currentCode = selectedNode.querySelector('textarea').textContent;
|
||||
|
||||
window.vues['code-editor'].open(currentCode, lang, (code, lang) => {
|
||||
window.components.first('code-editor').open(currentCode, lang, (code, lang) => {
|
||||
const editorElem = selectedNode.querySelector('.CodeMirror');
|
||||
const cmInstance = editorElem.CodeMirror;
|
||||
if (cmInstance) {
|
||||
|
@ -236,7 +236,7 @@ function codePlugin() {
|
|||
});
|
||||
}
|
||||
|
||||
function drawIoPlugin(drawioUrl, isDarkMode) {
|
||||
function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
|
||||
|
||||
let pageEditor = null;
|
||||
let currentNode = null;
|
||||
|
@ -270,7 +270,6 @@ function drawIoPlugin(drawioUrl, isDarkMode) {
|
|||
async function updateContent(pngData) {
|
||||
const id = "image-" + Math.random().toString(16).slice(2);
|
||||
const loadingImage = window.baseUrl('/loading.gif');
|
||||
const pageId = Number(document.getElementById('page-editor').getAttribute('page-id'));
|
||||
|
||||
// Handle updating an existing image
|
||||
if (currentNode) {
|
||||
|
@ -402,6 +401,11 @@ function listenForBookStackEditorEvents(editor) {
|
|||
editor.setContent(content);
|
||||
});
|
||||
|
||||
// Insert editor content at the current location
|
||||
window.$events.listen('editor::insert', ({html}) => {
|
||||
editor.insertContent(html);
|
||||
});
|
||||
|
||||
// Focus on the editor
|
||||
window.$events.listen('editor::focus', () => {
|
||||
editor.focus();
|
||||
|
@ -410,19 +414,19 @@ function listenForBookStackEditorEvents(editor) {
|
|||
|
||||
class WysiwygEditor {
|
||||
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
|
||||
const pageEditor = document.getElementById('page-editor');
|
||||
this.pageId = pageEditor.getAttribute('page-id');
|
||||
this.textDirection = pageEditor.getAttribute('text-direction');
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
|
||||
this.pageId = this.$opts.pageId;
|
||||
this.textDirection = this.$opts.textDirection;
|
||||
this.isDarkMode = document.documentElement.classList.contains('dark-mode');
|
||||
|
||||
this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media";
|
||||
this.plugins = "image table textcolor paste link autolink fullscreen code customhr autosave lists codeeditor media";
|
||||
this.loadPlugins();
|
||||
|
||||
this.tinyMceConfig = this.getTinyMceConfig();
|
||||
window.$events.emitPublic(elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
|
||||
window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
|
||||
window.tinymce.init(this.tinyMceConfig);
|
||||
}
|
||||
|
||||
|
@ -433,7 +437,7 @@ class WysiwygEditor {
|
|||
const drawioUrlElem = document.querySelector('[drawio-url]');
|
||||
if (drawioUrlElem) {
|
||||
const url = drawioUrlElem.getAttribute('drawio-url');
|
||||
drawIoPlugin(url, this.isDarkMode);
|
||||
drawIoPlugin(url, this.isDarkMode, this.pageId);
|
||||
this.plugins += ' drawio';
|
||||
}
|
||||
|
||||
|
@ -639,6 +643,7 @@ class WysiwygEditor {
|
|||
|
||||
});
|
||||
|
||||
// Custom drop event handling
|
||||
editor.on('drop', function (event) {
|
||||
let dom = editor.dom,
|
||||
rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
|
||||
|
|
|
@ -7,11 +7,10 @@ window.baseUrl = function(path) {
|
|||
};
|
||||
|
||||
// Set events and http services on window
|
||||
import Events from "./services/events"
|
||||
import events from "./services/events"
|
||||
import httpInstance from "./services/http"
|
||||
const eventManager = new Events();
|
||||
window.$http = httpInstance;
|
||||
window.$events = eventManager;
|
||||
window.$events = events;
|
||||
|
||||
// Translation setup
|
||||
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
|
||||
|
@ -19,14 +18,8 @@ import Translations from "./services/translations"
|
|||
const translator = new Translations();
|
||||
window.trans = translator.get.bind(translator);
|
||||
window.trans_choice = translator.getPlural.bind(translator);
|
||||
window.trans_plural = translator.parsePlural.bind(translator);
|
||||
|
||||
// Make services available to Vue instances
|
||||
import Vue from "vue"
|
||||
Vue.prototype.$http = httpInstance;
|
||||
Vue.prototype.$events = eventManager;
|
||||
|
||||
// Load Vues and components
|
||||
import vues from "./vues/vues"
|
||||
// Load Components
|
||||
import components from "./components"
|
||||
vues();
|
||||
components();
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue