Merge branch 'development' into release

This commit is contained in:
Dan Brown 2023-01-31 11:59:28 +00:00
commit a4fd673285
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
333 changed files with 5031 additions and 2281 deletions

View File

@ -268,6 +268,7 @@ OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
# Disable default third-party services such as Gravatar and Draw.IO # Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option # Service-specific options will override this option

View File

@ -176,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish Michał Stelmach (stelmach-web) :: Polish
arniom :: French arniom :: French
REMOVED_USER :: ; Dutch; Turkish REMOVED_USER :: ; French; Dutch; Turkish
林祖年 (contagion) :: Chinese Traditional 林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@ -302,3 +302,9 @@ Angelos Chouvardas (achouvardas) :: Greek
rndrss :: Portuguese, Brazilian rndrss :: Portuguese, Brazilian
rirac294 :: Russian rirac294 :: Russian
David Furman (thefourCraft) :: Hebrew David Furman (thefourCraft) :: Hebrew
Pafzedog :: French
Yllelder :: Spanish
Adrian Ocneanu (aocneanu) :: Romanian
Eduardo Castanho (EduardoCastanho) :: Portuguese
VIET NAM VPS (vietnamvps) :: Vietnamese
m4tthi4s :: French

View File

@ -16,7 +16,7 @@ jobs:
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
- name: Get Composer Cache Directory - name: Get Composer Cache Directory
id: composer-cache id: composer-cache

View File

@ -2,10 +2,12 @@
namespace BookStack\Actions; namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -40,6 +42,12 @@ class Activity extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
}
/** /**
* Returns text from the language files, Looks up by using the activity key. * Returns text from the language files, Looks up by using the activity key.
*/ */

View File

@ -2,7 +2,9 @@
namespace BookStack\Actions; namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model class Favourite extends Model
@ -16,4 +18,10 @@ class Favourite extends Model
{ {
return $this->morphTo(); return $this->morphTo();
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
}
} }

View File

@ -2,8 +2,10 @@
namespace BookStack\Actions; namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -27,6 +29,12 @@ class Tag extends Model
return $this->morphTo('entity'); return $this->morphTo('entity');
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
}
/** /**
* Get a full URL to start a tag name search for this tag name. * Get a full URL to start a tag name search for this tag name.
*/ */

View File

@ -2,8 +2,10 @@
namespace BookStack\Actions; namespace BookStack\Actions;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Interfaces\Viewable; use BookStack\Interfaces\Viewable;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -28,6 +30,12 @@ class View extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
}
/** /**
* Increment the current user's view count for the given viewable model. * Increment the current user's view count for the given viewable model.
*/ */

View File

@ -198,7 +198,8 @@ class OidcService
*/ */
protected function getUserDetails(OidcIdToken $token): array protected function getUserDetails(OidcIdToken $token): array
{ {
$id = $token->getClaim('sub'); $idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
return [ return [
'external_id' => $id, 'external_id' => $id,

View File

@ -0,0 +1,141 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
{
if ($this->isUserSystemAdmin($userRoleIds)) {
return true;
}
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
$status = $this->evaluatePermitsByType($permitsByType);
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
}
/**
* @param array<string, array<string, int>> $permitsByType
*/
protected function evaluatePermitsByType(array $permitsByType): ?int
{
// Return grant or reject from role-level if exists
if (count($permitsByType['role']) > 0) {
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
}
// Return fallback permission if exists
if (count($permitsByType['fallback']) > 0) {
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
}
return null;
}
/**
* @param string[] $typeIdChain
* @param array<string, EntityPermission[]> $permissionMapByTypeId
* @return array<string, array<string, int>>
*/
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
{
$permitsByType = ['fallback' => [], 'role' => []];
foreach ($typeIdChain as $typeId) {
$permissions = $permissionMapByTypeId[$typeId] ?? [];
foreach ($permissions as $permission) {
$roleId = $permission->role_id;
$type = $roleId === 0 ? 'fallback' : 'role';
if (!isset($permitsByType[$type][$roleId])) {
$permitsByType[$type][$roleId] = $permission->{$this->action};
}
}
if (isset($permitsByType['fallback'][0])) {
break;
}
}
return $permitsByType;
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$map = [];
foreach ($relevantPermissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id;
if (!isset($map[$key])) {
$map[$key] = [];
}
$map[$key][] = $permission;
}
return $map;
}
/**
* @return string[]
*/
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
{
// The array order here is very important due to the fact we walk up the chain
// elsewhere in the class. Earlier items in the chain have higher priority.
$chain = [$entity->type . ':' . $entity->id];
if ($entity->type === 'page' && $entity->chapter_id) {
$chain[] = 'chapter:' . $entity->chapter_id;
}
if ($entity->type === 'page' || $entity->type === 'chapter') {
$chain[] = 'book:' . $entity->book_id;
}
return $chain;
}
protected function isUserSystemAdmin($userRoleIds): bool
{
$adminRoleId = Role::getSystemRole('admin')->id;
return in_array($adminRoleId, $userRoleIds);
}
}

View File

@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB;
*/ */
class JointPermissionBuilder class JointPermissionBuilder
{ {
/**
* @var array<string, array<int, SimpleEntityData>>
*/
protected array $entityCache;
/** /**
* Re-generate all entity permission from scratch. * Re-generate all entity permission from scratch.
*/ */
@ -98,40 +93,6 @@ class JointPermissionBuilder
}); });
} }
/**
* Prepare the local entity cache and ensure it's empty.
*
* @param SimpleEntityData[] $entities
*/
protected function readyEntityCache(array $entities)
{
$this->entityCache = [];
foreach ($entities as $entity) {
if (!isset($this->entityCache[$entity->type])) {
$this->entityCache[$entity->type] = [];
}
$this->entityCache[$entity->type][$entity->id] = $entity;
}
}
/**
* Get a book via ID, Checks local cache.
*/
protected function getBook(int $bookId): SimpleEntityData
{
return $this->entityCache['book'][$bookId];
}
/**
* Get a chapter via ID, Checks local cache.
*/
protected function getChapter(int $chapterId): SimpleEntityData
{
return $this->entityCache['chapter'][$chapterId];
}
/** /**
* Get a query for fetching a book with its children. * Get a query for fetching a book with its children.
*/ */
@ -214,13 +175,7 @@ class JointPermissionBuilder
$simpleEntities = []; $simpleEntities = [];
foreach ($entities as $entity) { foreach ($entities as $entity) {
$attrs = $entity->getAttributes(); $simple = SimpleEntityData::fromEntity($entity);
$simple = new SimpleEntityData();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
$simpleEntities[] = $simple; $simpleEntities[] = $simple;
} }
@ -236,18 +191,10 @@ class JointPermissionBuilder
protected function createManyJointPermissions(array $originalEntities, array $roles) protected function createManyJointPermissions(array $originalEntities, array $roles)
{ {
$entities = $this->entitiesToSimpleEntities($originalEntities); $entities = $this->entitiesToSimpleEntities($originalEntities);
$this->readyEntityCache($entities);
$jointPermissions = []; $jointPermissions = [];
// Fetch related entity permissions // Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities); $permissions = new MassEntityPermissionEvaluator($entities, 'view');
// Create a mapping of explicit entity permissions
$permissionMap = [];
foreach ($permissions as $permission) {
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
$permissionMap[$key] = $permission->view;
}
// Create a mapping of role permissions // Create a mapping of role permissions
$rolePermissionMap = []; $rolePermissionMap = [];
@ -260,13 +207,14 @@ class JointPermissionBuilder
// Create Joint Permission Data // Create Joint Permission Data
foreach ($entities as $entity) { foreach ($entities as $entity) {
foreach ($roles as $role) { foreach ($roles as $role) {
$jointPermissions[] = $this->createJointPermissionData( $jp = $this->createJointPermissionData(
$entity, $entity,
$role->getRawAttribute('id'), $role->getRawAttribute('id'),
$permissionMap, $permissions,
$rolePermissionMap, $rolePermissionMap,
$role->system_name === 'admin' $role->system_name === 'admin'
); );
$jointPermissions[] = $jp;
} }
} }
@ -300,109 +248,45 @@ class JointPermissionBuilder
return $idsByType; return $idsByType;
} }
/**
* Get the entity permissions for all the given entities.
*
* @param SimpleEntityData[] $entities
*
* @return EntityPermission[]
*/
protected function getEntityPermissionsForEntities(array $entities): array
{
$idsByType = $this->entitiesToTypeIdMap($entities);
$permissionFetch = EntityPermission::query()
->where(function (Builder $query) use ($idsByType) {
foreach ($idsByType as $type => $ids) {
$query->orWhere(function (Builder $query) use ($type, $ids) {
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
});
}
});
return $permissionFetch->get()->all();
}
/** /**
* Create entity permission data for an entity and role * Create entity permission data for an entity and role
* for a particular action. * for a particular action.
*/ */
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
{ {
// Ensure system admin role retains permissions
if ($isAdminRole) {
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
}
// Return evaluated entity permission status if it has an affect.
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
if ($entityPermissionStatus !== null) {
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
}
// Otherwise default to the role-level permissions
$permissionPrefix = $entity->type . '-view'; $permissionPrefix = $entity->type . '-view';
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']); $roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']); $roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
if ($isAdminRole) { return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
}
if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
}
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
}
// For chapters and pages, Check if explicit permissions are set on the Book.
$book = $this->getBook($entity->book_id);
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
$hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
if ($chapterRestricted) {
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
}
}
return $this->createJointPermissionDataArray(
$entity,
$roleId,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
}
/**
* Check if entity permissions are defined within the given map, for the given entity and role.
* Checks for the default `role_id=0` backup option as a fallback.
*/
protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
{
$keyPrefix = $entity->type . ':' . $entity->id . ':';
return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
}
/**
* Check for an active restriction in an entity map.
*/
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
{
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
$defaultKey = $entity->type . ':' . $entity->id . ':0';
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false;
} }
/** /**
* Create an array of data with the information of an entity jointPermissions. * Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion. * Used to build data for bulk insertion.
*/ */
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
{ {
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
return [ return [
'entity_id' => $entity->id, 'entity_id' => $entity->id,
'entity_type' => $entity->type, 'entity_type' => $entity->type,
'has_permission' => $permissionAll, 'role_id' => $roleId,
'has_permission_own' => $permissionOwn, 'status' => $permissionStatus,
'owned_by' => $entity->owned_by, 'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
'role_id' => $roleId,
]; ];
} }
} }

View File

@ -0,0 +1,81 @@
<?php
namespace BookStack\Auth\Permissions;
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
{
/**
* @var SimpleEntityData[]
*/
protected array $entitiesInvolved;
protected array $permissionMapCache;
public function __construct(array $entitiesInvolved, string $action)
{
$this->entitiesInvolved = $entitiesInvolved;
parent::__construct($action);
}
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
{
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
return $this->evaluatePermitsByType($permitsByType);
}
/**
* @param string[] $typeIdChain
* @return array<string, EntityPermission[]>
*/
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
{
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
$relevantPermissions = [];
// Filter down permissions to just those for current typeId
// and current roleID or fallback permissions.
foreach ($typeIdChain as $typeId) {
$relevantPermissions[$typeId] = [
...($allPermissions[$typeId][$roleId] ?? []),
...($allPermissions[$typeId][0] ?? [])
];
}
return $relevantPermissions;
}
/**
* @return array<string, array<int, EntityPermission[]>>
*/
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
{
if (isset($this->permissionMapCache)) {
return $this->permissionMapCache;
}
$entityTypeIdChain = [];
foreach ($this->entitiesInvolved as $entity) {
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
}
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
// Manipulate permission map to also be keyed by roleId.
foreach ($permissionMap as $typeId => $permissions) {
$permissionMap[$typeId] = [];
foreach ($permissions as $permission) {
$roleId = $permission->getRawAttribute('role_id');
if (!isset($permissionMap[$typeId][$roleId])) {
$permissionMap[$typeId][$roleId] = [];
}
$permissionMap[$typeId][$roleId][] = $permission;
}
}
$this->permissionMapCache = $permissionMap;
return $this->permissionMapCache;
}
}

View File

@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Model; use BookStack\Model;
@ -61,46 +60,7 @@ class PermissionApplicator
{ {
$this->ensureValidEntityAction($action); $this->ensureValidEntityAction($action);
$adminRoleId = Role::getSystemRole('admin')->id; return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
if (in_array($adminRoleId, $userRoleIds)) {
return true;
}
// The chain order here is very important due to the fact we walk up the chain
// in the loop below. Earlier items in the chain have higher priority.
$chain = [$entity];
if ($entity instanceof Page && $entity->chapter_id) {
$chain[] = $entity->chapter;
}
if ($entity instanceof Page || $entity instanceof Chapter) {
$chain[] = $entity->book;
}
foreach ($chain as $currentEntity) {
$allowedByRoleId = $currentEntity->permissions()
->whereIn('role_id', [0, ...$userRoleIds])
->pluck($action, 'role_id');
// Continue up the chain if no applicable entity permission overrides.
if ($allowedByRoleId->isEmpty()) {
continue;
}
// If we have user-role-specific permissions set, allow if any of those
// role permissions allow access.
$hasDefault = $allowedByRoleId->has(0);
if (!$hasDefault || $allowedByRoleId->count() > 1) {
return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
return $roleId !== 0 && $allowed;
}) !== false;
}
// Otherwise, return the default "Other roles" fallback value.
return $allowedByRoleId->get(0);
}
return null;
} }
/** /**
@ -134,10 +94,12 @@ class PermissionApplicator
{ {
return $query->where(function (Builder $parentQuery) { return $query->where(function (Builder $parentQuery) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) { $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds()) $permissionQuery->select(['entity_id', 'entity_type'])
->where(function (Builder $query) { ->selectRaw('max(owner_id) as owner_id')
$this->addJointHasPermissionCheck($query, $this->currentUser()->id); ->selectRaw('max(status) as status')
}); ->whereIn('role_id', $this->getCurrentUserRoleIds())
->groupBy(['entity_type', 'entity_id'])
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
}); });
}); });
} }
@ -161,35 +123,23 @@ class PermissionApplicator
* Filter items that have entities set as a polymorphic relation. * Filter items that have entities set as a polymorphic relation.
* For simplicity, this will not return results attached to draft pages. * For simplicity, this will not return results attached to draft pages.
* Draft pages should never really have related items though. * Draft pages should never really have related items though.
*
* @param Builder|QueryBuilder $query
*/ */
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn) public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
{ {
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$pageMorphClass = (new Page())->getMorphClass(); $pageMorphClass = (new Page())->getMorphClass();
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) { return $this->restrictEntityQuery($query)
/** @var Builder $permissionQuery */ ->where(function ($query) use ($tableDetails, $pageMorphClass) {
$permissionQuery->select(['role_id'])->from('joint_permissions') /** @var Builder $query */
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
/** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('id')->from('pages') $query->select('id')->from('pages')
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass) ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('pages.draft', '=', false); ->where('pages.draft', '=', false);
}); });
}); });
return $q;
} }
/** /**
@ -201,49 +151,15 @@ class PermissionApplicator
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
{ {
$fullPageIdColumn = $tableName . '.' . $pageIdColumn; $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
$morphClass = (new Page())->getMorphClass(); return $this->restrictEntityQuery($query)
->where(function ($query) use ($fullPageIdColumn) {
$existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) { /** @var Builder $query */
/** @var Builder $permissionQuery */ $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions') $query->select('id')->from('pages')
->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn) ->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('joint_permissions.entity_type', '=', $morphClass) ->where('pages.draft', '=', false);
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
}); });
}; });
$q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
$query->whereExists($existsQuery)
->orWhere($fullPageIdColumn, '=', 0);
});
// Prevent visibility of non-owned draft pages
$q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
$query->select('id')->from('pages')
->whereColumn('pages.id', '=', $fullPageIdColumn)
->where(function (QueryBuilder $query) {
$query->where('pages.draft', '=', false)
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
});
});
return $q;
}
/**
* Add the query for checking the given user id has permission
* within the join_permissions table.
*
* @param QueryBuilder|Builder $query
*/
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('joint_permissions.has_permission_own', '=', true)
->where('joint_permissions.owned_by', '=', $userIdToCheck);
});
} }
/** /**

View File

@ -0,0 +1,11 @@
<?php
namespace BookStack\Auth\Permissions;
class PermissionStatus
{
const IMPLICIT_DENY = 0;
const IMPLICIT_ALLOW = 1;
const EXPLICIT_DENY = 2;
const EXPLICIT_ALLOW = 3;
}

View File

@ -2,6 +2,8 @@
namespace BookStack\Auth\Permissions; namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
class SimpleEntityData class SimpleEntityData
{ {
public int $id; public int $id;
@ -9,4 +11,18 @@ class SimpleEntityData
public int $owned_by; public int $owned_by;
public ?int $book_id; public ?int $book_id;
public ?int $chapter_id; public ?int $chapter_id;
public static function fromEntity(Entity $entity): self
{
$attrs = $entity->getAttributes();
$simple = new self();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
return $simple;
}
} }

View File

@ -200,6 +200,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function attachRole(Role $role) public function attachRole(Role $role)
{ {
$this->roles()->attach($role->id); $this->roles()->attach($role->id);
$this->unsetRelation('roles');
} }
/** /**

View File

@ -8,9 +8,12 @@ return [
// Dump user details after a login request for debugging purposes // Dump user details after a login request for debugging purposes
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Attribute, within a OpenId token, to find the user's display name // Claim, within an OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')), 'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),
// OAuth2/OpenId client id, as configured in your Authorization server. // OAuth2/OpenId client id, as configured in your Authorization server.
'client_id' => env('OIDC_CLIENT_ID', null), 'client_id' => env('OIDC_CLIENT_ID', null),

View File

@ -16,11 +16,20 @@ return [
'app-editor' => 'wysiwyg', 'app-editor' => 'wysiwyg',
'app-color' => '#206ea7', 'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)', 'app-color-light' => 'rgba(32,110,167,0.15)',
'link-color' => '#206ea7',
'bookshelf-color' => '#a94747', 'bookshelf-color' => '#a94747',
'book-color' => '#077b70', 'book-color' => '#077b70',
'chapter-color' => '#af4d0d', 'chapter-color' => '#af4d0d',
'page-color' => '#206ea7', 'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1', 'page-draft-color' => '#7e50b1',
'app-color-dark' => '#195785',
'app-color-light-dark' => 'rgba(32,110,167,0.15)',
'link-color-dark' => '#429fe3',
'bookshelf-color-dark' => '#ff5454',
'book-color-dark' => '#389f60',
'chapter-color-dark' => '#ee7a2d',
'page-color-dark' => '#429fe3',
'page-draft-color-dark' => '#a66ce8',
'app-custom-head' => false, 'app-custom-head' => false,
'registration-enabled' => false, 'registration-enabled' => false,

View File

@ -5,10 +5,10 @@ namespace BookStack\Entities\Tools\Markdown;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use League\CommonMark\Block\Element\ListItem; use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment; use League\CommonMark\Environment;
use League\CommonMark\Extension\Table\TableExtension; use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension; use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarkdownToHtml class MarkdownToHtml
{ {
@ -26,7 +26,7 @@ class MarkdownToHtml
$environment->addExtension(new TaskListExtension()); $environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension()); $environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment; $environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment); $converter = new MarkdownConverter($environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10); $environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);

View File

@ -66,14 +66,19 @@ class DrawioImageController extends Controller
*/ */
public function getAsBase64($id) public function getAsBase64($id)
{ {
$image = $this->imageRepo->getById($id); try {
if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) { $image = $this->imageRepo->getById($id);
return $this->jsonError('Image data could not be found'); } catch (Exception $exception) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
}
if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
} }
$imageData = $this->imageRepo->getImageData($image); $imageData = $this->imageRepo->getImageData($image);
if (is_null($imageData)) { if (is_null($imageData)) {
return $this->jsonError('Image data could not be found'); return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
} }
return response()->json([ return response()->json([

View File

@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType; use BookStack\Actions\ActivityType;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Settings\AppSettingsStore;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SettingController extends Controller class SettingController extends Controller
{ {
protected ImageRepo $imageRepo;
protected array $settingCategories = ['features', 'customization', 'registration']; protected array $settingCategories = ['features', 'customization', 'registration'];
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
/** /**
* Handle requests to the settings index path. * Handle requests to the settings index path.
*/ */
@ -48,37 +42,17 @@ class SettingController extends Controller
/** /**
* Update the specified settings in storage. * Update the specified settings in storage.
*/ */
public function update(Request $request, string $category) public function update(Request $request, AppSettingsStore $store, string $category)
{ {
$this->ensureCategoryExists($category); $this->ensureCategoryExists($category);
$this->preventAccessInDemoMode(); $this->preventAccessInDemoMode();
$this->checkPermission('settings-manage'); $this->checkPermission('settings-manage');
$this->validate($request, [ $this->validate($request, [
'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()), 'app_logo' => ['nullable', ...$this->getImageValidationRules()],
'app_icon' => ['nullable', ...$this->getImageValidationRules()],
]); ]);
// Cycles through posted settings and update them $store->storeFromUpdateRequest($request, $category);
foreach ($request->all() as $name => $value) {
$key = str_replace('setting-', '', trim($name));
if (strpos($name, 'setting-') !== 0) {
continue;
}
setting()->put($key, $value);
}
// Update logo image if set
if ($category === 'customization' && $request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->imageRepo->destroyByType('system');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
setting()->put('app-logo', $image->url);
}
// Clear logo image if requested
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
$this->imageRepo->destroyByType('system');
setting()->remove('app-logo');
}
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category); $this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
$this->showSuccessNotification(trans('settings.settings_save_success')); $this->showSuccessNotification(trans('settings.settings_save_success'));

View File

@ -164,6 +164,8 @@ class UserController extends Controller
// Delete the profile image if reset option is in request // Delete the profile image if reset option is in request
if ($request->has('profile_image_reset')) { if ($request->has('profile_image_reset')) {
$this->imageRepo->destroyImage($user->avatar); $this->imageRepo->destroyImage($user->avatar);
$user->image_id = 0;
$user->save();
} }
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}"; $redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";

View File

@ -3,10 +3,41 @@
namespace BookStack\Providers; namespace BookStack\Providers;
use BookStack\Translation\FileLoader; use BookStack\Translation\FileLoader;
use BookStack\Translation\MessageSelector;
use Illuminate\Translation\TranslationServiceProvider as BaseProvider; use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
use Illuminate\Translation\Translator;
class TranslationServiceProvider extends BaseProvider class TranslationServiceProvider extends BaseProvider
{ {
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->registerLoader();
// This is a tweak upon Laravel's based translation service registration to allow
// usage of a custom MessageSelector class
$this->app->singleton('translator', function ($app) {
$loader = $app['translation.loader'];
// When registering the translator component, we'll need to set the default
// locale as well as the fallback locale. So, we'll grab the application
// configuration so we can easily get both of these values from there.
$locale = $app['config']['app.locale'];
$trans = new Translator($loader, $locale);
$trans->setFallback($app['config']['app.fallback_locale']);
$trans->setSelector(new MessageSelector());
return $trans;
});
}
/** /**
* Register the translation line loader. * Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used. * Overrides the default register action from Laravel so a custom loader can be used.

View File

@ -2,7 +2,9 @@
namespace BookStack\References; namespace BookStack\References;
use BookStack\Auth\Permissions\JointPermission;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -24,4 +26,10 @@ class Reference extends Model
{ {
return $this->morphTo('to'); return $this->morphTo('to');
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'from_id')
->whereColumn('references.from_type', '=', 'joint_permissions.entity_type');
}
} }

View File

@ -5,6 +5,7 @@ namespace BookStack\References;
use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
@ -23,8 +24,7 @@ class ReferenceFetcher
*/ */
public function getPageReferencesToEntity(Entity $entity): Collection public function getPageReferencesToEntity(Entity $entity): Collection
{ {
$baseQuery = $entity->referencesTo() $baseQuery = $this->queryPageReferencesToEntity($entity)
->where('from_type', '=', (new Page())->getMorphClass())
->with([ ->with([
'from' => fn (Relation $query) => $query->select(Page::$listAttributes), 'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
'from.book' => fn (Relation $query) => $query->scopes('visible'), 'from.book' => fn (Relation $query) => $query->scopes('visible'),
@ -47,11 +47,8 @@ class ReferenceFetcher
*/ */
public function getPageReferenceCountToEntity(Entity $entity): int public function getPageReferenceCountToEntity(Entity $entity): int
{ {
$baseQuery = $entity->referencesTo()
->where('from_type', '=', (new Page())->getMorphClass());
$count = $this->permissions->restrictEntityRelationQuery( $count = $this->permissions->restrictEntityRelationQuery(
$baseQuery, $this->queryPageReferencesToEntity($entity),
'references', 'references',
'from_id', 'from_id',
'from_type' 'from_type'
@ -59,4 +56,12 @@ class ReferenceFetcher
return $count; return $count;
} }
protected function queryPageReferencesToEntity(Entity $entity): Builder
{
return Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id)
->where('from_type', '=', (new Page())->getMorphClass());
}
} }

View File

@ -112,12 +112,12 @@ class SearchIndex
* *
* @returns array<string, int> * @returns array<string, int>
*/ */
protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
{ {
$termMap = $this->textToTermCountMap($text); $termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) { foreach ($termMap as $term => $count) {
$termMap[$term] = $count * $scoreAdjustment; $termMap[$term] = floor($count * $scoreAdjustment);
} }
return $termMap; return $termMap;

View File

@ -0,0 +1,91 @@
<?php
namespace BookStack\Settings;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
class AppSettingsStore
{
protected ImageRepo $imageRepo;
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
public function storeFromUpdateRequest(Request $request, string $category)
{
$this->storeSimpleSettings($request);
if ($category === 'customization') {
$this->updateAppLogo($request);
$this->updateAppIcon($request);
}
}
protected function updateAppIcon(Request $request): void
{
$sizes = [180, 128, 64, 32];
// Update icon image if set
if ($request->hasFile('app_icon')) {
$iconFile = $request->file('app_icon');
$this->destroyExistingSettingImage('app-icon');
$image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256);
setting()->put('app-icon', $image->url);
foreach ($sizes as $size) {
$this->destroyExistingSettingImage('app-icon-' . $size);
$icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
setting()->put('app-icon-' . $size, $icon->url);
}
}
// Clear icon image if requested
if ($request->get('app_icon_reset')) {
$this->destroyExistingSettingImage('app-icon');
setting()->remove('app-icon');
foreach ($sizes as $size) {
$this->destroyExistingSettingImage('app-icon-' . $size);
setting()->remove('app-icon-' . $size);
}
}
}
protected function updateAppLogo(Request $request): void
{
// Update logo image if set
if ($request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->destroyExistingSettingImage('app-logo');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
setting()->put('app-logo', $image->url);
}
// Clear logo image if requested
if ($request->get('app_logo_reset')) {
$this->destroyExistingSettingImage('app-logo');
setting()->remove('app-logo');
}
}
protected function storeSimpleSettings(Request $request): void
{
foreach ($request->all() as $name => $value) {
if (strpos($name, 'setting-') !== 0) {
continue;
}
$key = str_replace('setting-', '', trim($name));
setting()->put($key, $value);
}
}
protected function destroyExistingSettingImage(string $settingKey)
{
$existingVal = setting()->get($settingKey);
if ($existingVal) {
$this->imageRepo->destroyByUrlAndType($existingVal, 'system');
}
}
}

View File

@ -12,15 +12,11 @@ use Illuminate\Contracts\Cache\Repository as Cache;
*/ */
class SettingService class SettingService
{ {
protected $setting; protected Setting $setting;
protected $cache; protected Cache $cache;
protected $localCache = []; protected array $localCache = [];
protected string $cachePrefix = 'setting-';
protected $cachePrefix = 'setting-';
/**
* SettingService constructor.
*/
public function __construct(Setting $setting, Cache $cache) public function __construct(Setting $setting, Cache $cache)
{ {
$this->setting = $setting; $this->setting = $setting;

View File

@ -0,0 +1,19 @@
<?php
namespace BookStack\Translation;
use Illuminate\Translation\MessageSelector as BaseClass;
/**
* This is a customization of the default Laravel MessageSelector class to tweak pluralization,
* so that is uses just the first part of the locale string to provide support with
* non-standard locales such as "de_informal".
*/
class MessageSelector extends BaseClass
{
public function getPluralIndex($locale, $number)
{
$locale = explode('_', $locale)[0];
return parent::getPluralIndex($locale, $number);
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Uploads; namespace BookStack\Uploads;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
@ -10,6 +11,7 @@ use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id * @property int $id
@ -56,6 +58,12 @@ class Attachment extends Model
return $this->belongsTo(Page::class, 'uploaded_to'); return $this->belongsTo(Page::class, 'uploaded_to');
} }
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
->where('joint_permissions.entity_type', '=', 'page');
}
/** /**
* Get the url of this file. * Get the url of this file.
*/ */

View File

@ -2,10 +2,12 @@
namespace BookStack\Uploads; namespace BookStack\Uploads;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Model; use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id * @property int $id
@ -25,6 +27,12 @@ class Image extends Model
protected $fillable = ['name']; protected $fillable = ['name'];
protected $hidden = []; protected $hidden = [];
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
->where('joint_permissions.entity_type', '=', 'page');
}
/** /**
* Get a thumbnail for this image. * Get a thumbnail for this image.
* *

View File

@ -123,7 +123,10 @@ class ImageRepo
public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
{ {
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio); $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
$this->loadThumbs($image);
if ($type !== 'system') {
$this->loadThumbs($image);
}
return $image; return $image;
} }
@ -180,13 +183,17 @@ class ImageRepo
} }
/** /**
* Destroy all images of a certain type. * Destroy images that have a specific URL and type combination.
* *
* @throws Exception * @throws Exception
*/ */
public function destroyByType(string $imageType): void public function destroyByUrlAndType(string $url, string $imageType): void
{ {
$images = Image::query()->where('type', '=', $imageType)->get(); $images = Image::query()
->where('url', '=', $url)
->where('type', '=', $imageType)
->get();
foreach ($images as $image) { foreach ($images as $image) {
$this->destroyImage($image); $this->destroyImage($image);
} }

View File

@ -126,7 +126,7 @@ class CspService
protected function getAllowedIframeHosts(): array protected function getAllowedIframeHosts(): array
{ {
$hosts = config('app.iframe_hosts', ''); $hosts = config('app.iframe_hosts') ?? '';
return array_filter(explode(' ', $hosts)); return array_filter(explode(' ', $hosts));
} }

552
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ class TagFactory extends Factory
public function definition() public function definition()
{ {
return [ return [
'name' => $this->faker->city, 'name' => $this->faker->city(),
'value' => $this->faker->sentence(3), 'value' => $this->faker->sentence(3),
]; ];
} }

View File

@ -18,7 +18,7 @@ class WebhookFactory extends Factory
{ {
return [ return [
'name' => 'My webhook for ' . $this->faker->country(), 'name' => 'My webhook for ' . $this->faker->country(),
'endpoint' => $this->faker->url, 'endpoint' => $this->faker->url(),
'active' => true, 'active' => true,
'timeout' => 3, 'timeout' => 3,
]; ];

View File

@ -22,11 +22,11 @@ class UserFactory extends Factory
*/ */
public function definition() public function definition()
{ {
$name = $this->faker->name; $name = $this->faker->name();
return [ return [
'name' => $name, 'name' => $name,
'email' => $this->faker->email, 'email' => $this->faker->email(),
'slug' => Str::slug($name . '-' . Str::random(5)), 'slug' => Str::slug($name . '-' . Str::random(5)),
'password' => Str::random(10), 'password' => Str::random(10),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),

View File

@ -22,9 +22,9 @@ class BookFactory extends Factory
public function definition() public function definition()
{ {
return [ return [
'name' => $this->faker->sentence, 'name' => $this->faker->sentence(),
'slug' => Str::random(10), 'slug' => Str::random(10),
'description' => $this->faker->paragraph, 'description' => $this->faker->paragraph(),
]; ];
} }
} }

View File

@ -22,9 +22,9 @@ class ChapterFactory extends Factory
public function definition() public function definition()
{ {
return [ return [
'name' => $this->faker->sentence, 'name' => $this->faker->sentence(),
'slug' => Str::random(10), 'slug' => Str::random(10),
'description' => $this->faker->paragraph, 'description' => $this->faker->paragraph(),
]; ];
} }
} }

View File

@ -24,7 +24,7 @@ class PageFactory extends Factory
$html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>'; $html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
return [ return [
'name' => $this->faker->sentence, 'name' => $this->faker->sentence(),
'slug' => Str::random(10), 'slug' => Str::random(10),
'html' => $html, 'html' => $html,
'text' => strip_tags($html), 'text' => strip_tags($html),

View File

@ -21,9 +21,9 @@ class ImageFactory extends Factory
public function definition() public function definition()
{ {
return [ return [
'name' => $this->faker->slug . '.jpg', 'name' => $this->faker->slug() . '.jpg',
'url' => $this->faker->url, 'url' => $this->faker->url(),
'path' => $this->faker->url, 'path' => $this->faker->url(),
'type' => 'gallery', 'type' => 'gallery',
'uploaded_to' => 0, 'uploaded_to' => 0,
]; ];

View File

@ -0,0 +1,52 @@
<?php
use BookStack\Auth\Permissions\JointPermissionBuilder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class RefactorJointPermissionsStorage extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Truncate before schema changes to avoid performance issues
// since we'll need to rebuild anyway.
DB::table('joint_permissions')->truncate();
if (Schema::hasColumn('joint_permissions', 'owned_by')) {
Schema::table('joint_permissions', function (Blueprint $table) {
$table->dropColumn(['has_permission', 'has_permission_own', 'owned_by']);
$table->unsignedTinyInteger('status')->index();
$table->unsignedInteger('owner_id')->nullable()->index();
});
}
// Rebuild permissions
app(JointPermissionBuilder::class)->rebuildForAll();
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::table('joint_permissions')->truncate();
Schema::table('joint_permissions', function (Blueprint $table) {
$table->dropColumn(['status', 'owner_id']);
$table->boolean('has_permission')->index();
$table->boolean('has_permission_own')->index();
$table->unsignedInteger('owned_by')->index();
});
}
}

View File

@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class CopyColorSettingsForDarkMode extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$colorSettings = [
'app-color',
'app-color-light',
'bookshelf-color',
'book-color',
'chapter-color',
'page-color',
'page-draft-color',
];
$existing = DB::table('settings')
->whereIn('setting_key', $colorSettings)
->get()->toArray();
$newData = [];
foreach ($existing as $setting) {
$newSetting = (array) $setting;
$newSetting['setting_key'] .= '-dark';
$newData[] = $newSetting;
if ($newSetting['setting_key'] === 'app-color-dark') {
$newSetting['setting_key'] = 'link-color';
$newData[] = $newSetting;
$newSetting['setting_key'] = 'link-color-dark';
$newData[] = $newSetting;
}
}
DB::table('settings')->insert($newData);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$colorSettings = [
'app-color-dark',
'link-color',
'link-color-dark',
'app-color-light-dark',
'bookshelf-color-dark',
'book-color-dark',
'chapter-color-dark',
'page-color-dark',
'page-draft-color-dark',
];
DB::table('settings')
->whereIn('setting_key', $colorSettings)
->delete();
}
}

View File

@ -29,6 +29,8 @@ The testing database will also need migrating and seeding beforehand. This can b
Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`. Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`.
If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the TestCase@setUp function.
## Code Standards ## Code Standards
PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer). PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer).

View File

@ -0,0 +1,363 @@
# Permission Scenario Testing
Due to complexity that can arise in the various combinations of permissions, this document details scenarios and their expected results.
Test cases are written ability abstract, since all abilities should act the same in theory. Functional test cases may test abilities separate due to implementation differences.
Tests are categorised by the most specific element involved in the scenario, where the below list is most specific to least:
- Role entity permissions.
- Fallback entity permissions.
- Role permissions.
## General Permission Logical Rules
The below are some general rules we follow to standardise the behaviour of permissions in the platform:
- Most specific permission application (as above) take priority and can deny less specific permissions.
- Parent role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role.
- Where both grant and deny exist at the same specificity, we side towards grant.
## Cases
### Content Role Permissions
These are tests related to item/entity permissions that are set only at a role level.
#### test_01_allow
- Role A has role all-page permission.
- User has Role A.
User granted page permission.
#### test_02_deny
- Role A has no page permission.
- User has Role A.
User denied page permission.
#### test_10_allow_on_own_with_own
- Role A has role own-page permission.
- User has Role A.
- User is owner of page.
User granted page permission.
#### test_11_deny_on_other_with_own
- Role A has role own-page permission.
- User has Role A.
- User is not owner of page.
User denied page permission.
#### test_20_multiple_role_conflicting_all
- Role A has role all-page permission.
- Role B has no page permission.
- User has Role A & B.
User granted page permission.
#### test_21_multiple_role_conflicting_own
- Role A has role own-page permission.
- Role B has no page permission.
- User has Role A & B.
- User is owner of page.
User granted page permission.
---
### Entity Role Permissions
These are tests related to entity-level role-specific permission overrides.
#### test_01_explicit_allow
- Page permissions have inherit disabled.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_02_explicit_deny
- Page permissions have inherit disabled.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_03_same_level_conflicting
- Page permissions have inherit disabled.
- Role A has entity allow page permission.
- Role B has entity deny page permission.
- User has both Role A & B.
User granted page permission.
Explicit grant overrides entity deny at same level.
#### test_20_inherit_allow
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity allow chapter permission.
- User has Role A.
User granted page permission.
#### test_21_inherit_deny
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny chapter permission.
- User has Role A.
User denied page permission.
#### test_22_same_level_conflict_inherit
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny chapter permission.
- Role B has entity allow chapter permission.
- User has both Role A & B.
User granted page permission.
#### test_30_child_inherit_override_allow
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny chapter permission.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_31_child_inherit_override_deny
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity allow chapter permission.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_40_multi_role_inherit_conflict_override_deny
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity deny page permission.
- Role B has entity allow chapter permission.
- User has Role A & B.
User granted page permission.
#### test_41_multi_role_inherit_conflict_retain_allow
- Page permissions have inherit enabled.
- Chapter permissions has inherit disabled.
- Role A has entity allow page permission.
- Role B has entity deny chapter permission.
- User has Role A & B.
User granted page permission.
#### test_50_role_override_allow
- Page permissions have inherit enabled.
- Role A has no page role permission.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_51_role_override_deny
- Page permissions have inherit enabled.
- Role A has no page-view-all role permission.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_60_inherited_role_override_allow
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has no page role permission.
- Role A has entity allow chapter permission.
- User has Role A.
User granted page permission.
#### test_61_inherited_role_override_deny
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has page role permission.
- Role A has entity denied chapter permission.
- User has Role A.
User denied page permission.
#### test_62_inherited_role_override_deny_on_own
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has own-page role permission.
- Role A has entity denied chapter permission.
- User has Role A.
- User owns Page.
User denied page permission.
#### test_70_multi_role_inheriting_deny
- Page permissions have inherit enabled.
- Role A has all page role permission.
- Role B has entity denied page permission.
- User has Role A and B.
User denied page permission.
#### test_71_multi_role_inheriting_deny_on_own
- Page permissions have inherit enabled.
- Role A has own page role permission.
- Role B has entity denied page permission.
- User has Role A and B.
- Use owns Page.
User denied page permission.
#### test_75_multi_role_inherited_deny_via_parent
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has all-pages role permission.
- Role B has entity denied chapter permission.
- User has Role A & B.
User denied page permission.
#### test_76_multi_role_inherited_deny_via_parent_on_own
- Page permissions have inherit enabled.
- Chapter permissions have inherit enabled.
- Role A has own page role permission.
- Role B has entity denied chapter permission.
- User has Role A & B.
User denied page permission.
#### test_80_fallback_override_allow
- Page permissions have inherit disabled.
- Page fallback has entity deny permission.
- Role A has entity allow page permission.
- User has Role A.
User granted page permission.
#### test_81_fallback_override_deny
- Page permissions have inherit disabled.
- Page fallback has entity allow permission.
- Role A has entity deny page permission.
- User has Role A.
User denied page permission.
#### test_84_fallback_override_allow_multi_role
- Page permissions have inherit disabled.
- Page fallback has entity deny permission.
- Role A has entity allow page permission.
- Role B has no entity page permissions.
- User has Role A & B.
User granted page permission.
#### test_85_fallback_override_deny_multi_role
- Page permissions have inherit disabled.
- Page fallback has entity allow permission.
- Role A has entity deny page permission.
- Role B has no entity page permissions.
- User has Role A & B.
User denied page permission.
#### test_86_fallback_override_allow_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity deny permission.
- Role A has entity allow chapter permission.
- User has Role A.
User granted page permission.
#### test_87_fallback_override_deny_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity allow permission.
- Role A has entity deny chapter permission.
- User has Role A.
User denied page permission.
#### test_88_fallback_override_allow_multi_role_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity deny permission.
- Role A has entity allow chapter permission.
- Role B has no entity chapter permissions.
- User has Role A & B.
User granted page permission.
#### test_89_fallback_override_deny_multi_role_inherit
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Chapter fallback has entity allow permission.
- Role A has entity deny chapter permission.
- Role B has no entity chapter permissions.
- User has Role A & B.
User denied page permission.
#### test_90_fallback_overrides_parent_entity_role_deny
- Chapter permissions have inherit disabled.
- Page permissions have inherit disabled.
- Chapter fallback has entity deny permission.
- Page fallback has entity deny permission.
- Role A has entity allow chapter permission.
- User has Role A.
User denied page permission.
#### test_91_fallback_overrides_parent_entity_role_inherit
- Book permissions have inherit disabled.
- Chapter permissions have inherit disabled.
- Page permissions have inherit enabled.
- Book fallback has entity deny permission.
- Chapter fallback has entity deny permission.
- Role A has entity allow book permission.
- User has Role A.
User denied page permission.

1076
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,11 +16,11 @@
}, },
"devDependencies": { "devDependencies": {
"chokidar-cli": "^3.0", "chokidar-cli": "^3.0",
"esbuild": "^0.15.12", "esbuild": "^0.17.3",
"livereload": "^0.9.3", "livereload": "^0.9.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"punycode": "^2.1.1", "punycode": "^2.3.0",
"sass": "^1.55.0" "sass": "^1.57.0"
}, },
"dependencies": { "dependencies": {
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
@ -28,7 +28,7 @@
"dropzone": "^5.9.3", "dropzone": "^5.9.3",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-task-lists": "^2.1.1", "markdown-it-task-lists": "^2.1.1",
"snabbdom": "^3.5.0", "snabbdom": "^3.5.1",
"sortablejs": "^1.15.0" "sortablejs": "^1.15.0"
} }
} }

BIN
public/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -45,7 +45,7 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
<img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net"> <img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
</a></td> </a></td>
<td><a href="https://cloudabove.com/hosting" target="_blank"> <td><a href="https://cloudabove.com/hosting" target="_blank">
<img height="100" src="https://raw.githubusercontent.com/BookStackApp/website/main/static/images/sponsors/cloudabove.svg" alt="Cloudabove"> <img height="100" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/cloudabove.png" alt="Cloudabove">
</a></td> </a></td>
</tr></tbody></table> </tr></tbody></table>
@ -55,6 +55,9 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank"> <td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted"> <img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
</a></td> </a></td>
<td><a href="https://www.practicali.be" target="_blank">
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/practicali.png" alt="Stellar Hosted">
</a></td>
</tr></tbody></table> </tr></tbody></table>
## 🛣️ Road Map ## 🛣️ Road Map

View File

@ -25,6 +25,7 @@ import 'codemirror/mode/properties/properties';
import 'codemirror/mode/python/python'; import 'codemirror/mode/python/python';
import 'codemirror/mode/ruby/ruby'; import 'codemirror/mode/ruby/ruby';
import 'codemirror/mode/rust/rust'; import 'codemirror/mode/rust/rust';
import 'codemirror/mode/scheme/scheme';
import 'codemirror/mode/shell/shell'; import 'codemirror/mode/shell/shell';
import 'codemirror/mode/smarty/smarty'; import 'codemirror/mode/smarty/smarty';
import 'codemirror/mode/sql/sql'; import 'codemirror/mode/sql/sql';
@ -76,6 +77,8 @@ const modeMap = {
mdown: 'markdown', mdown: 'markdown',
markdown: 'markdown', markdown: 'markdown',
ml: 'mllike', ml: 'mllike',
mssql: 'text/x-mssql',
mysql: 'text/x-mysql',
nginx: 'nginx', nginx: 'nginx',
octave: 'text/x-octave', octave: 'text/x-octave',
perl: 'perl', perl: 'perl',
@ -88,16 +91,21 @@ const modeMap = {
php: (content) => { php: (content) => {
return content.includes('<?php') ? 'php' : 'text/x-php'; return content.includes('<?php') ? 'php' : 'text/x-php';
}, },
pgsql: 'text/x-pgsql',
'pl/sql': 'text/x-plsql',
postgresql: 'text/x-pgsql',
py: 'python', py: 'python',
python: 'python', python: 'python',
ruby: 'ruby', ruby: 'ruby',
rust: 'rust', rust: 'rust',
rb: 'ruby', rb: 'ruby',
rs: 'rust', rs: 'rust',
scheme: 'scheme',
shell: 'shell', shell: 'shell',
sh: 'shell', sh: 'shell',
smarty: 'smarty', smarty: 'smarty',
sql: 'text/x-sql', sql: 'text/x-sql',
sqlite: 'text/x-sqlite',
stext: 'text/x-stex', stext: 'text/x-stex',
swift: 'text/x-swift', swift: 'text/x-swift',
toml: 'toml', toml: 'toml',

View File

@ -45,7 +45,7 @@ export class Attachments extends Component {
this.stopEdit(); this.stopEdit();
/** @var {Tabs} */ /** @var {Tabs} */
const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs'); const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs');
tabs.show('items'); tabs.show('attachment-panel-items');
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => { window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
this.list.innerHTML = resp.data; this.list.innerHTML = resp.data;
window.$components.init(this.list); window.$components.init(this.list);

View File

@ -1,4 +1,4 @@
import Sortable from "sortablejs"; import Sortable, {MultiDrag} from "sortablejs";
import {Component} from "./component"; import {Component} from "./component";
import {htmlToDom} from "../services/dom"; import {htmlToDom} from "../services/dom";
@ -37,6 +37,113 @@ const sortOperations = {
}, },
}; };
/**
* The available move actions.
* The active function indicates if the action is possible for the given item.
* The run function performs the move.
* @type {{up: {active(Element, ?Element, Element): boolean, run(Element, ?Element, Element)}}}
*/
const moveActions = {
up: {
active(elem, parent, book) {
return !(elem.previousElementSibling === null && !parent);
},
run(elem, parent, book) {
const newSibling = elem.previousElementSibling || parent;
newSibling.insertAdjacentElement('beforebegin', elem);
}
},
down: {
active(elem, parent, book) {
return !(elem.nextElementSibling === null && !parent);
},
run(elem, parent, book) {
const newSibling = elem.nextElementSibling || parent;
newSibling.insertAdjacentElement('afterend', elem);
}
},
next_book: {
active(elem, parent, book) {
return book.nextElementSibling !== null;
},
run(elem, parent, book) {
const newList = book.nextElementSibling.querySelector('ul');
newList.prepend(elem);
}
},
prev_book: {
active(elem, parent, book) {
return book.previousElementSibling !== null;
},
run(elem, parent, book) {
const newList = book.previousElementSibling.querySelector('ul');
newList.appendChild(elem);
}
},
next_chapter: {
active(elem, parent, book) {
return elem.dataset.type === 'page' && this.getNextChapter(elem, parent);
},
run(elem, parent, book) {
const nextChapter = this.getNextChapter(elem, parent);
nextChapter.querySelector('ul').prepend(elem);
},
getNextChapter(elem, parent) {
const topLevel = (parent || elem);
const topItems = Array.from(topLevel.parentElement.children);
const index = topItems.indexOf(topLevel);
return topItems.slice(index + 1).find(elem => elem.dataset.type === 'chapter');
}
},
prev_chapter: {
active(elem, parent, book) {
return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent);
},
run(elem, parent, book) {
const prevChapter = this.getPrevChapter(elem, parent);
prevChapter.querySelector('ul').append(elem);
},
getPrevChapter(elem, parent) {
const topLevel = (parent || elem);
const topItems = Array.from(topLevel.parentElement.children);
const index = topItems.indexOf(topLevel);
return topItems.slice(0, index).reverse().find(elem => elem.dataset.type === 'chapter');
}
},
book_end: {
active(elem, parent, book) {
return parent || (parent === null && elem.nextElementSibling);
},
run(elem, parent, book) {
book.querySelector('ul').append(elem);
}
},
book_start: {
active(elem, parent, book) {
return parent || (parent === null && elem.previousElementSibling);
},
run(elem, parent, book) {
book.querySelector('ul').prepend(elem);
}
},
before_chapter: {
active(elem, parent, book) {
return parent;
},
run(elem, parent, book) {
parent.insertAdjacentElement('beforebegin', elem);
}
},
after_chapter: {
active(elem, parent, book) {
return parent;
},
run(elem, parent, book) {
parent.insertAdjacentElement('afterend', elem);
}
},
};
export class BookSort extends Component { export class BookSort extends Component {
setup() { setup() {
@ -44,15 +151,34 @@ export class BookSort extends Component {
this.sortContainer = this.$refs.sortContainer; this.sortContainer = this.$refs.sortContainer;
this.input = this.$refs.input; this.input = this.$refs.input;
Sortable.mount(new MultiDrag());
const initialSortBox = this.container.querySelector('.sort-box'); const initialSortBox = this.container.querySelector('.sort-box');
this.setupBookSortable(initialSortBox); this.setupBookSortable(initialSortBox);
this.setupSortPresets(); this.setupSortPresets();
this.setupMoveActions();
window.$events.listen('entity-select-confirm', this.bookSelect.bind(this)); window.$events.listen('entity-select-change', this.bookSelect.bind(this));
} }
/** /**
* Setup the handlers for the preset sort type buttons. * Set up the handlers for the item-level move buttons.
*/
setupMoveActions() {
// Handle move button click
this.container.addEventListener('click', event => {
if (event.target.matches('[data-move]')) {
const action = event.target.getAttribute('data-move');
const sortItem = event.target.closest('[data-id]');
this.runSortAction(sortItem, action);
}
});
this.updateMoveActionStateForAll();
}
/**
* Set up the handlers for the preset sort type buttons.
*/ */
setupSortPresets() { setupSortPresets() {
let lastSort = ''; let lastSort = '';
@ -100,16 +226,19 @@ export class BookSort extends Component {
const newBookContainer = htmlToDom(resp.data); const newBookContainer = htmlToDom(resp.data);
this.sortContainer.append(newBookContainer); this.sortContainer.append(newBookContainer);
this.setupBookSortable(newBookContainer); this.setupBookSortable(newBookContainer);
this.updateMoveActionStateForAll();
const summary = newBookContainer.querySelector('summary');
summary.focus();
}); });
} }
/** /**
* Setup the given book container element to have sortable items. * Set up the given book container element to have sortable items.
* @param {Element} bookContainer * @param {Element} bookContainer
*/ */
setupBookSortable(bookContainer) { setupBookSortable(bookContainer) {
const sortElems = [bookContainer.querySelector('.sort-list')]; const sortElems = Array.from(bookContainer.querySelectorAll('.sort-list, .sortable-page-sublist'));
sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul'));
const bookGroupConfig = { const bookGroupConfig = {
name: 'book', name: 'book',
@ -125,22 +254,40 @@ export class BookSort extends Component {
} }
}; };
for (let sortElem of sortElems) { for (const sortElem of sortElems) {
new Sortable(sortElem, { Sortable.create(sortElem, {
group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig, group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
animation: 150, animation: 150,
fallbackOnBody: true, fallbackOnBody: true,
swapThreshold: 0.65, swapThreshold: 0.65,
onSort: this.updateMapInput.bind(this), onSort: (event) => {
this.ensureNoNestedChapters()
this.updateMapInput();
this.updateMoveActionStateForAll();
},
dragClass: 'bg-white', dragClass: 'bg-white',
ghostClass: 'primary-background-light', ghostClass: 'primary-background-light',
multiDrag: true, multiDrag: true,
multiDragKey: 'CTRL', multiDragKey: 'Control',
selectedClass: 'sortable-selected', selectedClass: 'sortable-selected',
}); });
} }
} }
/**
* Handle nested chapters by moving them to the parent book.
* Needed since sorting with multi-sort only checks group rules based on the active item,
* not all in group, therefore need to manually check after a sort.
* Must be done before updating the map input.
*/
ensureNoNestedChapters() {
const nestedChapters = this.container.querySelectorAll('[data-type="chapter"] [data-type="chapter"]');
for (const chapter of nestedChapters) {
const parentChapter = chapter.parentElement.closest('[data-type="chapter"]');
parentChapter.insertAdjacentElement('afterend', chapter);
}
}
/** /**
* Update the input with our sort data. * Update the input with our sort data.
*/ */
@ -202,4 +349,38 @@ export class BookSort extends Component {
} }
} }
/**
* Run the given sort action up the provided sort item.
* @param {Element} item
* @param {String} action
*/
runSortAction(item, action) {
const parentItem = item.parentElement.closest('li[data-id]');
const parentBook = item.parentElement.closest('[data-type="book"]');
moveActions[action].run(item, parentItem, parentBook);
this.updateMapInput();
this.updateMoveActionStateForAll();
item.scrollIntoView({behavior: 'smooth', block: 'nearest'});
item.focus();
}
/**
* Update the state of the available move actions on this item.
* @param {Element} item
*/
updateMoveActionState(item) {
const parentItem = item.parentElement.closest('li[data-id]');
const parentBook = item.parentElement.closest('[data-type="book"]');
for (const [action, functions] of Object.entries(moveActions)) {
const moveButton = item.querySelector(`[data-move="${action}"]`);
moveButton.disabled = !functions.active(item, parentItem, parentBook);
}
}
updateMoveActionStateForAll() {
const items = this.container.querySelectorAll('[data-type="chapter"],[data-type="page"]');
for (const item of items) {
this.updateMoveActionState(item);
}
}
} }

View File

@ -15,7 +15,6 @@ export class EntitySelector extends Component {
this.searchInput = this.$refs.search; this.searchInput = this.$refs.search;
this.loading = this.$refs.loading; this.loading = this.$refs.loading;
this.resultsContainer = this.$refs.results; this.resultsContainer = this.$refs.results;
this.addButton = this.$refs.add;
this.search = ''; this.search = '';
this.lastClick = 0; this.lastClick = 0;
@ -43,15 +42,6 @@ export class EntitySelector extends Component {
if (event.keyCode === 13) event.preventDefault(); if (event.keyCode === 13) event.preventDefault();
}); });
if (this.addButton) {
this.addButton.addEventListener('click', event => {
if (this.selectedItemData) {
this.confirmSelection(this.selectedItemData);
this.unselectAll();
}
});
}
// Keyboard navigation // Keyboard navigation
onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => { onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
if (e.ctrlKey && e.code === 'Enter') { if (e.ctrlKey && e.code === 'Enter') {

View File

@ -140,10 +140,9 @@ export class ImageManager extends Component {
} }
setActiveFilterTab(filterName) { setActiveFilterTab(filterName) {
this.filterTabs.forEach(t => t.classList.remove('selected')); for (const tab of this.filterTabs) {
const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName); const selected = tab.dataset.filter === filterName;
if (activeTab) { tab.setAttribute('aria-selected', selected ? 'true' : 'false');
activeTab.classList.add('selected');
} }
} }

View File

@ -41,7 +41,7 @@ export {PagePicker} from "./page-picker.js"
export {PermissionsTable} from "./permissions-table.js" export {PermissionsTable} from "./permissions-table.js"
export {Pointer} from "./pointer.js" export {Pointer} from "./pointer.js"
export {Popup} from "./popup.js" export {Popup} from "./popup.js"
export {SettingAppColorPicker} from "./setting-app-color-picker.js" export {SettingAppColorScheme} from "./setting-app-color-scheme.js"
export {SettingColorPicker} from "./setting-color-picker.js" export {SettingColorPicker} from "./setting-color-picker.js"
export {SettingHomepageControl} from "./setting-homepage-control.js" export {SettingHomepageControl} from "./setting-homepage-control.js"
export {ShelfSort} from "./shelf-sort.js" export {ShelfSort} from "./shelf-sort.js"

View File

@ -1,49 +0,0 @@
import {Component} from "./component";
export class SettingAppColorPicker extends Component {
setup() {
this.colorInput = this.$refs.input;
this.lightColorInput = this.$refs.lightInput;
this.colorInput.addEventListener('change', this.updateColor.bind(this));
this.colorInput.addEventListener('input', this.updateColor.bind(this));
}
/**
* Update the app colors as a preview, and create a light version of the color.
*/
updateColor() {
const hexVal = this.colorInput.value;
const rgb = this.hexToRgb(hexVal);
const rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
this.lightColorInput.value = rgbLightVal;
const customStyles = document.getElementById('custom-styles');
const oldColor = customStyles.getAttribute('data-color');
const oldColorLight = customStyles.getAttribute('data-color-light');
customStyles.innerHTML = customStyles.innerHTML.split(oldColor).join(hexVal);
customStyles.innerHTML = customStyles.innerHTML.split(oldColorLight).join(rgbLightVal);
customStyles.setAttribute('data-color', hexVal);
customStyles.setAttribute('data-color-light', rgbLightVal);
}
/**
* Covert a hex color code to rgb components.
* @attribution https://stackoverflow.com/a/5624139
* @param {String} hex
* @returns {{r: Number, g: Number, b: Number}}
*/
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return {
r: result ? parseInt(result[1], 16) : 0,
g: result ? parseInt(result[2], 16) : 0,
b: result ? parseInt(result[3], 16) : 0
};
}
}

View File

@ -0,0 +1,82 @@
import {Component} from "./component";
export class SettingAppColorScheme extends Component {
setup() {
this.container = this.$el;
this.mode = this.$opts.mode;
this.lightContainer = this.$refs.lightContainer;
this.darkContainer = this.$refs.darkContainer;
this.container.addEventListener('tabs-change', event => {
const panel = event.detail.showing;
const newMode = (panel === 'color-scheme-panel-light') ? 'light' : 'dark';
this.handleModeChange(newMode);
});
const onInputChange = (event) => {
this.updateAppColorsFromInputs();
if (event.target.name.startsWith('setting-app-color')) {
this.updateLightForInput(event.target);
}
};
this.container.addEventListener('change', onInputChange);
this.container.addEventListener('input', onInputChange);
}
handleModeChange(newMode) {
this.mode = newMode;
const isDark = (newMode === 'dark');
document.documentElement.classList.toggle('dark-mode', isDark);
this.updateAppColorsFromInputs();
}
updateAppColorsFromInputs() {
const inputContainer = this.mode === 'dark' ? this.darkContainer : this.lightContainer;
const inputs = inputContainer.querySelectorAll('input[type="color"]');
for (const input of inputs) {
const splitName = input.name.split('-');
const colorPos = splitName.indexOf('color');
let cssId = splitName.slice(1, colorPos).join('-');
if (cssId === 'app') {
cssId = 'primary';
}
const varName = '--color-' + cssId;
document.body.style.setProperty(varName, input.value);
}
}
/**
* Update the 'light' app color variant for the given input.
* @param {HTMLInputElement} input
*/
updateLightForInput(input) {
const lightName = input.name.replace('-color', '-color-light');
const hexVal = input.value;
const rgb = this.hexToRgb(hexVal);
const rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
console.log(input.name, lightName, hexVal, rgbLightVal)
const lightColorInput = this.container.querySelector(`input[name="${lightName}"][type="hidden"]`);
lightColorInput.value = rgbLightVal;
}
/**
* Covert a hex color code to rgb components.
* @attribution https://stackoverflow.com/a/5624139
* @param {String} hex
* @returns {{r: Number, g: Number, b: Number}}
*/
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return {
r: result ? parseInt(result[1], 16) : 0,
g: result ? parseInt(result[2], 16) : 0,
b: result ? parseInt(result[3], 16) : 0
};
}
}

View File

@ -15,6 +15,6 @@ export class SettingColorPicker extends Component {
setValue(value) { setValue(value) {
this.colorInput.value = value; this.colorInput.value = value;
this.colorInput.dispatchEvent(new Event('change')); this.colorInput.dispatchEvent(new Event('change', {bubbles: true}));
} }
} }

View File

@ -1,49 +1,49 @@
import {onSelect} from "../services/dom";
import {Component} from "./component"; import {Component} from "./component";
/** /**
* Tabs * Tabs
* Works by matching 'tabToggle<Key>' with 'tabContent<Key>' sections. * Uses accessible attributes to drive its functionality.
* On tab wrapping element:
* - role=tablist
* On tabs (Should be a button):
* - id
* - role=tab
* - aria-selected=true/false
* - aria-controls=<id-of-panel-section>
* On panels:
* - id
* - tabindex=0
* - role=tabpanel
* - aria-labelledby=<id-of-tab-for-panel>
* - hidden (If not shown by default).
*/ */
export class Tabs extends Component { export class Tabs extends Component {
setup() { setup() {
this.tabContentsByName = {}; this.container = this.$el;
this.tabButtonsByName = {}; this.tabs = Array.from(this.container.querySelectorAll('[role="tab"]'));
this.allContents = []; this.panels = Array.from(this.container.querySelectorAll('[role="tabpanel"]'));
this.allButtons = [];
for (const [key, elems] of Object.entries(this.$manyRefs || {})) { this.container.addEventListener('click', event => {
if (key.startsWith('toggle')) { const button = event.target.closest('[role="tab"]');
const cleanKey = key.replace('toggle', '').toLowerCase(); if (button) {
onSelect(elems, e => this.show(cleanKey)); this.show(button.getAttribute('aria-controls'));
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) { show(sectionId) {
this.allContents.forEach(c => { for (const panel of this.panels) {
c.classList.add('hidden'); panel.toggleAttribute('hidden', panel.id !== sectionId);
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'));
} }
for (const tab of this.tabs) {
const tabSection = tab.getAttribute('aria-controls');
const selected = tabSection === sectionId;
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
}
this.$emit('change', {showing: sectionId});
} }
} }

View File

@ -10,10 +10,11 @@ export class TagManager extends Component {
} }
setupListeners() { setupListeners() {
this.container.addEventListener('change', event => { this.container.addEventListener('input', event => {
/** @var {AddRemoveRows} **/ /** @var {AddRemoveRows} **/
const addRemoveComponent = window.$components.firstOnElement(this.addRemoveComponentEl, 'add-remove-rows'); const addRemoveComponent = window.$components.firstOnElement(this.addRemoveComponentEl, 'add-remove-rows');
if (!this.hasEmptyRows()) { if (!this.hasEmptyRows() && event.target.value) {
addRemoveComponent.add(); addRemoveComponent.add();
} }
}); });

View File

@ -95,8 +95,16 @@ async function upload(imageData, pageUploadedToId) {
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async function load(drawingId) { async function load(drawingId) {
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`)); try {
return `data:image/png;base64,${resp.data.content}`; const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
return `data:image/png;base64,${resp.data.content}`;
} catch (error) {
if (error instanceof window.$http.HttpError) {
window.$events.showResponseError(error);
}
close();
throw error;
}
} }
export default {show, close, upload, load}; export default {show, close, upload, load};

View File

@ -43,10 +43,8 @@ function emitPublic(targetElement, eventName, eventData) {
} }
/** /**
* Notify of a http error. * Notify of standard server-provided validation errors.
* Check for standard scenarios such as validation errors and * @param {Object} error
* formats an error notification accordingly.
* @param {Error} error
*/ */
function showValidationErrors(error) { function showValidationErrors(error) {
if (!error.status) return; if (!error.status) return;
@ -56,6 +54,17 @@ function showValidationErrors(error) {
} }
} }
/**
* Notify standard server-provided error messages.
* @param {Object} error
*/
function showResponseError(error) {
if (!error.status) return;
if (error.status >= 400 && error.data && error.data.message) {
emit('error', error.data.message);
}
}
export default { export default {
emit, emit,
emitPublic, emitPublic,
@ -63,4 +72,5 @@ export default {
success: (msg) => emit('success', msg), success: (msg) => emit('success', msg),
error: (msg) => emit('error', msg), error: (msg) => emit('error', msg),
showValidationErrors, showValidationErrors,
showResponseError,
} }

View File

@ -132,7 +132,7 @@ async function request(url, options = {}) {
}; };
if (!response.ok) { if (!response.ok) {
throw returnData; throw new HttpError(response, content);
} }
return returnData; return returnData;
@ -159,10 +159,24 @@ async function getResponseContent(response) {
return await response.text(); return await response.text();
} }
class HttpError extends Error {
constructor(response, content) {
super(response.statusText);
this.data = content;
this.headers = response.headers;
this.redirected = response.redirected;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
this.original = response;
}
}
export default { export default {
get: get, get: get,
post: post, post: post,
put: put, put: put,
patch: patch, patch: patch,
delete: performDelete, delete: performDelete,
HttpError: HttpError,
}; };

View File

@ -86,7 +86,7 @@ export class KeyboardNavigationHandler {
*/ */
#getFocusable() { #getFocusable() {
const focusable = []; const focusable = [];
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])'; const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
for (const container of this.containers) { for (const container of this.containers) {
focusable.push(...container.querySelectorAll(selector)) focusable.push(...container.querySelectorAll(selector))
} }

View File

@ -34,7 +34,7 @@ export function scrollAndHighlightElement(element) {
if (!element) return; if (!element) return;
element.scrollIntoView({behavior: 'smooth'}); element.scrollIntoView({behavior: 'smooth'});
const color = document.getElementById('custom-styles').getAttribute('data-color-light'); const color = getComputedStyle(document.body).getPropertyValue('--color-primary-light');
const initColor = window.getComputedStyle(element).getPropertyValue('background-color'); const initColor = window.getComputedStyle(element).getPropertyValue('background-color');
element.style.backgroundColor = color; element.style.backgroundColor = color;
setTimeout(() => { setTimeout(() => {

View File

@ -89,7 +89,7 @@ function drawingInit() {
return Promise.resolve(''); return Promise.resolve('');
} }
let drawingId = currentNode.getAttribute('drawio-diagram'); const drawingId = currentNode.getAttribute('drawio-diagram');
return DrawIO.load(drawingId); return DrawIO.load(drawingId);
} }

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'البحث في هذا الكتاب', 'books_search_this' => 'البحث في هذا الكتاب',
'books_navigation' => 'تصفح الكتاب', 'books_navigation' => 'تصفح الكتاب',
'books_sort' => 'فرز محتويات الكتاب', 'books_sort' => 'فرز محتويات الكتاب',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'فرز كتاب :bookName', 'books_sort_named' => 'فرز كتاب :bookName',
'books_sort_name' => 'ترتيب حسب الإسم', 'books_sort_name' => 'ترتيب حسب الإسم',
'books_sort_created' => 'ترتيب حسب تاريخ الإنشاء', 'books_sort_created' => 'ترتيب حسب تاريخ الإنشاء',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'الفصول الأخيرة', 'books_sort_chapters_last' => 'الفصول الأخيرة',
'books_sort_show_other' => 'عرض كتب أخرى', 'books_sort_show_other' => 'عرض كتب أخرى',
'books_sort_save' => 'حفظ الترتيب الجديد', 'books_sort_save' => 'حفظ الترتيب الجديد',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Copy Book', 'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied', 'books_copy_success' => 'Book successfully copied',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'لا يمكن للخادم إنشاء صور مصغرة. الرجاء التأكد من تثبيت إضافة GD PHP.', 'cannot_create_thumbs' => 'لا يمكن للخادم إنشاء صور مصغرة. الرجاء التأكد من تثبيت إضافة GD PHP.',
'server_upload_limit' => 'الخادم لا يسمح برفع ملفات بهذا الحجم. الرجاء محاولة الرفع بحجم أصغر.', 'server_upload_limit' => 'الخادم لا يسمح برفع ملفات بهذا الحجم. الرجاء محاولة الرفع بحجم أصغر.',
'uploaded' => 'الخادم لا يسمح برفع ملفات بهذا الحجم. الرجاء محاولة الرفع بحجم أصغر.', 'uploaded' => 'الخادم لا يسمح برفع ملفات بهذا الحجم. الرجاء محاولة الرفع بحجم أصغر.',
'file_upload_timeout' => 'انتهت عملية تحميل الملف.',
// Drawing & Images
'image_upload_error' => 'حدث خطأ خلال رفع الصورة', 'image_upload_error' => 'حدث خطأ خلال رفع الصورة',
'image_upload_type_error' => 'صيغة الصورة المرفوعة غير صالحة', 'image_upload_type_error' => 'صيغة الصورة المرفوعة غير صالحة',
'file_upload_timeout' => 'انتهت عملية تحميل الملف.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'لم يتم العثور على المرفق', 'attachment_not_found' => 'لم يتم العثور على المرفق',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'سيتم إدراج أي محتوى مضاف هنا في الجزء السفلي من قسم <head> من كل صفحة. هذا أمر مفيد لتجاوز الأنماط أو إضافة رمز التحليل.', 'app_custom_html_desc' => 'سيتم إدراج أي محتوى مضاف هنا في الجزء السفلي من قسم <head> من كل صفحة. هذا أمر مفيد لتجاوز الأنماط أو إضافة رمز التحليل.',
'app_custom_html_disabled_notice' => 'تم تعطيل محتوى HTML الرئيسي المخصص في صفحة الإعدادات هذه لضمان عكس أي تغييرات متتالية.', 'app_custom_html_disabled_notice' => 'تم تعطيل محتوى HTML الرئيسي المخصص في صفحة الإعدادات هذه لضمان عكس أي تغييرات متتالية.',
'app_logo' => 'شعار التطبيق', 'app_logo' => 'شعار التطبيق',
'app_logo_desc' => 'يجب أن تكون الصورة بارتفاع 43 بكسل. <br>سيتم تصغير الصور الأكبر من ذلك.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'اللون الأساسي للتطبيق', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'يجب أن تكون القيمة من نوع hex. <br>اترك الخانة فارغة للرجوع للون الافتراضي.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'الصفحة الرئيسية للتطبيق', 'app_homepage' => 'الصفحة الرئيسية للتطبيق',
'app_homepage_desc' => 'الرجاء اختيار صفحة لتصبح الصفحة الرئيسية بدل من الافتراضية. سيتم تجاهل جميع الأذونات الخاصة بالصفحة المختارة.', 'app_homepage_desc' => 'الرجاء اختيار صفحة لتصبح الصفحة الرئيسية بدل من الافتراضية. سيتم تجاهل جميع الأذونات الخاصة بالصفحة المختارة.',
'app_homepage_select' => 'اختر صفحة', 'app_homepage_select' => 'اختر صفحة',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'تعطيل التعليقات على جميع الصفحات داخل التطبيق. التعليقات الموجودة من الأصل لن تكون ظاهرة.', 'app_disable_comments_desc' => 'تعطيل التعليقات على جميع الصفحات داخل التطبيق. التعليقات الموجودة من الأصل لن تكون ظاهرة.',
// Color settings // Color settings
'content_colors' => 'ألوان المحتوى', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'تعيين الألوان لجميع العناصر في التسلسل الهرمي لتنظيم الصفحات. يوصى باختيار الألوان ذات السطوع المماثل للألوان الافتراضية للقراءة.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'لون الرف', 'bookshelf_color' => 'لون الرف',
'book_color' => 'لون الكتاب', 'book_color' => 'لون الكتاب',
'chapter_color' => 'لون الفصل', 'chapter_color' => 'لون الفصل',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Търси в книгата', 'books_search_this' => 'Търси в книгата',
'books_navigation' => 'Навигация на книгата', 'books_navigation' => 'Навигация на книгата',
'books_sort' => 'Сортирай съдържанието на книгата', 'books_sort' => 'Сортирай съдържанието на книгата',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Сортирай книга :bookName', 'books_sort_named' => 'Сортирай книга :bookName',
'books_sort_name' => 'Сортиране по име', 'books_sort_name' => 'Сортиране по име',
'books_sort_created' => 'Сортирай по дата на създаване', 'books_sort_created' => 'Сортирай по дата на създаване',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Последна глава', 'books_sort_chapters_last' => 'Последна глава',
'books_sort_show_other' => 'Покажи други книги', 'books_sort_show_other' => 'Покажи други книги',
'books_sort_save' => 'Запази новата подредба', 'books_sort_save' => 'Запази новата подредба',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Копирай книгата', 'books_copy' => 'Копирай книгата',
'books_copy_success' => 'Книгата е копирана успешно', 'books_copy_success' => 'Книгата е копирана успешно',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Сървърът не може да създаде малки изображения. Моля, увери се, че разширението GD PHP е инсталирано.', 'cannot_create_thumbs' => 'Сървърът не може да създаде малки изображения. Моля, увери се, че разширението GD PHP е инсталирано.',
'server_upload_limit' => 'Сървърът не позволява качвания с такъв размер. Моля, пробвайте файл с по-малък размер.', 'server_upload_limit' => 'Сървърът не позволява качвания с такъв размер. Моля, пробвайте файл с по-малък размер.',
'uploaded' => 'Сървърът не позволява качвания с такъв размер. Моля, пробвайте файл с по-малък размер.', 'uploaded' => 'Сървърът не позволява качвания с такъв размер. Моля, пробвайте файл с по-малък размер.',
'file_upload_timeout' => 'Качването на файла изтече.',
// Drawing & Images
'image_upload_error' => 'Възникна грешка при качването на изображението', 'image_upload_error' => 'Възникна грешка при качването на изображението',
'image_upload_type_error' => 'Типът на качваното изображение е невалиден', 'image_upload_type_error' => 'Типът на качваното изображение е невалиден',
'file_upload_timeout' => 'Качването на файла изтече.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Прикачения файл не е намерен', 'attachment_not_found' => 'Прикачения файл не е намерен',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Всяко съдържание, добавено тук, ще бъде поставено в долната част на секцията <head> на всяка страница. Това е удобно за преобладаващи стилове или добавяне на код за анализ.', 'app_custom_html_desc' => 'Всяко съдържание, добавено тук, ще бъде поставено в долната част на секцията <head> на всяка страница. Това е удобно за преобладаващи стилове или добавяне на код за анализ.',
'app_custom_html_disabled_notice' => 'Съдържанието на персонализираната HTML шапка е деактивирано на страницата с настройки, за да се гарантира, че евентуални лоши промени могат да бъдат върнати.', 'app_custom_html_disabled_notice' => 'Съдържанието на персонализираната HTML шапка е деактивирано на страницата с настройки, за да се гарантира, че евентуални лоши промени могат да бъдат върнати.',
'app_logo' => 'Лого на приложението', 'app_logo' => 'Лого на приложението',
'app_logo_desc' => 'Това изображение трябва да е с 43px височина. <br> Големите изображения ще бъдат намалени.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'Основен цвят на приложението', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'Изберете основния цвят на приложението, включително на банера, бутоните и линковете.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'Начлна страница на приложението', 'app_homepage' => 'Начлна страница на приложението',
'app_homepage_desc' => 'Изберете начална страница, която ще замени изгледа по подразбиране. Дефинираните права на страницата, която е избрана ще бъдат игнорирани.', 'app_homepage_desc' => 'Изберете начална страница, която ще замени изгледа по подразбиране. Дефинираните права на страницата, която е избрана ще бъдат игнорирани.',
'app_homepage_select' => 'Избери страница', 'app_homepage_select' => 'Избери страница',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Изключва коментарите във всички на страници на приложението. <br> Съществуващите коментари няма да се показват.', 'app_disable_comments_desc' => 'Изключва коментарите във всички на страници на приложението. <br> Съществуващите коментари няма да се показват.',
// Color settings // Color settings
'content_colors' => 'Цвят на съдържанието', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Настройва цветовете за всички елементи на йерархията за организацията на страницата. Избор на цвят с яркост, близка до цветовете по подразбиране, се препоръчва за четимостта.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Цвят на рафта', 'bookshelf_color' => 'Цвят на рафта',
'book_color' => 'Цвят на книгата', 'book_color' => 'Цвят на книгата',
'chapter_color' => 'Цвят на главата', 'chapter_color' => 'Цвят на главата',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Pretraži ovu knjigu', 'books_search_this' => 'Pretraži ovu knjigu',
'books_navigation' => 'Navigacija knjige', 'books_navigation' => 'Navigacija knjige',
'books_sort' => 'Sortiraj sadržaj knjige', 'books_sort' => 'Sortiraj sadržaj knjige',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Sortiraj knjigu :bookName', 'books_sort_named' => 'Sortiraj knjigu :bookName',
'books_sort_name' => 'Sortiraj po imenu', 'books_sort_name' => 'Sortiraj po imenu',
'books_sort_created' => 'Sortiraj po datumu kreiranja', 'books_sort_created' => 'Sortiraj po datumu kreiranja',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Poglavlja zadnja', 'books_sort_chapters_last' => 'Poglavlja zadnja',
'books_sort_show_other' => 'Prikaži druge knjige', 'books_sort_show_other' => 'Prikaži druge knjige',
'books_sort_save' => 'Spremi trenutni poredak', 'books_sort_save' => 'Spremi trenutni poredak',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Copy Book', 'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied', 'books_copy_success' => 'Book successfully copied',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Server ne može kreirati sličice. Provjerite da imate instaliranu GD PHP ekstenziju.', 'cannot_create_thumbs' => 'Server ne može kreirati sličice. Provjerite da imate instaliranu GD PHP ekstenziju.',
'server_upload_limit' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.', 'server_upload_limit' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.',
'uploaded' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.', 'uploaded' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.',
'file_upload_timeout' => 'Vrijeme učitavanja fajla je isteklo.',
// Drawing & Images
'image_upload_error' => 'Desila se greška prilikom učitavanja slike', 'image_upload_error' => 'Desila se greška prilikom učitavanja slike',
'image_upload_type_error' => 'Vrsta slike koja se učitava je neispravna', 'image_upload_type_error' => 'Vrsta slike koja se učitava je neispravna',
'file_upload_timeout' => 'Vrijeme učitavanja fajla je isteklo.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Prilog nije pronađen', 'attachment_not_found' => 'Prilog nije pronađen',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.', 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
'app_logo' => 'Application Logo', 'app_logo' => 'Application Logo',
'app_logo_desc' => 'This image should be 43px in height. <br>Large images will be scaled down.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'Application Primary Color', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'Application Homepage', 'app_homepage' => 'Application Homepage',
'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
'app_homepage_select' => 'Select a page', 'app_homepage_select' => 'Select a page',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.', 'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
// Color settings // Color settings
'content_colors' => 'Content Colors', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Shelf Color', 'bookshelf_color' => 'Shelf Color',
'book_color' => 'Book Color', 'book_color' => 'Book Color',
'chapter_color' => 'Chapter Color', 'chapter_color' => 'Chapter Color',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Cerca en aquest llibre', 'books_search_this' => 'Cerca en aquest llibre',
'books_navigation' => 'Navegació pel llibre', 'books_navigation' => 'Navegació pel llibre',
'books_sort' => 'Ordena el contingut del llibre', 'books_sort' => 'Ordena el contingut del llibre',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Ordena el llibre :bookName', 'books_sort_named' => 'Ordena el llibre :bookName',
'books_sort_name' => 'Ordena per nom', 'books_sort_name' => 'Ordena per nom',
'books_sort_created' => 'Ordena per data de creació', 'books_sort_created' => 'Ordena per data de creació',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Els capítols al final', 'books_sort_chapters_last' => 'Els capítols al final',
'books_sort_show_other' => 'Mostra altres llibres', 'books_sort_show_other' => 'Mostra altres llibres',
'books_sort_save' => 'Desa l\'ordre nou', 'books_sort_save' => 'Desa l\'ordre nou',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Copy Book', 'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied', 'books_copy_success' => 'Book successfully copied',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'El servidor no pot crear miniatures. Reviseu que tingueu instal·lada l\'extensió GD del PHP.', 'cannot_create_thumbs' => 'El servidor no pot crear miniatures. Reviseu que tingueu instal·lada l\'extensió GD del PHP.',
'server_upload_limit' => 'El servidor no permet pujades d\'aquesta mida. Proveu-ho amb una mida de fitxer més petita.', 'server_upload_limit' => 'El servidor no permet pujades d\'aquesta mida. Proveu-ho amb una mida de fitxer més petita.',
'uploaded' => 'El servidor no permet pujades d\'aquesta mida. Proveu-ho amb una mida de fitxer més petita.', 'uploaded' => 'El servidor no permet pujades d\'aquesta mida. Proveu-ho amb una mida de fitxer més petita.',
'file_upload_timeout' => 'La pujada del fitxer ha superat el temps màxim d\'espera.',
// Drawing & Images
'image_upload_error' => 'S\'ha produït un error en pujar la imatge', 'image_upload_error' => 'S\'ha produït un error en pujar la imatge',
'image_upload_type_error' => 'El tipus d\'imatge que heu pujat no és vàlid', 'image_upload_type_error' => 'El tipus d\'imatge que heu pujat no és vàlid',
'file_upload_timeout' => 'La pujada del fitxer ha superat el temps màxim d\'espera.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'No s\'ha trobat l\'adjunció', 'attachment_not_found' => 'No s\'ha trobat l\'adjunció',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Aquí podeu afegir contingut que s\'inserirà a la part final de la secció <head> de cada pàgina. És útil per a sobreescriure estils o afegir-hi codi d\'analítiques.', 'app_custom_html_desc' => 'Aquí podeu afegir contingut que s\'inserirà a la part final de la secció <head> de cada pàgina. És útil per a sobreescriure estils o afegir-hi codi d\'analítiques.',
'app_custom_html_disabled_notice' => 'El contingut personalitzat a la capçalera HTML es desactiva en aquesta pàgina de la configuració per a assegurar que qualsevol canvi que trenqui el web es pugui desfer.', 'app_custom_html_disabled_notice' => 'El contingut personalitzat a la capçalera HTML es desactiva en aquesta pàgina de la configuració per a assegurar que qualsevol canvi que trenqui el web es pugui desfer.',
'app_logo' => 'Logo de l\'aplicació', 'app_logo' => 'Logo de l\'aplicació',
'app_logo_desc' => 'Aquesta imatge hauria de tenir 43 px d\'alçada. <br>Les imatges grosses es reduiran.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'Color primari de l\'aplicació', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'Defineix el color primari de l\'aplicació, incloent-hi la part superior, els botons i els enllaços.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'Pàgina d\'inici de l\'aplicació', 'app_homepage' => 'Pàgina d\'inici de l\'aplicació',
'app_homepage_desc' => 'Seleccioneu la visualització que es mostrarà a la pàgina d\'inici en lloc de la visualització per defecte. Els permisos de pàgines s\'ignoraran per a les pàgines seleccionades.', 'app_homepage_desc' => 'Seleccioneu la visualització que es mostrarà a la pàgina d\'inici en lloc de la visualització per defecte. Els permisos de pàgines s\'ignoraran per a les pàgines seleccionades.',
'app_homepage_select' => 'Selecciona una pàgina', 'app_homepage_select' => 'Selecciona una pàgina',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Desactiva els comentaris a totes les pàgines de l\'aplicació. <br> Els comentaris existents no es mostraran.', 'app_disable_comments_desc' => 'Desactiva els comentaris a totes les pàgines de l\'aplicació. <br> Els comentaris existents no es mostraran.',
// Color settings // Color settings
'content_colors' => 'Colors del contingut', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Defineix els colors de tots els elements de la jerarquia d\'organització de pàgines. És recomanable triar colors amb una brillantor semblant als colors per defecte per a mantenir-ne la llegibilitat.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Color dels prestatges', 'bookshelf_color' => 'Color dels prestatges',
'book_color' => 'Color dels llibres', 'book_color' => 'Color dels llibres',
'chapter_color' => 'Color dels capítols', 'chapter_color' => 'Color dels capítols',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Prohledat tuto knihu', 'books_search_this' => 'Prohledat tuto knihu',
'books_navigation' => 'Navigace knihy', 'books_navigation' => 'Navigace knihy',
'books_sort' => 'Seřadit obsah knihy', 'books_sort' => 'Seřadit obsah knihy',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Seřadit knihu :bookName', 'books_sort_named' => 'Seřadit knihu :bookName',
'books_sort_name' => 'Seřadit podle názvu', 'books_sort_name' => 'Seřadit podle názvu',
'books_sort_created' => 'Seřadit podle data vytvoření', 'books_sort_created' => 'Seřadit podle data vytvoření',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Kapitoly jako poslední', 'books_sort_chapters_last' => 'Kapitoly jako poslední',
'books_sort_show_other' => 'Zobrazit ostatní knihy', 'books_sort_show_other' => 'Zobrazit ostatní knihy',
'books_sort_save' => 'Uložit nové pořadí', 'books_sort_save' => 'Uložit nové pořadí',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Kopírovat knihu', 'books_copy' => 'Kopírovat knihu',
'books_copy_success' => 'Kniha byla úspěšně zkopírována', 'books_copy_success' => 'Kniha byla úspěšně zkopírována',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Server nedokáže udělat náhledy. Zkontrolujte, že rozšíření GD pro PHP je nainstalováno.', 'cannot_create_thumbs' => 'Server nedokáže udělat náhledy. Zkontrolujte, že rozšíření GD pro PHP je nainstalováno.',
'server_upload_limit' => 'Server nepovoluje nahrávat tak veliké soubory. Zkuste prosím menší soubor.', 'server_upload_limit' => 'Server nepovoluje nahrávat tak veliké soubory. Zkuste prosím menší soubor.',
'uploaded' => 'Server nepovoluje nahrávat tak veliké soubory. Zkuste prosím menší soubor.', 'uploaded' => 'Server nepovoluje nahrávat tak veliké soubory. Zkuste prosím menší soubor.',
'file_upload_timeout' => 'Nahrávání souboru trvalo příliš dlouho a tak bylo ukončeno.',
// Drawing & Images
'image_upload_error' => 'Nastala chyba během nahrávání souboru', 'image_upload_error' => 'Nastala chyba během nahrávání souboru',
'image_upload_type_error' => 'Typ nahrávaného obrázku je neplatný.', 'image_upload_type_error' => 'Typ nahrávaného obrázku je neplatný.',
'file_upload_timeout' => 'Nahrávání souboru trvalo příliš dlouho a tak bylo ukončeno.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Příloha nenalezena', 'attachment_not_found' => 'Příloha nenalezena',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Cokoliv sem napíšete bude přidáno na konec sekce <head> v každém místě této aplikace. To se hodí pro přidávání nebo změnu CSS stylů nebo přidání kódu pro analýzu používání (např.: google analytics.).', 'app_custom_html_desc' => 'Cokoliv sem napíšete bude přidáno na konec sekce <head> v každém místě této aplikace. To se hodí pro přidávání nebo změnu CSS stylů nebo přidání kódu pro analýzu používání (např.: google analytics.).',
'app_custom_html_disabled_notice' => 'Na této stránce nastavení je zakázán vlastní obsah HTML hlavičky, aby bylo zajištěno, že bude možné vrátit případnou problematickou úpravu.', 'app_custom_html_disabled_notice' => 'Na této stránce nastavení je zakázán vlastní obsah HTML hlavičky, aby bylo zajištěno, že bude možné vrátit případnou problematickou úpravu.',
'app_logo' => 'Logo aplikace', 'app_logo' => 'Logo aplikace',
'app_logo_desc' => 'Tento obrázek by měl mít výšku 43px. <br>Větší obrázky zmenšíme na tuto velikost.', 'app_logo_desc' => 'Používá se v záhlaví aplikace, a v jiných oblastech. Tento obrázek by měl být velký 86px. Větší obrázky budou zmenšeny.',
'app_primary_color' => 'Hlavní barva aplikace', 'app_icon' => 'Ikona aplikace',
'app_primary_color_desc' => 'Nastaví hlavní barvu aplikace včetně panelů, tlačítek a odkazů.', 'app_icon_desc' => 'Tato ikona se používá pro záložky prohlížeče a ikony zástupců. Obrazek by měl být čtverec o velikosti 256px a formátu PNG.',
'app_homepage' => 'Úvodní stránka aplikace', 'app_homepage' => 'Úvodní stránka aplikace',
'app_homepage_desc' => 'Zvolte si zobrazení, které se použije jako úvodní stránka. U zvolených stránek bude ignorováno jejich oprávnění.', 'app_homepage_desc' => 'Zvolte si zobrazení, které se použije jako úvodní stránka. U zvolených stránek bude ignorováno jejich oprávnění.',
'app_homepage_select' => 'Zvolte stránku', 'app_homepage_select' => 'Zvolte stránku',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Vypne komentáře napříč všemi stránkami. <br> Existující komentáře se přestanou zobrazovat.', 'app_disable_comments_desc' => 'Vypne komentáře napříč všemi stránkami. <br> Existující komentáře se přestanou zobrazovat.',
// Color settings // Color settings
'content_colors' => 'Barvy obsahu', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Nastaví barvy pro všechny prvky v organizační struktuře stránky. Pro lepší čitelnost doporučujeme zvolit barvy s podobným jasem, jakou mají výchozí barvy.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Barva knihovny', 'bookshelf_color' => 'Barva knihovny',
'book_color' => 'Barva knihy', 'book_color' => 'Barva knihy',
'chapter_color' => 'Barva kapitoly', 'chapter_color' => 'Barva kapitoly',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Search this book', 'books_search_this' => 'Search this book',
'books_navigation' => 'Book Navigation', 'books_navigation' => 'Book Navigation',
'books_sort' => 'Sort Book Contents', 'books_sort' => 'Sort Book Contents',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Sort Book :bookName', 'books_sort_named' => 'Sort Book :bookName',
'books_sort_name' => 'Sort by Name', 'books_sort_name' => 'Sort by Name',
'books_sort_created' => 'Sort by Created Date', 'books_sort_created' => 'Sort by Created Date',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Chapters Last', 'books_sort_chapters_last' => 'Chapters Last',
'books_sort_show_other' => 'Show Other Books', 'books_sort_show_other' => 'Show Other Books',
'books_sort_save' => 'Save New Order', 'books_sort_save' => 'Save New Order',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Copy Book', 'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied', 'books_copy_success' => 'Book successfully copied',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Ni all y gweinydd greu mân-luniau. Gwiriwch fod gennych yr estyniad GD PHP wedi\'i osod.', 'cannot_create_thumbs' => 'Ni all y gweinydd greu mân-luniau. Gwiriwch fod gennych yr estyniad GD PHP wedi\'i osod.',
'server_upload_limit' => 'Nid yw\'r gweinydd yn caniatáu uwchlwythiadau o\'r maint hwn. Rhowch gynnig ar faint ffeil llai.', 'server_upload_limit' => 'Nid yw\'r gweinydd yn caniatáu uwchlwythiadau o\'r maint hwn. Rhowch gynnig ar faint ffeil llai.',
'uploaded' => 'Nid yw\'r gweinydd yn caniatáu uwchlwythiadau o\'r maint hwn. Rhowch gynnig ar faint ffeil llai.', 'uploaded' => 'Nid yw\'r gweinydd yn caniatáu uwchlwythiadau o\'r maint hwn. Rhowch gynnig ar faint ffeil llai.',
'file_upload_timeout' => 'Mae\'r amser uwchlwytho ffeil wedi dod i ben.',
// Drawing & Images
'image_upload_error' => 'Bu gwall wrth uwchlwytho\'r ddelwedd', 'image_upload_error' => 'Bu gwall wrth uwchlwytho\'r ddelwedd',
'image_upload_type_error' => 'Mae\'r math o ddelwedd sy\'n cael ei huwchlwytho yn annilys', 'image_upload_type_error' => 'Mae\'r math o ddelwedd sy\'n cael ei huwchlwytho yn annilys',
'file_upload_timeout' => 'Mae\'r amser uwchlwytho ffeil wedi dod i ben.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Ni chanfuwyd yr atodiad', 'attachment_not_found' => 'Ni chanfuwyd yr atodiad',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.', 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
'app_logo' => 'Application Logo', 'app_logo' => 'Application Logo',
'app_logo_desc' => 'This image should be 43px in height. <br>Large images will be scaled down.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'Application Primary Color', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'Application Homepage', 'app_homepage' => 'Application Homepage',
'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
'app_homepage_select' => 'Select a page', 'app_homepage_select' => 'Select a page',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.', 'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
// Color settings // Color settings
'content_colors' => 'Content Colors', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Shelf Color', 'bookshelf_color' => 'Shelf Color',
'book_color' => 'Book Color', 'book_color' => 'Book Color',
'chapter_color' => 'Chapter Color', 'chapter_color' => 'Chapter Color',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Søg i denne bog', 'books_search_this' => 'Søg i denne bog',
'books_navigation' => 'Bognavigation', 'books_navigation' => 'Bognavigation',
'books_sort' => 'Sorter bogindhold', 'books_sort' => 'Sorter bogindhold',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Sorter bog :bookName', 'books_sort_named' => 'Sorter bog :bookName',
'books_sort_name' => 'Sortér efter navn', 'books_sort_name' => 'Sortér efter navn',
'books_sort_created' => 'Sortér efter oprettelsesdato', 'books_sort_created' => 'Sortér efter oprettelsesdato',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Kapitler sidst', 'books_sort_chapters_last' => 'Kapitler sidst',
'books_sort_show_other' => 'Vis andre bøger', 'books_sort_show_other' => 'Vis andre bøger',
'books_sort_save' => 'Gem ny ordre', 'books_sort_save' => 'Gem ny ordre',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Kopier Bog', 'books_copy' => 'Kopier Bog',
'books_copy_success' => 'Bogen blev kopieret', 'books_copy_success' => 'Bogen blev kopieret',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Serveren kan ikke oprette miniaturer. Kontroller, at GD PHP-udvidelsen er installeret.', 'cannot_create_thumbs' => 'Serveren kan ikke oprette miniaturer. Kontroller, at GD PHP-udvidelsen er installeret.',
'server_upload_limit' => 'Serveren tillader ikke uploads af denne størrelse. Prøv en mindre filstørrelse.', 'server_upload_limit' => 'Serveren tillader ikke uploads af denne størrelse. Prøv en mindre filstørrelse.',
'uploaded' => 'Serveren tillader ikke uploads af denne størrelse. Prøv en mindre filstørrelse.', 'uploaded' => 'Serveren tillader ikke uploads af denne størrelse. Prøv en mindre filstørrelse.',
'file_upload_timeout' => 'Filuploaden udløb.',
// Drawing & Images
'image_upload_error' => 'Der opstod en fejl ved upload af billedet', 'image_upload_error' => 'Der opstod en fejl ved upload af billedet',
'image_upload_type_error' => 'Billedtypen, der uploades, er ugyldig', 'image_upload_type_error' => 'Billedtypen, der uploades, er ugyldig',
'file_upload_timeout' => 'Filuploaden udløb.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Vedhæftning ikke fundet', 'attachment_not_found' => 'Vedhæftning ikke fundet',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Alt indhold tilføjet her, vil blive indsat i bunden af <head> sektionen på alle sider. Dette er brugbart til overskrivning af styles og tilføjelse af analytics kode.', 'app_custom_html_desc' => 'Alt indhold tilføjet her, vil blive indsat i bunden af <head> sektionen på alle sider. Dette er brugbart til overskrivning af styles og tilføjelse af analytics kode.',
'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.', 'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.',
'app_logo' => 'Applikationslogo', 'app_logo' => 'Applikationslogo',
'app_logo_desc' => 'Dette billede skal være 43px højt. <br>Store billeder vil blive skaleret ned.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'Primær applikationsfarve', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'Sætter den primære farve for applikationen herunder banneret, knapper og links.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'Applikationsforside', 'app_homepage' => 'Applikationsforside',
'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.', 'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.',
'app_homepage_select' => 'Vælg en side', 'app_homepage_select' => 'Vælg en side',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Deaktiverer kommentarer på tværs af alle sider i applikationen. <br> Eksisterende kommentarer vises ikke.', 'app_disable_comments_desc' => 'Deaktiverer kommentarer på tværs af alle sider i applikationen. <br> Eksisterende kommentarer vises ikke.',
// Color settings // Color settings
'content_colors' => 'Indholdsfarver', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Sætter farver for alle elementer i sideorganisationshierarkiet. Valg af farver med en lignende lysstyrke som standardfarverne anbefales af hensyn til læsbarhed.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Bogreolfarve', 'bookshelf_color' => 'Bogreolfarve',
'book_color' => 'Bogfarve', 'book_color' => 'Bogfarve',
'chapter_color' => 'Kapitelfarve', 'chapter_color' => 'Kapitelfarve',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Dieses Buch durchsuchen', 'books_search_this' => 'Dieses Buch durchsuchen',
'books_navigation' => 'Buchnavigation', 'books_navigation' => 'Buchnavigation',
'books_sort' => 'Buchinhalte sortieren', 'books_sort' => 'Buchinhalte sortieren',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_named' => 'Buch ":bookName" sortieren',
'books_sort_name' => 'Sortieren nach Namen', 'books_sort_name' => 'Sortieren nach Namen',
'books_sort_created' => 'Sortieren nach Erstellungsdatum', 'books_sort_created' => 'Sortieren nach Erstellungsdatum',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Kapitel zuletzt', 'books_sort_chapters_last' => 'Kapitel zuletzt',
'books_sort_show_other' => 'Andere Bücher anzeigen', 'books_sort_show_other' => 'Andere Bücher anzeigen',
'books_sort_save' => 'Neue Reihenfolge speichern', 'books_sort_save' => 'Neue Reihenfolge speichern',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Buch kopieren', 'books_copy' => 'Buch kopieren',
'books_copy_success' => 'Das Buch wurde erfolgreich kopiert', 'books_copy_success' => 'Das Buch wurde erfolgreich kopiert',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Der Server kann keine Vorschau-Bilder erzeugen. Bitte prüfen Sie, ob die GD PHP-Erweiterung installiert ist.', 'cannot_create_thumbs' => 'Der Server kann keine Vorschau-Bilder erzeugen. Bitte prüfen Sie, ob die GD PHP-Erweiterung installiert ist.',
'server_upload_limit' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.', 'server_upload_limit' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.',
'uploaded' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.', 'uploaded' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.',
'file_upload_timeout' => 'Der Datei-Upload hat das Zeitlimit überschritten.',
// Drawing & Images
'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.', 'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.',
'image_upload_type_error' => 'Der Bildtyp der hochgeladenen Datei ist ungültig.', 'image_upload_type_error' => 'Der Bildtyp der hochgeladenen Datei ist ungültig.',
'file_upload_timeout' => 'Der Datei-Upload hat das Zeitlimit überschritten.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Anhang konnte nicht gefunden werden.', 'attachment_not_found' => 'Anhang konnte nicht gefunden werden.',

View File

@ -33,11 +33,9 @@ return [
'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der <head> Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics-Code hinzuzufügen.', 'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der <head> Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics-Code hinzuzufügen.',
'app_custom_html_disabled_notice' => 'Benutzerdefinierte HTML-Kopfzeileninhalte sind auf dieser Einstellungsseite deaktiviert, um sicherzustellen, dass alle Änderungen rückgängig gemacht werden können.', 'app_custom_html_disabled_notice' => 'Benutzerdefinierte HTML-Kopfzeileninhalte sind auf dieser Einstellungsseite deaktiviert, um sicherzustellen, dass alle Änderungen rückgängig gemacht werden können.',
'app_logo' => 'Anwendungslogo', 'app_logo' => 'Anwendungslogo',
'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
Größere Bilder werden verkleinert.', 'app_icon' => 'Application Icon',
'app_primary_color' => 'Primäre Anwendungsfarbe', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein.
Wenn Sie nichts eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt.',
'app_homepage' => 'Startseite der Anwendung', 'app_homepage' => 'Startseite der Anwendung',
'app_homepage_desc' => 'Wählen Sie eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.', 'app_homepage_desc' => 'Wählen Sie eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.',
'app_homepage_select' => 'Wählen Sie eine Seite aus', 'app_homepage_select' => 'Wählen Sie eine Seite aus',
@ -51,8 +49,12 @@ Wenn Sie nichts eingeben, wird die Anwendung auf die Standardfarbe zurückgesetz
'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.', 'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.',
// Color settings // Color settings
'content_colors' => 'Inhaltsfarben', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Legt Farben für alle Elemente in der Seitenorganisationshierarchie fest. Die Auswahl von Farben mit einer ähnlichen Helligkeit wie die Standardfarben wird zur Lesbarkeit empfohlen.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Regalfarbe', 'bookshelf_color' => 'Regalfarbe',
'book_color' => 'Buchfarbe', 'book_color' => 'Buchfarbe',
'chapter_color' => 'Kapitelfarbe', 'chapter_color' => 'Kapitelfarbe',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Dieses Buch durchsuchen', 'books_search_this' => 'Dieses Buch durchsuchen',
'books_navigation' => 'Buchnavigation', 'books_navigation' => 'Buchnavigation',
'books_sort' => 'Buchinhalte sortieren', 'books_sort' => 'Buchinhalte sortieren',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Buch ":bookName" sortieren', 'books_sort_named' => 'Buch ":bookName" sortieren',
'books_sort_name' => 'Sortieren nach Namen', 'books_sort_name' => 'Sortieren nach Namen',
'books_sort_created' => 'Sortieren nach Erstellungsdatum', 'books_sort_created' => 'Sortieren nach Erstellungsdatum',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Kapitel zuletzt', 'books_sort_chapters_last' => 'Kapitel zuletzt',
'books_sort_show_other' => 'Andere Bücher anzeigen', 'books_sort_show_other' => 'Andere Bücher anzeigen',
'books_sort_save' => 'Neue Reihenfolge speichern', 'books_sort_save' => 'Neue Reihenfolge speichern',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Buch kopieren', 'books_copy' => 'Buch kopieren',
'books_copy_success' => 'Buch erfolgreich kopiert', 'books_copy_success' => 'Buch erfolgreich kopiert',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Der Server kann keine Vorschau-Bilder erzeugen. Bitte prüfe, ob die GD PHP-Erweiterung installiert ist.', 'cannot_create_thumbs' => 'Der Server kann keine Vorschau-Bilder erzeugen. Bitte prüfe, ob die GD PHP-Erweiterung installiert ist.',
'server_upload_limit' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuche es mit einer kleineren Datei.', 'server_upload_limit' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuche es mit einer kleineren Datei.',
'uploaded' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuche es mit einer kleineren Datei.', 'uploaded' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuche es mit einer kleineren Datei.',
'file_upload_timeout' => 'Der Upload der Datei ist abgelaufen.',
// Drawing & Images
'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.', 'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.',
'image_upload_type_error' => 'Der Bildtyp der hochgeladenen Datei ist ungültig.', 'image_upload_type_error' => 'Der Bildtyp der hochgeladenen Datei ist ungültig.',
'file_upload_timeout' => 'Der Upload der Datei ist abgelaufen.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Anhang konnte nicht gefunden werden.', 'attachment_not_found' => 'Anhang konnte nicht gefunden werden.',

View File

@ -33,11 +33,9 @@ return [
'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der <head> Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics-Code hinzuzufügen.', 'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der <head> Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics-Code hinzuzufügen.',
'app_custom_html_disabled_notice' => 'Benutzerdefinierte HTML-Kopfzeileninhalte sind auf dieser Einstellungsseite deaktiviert, um sicherzustellen, dass alle Änderungen rückgängig gemacht werden können.', 'app_custom_html_disabled_notice' => 'Benutzerdefinierte HTML-Kopfzeileninhalte sind auf dieser Einstellungsseite deaktiviert, um sicherzustellen, dass alle Änderungen rückgängig gemacht werden können.',
'app_logo' => 'Anwendungslogo', 'app_logo' => 'Anwendungslogo',
'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
Größere Bilder werden verkleinert.', 'app_icon' => 'Application Icon',
'app_primary_color' => 'Primäre Anwendungsfarbe', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein.
Wenn du nichts eingibst, wird die Anwendung auf die Standardfarbe zurückgesetzt.',
'app_homepage' => 'Startseite der Anwendung', 'app_homepage' => 'Startseite der Anwendung',
'app_homepage_desc' => 'Wähle eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.', 'app_homepage_desc' => 'Wähle eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.',
'app_homepage_select' => 'Wähle eine Seite aus', 'app_homepage_select' => 'Wähle eine Seite aus',
@ -51,8 +49,12 @@ Wenn du nichts eingibst, wird die Anwendung auf die Standardfarbe zurückgesetzt
'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.', 'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.',
// Color settings // Color settings
'content_colors' => 'Inhaltsfarben', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Legt Farben für alle Elemente in der Seitenorganisationshierarchie fest. Die Auswahl von Farben mit einer ähnlichen Helligkeit wie die Standardfarben wird zur Lesbarkeit empfohlen.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Regalfarbe', 'bookshelf_color' => 'Regalfarbe',
'book_color' => 'Buchfarbe', 'book_color' => 'Buchfarbe',
'chapter_color' => 'Kapitelfarbe', 'chapter_color' => 'Kapitelfarbe',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Αναζήτηση σε αυτό το βιβλίο', 'books_search_this' => 'Αναζήτηση σε αυτό το βιβλίο',
'books_navigation' => 'Πλοήγηση Βιβλίου', 'books_navigation' => 'Πλοήγηση Βιβλίου',
'books_sort' => 'Ταξινόμηση Περιεχομένων Βιβλίου', 'books_sort' => 'Ταξινόμηση Περιεχομένων Βιβλίου',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Ταξινόμηση Βιβλίου :bookname', 'books_sort_named' => 'Ταξινόμηση Βιβλίου :bookname',
'books_sort_name' => 'Ταξινόμηση κατά όνομα', 'books_sort_name' => 'Ταξινόμηση κατά όνομα',
'books_sort_created' => 'Ταξινόμηση κατά ημερομηνία δημιουργίας', 'books_sort_created' => 'Ταξινόμηση κατά ημερομηνία δημιουργίας',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Τελευταία Κεφάλαια', 'books_sort_chapters_last' => 'Τελευταία Κεφάλαια',
'books_sort_show_other' => 'Εμφάνιση Άλλων Βιβλίων', 'books_sort_show_other' => 'Εμφάνιση Άλλων Βιβλίων',
'books_sort_save' => 'Αποθήκευση Νέας Ταξινόμησης', 'books_sort_save' => 'Αποθήκευση Νέας Ταξινόμησης',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Αντιγραφή Βιβλίου', 'books_copy' => 'Αντιγραφή Βιβλίου',
'books_copy_success' => 'Το βιβλίο αντιγράφηκε επιτυχώς', 'books_copy_success' => 'Το βιβλίο αντιγράφηκε επιτυχώς',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'Ο διακομιστής δεν μπορεί να δημιουργήσει μικρογραφίες. Παρακαλώ ελέγξτε ότι έχετε την επέκταση GD PHP εγκατεστημένη.', 'cannot_create_thumbs' => 'Ο διακομιστής δεν μπορεί να δημιουργήσει μικρογραφίες. Παρακαλώ ελέγξτε ότι έχετε την επέκταση GD PHP εγκατεστημένη.',
'server_upload_limit' => 'Ο διακομιστής δεν επιτρέπει τη μεταφόρτωση αυτού του μεγέθους. Παρακαλώ δοκιμάστε ένα μικρότερο μέγεθος αρχείου.', 'server_upload_limit' => 'Ο διακομιστής δεν επιτρέπει τη μεταφόρτωση αυτού του μεγέθους. Παρακαλώ δοκιμάστε ένα μικρότερο μέγεθος αρχείου.',
'uploaded' => 'Ο διακομιστής δεν επιτρέπει τη μεταφόρτωση αυτού του μεγέθους. Παρακαλώ δοκιμάστε ένα μικρότερο μέγεθος αρχείου.', 'uploaded' => 'Ο διακομιστής δεν επιτρέπει τη μεταφόρτωση αυτού του μεγέθους. Παρακαλώ δοκιμάστε ένα μικρότερο μέγεθος αρχείου.',
'file_upload_timeout' => 'Το χρονικό όριο μεταφόρτωσης αρχείου έληξε.',
// Drawing & Images
'image_upload_error' => 'Παρουσιάστηκε σφάλμα κατά το ανέβασμα της εικόνας.', 'image_upload_error' => 'Παρουσιάστηκε σφάλμα κατά το ανέβασμα της εικόνας.',
'image_upload_type_error' => 'Ο τύπος εικόνας που μεταφορτώθηκε δεν είναι έγκυρος', 'image_upload_type_error' => 'Ο τύπος εικόνας που μεταφορτώθηκε δεν είναι έγκυρος',
'file_upload_timeout' => 'Το χρονικό όριο μεταφόρτωσης αρχείου έληξε.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Το συνημμένο δεν βρέθηκε', 'attachment_not_found' => 'Το συνημμένο δεν βρέθηκε',

View File

@ -33,9 +33,9 @@ return [
'app_custom_html_desc' => 'Οποιοδήποτε περιεχόμενο προστίθεται εδώ θα εισαχθεί στο κάτω μέρος της ενότητας <head> κάθε σελίδας. Αυτό είναι βολικό για την παράκαμψη ή προσθήκη στυλ καθώς και την προσθήκη κώδικα αναλυτικών στοιχείων.', 'app_custom_html_desc' => 'Οποιοδήποτε περιεχόμενο προστίθεται εδώ θα εισαχθεί στο κάτω μέρος της ενότητας <head> κάθε σελίδας. Αυτό είναι βολικό για την παράκαμψη ή προσθήκη στυλ καθώς και την προσθήκη κώδικα αναλυτικών στοιχείων.',
'app_custom_html_disabled_notice' => 'Το προσαρμοσμένο περιεχόμενο κεφαλίδας HTML είναι απενεργοποιημένο σε αυτήν τη σελίδα ρυθμίσεων, για να διασφαλιστεί ότι τυχόν αλλαγές που θα πραγματοποιηθούν και θα προκαλέσουν δυσλειτουργία στην ιστοσελίδα σας, μπορούν να επαναφερθούν.', 'app_custom_html_disabled_notice' => 'Το προσαρμοσμένο περιεχόμενο κεφαλίδας HTML είναι απενεργοποιημένο σε αυτήν τη σελίδα ρυθμίσεων, για να διασφαλιστεί ότι τυχόν αλλαγές που θα πραγματοποιηθούν και θα προκαλέσουν δυσλειτουργία στην ιστοσελίδα σας, μπορούν να επαναφερθούν.',
'app_logo' => 'Λογότυπο εφαρμογής', 'app_logo' => 'Λογότυπο εφαρμογής',
'app_logo_desc' => 'Αυτή η εικόνα πρέπει να έχει <b>μέγιστο ύψος</b> 43px. <br>Οι μεγάλες εικόνες θα μειωθούν.', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.',
'app_primary_color' => 'Βασικό, χρώμα εφαρμογής', 'app_icon' => 'Application Icon',
'app_primary_color_desc' => 'Ορίζει το κύριο χρώμα για την εφαρμογή, συμπεριλαμβανομένων του banner, των κουμπιών και των συνδέσμων.', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.',
'app_homepage' => 'Αρχική σελίδα εφαρμογής', 'app_homepage' => 'Αρχική σελίδα εφαρμογής',
'app_homepage_desc' => 'Επιλέξτε μια προβολή για εμφάνιση στην αρχική σελίδα αντί για την προεπιλεγμένη προβολή. Τα δικαιώματα σελίδων αγνοούνται για επιλεγμένες σελίδες.', 'app_homepage_desc' => 'Επιλέξτε μια προβολή για εμφάνιση στην αρχική σελίδα αντί για την προεπιλεγμένη προβολή. Τα δικαιώματα σελίδων αγνοούνται για επιλεγμένες σελίδες.',
'app_homepage_select' => 'Επιλέξτε μια σελίδα', 'app_homepage_select' => 'Επιλέξτε μια σελίδα',
@ -49,8 +49,12 @@ return [
'app_disable_comments_desc' => 'Απενεργοποιεί τα σχόλια σε όλες τις σελίδες της εφαρμογής. <br> Τα υπάρχοντα σχόλια δεν εμφανίζονται.', 'app_disable_comments_desc' => 'Απενεργοποιεί τα σχόλια σε όλες τις σελίδες της εφαρμογής. <br> Τα υπάρχοντα σχόλια δεν εμφανίζονται.',
// Color settings // Color settings
'content_colors' => 'Χρώματα εφαρμογής', 'color_scheme' => 'Application Color Scheme',
'content_colors_desc' => 'Ορίζει τα χρώματα για όλα τα στοιχεία στην ιεραρχία οργάνωσης της ιστοσελίδας. <br/><b>Συνιστάται</b> η επιλογή χρωμάτων <u>με παρόμοια φωτεινότητα</u> με τα προεπιλεγμένα χρώματα για αναγνωσιμότητα.', 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
'ui_colors_desc' => 'Set the primary color and default link color for BookStack. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the Bookstack interface.',
'app_color' => 'Primary Color',
'link_color' => 'Default Link Color',
'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
'bookshelf_color' => 'Χρώμα Ραφιού', 'bookshelf_color' => 'Χρώμα Ραφιού',
'book_color' => 'Χρώμα Βιβλίων', 'book_color' => 'Χρώμα Βιβλίων',
'chapter_color' => 'Χρώμα Κεφαλαίων Βιβλίων', 'chapter_color' => 'Χρώμα Κεφαλαίων Βιβλίων',

View File

@ -141,6 +141,7 @@ return [
'books_search_this' => 'Search this book', 'books_search_this' => 'Search this book',
'books_navigation' => 'Book Navigation', 'books_navigation' => 'Book Navigation',
'books_sort' => 'Sort Book Contents', 'books_sort' => 'Sort Book Contents',
'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.',
'books_sort_named' => 'Sort Book :bookName', 'books_sort_named' => 'Sort Book :bookName',
'books_sort_name' => 'Sort by Name', 'books_sort_name' => 'Sort by Name',
'books_sort_created' => 'Sort by Created Date', 'books_sort_created' => 'Sort by Created Date',
@ -149,6 +150,17 @@ return [
'books_sort_chapters_last' => 'Chapters Last', 'books_sort_chapters_last' => 'Chapters Last',
'books_sort_show_other' => 'Show Other Books', 'books_sort_show_other' => 'Show Other Books',
'books_sort_save' => 'Save New Order', 'books_sort_save' => 'Save New Order',
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
'books_sort_move_up' => 'Move Up',
'books_sort_move_down' => 'Move Down',
'books_sort_move_prev_book' => 'Move to Previous Book',
'books_sort_move_next_book' => 'Move to Next Book',
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
'books_sort_move_book_start' => 'Move to Start of Book',
'books_sort_move_book_end' => 'Move to End of Book',
'books_sort_move_before_chapter' => 'Move to Before Chapter',
'books_sort_move_after_chapter' => 'Move to After Chapter',
'books_copy' => 'Copy Book', 'books_copy' => 'Copy Book',
'books_copy_success' => 'Book successfully copied', 'books_copy_success' => 'Book successfully copied',

View File

@ -45,9 +45,12 @@ return [
'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.',
'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.',
'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.',
'file_upload_timeout' => 'The file upload has timed out.',
// Drawing & Images
'image_upload_error' => 'An error occurred uploading the image', 'image_upload_error' => 'An error occurred uploading the image',
'image_upload_type_error' => 'The image type being uploaded is invalid', 'image_upload_type_error' => 'The image type being uploaded is invalid',
'file_upload_timeout' => 'The file upload has timed out.', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments // Attachments
'attachment_not_found' => 'Attachment not found', 'attachment_not_found' => 'Attachment not found',

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