Merge branch 'master' into release

This commit is contained in:
Dan Brown 2020-09-20 10:30:10 +01:00
commit e22c9cae91
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
396 changed files with 9126 additions and 9801 deletions

View File

@ -1,3 +1,11 @@
# This file, when named as ".env" in the root of your BookStack install
# folder, is used for the core configuration of the application.
# By default this file contains the most common required options but
# a full list of options can be found in the '.env.example.complete' file.
# NOTE: If any of your values contain a space or a hash you will need to
# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
# Application key
# Used for encryption where needed.
# Run `php artisan key:generate` to generate a valid key.
@ -5,7 +13,7 @@ APP_KEY=SomeRandomString
# Application URL
# Remove the hash below and set a URL if using BookStack behind
# a proxy, if using a third-party authentication option.
# a proxy or if using a third-party authentication option.
# This must be the root URL that you want to host BookStack on.
# All URL's in BookStack will be generated using this value.
#APP_URL=https://example.com
@ -25,11 +33,10 @@ MAIL_FROM_NAME=BookStack
MAIL_FROM=bookstack@example.com
# SMTP mail options
# These settings can be checked using the "Send a Test Email"
# feature found in the "Settings > Maintenance" area of the system.
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
# A full list of options can be found in the '.env.example.complete' file.

View File

@ -271,3 +271,11 @@ API_MAX_ITEM_COUNT=500
# The number of API requests that can be made per minute by a single user.
API_REQUESTS_PER_MIN=180
# Enable the logging of failed email+password logins with the given message.
# The default log channel below uses the php 'error_log' function which commonly
# results in messages being output to the webserver error logs.
# The message can contain a %u parameter which will be replaced with the login
# user identifier (Username or email).
LOG_FAILED_LOGIN_MESSAGE=false
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver

View File

@ -61,7 +61,7 @@ Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
aekramer :: Dutch
JachuPL :: Polish
milesteg :: Hungarian
Beenbag :: German
Beenbag :: German; German Informal
Lett3rs :: Danish
Julian (julian.henneberg) :: German; German Informal
3GNWn :: Danish
@ -98,3 +98,25 @@ Thinkverse (thinkverse) :: Swedish
alef (toishoki) :: Turkish
Robbert Feunekes (Muukuro) :: Dutch
seohyeon.joo :: Korean
Orenda (OREDNA) :: Bulgarian
Marek Pavelka (marapavelka) :: Czech
Venkinovec :: Czech
Tommy Ku (tommyku) :: Chinese Traditional; Japanese
Michał Bielejewski (bielej) :: Polish
jozefrebjak :: Slovak
Ikhwan Koo (Ikhwan.Koo) :: Korean
Whay (remkovdhoef) :: Dutch
jc7115 :: Chinese Traditional
주서현 (seohyeon.joo) :: Korean
ReadySystems :: Arabic
HFinch :: German; German Informal
brechtgijsens :: Dutch
Lowkey (v587ygq) :: Chinese Simplified
sdl-blue :: German Informal
sqlik :: Polish
Roy van Schaijk (royvanschaijk) :: Dutch
Simsimpicpic :: French
Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
tatsuya.info :: Japanese
fadiapp :: Arabic
Jakub “Jéžiš” Bouček (jakubboucek) :: Czech

View File

@ -4,6 +4,7 @@ use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Entity;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class ActivityService
{
@ -49,7 +50,7 @@ class ActivityService
protected function newActivityForUser(string $key, ?int $bookId = null): Activity
{
return $this->activity->newInstance()->forceFill([
'key' => strtolower($key),
'key' => strtolower($key),
'user_id' => $this->user->id,
'book_id' => $bookId ?? 0,
]);
@ -64,8 +65,8 @@ class ActivityService
{
$activities = $entity->activity()->get();
$entity->activity()->update([
'extra' => $entity->name,
'entity_id' => 0,
'extra' => $entity->name,
'entity_id' => 0,
'entity_type' => '',
]);
return $activities;
@ -159,4 +160,20 @@ class ActivityService
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace("%u", $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@ -9,6 +9,7 @@ use BookStack\Model;
class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type'];
/**
* Get the entity that this tag belongs to

View File

@ -2,71 +2,31 @@
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Entity;
use DB;
use Illuminate\Support\Collection;
/**
* Class TagRepo
* @package BookStack\Repos
*/
class TagRepo
{
protected $tag;
protected $entity;
protected $permissionService;
/**
* TagRepo constructor.
* @param \BookStack\Actions\Tag $attr
* @param \BookStack\Entities\Entity $ent
* @param \BookStack\Auth\Permissions\PermissionService $ps
*/
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
public function __construct(Tag $tag, PermissionService $ps)
{
$this->tag = $attr;
$this->entity = $ent;
$this->tag = $tag;
$this->permissionService = $ps;
}
/**
* Get an entity instance of its particular type.
* @param $entityType
* @param $entityId
* @param string $action
* @return \Illuminate\Database\Eloquent\Model|null|static
*/
public function getEntity($entityType, $entityId, $action = 'view')
{
$entityInstance = $this->entity->getEntityInstance($entityType);
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
return $searchQuery->first();
}
/**
* Get all tags for a particular entity.
* @param string $entityType
* @param int $entityId
* @return mixed
*/
public function getForEntity($entityType, $entityId)
{
$entity = $this->getEntity($entityType, $entityId);
if ($entity === null) {
return collect();
}
return $entity->tags;
}
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
* @param $searchTerm
* @return array
*/
public function getNameSuggestions($searchTerm = false)
public function getNameSuggestions(?string $searchTerm): Collection
{
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@ -82,13 +42,10 @@ class TagRepo
* Get tag value suggestions from scanning existing tag values.
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
* @param $searchTerm
* @param $tagName
* @return array
*/
public function getValueSuggestions($searchTerm = false, $tagName = false)
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
@ -96,7 +53,7 @@ class TagRepo
$query = $query->orderBy('count', 'desc')->take(50);
}
if ($tagName !== false) {
if ($tagName) {
$query = $query->where('name', '=', $tagName);
}
@ -106,35 +63,28 @@ class TagRepo
/**
* Save an array of tags to an entity
* @param \BookStack\Entities\Entity $entity
* @param array $tags
* @return array|\Illuminate\Database\Eloquent\Collection
*/
public function saveTagsToEntity(Entity $entity, $tags = [])
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
{
$entity->tags()->delete();
$newTags = [];
foreach ($tags as $tag) {
if (trim($tag['name']) === '') {
continue;
}
$newTags[] = $this->newInstanceFromInput($tag);
}
$newTags = collect($tags)->filter(function ($tag) {
return boolval(trim($tag['name']));
})->map(function ($tag) {
return $this->newInstanceFromInput($tag);
})->all();
return $entity->tags()->saveMany($newTags);
}
/**
* Create a new Tag instance from user input.
* @param $input
* @return \BookStack\Actions\Tag
* Input must be an array with a 'name' and an optional 'value' key.
*/
protected function newInstanceFromInput($input)
protected function newInstanceFromInput(array $input): Tag
{
$name = trim($input['name']);
$value = isset($input['value']) ? trim($input['value']) : '';
// Any other modification or cleanup required can go here
$values = ['name' => $name, 'value' => $value];
return $this->tag->newInstance($values);
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
}
}

View File

@ -3,6 +3,8 @@
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ExternalAuthService
{
@ -39,22 +41,14 @@ class ExternalAuthService
/**
* Match an array of group names to BookStack system roles.
* Formats group names to be lower-case and hyphenated.
* @param array $groupNames
* @return \Illuminate\Support\Collection
*/
protected function matchGroupsToSystemsRoles(array $groupNames)
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
$query->whereIn('name', $groupNames);
foreach ($groupNames as $groupName) {
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
}
})->get();
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});

View File

@ -71,15 +71,15 @@ class RegistrationService
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();
$message = '';
try {
$this->emailConfirmationService->sendConfirmation($newUser);
session()->flash('sent-email-confirmation', true);
} catch (Exception $e) {
$message = trans('auth.email_confirm_send_error');
throw new UserRegistrationException($message, '/register/confirm');
}
throw new UserRegistrationException($message, '/register/confirm');
}
return $newUser;

View File

@ -311,7 +311,6 @@ class Saml2Service extends ExternalAuthService
/**
* Get the user from the database for the specified details.
* @throws SamlException
* @throws UserRegistrationException
*/
protected function getOrRegisterUser(array $userDetails): ?User

View File

@ -3,25 +3,26 @@
use BookStack\Auth\Role;
use BookStack\Entities\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class JointPermission extends Model
{
protected $primaryKey = null;
public $timestamps = false;
/**
* Get the role that this points to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role()
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* Get the entity this points to.
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function entity()
public function entity(): MorphOne
{
return $this->morphOne(Entity::class, 'entity');
}

View File

@ -1,8 +1,9 @@
<?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class PermissionsRepo
@ -16,11 +17,8 @@ class PermissionsRepo
/**
* PermissionsRepo constructor.
* @param RolePermission $permission
* @param Role $role
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
*/
public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
{
$this->permission = $permission;
$this->role = $role;
@ -29,46 +27,34 @@ class PermissionsRepo
/**
* Get all the user roles from the system.
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getAllRoles()
public function getAllRoles(): Collection
{
return $this->role->all();
}
/**
* Get all the roles except for the provided one.
* @param Role $role
* @return mixed
*/
public function getAllRolesExcept(Role $role)
public function getAllRolesExcept(Role $role): Collection
{
return $this->role->where('id', '!=', $role->id)->get();
}
/**
* Get a role via its ID.
* @param $id
* @return mixed
*/
public function getRoleById($id)
public function getRoleById($id): Role
{
return $this->role->findOrFail($id);
return $this->role->newQuery()->findOrFail($id);
}
/**
* Save a new role into the system.
* @param array $roleData
* @return Role
*/
public function saveNewRole($roleData)
public function saveNewRole(array $roleData): Role
{
$role = $this->role->newInstance($roleData);
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
// Prevent duplicate names
while ($this->role->where('name', '=', $role->name)->count() > 0) {
$role->name .= strtolower(Str::random(2));
}
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
@ -80,13 +66,11 @@ class PermissionsRepo
/**
* Updates an existing role.
* Ensure Admin role always have core permissions.
* @param $roleId
* @param $roleData
* @throws PermissionsException
*/
public function updateRole($roleId, $roleData)
public function updateRole($roleId, array $roleData)
{
$role = $this->role->findOrFail($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') {
@ -108,16 +92,19 @@ class PermissionsRepo
/**
* Assign an list of permission names to an role.
* @param Role $role
* @param array $permissionNameArray
*/
public function assignRolePermissions(Role $role, $permissionNameArray = [])
public function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray && count($permissionNameArray) > 0) {
$permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
if ($permissionNameArray) {
$permissions = $this->permission->newQuery()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
}
$role->permissions()->sync($permissions);
}
@ -126,13 +113,13 @@ class PermissionsRepo
* Check it's not an admin role or set as default before deleting.
* If an migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id.
* @param $roleId
* @param $migrateRoleId
* @throws PermissionsException
* @throws Exception
*/
public function deleteRole($roleId, $migrateRoleId)
{
$role = $this->role->findOrFail($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
@ -142,9 +129,9 @@ class PermissionsRepo
}
if ($migrateRoleId) {
$newRole = $this->role->find($migrateRoleId);
$newRole = $this->role->newQuery()->find($migrateRoleId);
if ($newRole) {
$users = $role->users->pluck('id')->toArray();
$users = $role->users()->pluck('id')->toArray();
$newRole->users()->sync($users);
}
}

View File

@ -3,6 +3,9 @@
use BookStack\Auth\Role;
use BookStack\Model;
/**
* @property int $id
*/
class RolePermission extends Model
{
/**

View File

@ -3,13 +3,16 @@
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class Role
* @property int $id
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @package BookStack\Auth
* @property string $system_name
*/
class Role extends Model
{
@ -26,9 +29,8 @@ class Role extends Model
/**
* Get all related JointPermissions.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function jointPermissions()
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class);
}
@ -43,10 +45,8 @@ class Role extends Model
/**
* Check if this role has a permission.
* @param $permissionName
* @return bool
*/
public function hasPermission($permissionName)
public function hasPermission(string $permissionName): bool
{
$permissions = $this->getRelationValue('permissions');
foreach ($permissions as $permission) {
@ -59,7 +59,6 @@ class Role extends Model
/**
* Add a permission to this role.
* @param RolePermission $permission
*/
public function attachPermission(RolePermission $permission)
{
@ -68,7 +67,6 @@ class Role extends Model
/**
* Detach a single permission from this role.
* @param RolePermission $permission
*/
public function detachPermission(RolePermission $permission)
{
@ -76,39 +74,33 @@ class Role extends Model
}
/**
* Get the role object for the specified role.
* @param $roleName
* @return Role
* Get the role of the specified display name.
*/
public static function getRole($roleName)
public static function getRole(string $displayName): ?Role
{
return static::query()->where('name', '=', $roleName)->first();
return static::query()->where('display_name', '=', $displayName)->first();
}
/**
* Get the role object for the specified system role.
* @param $roleName
* @return Role
*/
public static function getSystemRole($roleName)
public static function getSystemRole(string $systemName): ?Role
{
return static::query()->where('system_name', '=', $roleName)->first();
return static::query()->where('system_name', '=', $systemName)->first();
}
/**
* Get all visible roles
* @return mixed
*/
public static function visible()
public static function visible(): Collection
{
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
/**
* Get the roles that can be restricted.
* @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
*/
public static function restrictable()
public static function restrictable(): Collection
{
return static::query()->where('system_name', '!=', 'admin')->get();
}

View File

@ -49,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at',
'created_at', 'updated_at', 'image_id',
];
/**
@ -101,12 +101,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Check if the user has a role.
* @param $role
* @return mixed
*/
public function hasRole($role)
public function hasRole($roleId): bool
{
return $this->roles->pluck('name')->contains($role);
return $this->roles->pluck('id')->contains($roleId);
}
/**
@ -163,7 +161,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Attach a role to this user.
* @param Role $role
*/
public function attachRole(Role $role)
{

View File

@ -238,7 +238,7 @@ class UserRepo
*/
public function getAllRoles()
{
return $this->role->newQuery()->orderBy('name', 'asc')->get();
return $this->role->newQuery()->orderBy('display_name', 'asc')->get();
}
/**

View File

@ -52,7 +52,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
'locales' => ['en', 'ar', 'bg', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@ -1,5 +1,7 @@
<?php
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
@ -73,10 +75,38 @@ return [
'level' => 'debug',
],
// Custom errorlog implementation that logs out a plain,
// non-formatted message intended for the webserver log.
'errorlog_plain_webserver' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => ErrorLogHandler::class,
'handler_with' => [4],
'formatter' => LineFormatter::class,
'formatter_with' => [
'format' => "%message%",
],
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
// Testing channel
// Uses a shared testing instance during tests
// so that logs can be checked against.
'testing' => [
'driver' => 'testing',
],
],
// Failed Login Message
// Allows a configurable message to be logged when a login request fails.
'failed_login' => [
'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
],
];

View File

@ -101,7 +101,7 @@ return [
'url' => env('SAML2_IDP_SLO', null),
// URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
// if not set, url for the SLO Request will be used
'responseUrl' => '',
'responseUrl' => null,
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only

View File

@ -19,7 +19,7 @@ class Book extends Entity implements HasCoverImage
public $searchFactor = 2;
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot'];
protected $hidden = ['restricted', 'pivot', 'image_id'];
/**
* Get the url for this book.

View File

@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['restricted'];
protected $hidden = ['restricted', 'image_id'];
/**
* Get the books in this shelf.

View File

@ -12,6 +12,7 @@ class Chapter extends BookChild
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $hidden = ['restricted', 'pivot'];
/**
* Get the pages that this chapter contains.

View File

@ -238,10 +238,8 @@ class Entity extends Ownable
/**
* Gets a limited-length version of the entities name.
* @param int $length
* @return string
*/
public function getShortName($length = 25)
public function getShortName(int $length = 25): string
{
if (mb_strlen($this->name) <= $length) {
return $this->name;
@ -288,7 +286,7 @@ class Entity extends Ownable
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity($this);
Permissions::buildJointPermissionsForEntity(clone $this);
}
/**
@ -297,7 +295,7 @@ class Entity extends Ownable
public function indexForSearch()
{
$searchService = app()->make(SearchService::class);
$searchService->indexEntity($this);
$searchService->indexEntity(clone $this);
}
/**

View File

@ -108,7 +108,7 @@ class PageContent
protected function toPlainText(): string
{
$html = $this->render(true);
return strip_tags($html);
return html_entity_decode(strip_tags($html));
}
/**

View File

@ -21,12 +21,14 @@ use Permissions;
*/
class Page extends BookChild
{
protected $fillable = ['name', 'html', 'priority', 'markdown'];
protected $fillable = ['name', 'priority', 'markdown'];
protected $simpleAttributes = ['name', 'id', 'slug'];
public $textField = 'text';
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
/**
* Get the entities that are visible to the current user.
*/

View File

@ -180,12 +180,11 @@ class PageRepo
$page->template = ($input['template'] === 'true');
}
$pageContent = new PageContent($page);
$pageContent->setNewHTML($input['html']);
$this->baseRepo->update($page, $input);
// Update with new details
$page->fill($input);
$pageContent = new PageContent($page);
$pageContent->setNewHTML($input['html']);
$page->revision_count++;
if (setting('app-editor') !== 'markdown') {
@ -211,7 +210,7 @@ class PageRepo
*/
protected function savePageRevision(Page $page, string $summary = null)
{
$revision = new PageRevision($page->toArray());
$revision = new PageRevision($page->getAttributes());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
@ -279,7 +278,7 @@ class PageRepo
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
$content = new PageContent($page);
$content->setNewHTML($page->html);
$content->setNewHTML($revision->html);
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();

View File

@ -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;
}
}

View File

@ -39,10 +39,6 @@ class SearchService
/**
* SearchService constructor.
* @param SearchTerm $searchTerm
* @param EntityProvider $entityProvider
* @param Connection $db
* @param PermissionService $permissionService
*/
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
{
@ -54,7 +50,6 @@ class SearchService
/**
* Set the database connection
* @param Connection $connection
*/
public function setConnection(Connection $connection)
{
@ -63,23 +58,18 @@ class SearchService
/**
* Search all entities in the system.
* @param string $searchString
* @param string $entityType
* @param int $page
* @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
* @param string $action
* @return array[int, Collection];
* The provided count is for each entity to search,
* Total returned could can be larger and not guaranteed.
*/
public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
{
$terms = $this->parseSearchString($searchString);
$entityTypes = array_keys($this->entityProvider->all());
$entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') {
$entityTypesToSearch = $entityType;
} else if (isset($terms['filters']['type'])) {
$entityTypesToSearch = explode('|', $terms['filters']['type']);
} else if (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
}
$results = collect();
@ -90,8 +80,8 @@ class SearchService
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
if ($entityTotal > $page * $count) {
$hasMore = true;
}
@ -103,29 +93,26 @@ class SearchService
'total' => $total,
'count' => count($results),
'has_more' => $hasMore,
'results' => $results->sortByDesc('score')->values()
'results' => $results->sortByDesc('score')->values(),
];
}
/**
* Search a book for entities
* @param integer $bookId
* @param string $searchString
* @return Collection
*/
public function searchBook($bookId, $searchString)
public function searchBook(int $bookId, string $searchString): Collection
{
$terms = $this->parseSearchString($searchString);
$opts = SearchOptions::fromString($searchString);
$entityTypes = ['page', 'chapter'];
$entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
$results = collect();
foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search);
}
return $results->sortByDesc('score')->take(20);
@ -133,30 +120,23 @@ class SearchService
/**
* Search a book for entities
* @param integer $chapterId
* @param string $searchString
* @return Collection
*/
public function searchChapter($chapterId, $searchString)
public function searchChapter(int $chapterId, string $searchString): Collection
{
$terms = $this->parseSearchString($searchString);
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
$opts = SearchOptions::fromString($searchString);
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
return $pages->sortByDesc('score');
}
/**
* Search across a particular entity type.
* @param array $terms
* @param string $entityType
* @param int $page
* @param int $count
* @param string $action
* @param bool $getCount Return the total count of the search
* Setting getCount = true will return the total
* matching instead of the items themselves.
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/
public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
{
$query = $this->buildEntitySearchQuery($terms, $entityType, $action);
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
if ($getCount) {
return $query->count();
}
@ -167,22 +147,18 @@ class SearchService
/**
* Create a search query for an entity
* @param array $terms
* @param string $entityType
* @param string $action
* @return EloquentBuilder
*/
protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
{
$entity = $this->entityProvider->get($entityType);
$entitySelect = $entity->newQuery();
// Handle normal search terms
if (count($terms['search']) > 0) {
if (count($searchOpts->searches) > 0) {
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
$subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms['search'] as $inputTerm) {
$subQuery->where(function (Builder $query) use ($searchOpts) {
foreach ($searchOpts->searches as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm .'%');
}
})->groupBy('entity_type', 'entity_id');
@ -193,9 +169,9 @@ class SearchService
}
// Handle exact term matching
if (count($terms['exact']) > 0) {
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
foreach ($terms['exact'] as $inputTerm) {
if (count($searchOpts->exacts) > 0) {
$entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
foreach ($searchOpts->exacts as $inputTerm) {
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
$query->where('name', 'like', '%'.$inputTerm .'%')
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
@ -205,12 +181,12 @@ class SearchService
}
// Handle tag searches
foreach ($terms['tags'] as $inputTerm) {
foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm);
}
// Handle filters
foreach ($terms['filters'] as $filterTerm => $filterValue) {
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm);
if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue);
@ -220,60 +196,10 @@ class SearchService
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
}
/**
* Parse a search string into components.
* @param $searchString
* @return array
*/
protected function parseSearchString($searchString)
{
$terms = [
'search' => [],
'exact' => [],
'tags' => [],
'filters' => []
];
$patterns = [
'exact' => '/"(.*?)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/'
];
// Parse special terms
foreach ($patterns as $termType => $pattern) {
$matches = [];
preg_match_all($pattern, $searchString, $matches);
if (count($matches) > 0) {
$terms[$termType] = $matches[1];
$searchString = preg_replace($pattern, '', $searchString);
}
}
// Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) {
if ($searchTerm !== '') {
$terms['search'][] = $searchTerm;
}
}
// Split filter values out
$splitFilters = [];
foreach ($terms['filters'] as $filter) {
$explodedFilter = explode(':', $filter, 2);
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
}
$terms['filters'] = $splitFilters;
return $terms;
}
/**
* Get the available query operators as a regex escaped list.
* @return mixed
*/
protected function getRegexEscapedOperators()
protected function getRegexEscapedOperators(): string
{
$escapedOperators = [];
foreach ($this->queryOperators as $operator) {
@ -284,11 +210,8 @@ class SearchService
/**
* Apply a tag search term onto a entity query.
* @param EloquentBuilder $query
* @param string $tagTerm
* @return mixed
*/
protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
{
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
@ -318,7 +241,6 @@ class SearchService
/**
* Index the given entity.
* @param Entity $entity
*/
public function indexEntity(Entity $entity)
{

View File

@ -1,5 +1,7 @@
<?php namespace BookStack\Entities;
use Illuminate\Support\Str;
class SlugGenerator
{
@ -32,9 +34,7 @@ class SlugGenerator
*/
protected function formatNameAsSlug(string $name): string
{
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
$slug = preg_replace('/\s{2,}/', ' ', $slug);
$slug = str_replace(' ', '-', $slug);
$slug = Str::slug($name);
if ($slug === "") {
$slug = substr(md5(rand(1, 500)), 0, 5);
}

View File

@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -26,6 +25,7 @@ class Handler extends ExceptionHandler
HttpException::class,
ModelNotFoundException::class,
ValidationException::class,
NotFoundException::class,
];
/**

View File

@ -8,7 +8,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BooksApiController extends ApiController
class BookApiController extends ApiController
{
protected $bookRepo;
@ -17,10 +17,12 @@ class BooksApiController extends ApiController
'create' => [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
'update' => [
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
];

View File

@ -5,9 +5,8 @@ use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class BooksExportApiController extends ApiController
class BookExportApiController extends ApiController
{
protected $bookRepo;
protected $exportService;

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -8,6 +8,7 @@ use BookStack\Uploads\AttachmentService;
use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\MessageBag;
use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
@ -60,26 +61,18 @@ class AttachmentController extends Controller
/**
* Update an uploaded attachment.
* @throws ValidationException
* @throws NotFoundException
*/
public function uploadUpdate(Request $request, $attachmentId)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
$this->checkOwnablePermission('view', $attachment->page);
$this->checkOwnablePermission('page-update', $attachment->page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError(trans('errors.attachment_page_mismatch'));
}
$uploadedFile = $request->file('file');
try {
@ -92,57 +85,87 @@ class AttachmentController extends Controller
}
/**
* Update the details of an existing file.
* @throws ValidationException
* @throws NotFoundException
* Get the update form for an attachment.
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function update(Request $request, $attachmentId)
public function getUpdateForm(string $attachmentId)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'string|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-update', $attachment->page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError(trans('errors.attachment_page_mismatch'));
return view('attachments.manager-edit-form', [
'attachment' => $attachment,
]);
}
/**
* 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);
}
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
return response()->json($attachment);
$this->checkOwnablePermission('view', $attachment->page);
$this->checkOwnablePermission('page-update', $attachment->page);
$this->checkOwnablePermission('attachment-create', $attachment);
$attachment = $this->attachmentService->updateFile($attachment, [
'name' => $request->get('attachment_edit_name'),
'link' => $request->get('attachment_edit_url'),
]);
return view('attachments.manager-edit-form', [
'attachment' => $attachment,
]);
}
/**
* Attach a link to a page.
* @throws ValidationException
* @throws NotFoundException
*/
public function attachLink(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'required|string|min:1|max:255'
]);
$pageId = $request->get('attachment_link_uploaded_to');
try {
$this->validate($request, [
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
'attachment_link_name' => 'required|string|min:1|max:255',
'attachment_link_url' => 'required|string|min:1|max:255'
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
'pageId' => $pageId,
'errors' => new MessageBag($exception->errors()),
]), 422);
}
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$attachmentName = $request->get('name');
$link = $request->get('link');
$attachmentName = $request->get('attachment_link_name');
$link = $request->get('attachment_link_url');
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
return response()->json($attachment);
return view('attachments.manager-link-form', [
'pageId' => $pageId,
]);
}
/**
@ -152,7 +175,9 @@ class AttachmentController extends Controller
{
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
return view('attachments.manager-list', [
'attachments' => $page->attachments->all(),
]);
}
/**
@ -163,14 +188,13 @@ class AttachmentController extends Controller
public function sortForPage(Request $request, int $pageId)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
'order' => 'required|array',
]);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
$attachmentOrder = $request->get('order');
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
return response()->json(['message' => trans('entities.attachments_order_updated')]);
}
@ -179,7 +203,7 @@ class AttachmentController extends Controller
* @throws FileNotFoundException
* @throws NotFoundException
*/
public function get(int $attachmentId)
public function get(string $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
try {
@ -200,11 +224,9 @@ class AttachmentController extends Controller
/**
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
* @throws Exception
*/
public function delete(int $attachmentId)
public function delete(string $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);

View File

@ -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,
]);
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers\Auth;
use Activity;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
@ -76,9 +77,13 @@ class LoginController extends Controller
]);
}
// Store the previous location for redirect after login
$previous = url()->previous('');
if (setting('app-public') && $previous && $previous !== url('/login')) {
redirect()->setIntendedUrl($previous);
if ($previous && $previous !== url('/login') && setting('app-public')) {
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
if ($isPreviousFromInstance) {
redirect()->setIntendedUrl($previous);
}
}
return view('auth.login', [
@ -98,6 +103,7 @@ class LoginController extends Controller
public function login(Request $request)
{
$this->validateLogin($request);
$username = $request->get($this->username());
// If the class is using the ThrottlesLogins trait, we can automatically throttle
// the login attempts for this application. We'll key this by the username and
@ -106,6 +112,7 @@ class LoginController extends Controller
$this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
Activity::logFailedLogin($username);
return $this->sendLockoutResponse($request);
}
@ -114,6 +121,7 @@ class LoginController extends Controller
return $this->sendLoginResponse($request);
}
} catch (LoginAttemptException $exception) {
Activity::logFailedLogin($username);
return $this->sendLoginAttemptExceptionResponse($exception, $request);
}
@ -122,6 +130,7 @@ class LoginController extends Controller
// user surpasses their maximum number of attempts they will get locked out.
$this->incrementLoginAttempts($request);
Activity::logFailedLogin($username);
return $this->sendFailedLoginResponse($request);
}

View File

@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Validation\ValidationException;
abstract class Controller extends BaseController
{
@ -132,23 +133,6 @@ abstract class Controller extends BaseController
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
}
/**
* Create the response for when a request fails validation.
* @param \Illuminate\Http\Request $request
* @param array $errors
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function buildFailedValidationResponse(Request $request, array $errors)
{
if ($request->expectsJson()) {
return response()->json(['validation' => $errors], 422);
}
return redirect()->to($this->getRedirectUrl())
->withInput($request->input())
->withErrors($errors, $this->errorBag());
}
/**
* Create a response that forces a download in the browser.
* @param string $content

View File

@ -30,7 +30,10 @@ class DrawioImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return response()->json($imgData);
return view('components.image-manager-list', [
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
}
/**
@ -72,6 +75,7 @@ class DrawioImageController extends Controller
if ($imageData === null) {
return $this->jsonError("Image data could not be found");
}
return response()->json([
'content' => base64_encode($imageData)
]);

View File

@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
use BookStack\Http\Controllers\Controller;
use Illuminate\Validation\ValidationException;
class GalleryImageController extends Controller
{
@ -13,7 +14,6 @@ class GalleryImageController extends Controller
/**
* GalleryImageController constructor.
* @param ImageRepo $imageRepo
*/
public function __construct(ImageRepo $imageRepo)
{
@ -24,8 +24,6 @@ class GalleryImageController extends Controller
/**
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function list(Request $request)
{
@ -35,14 +33,15 @@ class GalleryImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return response()->json($imgData);
return view('components.image-manager-list', [
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
}
/**
* Store a new gallery image in the system.
* @param Request $request
* @return Illuminate\Http\JsonResponse
* @throws \Exception
* @throws ValidationException
*/
public function create(Request $request)
{

View File

@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller;
use BookStack\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Filesystem\Filesystem as File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ImageController extends Controller
{
@ -17,9 +20,6 @@ class ImageController extends Controller
/**
* ImageController constructor.
* @param Image $image
* @param File $file
* @param ImageRepo $imageRepo
*/
public function __construct(Image $image, File $file, ImageRepo $imageRepo)
{
@ -31,8 +31,6 @@ class ImageController extends Controller
/**
* Provide an image file from storage.
* @param string $path
* @return mixed
*/
public function showImage(string $path)
{
@ -47,13 +45,10 @@ class ImageController extends Controller
/**
* Update image details
* @param Request $request
* @param integer $id
* @return \Illuminate\Http\JsonResponse
* @throws ImageUploadException
* @throws \Exception
* @throws ValidationException
*/
public function update(Request $request, $id)
public function update(Request $request, string $id)
{
$this->validate($request, [
'name' => 'required|min:2|string'
@ -64,47 +59,50 @@ class ImageController extends Controller
$this->checkOwnablePermission('image-update', $image);
$image = $this->imageRepo->updateImageDetails($image, $request->all());
return response()->json($image);
$this->imageRepo->loadThumbs($image);
return view('components.image-manager-form', [
'image' => $image,
'dependantPages' => null,
]);
}
/**
* Show the usage of an image on pages.
* Get the form for editing the given image.
* @throws Exception
*/
public function usage(int $id)
public function edit(Request $request, string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
if ($request->has('delete')) {
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
}
$result = count($pages) > 0 ? $pages : false;
return response()->json($result);
$this->imageRepo->loadThumbs($image);
return view('components.image-manager-form', [
'image' => $image,
'dependantPages' => $dependantPages ?? null,
]);
}
/**
* Deletes an image and all thumbnail/image files
* @param int $id
* @return \Illuminate\Http\JsonResponse
* @throws \Exception
* @throws Exception
*/
public function destroy($id)
public function destroy(string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkOwnablePermission('image-delete', $image);
$this->checkImagePermission($image);
$this->imageRepo->destroyImage($image);
return response()->json(trans('components.images_deleted'));
return response('');
}
/**
* Check related page permission and ensure type is drawio or gallery.
* @param Image $image
*/
protected function checkImagePermission(Image $image)
{

View File

@ -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();
}
}

View File

@ -163,6 +163,8 @@ class PageController extends Controller
public function getPageAjax(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->addHidden(['book']);
return response()->json($page);
}

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
@ -46,6 +47,9 @@ class PageRevisionController extends Controller
}
$page->fill($revision->toArray());
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [
@ -73,6 +77,9 @@ class PageRevisionController extends Controller
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
$page->fill($revision->toArray());
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
return view('pages.revision', [

View File

@ -2,7 +2,9 @@
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PermissionController extends Controller
{
@ -11,7 +13,6 @@ class PermissionController extends Controller
/**
* PermissionController constructor.
* @param \BookStack\Auth\Permissions\PermissionsRepo $permissionsRepo
*/
public function __construct(PermissionsRepo $permissionsRepo)
{
@ -31,7 +32,6 @@ class PermissionController extends Controller
/**
* Show the form to create a new role
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function createRole()
{
@ -41,15 +41,13 @@ class PermissionController extends Controller
/**
* Store a new role in the system.
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function storeRole(Request $request)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => 'required|min:3|max:200',
'description' => 'max:250'
'display_name' => 'required|min:3|max:180',
'description' => 'max:180'
]);
$this->permissionsRepo->saveNewRole($request->all());
@ -59,11 +57,9 @@ class PermissionController extends Controller
/**
* Show the form for editing a user role.
* @param $id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws PermissionsException
*/
public function editRole($id)
public function editRole(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
@ -75,18 +71,14 @@ class PermissionController extends Controller
/**
* Updates a user role.
* @param Request $request
* @param $id
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws PermissionsException
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function updateRole(Request $request, $id)
public function updateRole(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => 'required|min:3|max:200',
'description' => 'max:250'
'display_name' => 'required|min:3|max:180',
'description' => 'max:180'
]);
$this->permissionsRepo->updateRole($id, $request->all());
@ -97,10 +89,8 @@ class PermissionController extends Controller
/**
* Show the view to delete a role.
* Offers the chance to migrate users.
* @param $id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function showDeleteRole($id)
public function showDeleteRole(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
@ -113,11 +103,9 @@ class PermissionController extends Controller
/**
* Delete a role from the system,
* Migrate from a previous role if set.
* @param Request $request
* @param $id
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws Exception
*/
public function deleteRole(Request $request, $id)
public function deleteRole(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');

View File

@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
use BookStack\Entities\Entity;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\SearchService;
use BookStack\Entities\SearchOptions;
use Illuminate\Http\Request;
class SearchController extends Controller
@ -33,20 +34,22 @@ class SearchController extends Controller
*/
public function search(Request $request)
{
$searchTerm = $request->get('term');
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
$searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString();
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
$page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
$results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
return view('search.all', [
'entities' => $results['results'],
'totalResults' => $results['total'],
'searchTerm' => $searchTerm,
'searchTerm' => $fullSearchString,
'hasNextPage' => $results['has_more'],
'nextPageLink' => $nextPageLink
'nextPageLink' => $nextPageLink,
'options' => $searchOpts,
]);
}
@ -84,7 +87,7 @@ class SearchController extends Controller
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
$entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results'];
$entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else {
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
}

View File

@ -1,9 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Auth\User;
use BookStack\Notifications\TestEmail;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
class SettingController extends Controller
@ -74,63 +72,4 @@ class SettingController extends Controller
$redirectLocation = '/settings#' . $request->get('section', '');
return redirect(rtrim($redirectLocation, '#'));
}
/**
* Show the page for application maintenance.
*/
public function showMaintenance()
{
$this->checkPermission('settings-manage');
$this->setPageTitle(trans('settings.maint'));
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.maintenance', ['version' => $version]);
}
/**
* Action to clean-up images in the system.
*/
public function cleanupImages(Request $request, ImageService $imageService)
{
$this->checkPermission('settings-manage');
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
$dryRun = !($request->has('confirm'));
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
$deleteCount = count($imagesToDelete);
if ($deleteCount === 0) {
$this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
return redirect('/settings/maintenance')->withInput();
}
if ($dryRun) {
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
} else {
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
/**
* Action to send a test e-mail to the current user.
*/
public function sendTestEmail()
{
$this->checkPermission('settings-manage');
try {
user()->notify(new TestEmail());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
$this->showErrorNotification($errorMessage);
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
}

View File

@ -10,7 +10,6 @@ class TagController extends Controller
/**
* TagController constructor.
* @param $tagRepo
*/
public function __construct(TagRepo $tagRepo)
{
@ -18,39 +17,23 @@ class TagController extends Controller
parent::__construct();
}
/**
* Get all the Tags for a particular entity
* @param $entityType
* @param $entityId
* @return \Illuminate\Http\JsonResponse
*/
public function getForEntity($entityType, $entityId)
{
$tags = $this->tagRepo->getForEntity($entityType, $entityId);
return response()->json($tags);
}
/**
* Get tag name suggestions from a given search term.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getNameSuggestions(Request $request)
{
$searchTerm = $request->get('search', false);
$searchTerm = $request->get('search', null);
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions);
}
/**
* Get tag value suggestions from a given search term.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getValueSuggestions(Request $request)
{
$searchTerm = $request->get('search', false);
$tagName = $request->get('name', false);
$searchTerm = $request->get('search', null);
$tagName = $request->get('name', null);
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);
}

View File

@ -66,8 +66,8 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$validationRules = [
'name' => 'required',
'email' => 'required|email|unique:users,email'
'name' => 'required',
'email' => 'required|email|unique:users,email'
];
$authMethod = config('auth.method');

View File

@ -35,9 +35,9 @@ class ApiAuthenticate
{
// Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser()) {
if (signedInUser() || session()->isStarted()) {
$this->ensureEmailConfirmedIfRequested();
if (!auth()->user()->can('access-api')) {
if (!user()->can('access-api')) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
return;

View File

@ -44,6 +44,10 @@ class Authenticate
], 401);
}
if (session()->get('sent-email-confirmation') === true) {
return redirect('/register/confirm');
}
return redirect('/register/confirm/awaiting');
}
}

View File

@ -19,6 +19,7 @@ class Localization
*/
protected $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',

View File

@ -3,6 +3,13 @@
use BookStack\Entities\Page;
use BookStack\Ownable;
/**
* @property int id
* @property string name
* @property string path
* @property string extension
* @property bool external
*/
class Attachment extends Ownable
{
protected $fillable = ['name', 'order'];
@ -30,13 +37,28 @@ class Attachment extends Ownable
/**
* Get the url of this file.
* @return string
*/
public function getUrl()
public function getUrl(): string
{
if ($this->external && strpos($this->path, 'http') !== 0) {
return $this->path;
}
return url('/attachments/' . $this->id);
}
/**
* Generate a HTML link to this attachment.
*/
public function htmlLink(): string
{
return '<a target="_blank" href="'.e($this->getUrl()).'">'.e($this->name).'</a>';
}
/**
* Generate a markdown link to this attachment.
*/
public function markdownLink(): string
{
return '['. $this->name .']('. $this->getUrl() .')';
}
}

View File

@ -109,14 +109,14 @@ class AttachmentService extends UploadService
}
/**
* Updates the file ordering for a listing of attached files.
* @param array $attachmentList
* @param $pageId
* Updates the ordering for a listing of attached files.
*/
public function updateFileOrderWithinPage($attachmentList, $pageId)
public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
{
foreach ($attachmentList as $index => $attachment) {
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
foreach ($attachmentOrder as $index => $attachmentId) {
Attachment::query()->where('uploaded_to', '=', $pageId)
->where('id', '=', $attachmentId)
->update(['order' => $index]);
}
}

View File

@ -185,7 +185,7 @@ class ImageRepo
* Load thumbnails onto an image object.
* @throws Exception
*/
protected function loadThumbs(Image $image)
public function loadThumbs(Image $image)
{
$image->thumbs = [
'gallery' => $this->getThumbnail($image, 150, 150, false),
@ -219,4 +219,20 @@ class ImageRepo
return null;
}
}
/**
* Get the user visible pages using the given image.
*/
public function getPagesUsingImage(Image $image): array
{
$pages = Page::visible()
->where('html', 'like', '%' . $image->url . '%')
->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
}
return $pages->all();
}
}

View File

@ -124,29 +124,24 @@ class ImageService extends UploadService
}
/**
* Saves a new image
* @param string $imageName
* @param string $imageData
* @param string $type
* @param int $uploadedTo
* @return Image
* Save a new image into storage.
* @throws ImageUploadException
*/
private function saveNew($imageName, $imageData, $type, $uploadedTo = 0)
private function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$storage = $this->getStorage($type);
$secureUploads = setting('app-secure-images');
$imageName = str_replace(' ', '-', $imageName);
$fileName = $this->cleanImageFileName($imageName);
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
while ($storage->exists($imagePath . $imageName)) {
$imageName = Str::random(3) . $imageName;
while ($storage->exists($imagePath . $fileName)) {
$fileName = Str::random(3) . $fileName;
}
$fullPath = $imagePath . $imageName;
$fullPath = $imagePath . $fileName;
if ($secureUploads) {
$fullPath = $imagePath . Str::random(16) . '-' . $imageName;
$fullPath = $imagePath . Str::random(16) . '-' . $fileName;
}
try {
@ -175,6 +170,23 @@ class ImageService extends UploadService
return $image;
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
protected function cleanImageFileName(string $name): string
{
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('.', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {
$name = Str::random(10);
}
return $name . '.' . $extension;
}
/**
* Checks if the image is a gif. Returns true if it is, else false.
@ -223,6 +235,7 @@ class ImageService extends UploadService
$storage->setVisibility($thumbFilePath, 'public');
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
@ -292,11 +305,9 @@ class ImageService extends UploadService
/**
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path..
* @param string $path
* @return bool
* Searches for image thumbnails in addition to main provided path.
*/
protected function destroyImagesFromPath(string $path)
protected function destroyImagesFromPath(string $path): bool
{
$storage = $this->getStorage();
@ -306,8 +317,7 @@ class ImageService extends UploadService
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
$expectedIndex = strlen($imagePath) - strlen($imageFileName);
return strpos($imagePath, $imageFileName) === $expectedIndex;
return basename($imagePath) === $imageFileName;
});
$storage->delete($imagesToDelete->all());

View File

@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string
* Generate a url with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
* @param string $path
* @param array $data
* @param array $overrideData
* @return string
*/
function sortUrl(string $path, array $data, array $overrideData = []): string
{
@ -166,7 +162,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} else {
} elseif (isset($overrideData['sort'])) {
$queryData['order'] = 'asc';
}

View File

@ -13,14 +13,14 @@
"ext-mbstring": "*",
"ext-tidy": "*",
"ext-xml": "*",
"barryvdh/laravel-dompdf": "^0.8.5",
"barryvdh/laravel-snappy": "^0.4.5",
"barryvdh/laravel-dompdf": "^0.8.6",
"barryvdh/laravel-snappy": "^0.4.7",
"doctrine/dbal": "^2.9",
"facade/ignition": "^1.4",
"fideloper/proxy": "^4.0",
"gathercontent/htmldiff": "^0.2.1",
"intervention/image": "^2.5",
"laravel/framework": "^6.12",
"laravel/framework": "^6.18",
"laravel/socialite": "^4.3.2",
"league/commonmark": "^1.4",
"league/flysystem-aws-s3-v3": "^1.0",

2166
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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();
});
}
}

View File

@ -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`, ' ', '-'))"),
]);
}
}

View File

@ -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');
});
}
}

View File

@ -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"}
]
}

View File

@ -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"}
]
}

View File

@ -7,15 +7,12 @@
"updated_at": "2020-01-12 14:11:51",
"created_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"image_id": 452,
"tags": [
{
"id": 13,

View File

@ -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"
}
]
}

View File

@ -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
}

View File

@ -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
}
]
}

View File

@ -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"
}
]
}

View File

@ -5,15 +5,12 @@
"description": "This is my shelf with some books",
"created_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"image_id": 501,
"created_at": "2020-04-10 13:24:09",
"updated_at": "2020-04-10 13:31:04",
"tags": [

99
dev/docs/components.md Normal file
View File

@ -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);
```

5759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,33 @@
{
"private": true,
"scripts": {
"build": "webpack",
"production": "NODE_ENV=production webpack && rm -f ./public/dist/*styles.js",
"build-profile": "NODE_ENV=production webpack --profile --json > webpack-stats.json && rm -f ./public/dist/*styles.js",
"build:css:dev": "sass ./resources/sass:./public/dist",
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2020",
"build:js:watch": "chokidar \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --minify",
"build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production",
"dev": "npm-run-all --parallel watch livereload",
"watch": "webpack --watch",
"watch": "npm-run-all --parallel build:*:watch",
"livereload": "livereload ./public/dist/",
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
},
"devDependencies": {
"css-loader": "^3.4.2",
"chokidar-cli": "^2.1.0",
"esbuild": "0.6.30",
"livereload": "^0.9.1",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.1",
"npm-run-all": "^4.1.5",
"sass-loader": "^8.0.2",
"style-loader": "^1.1.3",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
"punycode": "^2.1.1",
"sass": "^1.26.10"
},
"dependencies": {
"clipboard": "^2.0.6",
"codemirror": "^5.52.2",
"dropzone": "^5.7.0",
"markdown-it": "^10.0.0",
"codemirror": "^5.57.0",
"dropzone": "^5.7.2",
"markdown-it": "^11.0.0",
"markdown-it-task-lists": "^2.1.1",
"sortablejs": "^1.10.2",
"vue": "^2.6.11",
"vuedraggable": "^2.23.2"
},
"browser": {
"vue": "vue/dist/vue.common.js"
"sortablejs": "^1.10.2"
}
}

View File

@ -3,6 +3,7 @@
<description>The coding standard for BookStack.</description>
<file>app</file>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
<arg value="np"/>
<rule ref="PSR2"/>
</ruleset>

View File

@ -51,5 +51,7 @@
<server name="DEBUGBAR_ENABLED" value="false"/>
<server name="SAML2_ENABLED" value="false"/>
<server name="API_REQUESTS_PER_MIN" value="180"/>
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
</php>
</phpunit>

View File

@ -51,7 +51,7 @@ All development on BookStack is currently done on the master branch. When it's t
* [Node.js](https://nodejs.org/en/) v10.0+
This project uses SASS for CSS development and this is built, along with the JavaScript, using webpack. The below npm commands can be used to install the dependencies & run the build tasks:
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
``` bash
# Install NPM Dependencies
@ -157,8 +157,7 @@ These are the great open-source projects used to help build BookStack:
* [Laravel](http://laravel.com/)
* [TinyMCE](https://www.tinymce.com/)
* [CodeMirror](https://codemirror.net)
* [Vue.js](http://vuejs.org/)
* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
* [Sortable](https://github.com/SortableJS/Sortable)
* [Google Material Icons](https://material.io/icons/)
* [Dropzone.js](http://www.dropzonejs.com/)
* [clipboard.js](https://clipboardjs.com/)

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -37,7 +37,7 @@ class Collapsible {
}
openIfContainsError() {
const error = this.content.querySelector('.text-neg');
const error = this.content.querySelector('.text-neg.text-small');
if (error) {
this.open();
}

View File

@ -3,14 +3,16 @@ import {onSelect} from "../services/dom";
/**
* Dropdown
* Provides some simple logic to create simple dropdown menus.
* @extends {Component}
*/
class DropDown {
constructor(elem) {
this.container = elem;
this.menu = elem.querySelector('.dropdown-menu, [dropdown-menu]');
this.moveMenu = elem.hasAttribute('dropdown-move-menu');
this.toggle = elem.querySelector('[dropdown-toggle]');
setup() {
this.container = this.$el;
this.menu = this.$refs.menu;
this.toggle = this.$refs.toggle;
this.moveMenu = this.$opts.moveMenu;
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
this.body = document.body;
this.showing = false;

View File

@ -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;

View File

@ -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;

View File

@ -1,27 +1,29 @@
/**
* Entity Selector Popup
* @extends {Component}
*/
class EntitySelectorPopup {
constructor(elem) {
this.elem = elem;
setup() {
this.elem = this.$el;
this.selectButton = this.$refs.select;
window.EntitySelectorPopup = this;
this.callback = null;
this.selection = null;
this.selectButton = elem.querySelector('.entity-link-selector-confirm');
this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
window.$events.listen('entity-select-confirm', this.onSelectionConfirm.bind(this));
}
show(callback) {
this.callback = callback;
this.elem.components.overlay.show();
this.elem.components.popup.show();
}
hide() {
this.elem.components.overlay.hide();
this.elem.components.popup.hide();
}
onSelectButtonClick() {

View File

@ -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;

View File

@ -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;

View File

@ -1,109 +1,266 @@
import dropdown from "./dropdown";
import overlay from "./overlay";
import backToTop from "./back-to-top";
import notification from "./notification";
import chapterToggle from "./chapter-toggle";
import expandToggle from "./expand-toggle";
import entitySelectorPopup from "./entity-selector-popup";
import entitySelector from "./entity-selector";
import sidebar from "./sidebar";
import pagePicker from "./page-picker";
import pageComments from "./page-comments";
import wysiwygEditor from "./wysiwyg-editor";
import markdownEditor from "./markdown-editor";
import editorToolbox from "./editor-toolbox";
import imagePicker from "./image-picker";
import collapsible from "./collapsible";
import toggleSwitch from "./toggle-switch";
import pageDisplay from "./page-display";
import shelfSort from "./shelf-sort";
import homepageControl from "./homepage-control";
import headerMobileToggle from "./header-mobile-toggle";
import listSortControl from "./list-sort-control";
import triLayout from "./tri-layout";
import breadcrumbListing from "./breadcrumb-listing";
import permissionsTable from "./permissions-table";
import customCheckbox from "./custom-checkbox";
import bookSort from "./book-sort";
import settingAppColorPicker from "./setting-app-color-picker";
import settingColorPicker from "./setting-color-picker";
import entityPermissionsEditor from "./entity-permissions-editor";
import templateManager from "./template-manager";
import newUserPassword from "./new-user-password";
import detailsHighlighter from "./details-highlighter";
import codeHighlighter from "./code-highlighter";
import addRemoveRows from "./add-remove-rows.js"
import ajaxDeleteRow from "./ajax-delete-row.js"
import ajaxForm from "./ajax-form.js"
import attachments from "./attachments.js"
import autoSuggest from "./auto-suggest.js"
import backToTop from "./back-to-top.js"
import bookSort from "./book-sort.js"
import breadcrumbListing from "./breadcrumb-listing.js"
import chapterToggle from "./chapter-toggle.js"
import codeEditor from "./code-editor.js"
import codeHighlighter from "./code-highlighter.js"
import collapsible from "./collapsible.js"
import customCheckbox from "./custom-checkbox.js"
import detailsHighlighter from "./details-highlighter.js"
import dropdown from "./dropdown.js"
import dropzone from "./dropzone.js"
import editorToolbox from "./editor-toolbox.js"
import entityPermissionsEditor from "./entity-permissions-editor.js"
import entitySearch from "./entity-search.js"
import entitySelector from "./entity-selector.js"
import entitySelectorPopup from "./entity-selector-popup.js"
import eventEmitSelect from "./event-emit-select.js"
import expandToggle from "./expand-toggle.js"
import headerMobileToggle from "./header-mobile-toggle.js"
import homepageControl from "./homepage-control.js"
import imageManager from "./image-manager.js"
import imagePicker from "./image-picker.js"
import index from "./index.js"
import listSortControl from "./list-sort-control.js"
import markdownEditor from "./markdown-editor.js"
import newUserPassword from "./new-user-password.js"
import notification from "./notification.js"
import optionalInput from "./optional-input.js"
import pageComments from "./page-comments.js"
import pageDisplay from "./page-display.js"
import pageEditor from "./page-editor.js"
import pagePicker from "./page-picker.js"
import permissionsTable from "./permissions-table.js"
import popup from "./popup.js"
import settingAppColorPicker from "./setting-app-color-picker.js"
import settingColorPicker from "./setting-color-picker.js"
import shelfSort from "./shelf-sort.js"
import sidebar from "./sidebar.js"
import sortableList from "./sortable-list.js"
import submitOnChange from "./submit-on-change.js"
import tabs from "./tabs.js"
import tagManager from "./tag-manager.js"
import templateManager from "./template-manager.js"
import toggleSwitch from "./toggle-switch.js"
import triLayout from "./tri-layout.js"
import wysiwygEditor from "./wysiwyg-editor.js"
const componentMapping = {
'dropdown': dropdown,
'overlay': overlay,
'back-to-top': backToTop,
'notification': notification,
'chapter-toggle': chapterToggle,
'expand-toggle': expandToggle,
'entity-selector-popup': entitySelectorPopup,
'entity-selector': entitySelector,
'sidebar': sidebar,
'page-picker': pagePicker,
'page-comments': pageComments,
'wysiwyg-editor': wysiwygEditor,
'markdown-editor': markdownEditor,
'editor-toolbox': editorToolbox,
'image-picker': imagePicker,
'collapsible': collapsible,
'toggle-switch': toggleSwitch,
'page-display': pageDisplay,
'shelf-sort': shelfSort,
'homepage-control': homepageControl,
'header-mobile-toggle': headerMobileToggle,
'list-sort-control': listSortControl,
'tri-layout': triLayout,
'breadcrumb-listing': breadcrumbListing,
'permissions-table': permissionsTable,
'custom-checkbox': customCheckbox,
'book-sort': bookSort,
'setting-app-color-picker': settingAppColorPicker,
'setting-color-picker': settingColorPicker,
'entity-permissions-editor': entityPermissionsEditor,
'template-manager': templateManager,
'new-user-password': newUserPassword,
'details-highlighter': detailsHighlighter,
'code-highlighter': codeHighlighter,
"add-remove-rows": addRemoveRows,
"ajax-delete-row": ajaxDeleteRow,
"ajax-form": ajaxForm,
"attachments": attachments,
"auto-suggest": autoSuggest,
"back-to-top": backToTop,
"book-sort": bookSort,
"breadcrumb-listing": breadcrumbListing,
"chapter-toggle": chapterToggle,
"code-editor": codeEditor,
"code-highlighter": codeHighlighter,
"collapsible": collapsible,
"custom-checkbox": customCheckbox,
"details-highlighter": detailsHighlighter,
"dropdown": dropdown,
"dropzone": dropzone,
"editor-toolbox": editorToolbox,
"entity-permissions-editor": entityPermissionsEditor,
"entity-search": entitySearch,
"entity-selector": entitySelector,
"entity-selector-popup": entitySelectorPopup,
"event-emit-select": eventEmitSelect,
"expand-toggle": expandToggle,
"header-mobile-toggle": headerMobileToggle,
"homepage-control": homepageControl,
"image-manager": imageManager,
"image-picker": imagePicker,
"index": index,
"list-sort-control": listSortControl,
"markdown-editor": markdownEditor,
"new-user-password": newUserPassword,
"notification": notification,
"optional-input": optionalInput,
"page-comments": pageComments,
"page-display": pageDisplay,
"page-editor": pageEditor,
"page-picker": pagePicker,
"permissions-table": permissionsTable,
"popup": popup,
"setting-app-color-picker": settingAppColorPicker,
"setting-color-picker": settingColorPicker,
"shelf-sort": shelfSort,
"sidebar": sidebar,
"sortable-list": sortableList,
"submit-on-change": submitOnChange,
"tabs": tabs,
"tag-manager": tagManager,
"template-manager": templateManager,
"toggle-switch": toggleSwitch,
"tri-layout": triLayout,
"wysiwyg-editor": wysiwygEditor,
};
window.components = {};
const componentNames = Object.keys(componentMapping);
/**
* Initialize components of the given name within the given element.
* @param {String} componentName
* @param {HTMLElement|Document} parentElement
*/
function initComponent(componentName, parentElement) {
let elems = parentElement.querySelectorAll(`[${componentName}]`);
if (elems.length === 0) return;
let component = componentMapping[componentName];
if (typeof window.components[componentName] === "undefined") window.components[componentName] = [];
function searchForComponentInParent(componentName, parentElement) {
const elems = parentElement.querySelectorAll(`[${componentName}]`);
for (let j = 0, jLen = elems.length; j < jLen; j++) {
let instance = new component(elems[j]);
if (typeof elems[j].components === 'undefined') elems[j].components = {};
elems[j].components[componentName] = instance;
window.components[componentName].push(instance);
initComponent(componentName, elems[j]);
}
}
/**
* Initialize a component instance on the given dom element.
* @param {String} name
* @param {Element} element
*/
function initComponent(name, element) {
const componentModel = componentMapping[name];
if (componentModel === undefined) return;
// Create our component instance
let instance;
try {
instance = new componentModel(element);
instance.$el = element;
const allRefs = parseRefs(name, element);
instance.$refs = allRefs.refs;
instance.$manyRefs = allRefs.manyRefs;
instance.$opts = parseOpts(name, element);
instance.$emit = (eventName, data = {}) => {
data.from = instance;
const event = new CustomEvent(`${name}-${eventName}`, {
bubbles: true,
detail: data
});
instance.$el.dispatchEvent(event);
};
if (typeof instance.setup === 'function') {
instance.setup();
}
} catch (e) {
console.error('Failed to create component', e, name, element);
}
// Add to global listing
if (typeof window.components[name] === "undefined") {
window.components[name] = [];
}
window.components[name].push(instance);
// Add to element listing
if (typeof element.components === 'undefined') {
element.components = {};
}
element.components[name] = instance;
}
/**
* Parse out the element references within the given element
* for the given component name.
* @param {String} name
* @param {Element} element
*/
function parseRefs(name, element) {
const refs = {};
const manyRefs = {};
const prefix = `${name}@`
const selector = `[refs*="${prefix}"]`;
const refElems = [...element.querySelectorAll(selector)];
if (element.matches(selector)) {
refElems.push(element);
}
for (const el of refElems) {
const refNames = el.getAttribute('refs')
.split(' ')
.filter(str => str.startsWith(prefix))
.map(str => str.replace(prefix, ''))
.map(kebabToCamel);
for (const ref of refNames) {
refs[ref] = el;
if (typeof manyRefs[ref] === 'undefined') {
manyRefs[ref] = [];
}
manyRefs[ref].push(el);
}
}
return {refs, manyRefs};
}
/**
* Parse out the element component options.
* @param {String} name
* @param {Element} element
* @return {Object<String, String>}
*/
function parseOpts(name, element) {
const opts = {};
const prefix = `option:${name}:`;
for (const {name, value} of element.attributes) {
if (name.startsWith(prefix)) {
const optName = name.replace(prefix, '');
opts[kebabToCamel(optName)] = value || '';
}
}
return opts;
}
/**
* Convert a kebab-case string to camelCase
* @param {String} kebab
* @returns {string}
*/
function kebabToCamel(kebab) {
const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
const words = kebab.split('-');
return words[0] + words.slice(1).map(ucFirst).join('');
}
/**
* Initialize all components found within the given element.
* @param parentElement
*/
function initAll(parentElement) {
if (typeof parentElement === 'undefined') parentElement = document;
for (let i = 0, len = componentNames.length; i < len; i++) {
initComponent(componentNames[i], parentElement);
// Old attribute system
for (const componentName of Object.keys(componentMapping)) {
searchForComponentInParent(componentName, parentElement);
}
// New component system
const componentElems = parentElement.querySelectorAll(`[component],[components]`);
for (const el of componentElems) {
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
for (const name of componentNames) {
initComponent(name, el);
}
}
}
window.components.init = initAll;
window.components.first = (name) => (window.components[name] || [null])[0];
export default initAll;
/**
* @typedef Component
* @property {HTMLElement} $el
* @property {Object<String, HTMLElement>} $refs
* @property {Object<String, HTMLElement[]>} $manyRefs
* @property {Object<String, String>} $opts
* @property {function(string, Object)} $emit
*/

View File

@ -8,12 +8,11 @@ import DrawIO from "../services/drawio";
class MarkdownEditor {
constructor(elem) {
this.elem = elem;
setup() {
this.elem = this.$el;
const pageEditor = document.getElementById('page-editor');
this.pageId = pageEditor.getAttribute('page-id');
this.textDirection = pageEditor.getAttribute('text-direction');
this.pageId = this.$opts.pageId;
this.textDirection = this.$opts.textDirection;
this.markdown = new MarkdownIt({html: true});
this.markdown.use(mdTasksLists, {label: true});
@ -27,12 +26,18 @@ class MarkdownEditor {
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
this.display.addEventListener('load', () => {
const displayLoad = () => {
this.displayDoc = this.display.contentDocument;
this.init();
});
};
window.$events.emitPublic(elem, 'editor-markdown::setup', {
if (this.display.contentDocument.readyState === 'complete') {
displayLoad();
} else {
this.display.addEventListener('load', displayLoad.bind(this));
}
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
markdownIt: this.markdown,
displayEl: this.display,
codeMirrorInstance: this.cm,
@ -251,7 +256,7 @@ class MarkdownEditor {
}
const clipboard = new Clipboard(event.dataTransfer);
if (clipboard.hasItems()) {
if (clipboard.hasItems() && clipboard.getImages().length > 0) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.stopPropagation();
@ -558,6 +563,12 @@ class MarkdownEditor {
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
});
// Insert editor content at the current location
window.$events.listen('editor::insert', (eventContent) => {
const markdown = getContentToInsert(eventContent);
this.cm.replaceSelection(markdown);
});
// Focus on editor
window.$events.listen('editor::focus', () => {
this.cm.focus();

View File

@ -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;

View File

@ -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;

View File

@ -1,16 +1,31 @@
import {scrollAndHighlightElement} from "../services/util";
/**
* @extends {Component}
*/
class PageComments {
constructor(elem) {
this.elem = elem;
this.pageId = Number(elem.getAttribute('page-id'));
setup() {
this.elem = this.$el;
this.pageId = Number(this.$opts.pageId);
// Element references
this.container = this.$refs.commentContainer;
this.formContainer = this.$refs.formContainer;
this.commentCountBar = this.$refs.commentCountBar;
this.addButtonContainer = this.$refs.addButtonContainer;
this.replyToRow = this.$refs.replyToRow;
// Translations
this.updatedText = this.$opts.updatedText;
this.deletedText = this.$opts.deletedText;
this.createdText = this.$opts.createdText;
this.countText = this.$opts.countText;
// Internal State
this.editingComment = null;
this.parentId = null;
this.container = elem.querySelector('[comment-container]');
this.formContainer = elem.querySelector('[comment-form-container]');
if (this.formContainer) {
this.form = this.formContainer.querySelector('form');
this.formInput = this.form.querySelector('textarea');
@ -32,13 +47,14 @@ class PageComments {
if (actionElem === null) return;
event.preventDefault();
let action = actionElem.getAttribute('action');
if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
const action = actionElem.getAttribute('action');
const comment = actionElem.closest('[comment]');
if (action === 'edit') this.editComment(comment);
if (action === 'closeUpdateForm') this.closeUpdateForm();
if (action === 'delete') this.deleteComment(actionElem.closest('[comment]'));
if (action === 'delete') this.deleteComment(comment);
if (action === 'addComment') this.showForm();
if (action === 'hideForm') this.hideForm();
if (action === 'reply') this.setReply(actionElem.closest('[comment]'));
if (action === 'reply') this.setReply(comment);
if (action === 'remove-reply-to') this.removeReplyTo();
}
@ -69,14 +85,15 @@ class PageComments {
};
this.showLoading(form);
let commentId = this.editingComment.getAttribute('comment');
window.$http.put(`/ajax/comment/${commentId}`, reqData).then(resp => {
window.$http.put(`/comment/${commentId}`, reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
this.editingComment.innerHTML = newComment.children[0].innerHTML;
window.$events.emit('success', window.trans('entities.comment_updated_success'));
window.$events.success(this.updatedText);
window.components.init(this.editingComment);
this.closeUpdateForm();
this.editingComment = null;
}).catch(window.$events.showValidationErrors).then(() => {
this.hideLoading(form);
});
}
@ -84,9 +101,9 @@ class PageComments {
deleteComment(commentElem) {
let id = commentElem.getAttribute('comment');
this.showLoading(commentElem.querySelector('[comment-content]'));
window.$http.delete(`/ajax/comment/${id}`).then(resp => {
window.$http.delete(`/comment/${id}`).then(resp => {
commentElem.parentNode.removeChild(commentElem);
window.$events.emit('success', window.trans('entities.comment_deleted_success'));
window.$events.success(this.deletedText);
this.updateCount();
this.hideForm();
});
@ -101,21 +118,24 @@ class PageComments {
parent_id: this.parentId || null,
};
this.showLoading(this.form);
window.$http.post(`/ajax/page/${this.pageId}/comment`, reqData).then(resp => {
window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
let newElem = newComment.children[0];
this.container.appendChild(newElem);
window.components.init(newElem);
window.$events.emit('success', window.trans('entities.comment_created_success'));
window.$events.success(this.createdText);
this.resetForm();
this.updateCount();
}).catch(err => {
window.$events.showValidationErrors(err);
this.hideLoading(this.form);
});
}
updateCount() {
let count = this.container.children.length;
this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count});
this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count});
}
resetForm() {
@ -129,7 +149,7 @@ class PageComments {
showForm() {
this.formContainer.style.display = 'block';
this.formContainer.parentNode.style.display = 'block';
this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
this.addButtonContainer.style.display = 'none';
this.formInput.focus();
this.formInput.scrollIntoView({behavior: "smooth"});
}
@ -137,14 +157,12 @@ class PageComments {
hideForm() {
this.formContainer.style.display = 'none';
this.formContainer.parentNode.style.display = 'none';
const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
if (this.getCommentCount() > 0) {
this.elem.appendChild(addButtonContainer)
this.elem.appendChild(this.addButtonContainer)
} else {
const countBar = this.elem.querySelector('[comment-count-bar]');
countBar.appendChild(addButtonContainer);
this.commentCountBar.appendChild(this.addButtonContainer);
}
addButtonContainer.style.display = 'block';
this.addButtonContainer.style.display = 'block';
}
getCommentCount() {
@ -154,15 +172,15 @@ class PageComments {
setReply(commentElem) {
this.showForm();
this.parentId = Number(commentElem.getAttribute('local-id'));
this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
this.replyToRow.style.display = 'block';
const replyLink = this.replyToRow.querySelector('a');
replyLink.textContent = `#${this.parentId}`;
replyLink.href = `#comment${this.parentId}`;
}
removeReplyTo() {
this.parentId = null;
this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
this.replyToRow.style.display = 'none';
}
showLoading(formElem) {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -137,7 +137,7 @@ function codePlugin() {
if (!elemIsCodeBlock(selectedNode)) {
const providedCode = editor.selection.getNode().textContent;
window.vues['code-editor'].open(providedCode, '', (code, lang) => {
window.components.first('code-editor').open(providedCode, '', (code, lang) => {
const wrap = document.createElement('div');
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
wrap.querySelector('code').innerText = code;
@ -155,7 +155,7 @@ function codePlugin() {
let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
let currentCode = selectedNode.querySelector('textarea').textContent;
window.vues['code-editor'].open(currentCode, lang, (code, lang) => {
window.components.first('code-editor').open(currentCode, lang, (code, lang) => {
const editorElem = selectedNode.querySelector('.CodeMirror');
const cmInstance = editorElem.CodeMirror;
if (cmInstance) {
@ -236,7 +236,7 @@ function codePlugin() {
});
}
function drawIoPlugin(drawioUrl, isDarkMode) {
function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
let pageEditor = null;
let currentNode = null;
@ -270,7 +270,6 @@ function drawIoPlugin(drawioUrl, isDarkMode) {
async function updateContent(pngData) {
const id = "image-" + Math.random().toString(16).slice(2);
const loadingImage = window.baseUrl('/loading.gif');
const pageId = Number(document.getElementById('page-editor').getAttribute('page-id'));
// Handle updating an existing image
if (currentNode) {
@ -402,6 +401,11 @@ function listenForBookStackEditorEvents(editor) {
editor.setContent(content);
});
// Insert editor content at the current location
window.$events.listen('editor::insert', ({html}) => {
editor.insertContent(html);
});
// Focus on the editor
window.$events.listen('editor::focus', () => {
editor.focus();
@ -410,19 +414,19 @@ function listenForBookStackEditorEvents(editor) {
class WysiwygEditor {
constructor(elem) {
this.elem = elem;
const pageEditor = document.getElementById('page-editor');
this.pageId = pageEditor.getAttribute('page-id');
this.textDirection = pageEditor.getAttribute('text-direction');
setup() {
this.elem = this.$el;
this.pageId = this.$opts.pageId;
this.textDirection = this.$opts.textDirection;
this.isDarkMode = document.documentElement.classList.contains('dark-mode');
this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media";
this.plugins = "image table textcolor paste link autolink fullscreen code customhr autosave lists codeeditor media";
this.loadPlugins();
this.tinyMceConfig = this.getTinyMceConfig();
window.$events.emitPublic(elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
window.tinymce.init(this.tinyMceConfig);
}
@ -433,7 +437,7 @@ class WysiwygEditor {
const drawioUrlElem = document.querySelector('[drawio-url]');
if (drawioUrlElem) {
const url = drawioUrlElem.getAttribute('drawio-url');
drawIoPlugin(url, this.isDarkMode);
drawIoPlugin(url, this.isDarkMode, this.pageId);
this.plugins += ' drawio';
}
@ -639,6 +643,7 @@ class WysiwygEditor {
});
// Custom drop event handling
editor.on('drop', function (event) {
let dom = editor.dom,
rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());

Some files were not shown because too many files have changed in this diff Show More