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
|
# Application key
|
||||||
# Used for encryption where needed.
|
# Used for encryption where needed.
|
||||||
# Run `php artisan key:generate` to generate a valid key.
|
# Run `php artisan key:generate` to generate a valid key.
|
||||||
|
@ -5,7 +13,7 @@ APP_KEY=SomeRandomString
|
||||||
|
|
||||||
# Application URL
|
# Application URL
|
||||||
# Remove the hash below and set a URL if using BookStack behind
|
# 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.
|
# This must be the root URL that you want to host BookStack on.
|
||||||
# All URL's in BookStack will be generated using this value.
|
# All URL's in BookStack will be generated using this value.
|
||||||
#APP_URL=https://example.com
|
#APP_URL=https://example.com
|
||||||
|
@ -25,11 +33,10 @@ MAIL_FROM_NAME=BookStack
|
||||||
MAIL_FROM=bookstack@example.com
|
MAIL_FROM=bookstack@example.com
|
||||||
|
|
||||||
# SMTP mail options
|
# 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_HOST=localhost
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=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.
|
# The number of API requests that can be made per minute by a single user.
|
||||||
API_REQUESTS_PER_MIN=180
|
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
|
aekramer :: Dutch
|
||||||
JachuPL :: Polish
|
JachuPL :: Polish
|
||||||
milesteg :: Hungarian
|
milesteg :: Hungarian
|
||||||
Beenbag :: German
|
Beenbag :: German; German Informal
|
||||||
Lett3rs :: Danish
|
Lett3rs :: Danish
|
||||||
Julian (julian.henneberg) :: German; German Informal
|
Julian (julian.henneberg) :: German; German Informal
|
||||||
3GNWn :: Danish
|
3GNWn :: Danish
|
||||||
|
@ -98,3 +98,25 @@ Thinkverse (thinkverse) :: Swedish
|
||||||
alef (toishoki) :: Turkish
|
alef (toishoki) :: Turkish
|
||||||
Robbert Feunekes (Muukuro) :: Dutch
|
Robbert Feunekes (Muukuro) :: Dutch
|
||||||
seohyeon.joo :: Korean
|
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\Auth\User;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class ActivityService
|
class ActivityService
|
||||||
{
|
{
|
||||||
|
@ -159,4 +160,20 @@ class ActivityService
|
||||||
session()->flash('success', $message);
|
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
|
class Tag extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['name', 'value', 'order'];
|
protected $fillable = ['name', 'value', 'order'];
|
||||||
|
protected $hidden = ['id', 'entity_id', 'entity_type'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the entity that this tag belongs to
|
* Get the entity that this tag belongs to
|
||||||
|
|
|
@ -2,71 +2,31 @@
|
||||||
|
|
||||||
use BookStack\Auth\Permissions\PermissionService;
|
use BookStack\Auth\Permissions\PermissionService;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
|
use DB;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
/**
|
|
||||||
* Class TagRepo
|
|
||||||
* @package BookStack\Repos
|
|
||||||
*/
|
|
||||||
class TagRepo
|
class TagRepo
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $tag;
|
protected $tag;
|
||||||
protected $entity;
|
|
||||||
protected $permissionService;
|
protected $permissionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TagRepo constructor.
|
* 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->tag = $tag;
|
||||||
$this->entity = $ent;
|
|
||||||
$this->permissionService = $ps;
|
$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.
|
* Get tag name suggestions from scanning existing tag names.
|
||||||
* If no search term is given the 50 most popular tag names are provided.
|
* 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) {
|
if ($searchTerm) {
|
||||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||||
|
@ -82,13 +42,10 @@ class TagRepo
|
||||||
* Get tag value suggestions from scanning existing tag values.
|
* Get tag value suggestions from scanning existing tag values.
|
||||||
* If no search is given the 50 most popular values are provided.
|
* 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.
|
* 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) {
|
if ($searchTerm) {
|
||||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||||
|
@ -96,7 +53,7 @@ class TagRepo
|
||||||
$query = $query->orderBy('count', 'desc')->take(50);
|
$query = $query->orderBy('count', 'desc')->take(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tagName !== false) {
|
if ($tagName) {
|
||||||
$query = $query->where('name', '=', $tagName);
|
$query = $query->where('name', '=', $tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,35 +63,28 @@ class TagRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save an array of tags to an entity
|
* 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();
|
$entity->tags()->delete();
|
||||||
$newTags = [];
|
|
||||||
foreach ($tags as $tag) {
|
$newTags = collect($tags)->filter(function ($tag) {
|
||||||
if (trim($tag['name']) === '') {
|
return boolval(trim($tag['name']));
|
||||||
continue;
|
})->map(function ($tag) {
|
||||||
}
|
return $this->newInstanceFromInput($tag);
|
||||||
$newTags[] = $this->newInstanceFromInput($tag);
|
})->all();
|
||||||
}
|
|
||||||
|
|
||||||
return $entity->tags()->saveMany($newTags);
|
return $entity->tags()->saveMany($newTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new Tag instance from user input.
|
* Create a new Tag instance from user input.
|
||||||
* @param $input
|
* Input must be an array with a 'name' and an optional 'value' key.
|
||||||
* @return \BookStack\Actions\Tag
|
|
||||||
*/
|
*/
|
||||||
protected function newInstanceFromInput($input)
|
protected function newInstanceFromInput(array $input): Tag
|
||||||
{
|
{
|
||||||
$name = trim($input['name']);
|
$name = trim($input['name']);
|
||||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||||
// Any other modification or cleanup required can go here
|
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
|
||||||
$values = ['name' => $name, 'value' => $value];
|
|
||||||
return $this->tag->newInstance($values);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
use BookStack\Auth\Role;
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class ExternalAuthService
|
class ExternalAuthService
|
||||||
{
|
{
|
||||||
|
@ -39,22 +41,14 @@ class ExternalAuthService
|
||||||
/**
|
/**
|
||||||
* Match an array of group names to BookStack system roles.
|
* Match an array of group names to BookStack system roles.
|
||||||
* Formats group names to be lower-case and hyphenated.
|
* 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) {
|
foreach ($groupNames as $i => $groupName) {
|
||||||
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
|
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
|
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
|
||||||
$query->whereIn('name', $groupNames);
|
|
||||||
foreach ($groupNames as $groupName) {
|
|
||||||
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
|
|
||||||
}
|
|
||||||
})->get();
|
|
||||||
|
|
||||||
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
|
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
|
||||||
return $this->roleMatchesGroupNames($role, $groupNames);
|
return $this->roleMatchesGroupNames($role, $groupNames);
|
||||||
});
|
});
|
||||||
|
|
|
@ -71,15 +71,15 @@ class RegistrationService
|
||||||
// Start email confirmation flow if required
|
// Start email confirmation flow if required
|
||||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||||
$newUser->save();
|
$newUser->save();
|
||||||
$message = '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->emailConfirmationService->sendConfirmation($newUser);
|
$this->emailConfirmationService->sendConfirmation($newUser);
|
||||||
|
session()->flash('sent-email-confirmation', true);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$message = trans('auth.email_confirm_send_error');
|
$message = trans('auth.email_confirm_send_error');
|
||||||
|
throw new UserRegistrationException($message, '/register/confirm');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new UserRegistrationException($message, '/register/confirm');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $newUser;
|
return $newUser;
|
||||||
|
|
|
@ -311,7 +311,6 @@ class Saml2Service extends ExternalAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user from the database for the specified details.
|
* Get the user from the database for the specified details.
|
||||||
* @throws SamlException
|
|
||||||
* @throws UserRegistrationException
|
* @throws UserRegistrationException
|
||||||
*/
|
*/
|
||||||
protected function getOrRegisterUser(array $userDetails): ?User
|
protected function getOrRegisterUser(array $userDetails): ?User
|
||||||
|
|
|
@ -3,25 +3,26 @@
|
||||||
use BookStack\Auth\Role;
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
|
|
||||||
class JointPermission extends Model
|
class JointPermission extends Model
|
||||||
{
|
{
|
||||||
|
protected $primaryKey = null;
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the role that this points to.
|
* 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);
|
return $this->belongsTo(Role::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the entity this points to.
|
* 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');
|
return $this->morphOne(Entity::class, 'entity');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
<?php namespace BookStack\Auth\Permissions;
|
<?php namespace BookStack\Auth\Permissions;
|
||||||
|
|
||||||
use BookStack\Auth\Permissions;
|
|
||||||
use BookStack\Auth\Role;
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PermissionsRepo
|
class PermissionsRepo
|
||||||
|
@ -16,11 +17,8 @@ class PermissionsRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PermissionsRepo constructor.
|
* 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->permission = $permission;
|
||||||
$this->role = $role;
|
$this->role = $role;
|
||||||
|
@ -29,46 +27,34 @@ class PermissionsRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the user roles from the system.
|
* 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();
|
return $this->role->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the roles except for the provided one.
|
* 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();
|
return $this->role->where('id', '!=', $role->id)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a role via its ID.
|
* 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.
|
* 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 = $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();
|
$role->save();
|
||||||
|
|
||||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||||
|
@ -80,13 +66,11 @@ class PermissionsRepo
|
||||||
/**
|
/**
|
||||||
* Updates an existing role.
|
* Updates an existing role.
|
||||||
* Ensure Admin role always have core permissions.
|
* 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']) : [];
|
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||||
if ($role->system_name === 'admin') {
|
if ($role->system_name === 'admin') {
|
||||||
|
@ -108,16 +92,19 @@ class PermissionsRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign an list of permission names to an role.
|
* 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 = [];
|
$permissions = [];
|
||||||
$permissionNameArray = array_values($permissionNameArray);
|
$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);
|
$role->permissions()->sync($permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,13 +113,13 @@ class PermissionsRepo
|
||||||
* Check it's not an admin role or set as default before deleting.
|
* 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
|
* If an migration Role ID is specified the users assign to the current role
|
||||||
* will be added to the role of the specified id.
|
* will be added to the role of the specified id.
|
||||||
* @param $roleId
|
|
||||||
* @param $migrateRoleId
|
|
||||||
* @throws PermissionsException
|
* @throws PermissionsException
|
||||||
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function deleteRole($roleId, $migrateRoleId)
|
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.
|
// Prevent deleting admin role or default registration role.
|
||||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||||
|
@ -142,9 +129,9 @@ class PermissionsRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($migrateRoleId) {
|
if ($migrateRoleId) {
|
||||||
$newRole = $this->role->find($migrateRoleId);
|
$newRole = $this->role->newQuery()->find($migrateRoleId);
|
||||||
if ($newRole) {
|
if ($newRole) {
|
||||||
$users = $role->users->pluck('id')->toArray();
|
$users = $role->users()->pluck('id')->toArray();
|
||||||
$newRole->users()->sync($users);
|
$newRole->users()->sync($users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,9 @@
|
||||||
use BookStack\Auth\Role;
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
*/
|
||||||
class RolePermission extends Model
|
class RolePermission extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,13 +3,16 @@
|
||||||
use BookStack\Auth\Permissions\JointPermission;
|
use BookStack\Auth\Permissions\JointPermission;
|
||||||
use BookStack\Auth\Permissions\RolePermission;
|
use BookStack\Auth\Permissions\RolePermission;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class Role
|
* Class Role
|
||||||
|
* @property int $id
|
||||||
* @property string $display_name
|
* @property string $display_name
|
||||||
* @property string $description
|
* @property string $description
|
||||||
* @property string $external_auth_id
|
* @property string $external_auth_id
|
||||||
* @package BookStack\Auth
|
* @property string $system_name
|
||||||
*/
|
*/
|
||||||
class Role extends Model
|
class Role extends Model
|
||||||
{
|
{
|
||||||
|
@ -26,9 +29,8 @@ class Role extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all related JointPermissions.
|
* Get all related JointPermissions.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
|
||||||
*/
|
*/
|
||||||
public function jointPermissions()
|
public function jointPermissions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(JointPermission::class);
|
return $this->hasMany(JointPermission::class);
|
||||||
}
|
}
|
||||||
|
@ -43,10 +45,8 @@ class Role extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this role has a permission.
|
* 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');
|
$permissions = $this->getRelationValue('permissions');
|
||||||
foreach ($permissions as $permission) {
|
foreach ($permissions as $permission) {
|
||||||
|
@ -59,7 +59,6 @@ class Role extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a permission to this role.
|
* Add a permission to this role.
|
||||||
* @param RolePermission $permission
|
|
||||||
*/
|
*/
|
||||||
public function attachPermission(RolePermission $permission)
|
public function attachPermission(RolePermission $permission)
|
||||||
{
|
{
|
||||||
|
@ -68,7 +67,6 @@ class Role extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach a single permission from this role.
|
* Detach a single permission from this role.
|
||||||
* @param RolePermission $permission
|
|
||||||
*/
|
*/
|
||||||
public function detachPermission(RolePermission $permission)
|
public function detachPermission(RolePermission $permission)
|
||||||
{
|
{
|
||||||
|
@ -76,39 +74,33 @@ class Role extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the role object for the specified role.
|
* Get the role of the specified display name.
|
||||||
* @param $roleName
|
|
||||||
* @return Role
|
|
||||||
*/
|
*/
|
||||||
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.
|
* 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
|
* Get all visible roles
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public static function visible()
|
public static function visible(): Collection
|
||||||
{
|
{
|
||||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the roles that can be restricted.
|
* 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();
|
return static::query()->where('system_name', '!=', 'admin')->get();
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||||
*/
|
*/
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
|
'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.
|
* 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.
|
* Attach a role to this user.
|
||||||
* @param Role $role
|
|
||||||
*/
|
*/
|
||||||
public function attachRole(Role $role)
|
public function attachRole(Role $role)
|
||||||
{
|
{
|
||||||
|
|
|
@ -238,7 +238,7 @@ class UserRepo
|
||||||
*/
|
*/
|
||||||
public function getAllRoles()
|
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'),
|
'locale' => env('APP_LANG', 'en'),
|
||||||
|
|
||||||
// Locales available
|
// 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
|
// Application Fallback Locale
|
||||||
'fallback_locale' => 'en',
|
'fallback_locale' => 'en',
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Formatter\LineFormatter;
|
||||||
|
use Monolog\Handler\ErrorLogHandler;
|
||||||
use Monolog\Handler\NullHandler;
|
use Monolog\Handler\NullHandler;
|
||||||
use Monolog\Handler\StreamHandler;
|
use Monolog\Handler\StreamHandler;
|
||||||
|
|
||||||
|
@ -73,10 +75,38 @@ return [
|
||||||
'level' => 'debug',
|
'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' => [
|
'null' => [
|
||||||
'driver' => 'monolog',
|
'driver' => 'monolog',
|
||||||
'handler' => NullHandler::class,
|
'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' => env('SAML2_IDP_SLO', null),
|
||||||
// URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
|
// 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
|
// if not set, url for the SLO Request will be used
|
||||||
'responseUrl' => '',
|
'responseUrl' => null,
|
||||||
// SAML protocol binding to be used when returning the <Response>
|
// SAML protocol binding to be used when returning the <Response>
|
||||||
// message. Onelogin Toolkit supports for this endpoint the
|
// message. Onelogin Toolkit supports for this endpoint the
|
||||||
// HTTP-Redirect binding only
|
// HTTP-Redirect binding only
|
||||||
|
|
|
@ -19,7 +19,7 @@ class Book extends Entity implements HasCoverImage
|
||||||
public $searchFactor = 2;
|
public $searchFactor = 2;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description'];
|
protected $fillable = ['name', 'description'];
|
||||||
protected $hidden = ['restricted', 'pivot'];
|
protected $hidden = ['restricted', 'pivot', 'image_id'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the url for this book.
|
* Get the url for this book.
|
||||||
|
|
|
@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'image_id'];
|
protected $fillable = ['name', 'description', 'image_id'];
|
||||||
|
|
||||||
protected $hidden = ['restricted'];
|
protected $hidden = ['restricted', 'image_id'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the books in this shelf.
|
* Get the books in this shelf.
|
||||||
|
|
|
@ -12,6 +12,7 @@ class Chapter extends BookChild
|
||||||
public $searchFactor = 1.3;
|
public $searchFactor = 1.3;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
||||||
|
protected $hidden = ['restricted', 'pivot'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the pages that this chapter contains.
|
* Get the pages that this chapter contains.
|
||||||
|
|
|
@ -238,10 +238,8 @@ class Entity extends Ownable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a limited-length version of the entities name.
|
* 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) {
|
if (mb_strlen($this->name) <= $length) {
|
||||||
return $this->name;
|
return $this->name;
|
||||||
|
@ -288,7 +286,7 @@ class Entity extends Ownable
|
||||||
public function rebuildPermissions()
|
public function rebuildPermissions()
|
||||||
{
|
{
|
||||||
/** @noinspection PhpUnhandledExceptionInspection */
|
/** @noinspection PhpUnhandledExceptionInspection */
|
||||||
Permissions::buildJointPermissionsForEntity($this);
|
Permissions::buildJointPermissionsForEntity(clone $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -297,7 +295,7 @@ class Entity extends Ownable
|
||||||
public function indexForSearch()
|
public function indexForSearch()
|
||||||
{
|
{
|
||||||
$searchService = app()->make(SearchService::class);
|
$searchService = app()->make(SearchService::class);
|
||||||
$searchService->indexEntity($this);
|
$searchService->indexEntity(clone $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -108,7 +108,7 @@ class PageContent
|
||||||
protected function toPlainText(): string
|
protected function toPlainText(): string
|
||||||
{
|
{
|
||||||
$html = $this->render(true);
|
$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
|
class Page extends BookChild
|
||||||
{
|
{
|
||||||
protected $fillable = ['name', 'html', 'priority', 'markdown'];
|
protected $fillable = ['name', 'priority', 'markdown'];
|
||||||
|
|
||||||
protected $simpleAttributes = ['name', 'id', 'slug'];
|
protected $simpleAttributes = ['name', 'id', 'slug'];
|
||||||
|
|
||||||
public $textField = 'text';
|
public $textField = 'text';
|
||||||
|
|
||||||
|
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the entities that are visible to the current user.
|
* Get the entities that are visible to the current user.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -180,12 +180,11 @@ class PageRepo
|
||||||
$page->template = ($input['template'] === 'true');
|
$page->template = ($input['template'] === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pageContent = new PageContent($page);
|
||||||
|
$pageContent->setNewHTML($input['html']);
|
||||||
$this->baseRepo->update($page, $input);
|
$this->baseRepo->update($page, $input);
|
||||||
|
|
||||||
// Update with new details
|
// Update with new details
|
||||||
$page->fill($input);
|
|
||||||
$pageContent = new PageContent($page);
|
|
||||||
$pageContent->setNewHTML($input['html']);
|
|
||||||
$page->revision_count++;
|
$page->revision_count++;
|
||||||
|
|
||||||
if (setting('app-editor') !== 'markdown') {
|
if (setting('app-editor') !== 'markdown') {
|
||||||
|
@ -211,7 +210,7 @@ class PageRepo
|
||||||
*/
|
*/
|
||||||
protected function savePageRevision(Page $page, string $summary = null)
|
protected function savePageRevision(Page $page, string $summary = null)
|
||||||
{
|
{
|
||||||
$revision = new PageRevision($page->toArray());
|
$revision = new PageRevision($page->getAttributes());
|
||||||
|
|
||||||
if (setting('app-editor') !== 'markdown') {
|
if (setting('app-editor') !== 'markdown') {
|
||||||
$revision->markdown = '';
|
$revision->markdown = '';
|
||||||
|
@ -279,7 +278,7 @@ class PageRepo
|
||||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||||
$page->fill($revision->toArray());
|
$page->fill($revision->toArray());
|
||||||
$content = new PageContent($page);
|
$content = new PageContent($page);
|
||||||
$content->setNewHTML($page->html);
|
$content->setNewHTML($revision->html);
|
||||||
$page->updated_by = user()->id;
|
$page->updated_by = user()->id;
|
||||||
$page->refreshSlug();
|
$page->refreshSlug();
|
||||||
$page->save();
|
$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.
|
* 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)
|
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
|
||||||
{
|
{
|
||||||
|
@ -54,7 +50,6 @@ class SearchService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the database connection
|
* Set the database connection
|
||||||
* @param Connection $connection
|
|
||||||
*/
|
*/
|
||||||
public function setConnection(Connection $connection)
|
public function setConnection(Connection $connection)
|
||||||
{
|
{
|
||||||
|
@ -63,23 +58,18 @@ class SearchService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search all entities in the system.
|
* Search all entities in the system.
|
||||||
* @param string $searchString
|
* The provided count is for each entity to search,
|
||||||
* @param string $entityType
|
* Total returned could can be larger and not guaranteed.
|
||||||
* @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];
|
|
||||||
*/
|
*/
|
||||||
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());
|
$entityTypes = array_keys($this->entityProvider->all());
|
||||||
$entityTypesToSearch = $entityTypes;
|
$entityTypesToSearch = $entityTypes;
|
||||||
|
|
||||||
if ($entityType !== 'all') {
|
if ($entityType !== 'all') {
|
||||||
$entityTypesToSearch = $entityType;
|
$entityTypesToSearch = $entityType;
|
||||||
} else if (isset($terms['filters']['type'])) {
|
} else if (isset($searchOpts->filters['type'])) {
|
||||||
$entityTypesToSearch = explode('|', $terms['filters']['type']);
|
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = collect();
|
$results = collect();
|
||||||
|
@ -90,8 +80,8 @@ class SearchService
|
||||||
if (!in_array($entityType, $entityTypes)) {
|
if (!in_array($entityType, $entityTypes)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
|
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
|
||||||
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
|
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
|
||||||
if ($entityTotal > $page * $count) {
|
if ($entityTotal > $page * $count) {
|
||||||
$hasMore = true;
|
$hasMore = true;
|
||||||
}
|
}
|
||||||
|
@ -103,29 +93,26 @@ class SearchService
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'count' => count($results),
|
'count' => count($results),
|
||||||
'has_more' => $hasMore,
|
'has_more' => $hasMore,
|
||||||
'results' => $results->sortByDesc('score')->values()
|
'results' => $results->sortByDesc('score')->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search a book for entities
|
* 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'];
|
$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();
|
$results = collect();
|
||||||
foreach ($entityTypesToSearch as $entityType) {
|
foreach ($entityTypesToSearch as $entityType) {
|
||||||
if (!in_array($entityType, $entityTypes)) {
|
if (!in_array($entityType, $entityTypes)) {
|
||||||
continue;
|
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);
|
$results = $results->merge($search);
|
||||||
}
|
}
|
||||||
return $results->sortByDesc('score')->take(20);
|
return $results->sortByDesc('score')->take(20);
|
||||||
|
@ -133,30 +120,23 @@ class SearchService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search a book for entities
|
* 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);
|
$opts = SearchOptions::fromString($searchString);
|
||||||
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
||||||
return $pages->sortByDesc('score');
|
return $pages->sortByDesc('score');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search across a particular entity type.
|
* Search across a particular entity type.
|
||||||
* @param array $terms
|
* Setting getCount = true will return the total
|
||||||
* @param string $entityType
|
* matching instead of the items themselves.
|
||||||
* @param int $page
|
|
||||||
* @param int $count
|
|
||||||
* @param string $action
|
|
||||||
* @param bool $getCount Return the total count of the search
|
|
||||||
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
|
* @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) {
|
if ($getCount) {
|
||||||
return $query->count();
|
return $query->count();
|
||||||
}
|
}
|
||||||
|
@ -167,22 +147,18 @@ class SearchService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a search query for an entity
|
* 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);
|
$entity = $this->entityProvider->get($entityType);
|
||||||
$entitySelect = $entity->newQuery();
|
$entitySelect = $entity->newQuery();
|
||||||
|
|
||||||
// Handle normal search terms
|
// 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 = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
|
||||||
$subQuery->where('entity_type', '=', $entity->getMorphClass());
|
$subQuery->where('entity_type', '=', $entity->getMorphClass());
|
||||||
$subQuery->where(function (Builder $query) use ($terms) {
|
$subQuery->where(function (Builder $query) use ($searchOpts) {
|
||||||
foreach ($terms['search'] as $inputTerm) {
|
foreach ($searchOpts->searches as $inputTerm) {
|
||||||
$query->orWhere('term', 'like', $inputTerm .'%');
|
$query->orWhere('term', 'like', $inputTerm .'%');
|
||||||
}
|
}
|
||||||
})->groupBy('entity_type', 'entity_id');
|
})->groupBy('entity_type', 'entity_id');
|
||||||
|
@ -193,9 +169,9 @@ class SearchService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle exact term matching
|
// Handle exact term matching
|
||||||
if (count($terms['exact']) > 0) {
|
if (count($searchOpts->exacts) > 0) {
|
||||||
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
|
$entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
|
||||||
foreach ($terms['exact'] as $inputTerm) {
|
foreach ($searchOpts->exacts as $inputTerm) {
|
||||||
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
|
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
|
||||||
$query->where('name', 'like', '%'.$inputTerm .'%')
|
$query->where('name', 'like', '%'.$inputTerm .'%')
|
||||||
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
|
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
|
||||||
|
@ -205,12 +181,12 @@ class SearchService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tag searches
|
// Handle tag searches
|
||||||
foreach ($terms['tags'] as $inputTerm) {
|
foreach ($searchOpts->tags as $inputTerm) {
|
||||||
$this->applyTagSearch($entitySelect, $inputTerm);
|
$this->applyTagSearch($entitySelect, $inputTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle filters
|
// Handle filters
|
||||||
foreach ($terms['filters'] as $filterTerm => $filterValue) {
|
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
|
||||||
$functionName = Str::camel('filter_' . $filterTerm);
|
$functionName = Str::camel('filter_' . $filterTerm);
|
||||||
if (method_exists($this, $functionName)) {
|
if (method_exists($this, $functionName)) {
|
||||||
$this->$functionName($entitySelect, $entity, $filterValue);
|
$this->$functionName($entitySelect, $entity, $filterValue);
|
||||||
|
@ -220,60 +196,10 @@ class SearchService
|
||||||
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
|
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.
|
* Get the available query operators as a regex escaped list.
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
protected function getRegexEscapedOperators()
|
protected function getRegexEscapedOperators(): string
|
||||||
{
|
{
|
||||||
$escapedOperators = [];
|
$escapedOperators = [];
|
||||||
foreach ($this->queryOperators as $operator) {
|
foreach ($this->queryOperators as $operator) {
|
||||||
|
@ -284,11 +210,8 @@ class SearchService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a tag search term onto a entity query.
|
* 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);
|
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
|
||||||
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
|
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
|
||||||
|
@ -318,7 +241,6 @@ class SearchService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Index the given entity.
|
* Index the given entity.
|
||||||
* @param Entity $entity
|
|
||||||
*/
|
*/
|
||||||
public function indexEntity(Entity $entity)
|
public function indexEntity(Entity $entity)
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<?php namespace BookStack\Entities;
|
<?php namespace BookStack\Entities;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SlugGenerator
|
class SlugGenerator
|
||||||
{
|
{
|
||||||
|
|
||||||
|
@ -32,9 +34,7 @@ class SlugGenerator
|
||||||
*/
|
*/
|
||||||
protected function formatNameAsSlug(string $name): string
|
protected function formatNameAsSlug(string $name): string
|
||||||
{
|
{
|
||||||
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
|
$slug = Str::slug($name);
|
||||||
$slug = preg_replace('/\s{2,}/', ' ', $slug);
|
|
||||||
$slug = str_replace(' ', '-', $slug);
|
|
||||||
if ($slug === "") {
|
if ($slug === "") {
|
||||||
$slug = substr(md5(rand(1, 500)), 0, 5);
|
$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\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
@ -26,6 +25,7 @@ class Handler extends ExceptionHandler
|
||||||
HttpException::class,
|
HttpException::class,
|
||||||
ModelNotFoundException::class,
|
ModelNotFoundException::class,
|
||||||
ValidationException::class,
|
ValidationException::class,
|
||||||
|
NotFoundException::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -8,7 +8,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class BooksApiController extends ApiController
|
class BookApiController extends ApiController
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $bookRepo;
|
protected $bookRepo;
|
||||||
|
@ -17,10 +17,12 @@ class BooksApiController extends ApiController
|
||||||
'create' => [
|
'create' => [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'description' => 'string|max:1000',
|
'description' => 'string|max:1000',
|
||||||
|
'tags' => 'array',
|
||||||
],
|
],
|
||||||
'update' => [
|
'update' => [
|
||||||
'name' => 'string|min:1|max:255',
|
'name' => 'string|min:1|max:255',
|
||||||
'description' => 'string|max:1000',
|
'description' => 'string|max:1000',
|
||||||
|
'tags' => 'array',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
@ -5,9 +5,8 @@ use BookStack\Entities\ExportService;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class BooksExportApiController extends ApiController
|
class BookExportApiController extends ApiController
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $bookRepo;
|
protected $bookRepo;
|
||||||
protected $exportService;
|
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 Exception;
|
||||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\MessageBag;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AttachmentController extends Controller
|
class AttachmentController extends Controller
|
||||||
|
@ -60,26 +61,18 @@ class AttachmentController extends Controller
|
||||||
/**
|
/**
|
||||||
* Update an uploaded attachment.
|
* Update an uploaded attachment.
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function uploadUpdate(Request $request, $attachmentId)
|
public function uploadUpdate(Request $request, $attachmentId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
|
||||||
'file' => 'required|file'
|
'file' => 'required|file'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageId = $request->get('uploaded_to');
|
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
|
||||||
$page = $this->pageRepo->getById($pageId);
|
$this->checkOwnablePermission('view', $attachment->page);
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||||
|
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
|
||||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||||
|
|
||||||
if (intval($pageId) !== intval($attachment->uploaded_to)) {
|
|
||||||
return $this->jsonError(trans('errors.attachment_page_mismatch'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$uploadedFile = $request->file('file');
|
$uploadedFile = $request->file('file');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -92,57 +85,87 @@ class AttachmentController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the details of an existing file.
|
* Get the update form for an attachment.
|
||||||
* @throws ValidationException
|
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
*/
|
||||||
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);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
|
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||||
|
|
||||||
if (intval($pageId) !== intval($attachment->uploaded_to)) {
|
return view('attachments.manager-edit-form', [
|
||||||
return $this->jsonError(trans('errors.attachment_page_mismatch'));
|
'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.
|
* Attach a link to a page.
|
||||||
* @throws ValidationException
|
|
||||||
* @throws NotFoundException
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
public function attachLink(Request $request)
|
public function attachLink(Request $request)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$pageId = $request->get('attachment_link_uploaded_to');
|
||||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
|
||||||
'name' => 'required|string|min:1|max:255',
|
try {
|
||||||
'link' => 'required|string|min:1|max:255'
|
$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);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
|
|
||||||
$this->checkPermission('attachment-create-all');
|
$this->checkPermission('attachment-create-all');
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
|
||||||
$attachmentName = $request->get('name');
|
$attachmentName = $request->get('attachment_link_name');
|
||||||
$link = $request->get('link');
|
$link = $request->get('attachment_link_url');
|
||||||
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
|
$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);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
$this->checkOwnablePermission('page-view', $page);
|
$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)
|
public function sortForPage(Request $request, int $pageId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'files' => 'required|array',
|
'order' => 'required|array',
|
||||||
'files.*.id' => 'required|integer',
|
|
||||||
]);
|
]);
|
||||||
$page = $this->pageRepo->getById($pageId);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
|
||||||
$attachments = $request->get('files');
|
$attachmentOrder = $request->get('order');
|
||||||
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
|
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
|
||||||
return response()->json(['message' => trans('entities.attachments_order_updated')]);
|
return response()->json(['message' => trans('entities.attachments_order_updated')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,7 +203,7 @@ class AttachmentController extends Controller
|
||||||
* @throws FileNotFoundException
|
* @throws FileNotFoundException
|
||||||
* @throws NotFoundException
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
public function get(int $attachmentId)
|
public function get(string $attachmentId)
|
||||||
{
|
{
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
try {
|
try {
|
||||||
|
@ -200,11 +224,9 @@ class AttachmentController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a specific attachment in the system.
|
* Delete a specific attachment in the system.
|
||||||
* @param $attachmentId
|
|
||||||
* @return mixed
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function delete(int $attachmentId)
|
public function delete(string $attachmentId)
|
||||||
{
|
{
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
$this->checkOwnablePermission('attachment-delete', $attachment);
|
$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;
|
namespace BookStack\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use Activity;
|
||||||
use BookStack\Auth\Access\SocialAuthService;
|
use BookStack\Auth\Access\SocialAuthService;
|
||||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||||
use BookStack\Exceptions\LoginAttemptException;
|
use BookStack\Exceptions\LoginAttemptException;
|
||||||
|
@ -76,10 +77,14 @@ class LoginController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the previous location for redirect after login
|
||||||
$previous = url()->previous('');
|
$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);
|
redirect()->setIntendedUrl($previous);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view('auth.login', [
|
return view('auth.login', [
|
||||||
'socialDrivers' => $socialDrivers,
|
'socialDrivers' => $socialDrivers,
|
||||||
|
@ -98,6 +103,7 @@ class LoginController extends Controller
|
||||||
public function login(Request $request)
|
public function login(Request $request)
|
||||||
{
|
{
|
||||||
$this->validateLogin($request);
|
$this->validateLogin($request);
|
||||||
|
$username = $request->get($this->username());
|
||||||
|
|
||||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
// 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
|
// 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->hasTooManyLoginAttempts($request)) {
|
||||||
$this->fireLockoutEvent($request);
|
$this->fireLockoutEvent($request);
|
||||||
|
|
||||||
|
Activity::logFailedLogin($username);
|
||||||
return $this->sendLockoutResponse($request);
|
return $this->sendLockoutResponse($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,6 +121,7 @@ class LoginController extends Controller
|
||||||
return $this->sendLoginResponse($request);
|
return $this->sendLoginResponse($request);
|
||||||
}
|
}
|
||||||
} catch (LoginAttemptException $exception) {
|
} catch (LoginAttemptException $exception) {
|
||||||
|
Activity::logFailedLogin($username);
|
||||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
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.
|
// user surpasses their maximum number of attempts they will get locked out.
|
||||||
$this->incrementLoginAttempts($request);
|
$this->incrementLoginAttempts($request);
|
||||||
|
|
||||||
|
Activity::logFailedLogin($username);
|
||||||
return $this->sendFailedLoginResponse($request);
|
return $this->sendFailedLoginResponse($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
abstract class Controller extends BaseController
|
abstract class Controller extends BaseController
|
||||||
{
|
{
|
||||||
|
@ -132,23 +133,6 @@ abstract class Controller extends BaseController
|
||||||
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
|
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.
|
* Create a response that forces a download in the browser.
|
||||||
* @param string $content
|
* @param string $content
|
||||||
|
|
|
@ -30,7 +30,10 @@ class DrawioImageController extends Controller
|
||||||
$parentTypeFilter = $request->get('filter_type', null);
|
$parentTypeFilter = $request->get('filter_type', null);
|
||||||
|
|
||||||
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
$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) {
|
if ($imageData === null) {
|
||||||
return $this->jsonError("Image data could not be found");
|
return $this->jsonError("Image data could not be found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'content' => base64_encode($imageData)
|
'content' => base64_encode($imageData)
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
|
||||||
use BookStack\Uploads\ImageRepo;
|
use BookStack\Uploads\ImageRepo;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class GalleryImageController extends Controller
|
class GalleryImageController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -13,7 +14,6 @@ class GalleryImageController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GalleryImageController constructor.
|
* GalleryImageController constructor.
|
||||||
* @param ImageRepo $imageRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(ImageRepo $imageRepo)
|
public function __construct(ImageRepo $imageRepo)
|
||||||
{
|
{
|
||||||
|
@ -24,8 +24,6 @@ class GalleryImageController extends Controller
|
||||||
/**
|
/**
|
||||||
* Get a list of gallery images, in a list.
|
* Get a list of gallery images, in a list.
|
||||||
* Can be paged and filtered by entity.
|
* Can be paged and filtered by entity.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function list(Request $request)
|
public function list(Request $request)
|
||||||
{
|
{
|
||||||
|
@ -35,14 +33,15 @@ class GalleryImageController extends Controller
|
||||||
$parentTypeFilter = $request->get('filter_type', null);
|
$parentTypeFilter = $request->get('filter_type', null);
|
||||||
|
|
||||||
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
$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.
|
* Store a new gallery image in the system.
|
||||||
* @param Request $request
|
* @throws ValidationException
|
||||||
* @return Illuminate\Http\JsonResponse
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
public function create(Request $request)
|
public function create(Request $request)
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller;
|
||||||
use BookStack\Repos\PageRepo;
|
use BookStack\Repos\PageRepo;
|
||||||
use BookStack\Uploads\Image;
|
use BookStack\Uploads\Image;
|
||||||
use BookStack\Uploads\ImageRepo;
|
use BookStack\Uploads\ImageRepo;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Filesystem\Filesystem as File;
|
use Illuminate\Filesystem\Filesystem as File;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ImageController extends Controller
|
class ImageController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -17,9 +20,6 @@ class ImageController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageController constructor.
|
* ImageController constructor.
|
||||||
* @param Image $image
|
|
||||||
* @param File $file
|
|
||||||
* @param ImageRepo $imageRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(Image $image, File $file, 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.
|
* Provide an image file from storage.
|
||||||
* @param string $path
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function showImage(string $path)
|
public function showImage(string $path)
|
||||||
{
|
{
|
||||||
|
@ -47,13 +45,10 @@ class ImageController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update image details
|
* Update image details
|
||||||
* @param Request $request
|
|
||||||
* @param integer $id
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
* @throws ImageUploadException
|
* @throws ImageUploadException
|
||||||
* @throws \Exception
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, $id)
|
public function update(Request $request, string $id)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'name' => 'required|min:2|string'
|
'name' => 'required|min:2|string'
|
||||||
|
@ -64,47 +59,50 @@ class ImageController extends Controller
|
||||||
$this->checkOwnablePermission('image-update', $image);
|
$this->checkOwnablePermission('image-update', $image);
|
||||||
|
|
||||||
$image = $this->imageRepo->updateImageDetails($image, $request->all());
|
$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);
|
$image = $this->imageRepo->getById($id);
|
||||||
$this->checkImagePermission($image);
|
$this->checkImagePermission($image);
|
||||||
|
|
||||||
$pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
|
if ($request->has('delete')) {
|
||||||
foreach ($pages as $page) {
|
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
|
||||||
$page->url = $page->getUrl();
|
|
||||||
$page->html = '';
|
|
||||||
$page->text = '';
|
|
||||||
}
|
}
|
||||||
$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
|
* Deletes an image and all thumbnail/image files
|
||||||
* @param int $id
|
* @throws Exception
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
public function destroy($id)
|
public function destroy(string $id)
|
||||||
{
|
{
|
||||||
$image = $this->imageRepo->getById($id);
|
$image = $this->imageRepo->getById($id);
|
||||||
$this->checkOwnablePermission('image-delete', $image);
|
$this->checkOwnablePermission('image-delete', $image);
|
||||||
$this->checkImagePermission($image);
|
$this->checkImagePermission($image);
|
||||||
|
|
||||||
$this->imageRepo->destroyImage($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.
|
* Check related page permission and ensure type is drawio or gallery.
|
||||||
* @param Image $image
|
|
||||||
*/
|
*/
|
||||||
protected function checkImagePermission(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)
|
public function getPageAjax(int $pageId)
|
||||||
{
|
{
|
||||||
$page = $this->pageRepo->getById($pageId);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
|
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||||
|
$page->addHidden(['book']);
|
||||||
return response()->json($page);
|
return response()->json($page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<?php namespace BookStack\Http\Controllers;
|
<?php namespace BookStack\Http\Controllers;
|
||||||
|
|
||||||
|
use BookStack\Entities\Managers\PageContent;
|
||||||
use BookStack\Entities\Repos\PageRepo;
|
use BookStack\Entities\Repos\PageRepo;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
|
@ -46,6 +47,9 @@ class PageRevisionController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
$page->fill($revision->toArray());
|
$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()]));
|
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||||
return view('pages.revision', [
|
return view('pages.revision', [
|
||||||
|
@ -73,6 +77,9 @@ class PageRevisionController extends Controller
|
||||||
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
|
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
|
||||||
|
|
||||||
$page->fill($revision->toArray());
|
$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()]));
|
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
|
||||||
|
|
||||||
return view('pages.revision', [
|
return view('pages.revision', [
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PermissionController extends Controller
|
class PermissionController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -11,7 +13,6 @@ class PermissionController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PermissionController constructor.
|
* PermissionController constructor.
|
||||||
* @param \BookStack\Auth\Permissions\PermissionsRepo $permissionsRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(PermissionsRepo $permissionsRepo)
|
public function __construct(PermissionsRepo $permissionsRepo)
|
||||||
{
|
{
|
||||||
|
@ -31,7 +32,6 @@ class PermissionController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form to create a new role
|
* Show the form to create a new role
|
||||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
|
||||||
*/
|
*/
|
||||||
public function createRole()
|
public function createRole()
|
||||||
{
|
{
|
||||||
|
@ -41,15 +41,13 @@ class PermissionController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a new role in the system.
|
* Store a new role in the system.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
|
||||||
*/
|
*/
|
||||||
public function storeRole(Request $request)
|
public function storeRole(Request $request)
|
||||||
{
|
{
|
||||||
$this->checkPermission('user-roles-manage');
|
$this->checkPermission('user-roles-manage');
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'display_name' => 'required|min:3|max:200',
|
'display_name' => 'required|min:3|max:180',
|
||||||
'description' => 'max:250'
|
'description' => 'max:180'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->permissionsRepo->saveNewRole($request->all());
|
$this->permissionsRepo->saveNewRole($request->all());
|
||||||
|
@ -59,11 +57,9 @@ class PermissionController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing a user role.
|
* Show the form for editing a user role.
|
||||||
* @param $id
|
|
||||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
|
||||||
* @throws PermissionsException
|
* @throws PermissionsException
|
||||||
*/
|
*/
|
||||||
public function editRole($id)
|
public function editRole(string $id)
|
||||||
{
|
{
|
||||||
$this->checkPermission('user-roles-manage');
|
$this->checkPermission('user-roles-manage');
|
||||||
$role = $this->permissionsRepo->getRoleById($id);
|
$role = $this->permissionsRepo->getRoleById($id);
|
||||||
|
@ -75,18 +71,14 @@ class PermissionController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a user role.
|
* Updates a user role.
|
||||||
* @param Request $request
|
* @throws ValidationException
|
||||||
* @param $id
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
|
||||||
* @throws PermissionsException
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
*/
|
||||||
public function updateRole(Request $request, $id)
|
public function updateRole(Request $request, string $id)
|
||||||
{
|
{
|
||||||
$this->checkPermission('user-roles-manage');
|
$this->checkPermission('user-roles-manage');
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'display_name' => 'required|min:3|max:200',
|
'display_name' => 'required|min:3|max:180',
|
||||||
'description' => 'max:250'
|
'description' => 'max:180'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->permissionsRepo->updateRole($id, $request->all());
|
$this->permissionsRepo->updateRole($id, $request->all());
|
||||||
|
@ -97,10 +89,8 @@ class PermissionController extends Controller
|
||||||
/**
|
/**
|
||||||
* Show the view to delete a role.
|
* Show the view to delete a role.
|
||||||
* Offers the chance to migrate users.
|
* 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');
|
$this->checkPermission('user-roles-manage');
|
||||||
$role = $this->permissionsRepo->getRoleById($id);
|
$role = $this->permissionsRepo->getRoleById($id);
|
||||||
|
@ -113,11 +103,9 @@ class PermissionController extends Controller
|
||||||
/**
|
/**
|
||||||
* Delete a role from the system,
|
* Delete a role from the system,
|
||||||
* Migrate from a previous role if set.
|
* Migrate from a previous role if set.
|
||||||
* @param Request $request
|
* @throws Exception
|
||||||
* @param $id
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
|
||||||
*/
|
*/
|
||||||
public function deleteRole(Request $request, $id)
|
public function deleteRole(Request $request, string $id)
|
||||||
{
|
{
|
||||||
$this->checkPermission('user-roles-manage');
|
$this->checkPermission('user-roles-manage');
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
use BookStack\Entities\Managers\EntityContext;
|
use BookStack\Entities\Managers\EntityContext;
|
||||||
use BookStack\Entities\SearchService;
|
use BookStack\Entities\SearchService;
|
||||||
|
use BookStack\Entities\SearchOptions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SearchController extends Controller
|
class SearchController extends Controller
|
||||||
|
@ -33,20 +34,22 @@ class SearchController extends Controller
|
||||||
*/
|
*/
|
||||||
public function search(Request $request)
|
public function search(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('term');
|
$searchOpts = SearchOptions::fromRequest($request);
|
||||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
|
$fullSearchString = $searchOpts->toString();
|
||||||
|
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||||
|
|
||||||
$page = intval($request->get('page', '0')) ?: 1;
|
$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', [
|
return view('search.all', [
|
||||||
'entities' => $results['results'],
|
'entities' => $results['results'],
|
||||||
'totalResults' => $results['total'],
|
'totalResults' => $results['total'],
|
||||||
'searchTerm' => $searchTerm,
|
'searchTerm' => $fullSearchString,
|
||||||
'hasNextPage' => $results['has_more'],
|
'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
|
// Search for entities otherwise show most popular
|
||||||
if ($searchTerm !== false) {
|
if ($searchTerm !== false) {
|
||||||
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
|
$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 {
|
} else {
|
||||||
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
|
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
<?php namespace BookStack\Http\Controllers;
|
<?php namespace BookStack\Http\Controllers;
|
||||||
|
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
use BookStack\Notifications\TestEmail;
|
|
||||||
use BookStack\Uploads\ImageRepo;
|
use BookStack\Uploads\ImageRepo;
|
||||||
use BookStack\Uploads\ImageService;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SettingController extends Controller
|
class SettingController extends Controller
|
||||||
|
@ -74,63 +72,4 @@ class SettingController extends Controller
|
||||||
$redirectLocation = '/settings#' . $request->get('section', '');
|
$redirectLocation = '/settings#' . $request->get('section', '');
|
||||||
return redirect(rtrim($redirectLocation, '#'));
|
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.
|
* TagController constructor.
|
||||||
* @param $tagRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(TagRepo $tagRepo)
|
public function __construct(TagRepo $tagRepo)
|
||||||
{
|
{
|
||||||
|
@ -18,39 +17,23 @@ class TagController extends Controller
|
||||||
parent::__construct();
|
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.
|
* Get tag name suggestions from a given search term.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function getNameSuggestions(Request $request)
|
public function getNameSuggestions(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('search', false);
|
$searchTerm = $request->get('search', null);
|
||||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||||
return response()->json($suggestions);
|
return response()->json($suggestions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tag value suggestions from a given search term.
|
* Get tag value suggestions from a given search term.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function getValueSuggestions(Request $request)
|
public function getValueSuggestions(Request $request)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->get('search', false);
|
$searchTerm = $request->get('search', null);
|
||||||
$tagName = $request->get('name', false);
|
$tagName = $request->get('name', null);
|
||||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||||
return response()->json($suggestions);
|
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.
|
// 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.
|
// 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();
|
$this->ensureEmailConfirmedIfRequested();
|
||||||
if (!auth()->user()->can('access-api')) {
|
if (!user()->can('access-api')) {
|
||||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -44,6 +44,10 @@ class Authenticate
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session()->get('sent-email-confirmation') === true) {
|
||||||
|
return redirect('/register/confirm');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect('/register/confirm/awaiting');
|
return redirect('/register/confirm/awaiting');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ class Localization
|
||||||
*/
|
*/
|
||||||
protected $localeMap = [
|
protected $localeMap = [
|
||||||
'ar' => 'ar',
|
'ar' => 'ar',
|
||||||
|
'bg' => 'bg_BG',
|
||||||
'da' => 'da_DK',
|
'da' => 'da_DK',
|
||||||
'de' => 'de_DE',
|
'de' => 'de_DE',
|
||||||
'de_informal' => 'de_DE',
|
'de_informal' => 'de_DE',
|
||||||
|
|
|
@ -3,6 +3,13 @@
|
||||||
use BookStack\Entities\Page;
|
use BookStack\Entities\Page;
|
||||||
use BookStack\Ownable;
|
use BookStack\Ownable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int id
|
||||||
|
* @property string name
|
||||||
|
* @property string path
|
||||||
|
* @property string extension
|
||||||
|
* @property bool external
|
||||||
|
*/
|
||||||
class Attachment extends Ownable
|
class Attachment extends Ownable
|
||||||
{
|
{
|
||||||
protected $fillable = ['name', 'order'];
|
protected $fillable = ['name', 'order'];
|
||||||
|
@ -30,13 +37,28 @@ class Attachment extends Ownable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the url of this file.
|
* Get the url of this file.
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public function getUrl()
|
public function getUrl(): string
|
||||||
{
|
{
|
||||||
if ($this->external && strpos($this->path, 'http') !== 0) {
|
if ($this->external && strpos($this->path, 'http') !== 0) {
|
||||||
return $this->path;
|
return $this->path;
|
||||||
}
|
}
|
||||||
return url('/attachments/' . $this->id);
|
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.
|
* Updates the ordering for a listing of attached files.
|
||||||
* @param array $attachmentList
|
|
||||||
* @param $pageId
|
|
||||||
*/
|
*/
|
||||||
public function updateFileOrderWithinPage($attachmentList, $pageId)
|
public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
|
||||||
{
|
{
|
||||||
foreach ($attachmentList as $index => $attachment) {
|
foreach ($attachmentOrder as $index => $attachmentId) {
|
||||||
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
|
Attachment::query()->where('uploaded_to', '=', $pageId)
|
||||||
|
->where('id', '=', $attachmentId)
|
||||||
|
->update(['order' => $index]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -185,7 +185,7 @@ class ImageRepo
|
||||||
* Load thumbnails onto an image object.
|
* Load thumbnails onto an image object.
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function loadThumbs(Image $image)
|
public function loadThumbs(Image $image)
|
||||||
{
|
{
|
||||||
$image->thumbs = [
|
$image->thumbs = [
|
||||||
'gallery' => $this->getThumbnail($image, 150, 150, false),
|
'gallery' => $this->getThumbnail($image, 150, 150, false),
|
||||||
|
@ -219,4 +219,20 @@ class ImageRepo
|
||||||
return null;
|
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
|
* Save a new image into storage.
|
||||||
* @param string $imageName
|
|
||||||
* @param string $imageData
|
|
||||||
* @param string $type
|
|
||||||
* @param int $uploadedTo
|
|
||||||
* @return Image
|
|
||||||
* @throws ImageUploadException
|
* @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);
|
$storage = $this->getStorage($type);
|
||||||
$secureUploads = setting('app-secure-images');
|
$secureUploads = setting('app-secure-images');
|
||||||
$imageName = str_replace(' ', '-', $imageName);
|
$fileName = $this->cleanImageFileName($imageName);
|
||||||
|
|
||||||
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
|
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
|
||||||
|
|
||||||
while ($storage->exists($imagePath . $imageName)) {
|
while ($storage->exists($imagePath . $fileName)) {
|
||||||
$imageName = Str::random(3) . $imageName;
|
$fileName = Str::random(3) . $fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fullPath = $imagePath . $imageName;
|
$fullPath = $imagePath . $fileName;
|
||||||
if ($secureUploads) {
|
if ($secureUploads) {
|
||||||
$fullPath = $imagePath . Str::random(16) . '-' . $imageName;
|
$fullPath = $imagePath . Str::random(16) . '-' . $fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -175,6 +170,23 @@ class ImageService extends UploadService
|
||||||
return $image;
|
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.
|
* 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');
|
$storage->setVisibility($thumbFilePath, 'public');
|
||||||
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
|
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
|
||||||
|
|
||||||
|
|
||||||
return $this->getPublicUrl($thumbFilePath);
|
return $this->getPublicUrl($thumbFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -292,11 +305,9 @@ class ImageService extends UploadService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroys an image at the given path.
|
* Destroys an image at the given path.
|
||||||
* Searches for image thumbnails in addition to main provided path..
|
* Searches for image thumbnails in addition to main provided path.
|
||||||
* @param string $path
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
protected function destroyImagesFromPath(string $path)
|
protected function destroyImagesFromPath(string $path): bool
|
||||||
{
|
{
|
||||||
$storage = $this->getStorage();
|
$storage = $this->getStorage();
|
||||||
|
|
||||||
|
@ -306,8 +317,7 @@ class ImageService extends UploadService
|
||||||
|
|
||||||
// Delete image files
|
// Delete image files
|
||||||
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
||||||
$expectedIndex = strlen($imagePath) - strlen($imageFileName);
|
return basename($imagePath) === $imageFileName;
|
||||||
return strpos($imagePath, $imageFileName) === $expectedIndex;
|
|
||||||
});
|
});
|
||||||
$storage->delete($imagesToDelete->all());
|
$storage->delete($imagesToDelete->all());
|
||||||
|
|
||||||
|
|
|
@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string
|
||||||
* Generate a url with multiple parameters for sorting purposes.
|
* Generate a url with multiple parameters for sorting purposes.
|
||||||
* Works out the logic to set the correct sorting direction
|
* Works out the logic to set the correct sorting direction
|
||||||
* Discards empty parameters and allows overriding.
|
* 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
|
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
|
// Change sorting direction is already sorted on current attribute
|
||||||
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
|
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
|
||||||
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
|
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
|
||||||
} else {
|
} elseif (isset($overrideData['sort'])) {
|
||||||
$queryData['order'] = 'asc';
|
$queryData['order'] = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,14 +13,14 @@
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-tidy": "*",
|
"ext-tidy": "*",
|
||||||
"ext-xml": "*",
|
"ext-xml": "*",
|
||||||
"barryvdh/laravel-dompdf": "^0.8.5",
|
"barryvdh/laravel-dompdf": "^0.8.6",
|
||||||
"barryvdh/laravel-snappy": "^0.4.5",
|
"barryvdh/laravel-snappy": "^0.4.7",
|
||||||
"doctrine/dbal": "^2.9",
|
"doctrine/dbal": "^2.9",
|
||||||
"facade/ignition": "^1.4",
|
"facade/ignition": "^1.4",
|
||||||
"fideloper/proxy": "^4.0",
|
"fideloper/proxy": "^4.0",
|
||||||
"gathercontent/htmldiff": "^0.2.1",
|
"gathercontent/htmldiff": "^0.2.1",
|
||||||
"intervention/image": "^2.5",
|
"intervention/image": "^2.5",
|
||||||
"laravel/framework": "^6.12",
|
"laravel/framework": "^6.18",
|
||||||
"laravel/socialite": "^4.3.2",
|
"laravel/socialite": "^4.3.2",
|
||||||
"league/commonmark": "^1.4",
|
"league/commonmark": "^1.4",
|
||||||
"league/flysystem-aws-s3-v3": "^1.0",
|
"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",
|
"updated_at": "2020-01-12 14:11:51",
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Admin",
|
"name": "Admin"
|
||||||
"image_id": 48
|
|
||||||
},
|
},
|
||||||
"updated_by": {
|
"updated_by": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Admin",
|
"name": "Admin"
|
||||||
"image_id": 48
|
|
||||||
},
|
},
|
||||||
"image_id": 452,
|
|
||||||
"tags": [
|
"tags": [
|
||||||
{
|
{
|
||||||
"id": 13,
|
"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",
|
"description": "This is my shelf with some books",
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Admin",
|
"name": "Admin"
|
||||||
"image_id": 48
|
|
||||||
},
|
},
|
||||||
"updated_by": {
|
"updated_by": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Admin",
|
"name": "Admin"
|
||||||
"image_id": 48
|
|
||||||
},
|
},
|
||||||
"image_id": 501,
|
|
||||||
"created_at": "2020-04-10 13:24:09",
|
"created_at": "2020-04-10 13:24:09",
|
||||||
"updated_at": "2020-04-10 13:31:04",
|
"updated_at": "2020-04-10 13:31:04",
|
||||||
"tags": [
|
"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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack",
|
"build:css:dev": "sass ./resources/sass:./public/dist",
|
||||||
"production": "NODE_ENV=production webpack && rm -f ./public/dist/*styles.js",
|
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
|
||||||
"build-profile": "NODE_ENV=production webpack --profile --json > webpack-stats.json && rm -f ./public/dist/*styles.js",
|
"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",
|
"dev": "npm-run-all --parallel watch livereload",
|
||||||
"watch": "webpack --watch",
|
"watch": "npm-run-all --parallel build:*:watch",
|
||||||
"livereload": "livereload ./public/dist/",
|
"livereload": "livereload ./public/dist/",
|
||||||
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
|
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"css-loader": "^3.4.2",
|
"chokidar-cli": "^2.1.0",
|
||||||
|
"esbuild": "0.6.30",
|
||||||
"livereload": "^0.9.1",
|
"livereload": "^0.9.1",
|
||||||
"mini-css-extract-plugin": "^0.9.0",
|
|
||||||
"node-sass": "^4.13.1",
|
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"sass-loader": "^8.0.2",
|
"punycode": "^2.1.1",
|
||||||
"style-loader": "^1.1.3",
|
"sass": "^1.26.10"
|
||||||
"webpack": "^4.42.1",
|
|
||||||
"webpack-cli": "^3.3.11"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clipboard": "^2.0.6",
|
"clipboard": "^2.0.6",
|
||||||
"codemirror": "^5.52.2",
|
"codemirror": "^5.57.0",
|
||||||
"dropzone": "^5.7.0",
|
"dropzone": "^5.7.2",
|
||||||
"markdown-it": "^10.0.0",
|
"markdown-it": "^11.0.0",
|
||||||
"markdown-it-task-lists": "^2.1.1",
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
"sortablejs": "^1.10.2",
|
"sortablejs": "^1.10.2"
|
||||||
"vue": "^2.6.11",
|
|
||||||
"vuedraggable": "^2.23.2"
|
|
||||||
},
|
|
||||||
"browser": {
|
|
||||||
"vue": "vue/dist/vue.common.js"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<description>The coding standard for BookStack.</description>
|
<description>The coding standard for BookStack.</description>
|
||||||
<file>app</file>
|
<file>app</file>
|
||||||
<exclude-pattern>*/migrations/*</exclude-pattern>
|
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||||
|
<exclude-pattern>*/tests/*</exclude-pattern>
|
||||||
<arg value="np"/>
|
<arg value="np"/>
|
||||||
<rule ref="PSR2"/>
|
<rule ref="PSR2"/>
|
||||||
</ruleset>
|
</ruleset>
|
|
@ -51,5 +51,7 @@
|
||||||
<server name="DEBUGBAR_ENABLED" value="false"/>
|
<server name="DEBUGBAR_ENABLED" value="false"/>
|
||||||
<server name="SAML2_ENABLED" value="false"/>
|
<server name="SAML2_ENABLED" value="false"/>
|
||||||
<server name="API_REQUESTS_PER_MIN" value="180"/>
|
<server name="API_REQUESTS_PER_MIN" value="180"/>
|
||||||
|
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
|
||||||
|
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</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+
|
* [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
|
``` bash
|
||||||
# Install NPM Dependencies
|
# Install NPM Dependencies
|
||||||
|
@ -157,8 +157,7 @@ These are the great open-source projects used to help build BookStack:
|
||||||
* [Laravel](http://laravel.com/)
|
* [Laravel](http://laravel.com/)
|
||||||
* [TinyMCE](https://www.tinymce.com/)
|
* [TinyMCE](https://www.tinymce.com/)
|
||||||
* [CodeMirror](https://codemirror.net)
|
* [CodeMirror](https://codemirror.net)
|
||||||
* [Vue.js](http://vuejs.org/)
|
* [Sortable](https://github.com/SortableJS/Sortable)
|
||||||
* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
|
|
||||||
* [Google Material Icons](https://material.io/icons/)
|
* [Google Material Icons](https://material.io/icons/)
|
||||||
* [Dropzone.js](http://www.dropzonejs.com/)
|
* [Dropzone.js](http://www.dropzonejs.com/)
|
||||||
* [clipboard.js](https://clipboardjs.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() {
|
openIfContainsError() {
|
||||||
const error = this.content.querySelector('.text-neg');
|
const error = this.content.querySelector('.text-neg.text-small');
|
||||||
if (error) {
|
if (error) {
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,16 @@ import {onSelect} from "../services/dom";
|
||||||
/**
|
/**
|
||||||
* Dropdown
|
* Dropdown
|
||||||
* Provides some simple logic to create simple dropdown menus.
|
* Provides some simple logic to create simple dropdown menus.
|
||||||
|
* @extends {Component}
|
||||||
*/
|
*/
|
||||||
class DropDown {
|
class DropDown {
|
||||||
|
|
||||||
constructor(elem) {
|
setup() {
|
||||||
this.container = elem;
|
this.container = this.$el;
|
||||||
this.menu = elem.querySelector('.dropdown-menu, [dropdown-menu]');
|
this.menu = this.$refs.menu;
|
||||||
this.moveMenu = elem.hasAttribute('dropdown-move-menu');
|
this.toggle = this.$refs.toggle;
|
||||||
this.toggle = elem.querySelector('[dropdown-toggle]');
|
this.moveMenu = this.$opts.moveMenu;
|
||||||
|
|
||||||
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
||||||
this.body = document.body;
|
this.body = document.body;
|
||||||
this.showing = false;
|
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 {
|
class EntitySelectorPopup {
|
||||||
|
|
||||||
constructor(elem) {
|
setup() {
|
||||||
this.elem = elem;
|
this.elem = this.$el;
|
||||||
|
this.selectButton = this.$refs.select;
|
||||||
window.EntitySelectorPopup = this;
|
window.EntitySelectorPopup = this;
|
||||||
|
|
||||||
this.callback = null;
|
this.callback = null;
|
||||||
this.selection = null;
|
this.selection = null;
|
||||||
|
|
||||||
this.selectButton = elem.querySelector('.entity-link-selector-confirm');
|
|
||||||
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
|
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
|
||||||
|
|
||||||
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
|
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
|
||||||
window.$events.listen('entity-select-confirm', this.onSelectionConfirm.bind(this));
|
window.$events.listen('entity-select-confirm', this.onSelectionConfirm.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
show(callback) {
|
show(callback) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.elem.components.overlay.show();
|
this.elem.components.popup.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
this.elem.components.overlay.hide();
|
this.elem.components.popup.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectButtonClick() {
|
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 addRemoveRows from "./add-remove-rows.js"
|
||||||
import overlay from "./overlay";
|
import ajaxDeleteRow from "./ajax-delete-row.js"
|
||||||
import backToTop from "./back-to-top";
|
import ajaxForm from "./ajax-form.js"
|
||||||
import notification from "./notification";
|
import attachments from "./attachments.js"
|
||||||
import chapterToggle from "./chapter-toggle";
|
import autoSuggest from "./auto-suggest.js"
|
||||||
import expandToggle from "./expand-toggle";
|
import backToTop from "./back-to-top.js"
|
||||||
import entitySelectorPopup from "./entity-selector-popup";
|
import bookSort from "./book-sort.js"
|
||||||
import entitySelector from "./entity-selector";
|
import breadcrumbListing from "./breadcrumb-listing.js"
|
||||||
import sidebar from "./sidebar";
|
import chapterToggle from "./chapter-toggle.js"
|
||||||
import pagePicker from "./page-picker";
|
import codeEditor from "./code-editor.js"
|
||||||
import pageComments from "./page-comments";
|
import codeHighlighter from "./code-highlighter.js"
|
||||||
import wysiwygEditor from "./wysiwyg-editor";
|
import collapsible from "./collapsible.js"
|
||||||
import markdownEditor from "./markdown-editor";
|
import customCheckbox from "./custom-checkbox.js"
|
||||||
import editorToolbox from "./editor-toolbox";
|
import detailsHighlighter from "./details-highlighter.js"
|
||||||
import imagePicker from "./image-picker";
|
import dropdown from "./dropdown.js"
|
||||||
import collapsible from "./collapsible";
|
import dropzone from "./dropzone.js"
|
||||||
import toggleSwitch from "./toggle-switch";
|
import editorToolbox from "./editor-toolbox.js"
|
||||||
import pageDisplay from "./page-display";
|
import entityPermissionsEditor from "./entity-permissions-editor.js"
|
||||||
import shelfSort from "./shelf-sort";
|
import entitySearch from "./entity-search.js"
|
||||||
import homepageControl from "./homepage-control";
|
import entitySelector from "./entity-selector.js"
|
||||||
import headerMobileToggle from "./header-mobile-toggle";
|
import entitySelectorPopup from "./entity-selector-popup.js"
|
||||||
import listSortControl from "./list-sort-control";
|
import eventEmitSelect from "./event-emit-select.js"
|
||||||
import triLayout from "./tri-layout";
|
import expandToggle from "./expand-toggle.js"
|
||||||
import breadcrumbListing from "./breadcrumb-listing";
|
import headerMobileToggle from "./header-mobile-toggle.js"
|
||||||
import permissionsTable from "./permissions-table";
|
import homepageControl from "./homepage-control.js"
|
||||||
import customCheckbox from "./custom-checkbox";
|
import imageManager from "./image-manager.js"
|
||||||
import bookSort from "./book-sort";
|
import imagePicker from "./image-picker.js"
|
||||||
import settingAppColorPicker from "./setting-app-color-picker";
|
import index from "./index.js"
|
||||||
import settingColorPicker from "./setting-color-picker";
|
import listSortControl from "./list-sort-control.js"
|
||||||
import entityPermissionsEditor from "./entity-permissions-editor";
|
import markdownEditor from "./markdown-editor.js"
|
||||||
import templateManager from "./template-manager";
|
import newUserPassword from "./new-user-password.js"
|
||||||
import newUserPassword from "./new-user-password";
|
import notification from "./notification.js"
|
||||||
import detailsHighlighter from "./details-highlighter";
|
import optionalInput from "./optional-input.js"
|
||||||
import codeHighlighter from "./code-highlighter";
|
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 = {
|
const componentMapping = {
|
||||||
'dropdown': dropdown,
|
"add-remove-rows": addRemoveRows,
|
||||||
'overlay': overlay,
|
"ajax-delete-row": ajaxDeleteRow,
|
||||||
'back-to-top': backToTop,
|
"ajax-form": ajaxForm,
|
||||||
'notification': notification,
|
"attachments": attachments,
|
||||||
'chapter-toggle': chapterToggle,
|
"auto-suggest": autoSuggest,
|
||||||
'expand-toggle': expandToggle,
|
"back-to-top": backToTop,
|
||||||
'entity-selector-popup': entitySelectorPopup,
|
"book-sort": bookSort,
|
||||||
'entity-selector': entitySelector,
|
"breadcrumb-listing": breadcrumbListing,
|
||||||
'sidebar': sidebar,
|
"chapter-toggle": chapterToggle,
|
||||||
'page-picker': pagePicker,
|
"code-editor": codeEditor,
|
||||||
'page-comments': pageComments,
|
"code-highlighter": codeHighlighter,
|
||||||
'wysiwyg-editor': wysiwygEditor,
|
"collapsible": collapsible,
|
||||||
'markdown-editor': markdownEditor,
|
"custom-checkbox": customCheckbox,
|
||||||
'editor-toolbox': editorToolbox,
|
"details-highlighter": detailsHighlighter,
|
||||||
'image-picker': imagePicker,
|
"dropdown": dropdown,
|
||||||
'collapsible': collapsible,
|
"dropzone": dropzone,
|
||||||
'toggle-switch': toggleSwitch,
|
"editor-toolbox": editorToolbox,
|
||||||
'page-display': pageDisplay,
|
"entity-permissions-editor": entityPermissionsEditor,
|
||||||
'shelf-sort': shelfSort,
|
"entity-search": entitySearch,
|
||||||
'homepage-control': homepageControl,
|
"entity-selector": entitySelector,
|
||||||
'header-mobile-toggle': headerMobileToggle,
|
"entity-selector-popup": entitySelectorPopup,
|
||||||
'list-sort-control': listSortControl,
|
"event-emit-select": eventEmitSelect,
|
||||||
'tri-layout': triLayout,
|
"expand-toggle": expandToggle,
|
||||||
'breadcrumb-listing': breadcrumbListing,
|
"header-mobile-toggle": headerMobileToggle,
|
||||||
'permissions-table': permissionsTable,
|
"homepage-control": homepageControl,
|
||||||
'custom-checkbox': customCheckbox,
|
"image-manager": imageManager,
|
||||||
'book-sort': bookSort,
|
"image-picker": imagePicker,
|
||||||
'setting-app-color-picker': settingAppColorPicker,
|
"index": index,
|
||||||
'setting-color-picker': settingColorPicker,
|
"list-sort-control": listSortControl,
|
||||||
'entity-permissions-editor': entityPermissionsEditor,
|
"markdown-editor": markdownEditor,
|
||||||
'template-manager': templateManager,
|
"new-user-password": newUserPassword,
|
||||||
'new-user-password': newUserPassword,
|
"notification": notification,
|
||||||
'details-highlighter': detailsHighlighter,
|
"optional-input": optionalInput,
|
||||||
'code-highlighter': codeHighlighter,
|
"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 = {};
|
window.components = {};
|
||||||
|
|
||||||
const componentNames = Object.keys(componentMapping);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize components of the given name within the given element.
|
* Initialize components of the given name within the given element.
|
||||||
* @param {String} componentName
|
* @param {String} componentName
|
||||||
* @param {HTMLElement|Document} parentElement
|
* @param {HTMLElement|Document} parentElement
|
||||||
*/
|
*/
|
||||||
function initComponent(componentName, parentElement) {
|
function searchForComponentInParent(componentName, parentElement) {
|
||||||
let elems = parentElement.querySelectorAll(`[${componentName}]`);
|
const elems = parentElement.querySelectorAll(`[${componentName}]`);
|
||||||
if (elems.length === 0) return;
|
|
||||||
|
|
||||||
let component = componentMapping[componentName];
|
|
||||||
if (typeof window.components[componentName] === "undefined") window.components[componentName] = [];
|
|
||||||
for (let j = 0, jLen = elems.length; j < jLen; j++) {
|
for (let j = 0, jLen = elems.length; j < jLen; j++) {
|
||||||
let instance = new component(elems[j]);
|
initComponent(componentName, elems[j]);
|
||||||
if (typeof elems[j].components === 'undefined') elems[j].components = {};
|
|
||||||
elems[j].components[componentName] = instance;
|
|
||||||
window.components[componentName].push(instance);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Initialize all components found within the given element.
|
||||||
* @param parentElement
|
* @param parentElement
|
||||||
*/
|
*/
|
||||||
function initAll(parentElement) {
|
function initAll(parentElement) {
|
||||||
if (typeof parentElement === 'undefined') parentElement = document;
|
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.init = initAll;
|
||||||
|
window.components.first = (name) => (window.components[name] || [null])[0];
|
||||||
|
|
||||||
export default initAll;
|
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 {
|
class MarkdownEditor {
|
||||||
|
|
||||||
constructor(elem) {
|
setup() {
|
||||||
this.elem = elem;
|
this.elem = this.$el;
|
||||||
|
|
||||||
const pageEditor = document.getElementById('page-editor');
|
this.pageId = this.$opts.pageId;
|
||||||
this.pageId = pageEditor.getAttribute('page-id');
|
this.textDirection = this.$opts.textDirection;
|
||||||
this.textDirection = pageEditor.getAttribute('text-direction');
|
|
||||||
|
|
||||||
this.markdown = new MarkdownIt({html: true});
|
this.markdown = new MarkdownIt({html: true});
|
||||||
this.markdown.use(mdTasksLists, {label: true});
|
this.markdown.use(mdTasksLists, {label: true});
|
||||||
|
@ -27,12 +26,18 @@ class MarkdownEditor {
|
||||||
|
|
||||||
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
|
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
|
||||||
|
|
||||||
this.display.addEventListener('load', () => {
|
const displayLoad = () => {
|
||||||
this.displayDoc = this.display.contentDocument;
|
this.displayDoc = this.display.contentDocument;
|
||||||
this.init();
|
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,
|
markdownIt: this.markdown,
|
||||||
displayEl: this.display,
|
displayEl: this.display,
|
||||||
codeMirrorInstance: this.cm,
|
codeMirrorInstance: this.cm,
|
||||||
|
@ -251,7 +256,7 @@ class MarkdownEditor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const clipboard = new Clipboard(event.dataTransfer);
|
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});
|
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
|
||||||
cm.setCursor(cursorPos);
|
cm.setCursor(cursorPos);
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@ -558,6 +563,12 @@ class MarkdownEditor {
|
||||||
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
|
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
|
// Focus on editor
|
||||||
window.$events.listen('editor::focus', () => {
|
window.$events.listen('editor::focus', () => {
|
||||||
this.cm.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";
|
import {scrollAndHighlightElement} from "../services/util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends {Component}
|
||||||
|
*/
|
||||||
class PageComments {
|
class PageComments {
|
||||||
|
|
||||||
constructor(elem) {
|
setup() {
|
||||||
this.elem = elem;
|
this.elem = this.$el;
|
||||||
this.pageId = Number(elem.getAttribute('page-id'));
|
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.editingComment = null;
|
||||||
this.parentId = null;
|
this.parentId = null;
|
||||||
|
|
||||||
this.container = elem.querySelector('[comment-container]');
|
|
||||||
this.formContainer = elem.querySelector('[comment-form-container]');
|
|
||||||
|
|
||||||
if (this.formContainer) {
|
if (this.formContainer) {
|
||||||
this.form = this.formContainer.querySelector('form');
|
this.form = this.formContainer.querySelector('form');
|
||||||
this.formInput = this.form.querySelector('textarea');
|
this.formInput = this.form.querySelector('textarea');
|
||||||
|
@ -32,13 +47,14 @@ class PageComments {
|
||||||
if (actionElem === null) return;
|
if (actionElem === null) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
let action = actionElem.getAttribute('action');
|
const action = actionElem.getAttribute('action');
|
||||||
if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
|
const comment = actionElem.closest('[comment]');
|
||||||
|
if (action === 'edit') this.editComment(comment);
|
||||||
if (action === 'closeUpdateForm') this.closeUpdateForm();
|
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 === 'addComment') this.showForm();
|
||||||
if (action === 'hideForm') this.hideForm();
|
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();
|
if (action === 'remove-reply-to') this.removeReplyTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,14 +85,15 @@ class PageComments {
|
||||||
};
|
};
|
||||||
this.showLoading(form);
|
this.showLoading(form);
|
||||||
let commentId = this.editingComment.getAttribute('comment');
|
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');
|
let newComment = document.createElement('div');
|
||||||
newComment.innerHTML = resp.data;
|
newComment.innerHTML = resp.data;
|
||||||
this.editingComment.innerHTML = newComment.children[0].innerHTML;
|
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);
|
window.components.init(this.editingComment);
|
||||||
this.closeUpdateForm();
|
this.closeUpdateForm();
|
||||||
this.editingComment = null;
|
this.editingComment = null;
|
||||||
|
}).catch(window.$events.showValidationErrors).then(() => {
|
||||||
this.hideLoading(form);
|
this.hideLoading(form);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -84,9 +101,9 @@ class PageComments {
|
||||||
deleteComment(commentElem) {
|
deleteComment(commentElem) {
|
||||||
let id = commentElem.getAttribute('comment');
|
let id = commentElem.getAttribute('comment');
|
||||||
this.showLoading(commentElem.querySelector('[comment-content]'));
|
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);
|
commentElem.parentNode.removeChild(commentElem);
|
||||||
window.$events.emit('success', window.trans('entities.comment_deleted_success'));
|
window.$events.success(this.deletedText);
|
||||||
this.updateCount();
|
this.updateCount();
|
||||||
this.hideForm();
|
this.hideForm();
|
||||||
});
|
});
|
||||||
|
@ -101,21 +118,24 @@ class PageComments {
|
||||||
parent_id: this.parentId || null,
|
parent_id: this.parentId || null,
|
||||||
};
|
};
|
||||||
this.showLoading(this.form);
|
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');
|
let newComment = document.createElement('div');
|
||||||
newComment.innerHTML = resp.data;
|
newComment.innerHTML = resp.data;
|
||||||
let newElem = newComment.children[0];
|
let newElem = newComment.children[0];
|
||||||
this.container.appendChild(newElem);
|
this.container.appendChild(newElem);
|
||||||
window.components.init(newElem);
|
window.components.init(newElem);
|
||||||
window.$events.emit('success', window.trans('entities.comment_created_success'));
|
window.$events.success(this.createdText);
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
this.updateCount();
|
this.updateCount();
|
||||||
|
}).catch(err => {
|
||||||
|
window.$events.showValidationErrors(err);
|
||||||
|
this.hideLoading(this.form);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCount() {
|
updateCount() {
|
||||||
let count = this.container.children.length;
|
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() {
|
resetForm() {
|
||||||
|
@ -129,7 +149,7 @@ class PageComments {
|
||||||
showForm() {
|
showForm() {
|
||||||
this.formContainer.style.display = 'block';
|
this.formContainer.style.display = 'block';
|
||||||
this.formContainer.parentNode.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.focus();
|
||||||
this.formInput.scrollIntoView({behavior: "smooth"});
|
this.formInput.scrollIntoView({behavior: "smooth"});
|
||||||
}
|
}
|
||||||
|
@ -137,14 +157,12 @@ class PageComments {
|
||||||
hideForm() {
|
hideForm() {
|
||||||
this.formContainer.style.display = 'none';
|
this.formContainer.style.display = 'none';
|
||||||
this.formContainer.parentNode.style.display = 'none';
|
this.formContainer.parentNode.style.display = 'none';
|
||||||
const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
|
|
||||||
if (this.getCommentCount() > 0) {
|
if (this.getCommentCount() > 0) {
|
||||||
this.elem.appendChild(addButtonContainer)
|
this.elem.appendChild(this.addButtonContainer)
|
||||||
} else {
|
} else {
|
||||||
const countBar = this.elem.querySelector('[comment-count-bar]');
|
this.commentCountBar.appendChild(this.addButtonContainer);
|
||||||
countBar.appendChild(addButtonContainer);
|
|
||||||
}
|
}
|
||||||
addButtonContainer.style.display = 'block';
|
this.addButtonContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommentCount() {
|
getCommentCount() {
|
||||||
|
@ -154,15 +172,15 @@ class PageComments {
|
||||||
setReply(commentElem) {
|
setReply(commentElem) {
|
||||||
this.showForm();
|
this.showForm();
|
||||||
this.parentId = Number(commentElem.getAttribute('local-id'));
|
this.parentId = Number(commentElem.getAttribute('local-id'));
|
||||||
this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
|
this.replyToRow.style.display = 'block';
|
||||||
let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
|
const replyLink = this.replyToRow.querySelector('a');
|
||||||
replyLink.textContent = `#${this.parentId}`;
|
replyLink.textContent = `#${this.parentId}`;
|
||||||
replyLink.href = `#comment${this.parentId}`;
|
replyLink.href = `#comment${this.parentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeReplyTo() {
|
removeReplyTo() {
|
||||||
this.parentId = null;
|
this.parentId = null;
|
||||||
this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
|
this.replyToRow.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
showLoading(formElem) {
|
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)) {
|
if (!elemIsCodeBlock(selectedNode)) {
|
||||||
const providedCode = editor.selection.getNode().textContent;
|
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');
|
const wrap = document.createElement('div');
|
||||||
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
|
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
|
||||||
wrap.querySelector('code').innerText = code;
|
wrap.querySelector('code').innerText = code;
|
||||||
|
@ -155,7 +155,7 @@ function codePlugin() {
|
||||||
let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
|
let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
|
||||||
let currentCode = selectedNode.querySelector('textarea').textContent;
|
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 editorElem = selectedNode.querySelector('.CodeMirror');
|
||||||
const cmInstance = editorElem.CodeMirror;
|
const cmInstance = editorElem.CodeMirror;
|
||||||
if (cmInstance) {
|
if (cmInstance) {
|
||||||
|
@ -236,7 +236,7 @@ function codePlugin() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawIoPlugin(drawioUrl, isDarkMode) {
|
function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
|
||||||
|
|
||||||
let pageEditor = null;
|
let pageEditor = null;
|
||||||
let currentNode = null;
|
let currentNode = null;
|
||||||
|
@ -270,7 +270,6 @@ function drawIoPlugin(drawioUrl, isDarkMode) {
|
||||||
async function updateContent(pngData) {
|
async function updateContent(pngData) {
|
||||||
const id = "image-" + Math.random().toString(16).slice(2);
|
const id = "image-" + Math.random().toString(16).slice(2);
|
||||||
const loadingImage = window.baseUrl('/loading.gif');
|
const loadingImage = window.baseUrl('/loading.gif');
|
||||||
const pageId = Number(document.getElementById('page-editor').getAttribute('page-id'));
|
|
||||||
|
|
||||||
// Handle updating an existing image
|
// Handle updating an existing image
|
||||||
if (currentNode) {
|
if (currentNode) {
|
||||||
|
@ -402,6 +401,11 @@ function listenForBookStackEditorEvents(editor) {
|
||||||
editor.setContent(content);
|
editor.setContent(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Insert editor content at the current location
|
||||||
|
window.$events.listen('editor::insert', ({html}) => {
|
||||||
|
editor.insertContent(html);
|
||||||
|
});
|
||||||
|
|
||||||
// Focus on the editor
|
// Focus on the editor
|
||||||
window.$events.listen('editor::focus', () => {
|
window.$events.listen('editor::focus', () => {
|
||||||
editor.focus();
|
editor.focus();
|
||||||
|
@ -410,19 +414,19 @@ function listenForBookStackEditorEvents(editor) {
|
||||||
|
|
||||||
class WysiwygEditor {
|
class WysiwygEditor {
|
||||||
|
|
||||||
constructor(elem) {
|
|
||||||
this.elem = elem;
|
|
||||||
|
|
||||||
const pageEditor = document.getElementById('page-editor');
|
setup() {
|
||||||
this.pageId = pageEditor.getAttribute('page-id');
|
this.elem = this.$el;
|
||||||
this.textDirection = pageEditor.getAttribute('text-direction');
|
|
||||||
|
this.pageId = this.$opts.pageId;
|
||||||
|
this.textDirection = this.$opts.textDirection;
|
||||||
this.isDarkMode = document.documentElement.classList.contains('dark-mode');
|
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.loadPlugins();
|
||||||
|
|
||||||
this.tinyMceConfig = this.getTinyMceConfig();
|
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);
|
window.tinymce.init(this.tinyMceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -433,7 +437,7 @@ class WysiwygEditor {
|
||||||
const drawioUrlElem = document.querySelector('[drawio-url]');
|
const drawioUrlElem = document.querySelector('[drawio-url]');
|
||||||
if (drawioUrlElem) {
|
if (drawioUrlElem) {
|
||||||
const url = drawioUrlElem.getAttribute('drawio-url');
|
const url = drawioUrlElem.getAttribute('drawio-url');
|
||||||
drawIoPlugin(url, this.isDarkMode);
|
drawIoPlugin(url, this.isDarkMode, this.pageId);
|
||||||
this.plugins += ' drawio';
|
this.plugins += ' drawio';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -639,6 +643,7 @@ class WysiwygEditor {
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom drop event handling
|
||||||
editor.on('drop', function (event) {
|
editor.on('drop', function (event) {
|
||||||
let dom = editor.dom,
|
let dom = editor.dom,
|
||||||
rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
|
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
|
// Set events and http services on window
|
||||||
import Events from "./services/events"
|
import events from "./services/events"
|
||||||
import httpInstance from "./services/http"
|
import httpInstance from "./services/http"
|
||||||
const eventManager = new Events();
|
|
||||||
window.$http = httpInstance;
|
window.$http = httpInstance;
|
||||||
window.$events = eventManager;
|
window.$events = events;
|
||||||
|
|
||||||
// Translation setup
|
// Translation setup
|
||||||
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
|
// 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();
|
const translator = new Translations();
|
||||||
window.trans = translator.get.bind(translator);
|
window.trans = translator.get.bind(translator);
|
||||||
window.trans_choice = translator.getPlural.bind(translator);
|
window.trans_choice = translator.getPlural.bind(translator);
|
||||||
|
window.trans_plural = translator.parsePlural.bind(translator);
|
||||||
|
|
||||||
// Make services available to Vue instances
|
// Load Components
|
||||||
import Vue from "vue"
|
|
||||||
Vue.prototype.$http = httpInstance;
|
|
||||||
Vue.prototype.$events = eventManager;
|
|
||||||
|
|
||||||
// Load Vues and components
|
|
||||||
import vues from "./vues/vues"
|
|
||||||
import components from "./components"
|
import components from "./components"
|
||||||
vues();
|
|
||||||
components();
|
components();
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue