diff --git a/.env.example b/.env.example index 4dee3b334..13114b8b0 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,13 @@ DB_DATABASE=database_database DB_USERNAME=database_username DB_PASSWORD=database_user_password +# Storage system to use +# By default files are stored on the local filesystem, with images being placed in +# public web space so they can be efficiently served directly by the web-server. +# For other options with different security levels & considerations, refer to: +# https://www.bookstackapp.com/docs/admin/upload-config/ +STORAGE_TYPE=local + # Mail system to use # Can be 'smtp' or 'sendmail' MAIL_DRIVER=smtp diff --git a/.env.example.complete b/.env.example.complete index 25687aaac..18e7bd00d 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -36,10 +36,14 @@ APP_LANG=en # APP_LANG will be used if such a header is not provided. APP_AUTO_LANG_PUBLIC=true -# Application timezone -# Used where dates are displayed such as on exported content. +# Application timezones +# The first option is used to determine what timezone is used for date storage. +# Leaving that as "UTC" is advised. +# The second option is used to set the timezone which will be used for date +# formatting and display. This defaults to the "APP_TIMEZONE" value. # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php APP_TIMEZONE=UTC +APP_DISPLAY_TIMEZONE=UTC # Application theme # Used to specific a themes/ folder where BookStack UI diff --git a/.github/translators.txt b/.github/translators.txt index bcedaaf62..b69770939 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German Rem (Rem9000) :: Dutch Michał Stelmach (stelmach-web) :: Polish arniom :: French -REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish; +REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish; 林祖年 (contagion) :: Chinese Traditional Siamak Guodarzi (siamakgoudarzi88) :: Persian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian @@ -222,7 +222,7 @@ SmokingCrop :: Dutch Maciej Lebiest (Szwendacz) :: Polish DiscordDigital :: German; German Informal Gábor Marton (dodver) :: Hungarian -Jasell :: Swedish +Jakob Åsell (Jasell) :: Swedish Ghost_chu (ghostchu) :: Chinese Simplified Ravid Shachar (ravidshachar) :: Hebrew Helga Guchshenskaya (guchshenskaya) :: Russian @@ -503,3 +503,21 @@ Firr (FirrV) :: Russian João Faro (FaroJoaoFaro) :: Portuguese Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian Chris (furesoft) :: German +Silvia Isern (eiendragon) :: Catalan +Dennis Kron Pedersen (ahjdp) :: Danish +iamwhoiamwhoami :: Swedish +Grogui :: French +MrCharlesIII :: Arabic +David Olsen (dawin) :: Danish +ltnzr :: French +Frank Holler (holler.frank) :: German; German Informal +Korab Arifi (korabidev) :: Albanian +Petr Husák (petrhusak) :: Czech +Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian +Amr (amr3k) :: Arabic +Tahsin Ahmed (tahsinahmed2012) :: Bengali +bojan_che :: Serbian (Cyrillic) +setiawan setiawan (culture.setiawan) :: Indonesian +Donald Mac Kenzie (kiuman) :: Norwegian Bokmal +Gabriel Silver (GabrielBSilver) :: Hebrew +Tomas Darius Davainis (Tomasdd) :: Lithuanian diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 23e58a772..80075c3f7 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 64132f673..5f4c16caf 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 diff --git a/LICENSE b/LICENSE index 9b94d9be3..d7961a613 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors. +Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/app/Access/Controllers/OidcController.php b/app/Access/Controllers/OidcController.php index 055d4c140..654ed6928 100644 --- a/app/Access/Controllers/OidcController.php +++ b/app/Access/Controllers/OidcController.php @@ -9,11 +9,9 @@ use Illuminate\Http\Request; class OidcController extends Controller { - protected OidcService $oidcService; - - public function __construct(OidcService $oidcService) - { - $this->oidcService = $oidcService; + public function __construct( + protected OidcService $oidcService + ) { $this->middleware('guard:oidc'); } @@ -30,7 +28,7 @@ class OidcController extends Controller return redirect('/login'); } - session()->flash('oidc_state', $loginDetails['state']); + session()->put('oidc_state', time() . ':' . $loginDetails['state']); return redirect($loginDetails['url']); } @@ -41,10 +39,16 @@ class OidcController extends Controller */ public function callback(Request $request) { - $storedState = session()->pull('oidc_state'); $responseState = $request->query('state'); + $splitState = explode(':', session()->pull('oidc_state', ':'), 2); + if (count($splitState) !== 2) { + $splitState = [null, null]; + } - if ($storedState !== $responseState) { + [$storedStateTime, $storedState] = $splitState; + $threeMinutesAgo = time() - 3 * 60; + + if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) { $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); return redirect('/login'); @@ -62,7 +66,7 @@ class OidcController extends Controller } /** - * Log the user out then start the OIDC RP-initiated logout process. + * Log the user out, then start the OIDC RP-initiated logout process. */ public function logout() { diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index 2b5ddfbf3..2165fd459 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -2,33 +2,18 @@ namespace BookStack\Access; +use BookStack\Users\Models\User; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\UserProvider; -use Illuminate\Database\Eloquent\Model; class ExternalBaseUserProvider implements UserProvider { - public function __construct( - protected string $model - ) { - } - - /** - * Create a new instance of the model. - */ - public function createModel(): Model - { - $class = '\\' . ltrim($this->model, '\\'); - - return new $class(); - } - /** * Retrieve a user by their unique identifier. */ public function retrieveById(mixed $identifier): ?Authenticatable { - return $this->createModel()->newQuery()->find($identifier); + return User::query()->find($identifier); } /** @@ -59,10 +44,7 @@ class ExternalBaseUserProvider implements UserProvider */ public function retrieveByCredentials(array $credentials): ?Authenticatable { - // Search current user base by looking up a uid - $model = $this->createModel(); - - return $model->newQuery() + return User::query() ->where('external_auth_id', $credentials['external_auth_id']) ->first(); } diff --git a/app/Access/Guards/AsyncExternalBaseSessionGuard.php b/app/Access/Guards/AsyncExternalBaseSessionGuard.php index ab982a6b0..b66fbe95e 100644 --- a/app/Access/Guards/AsyncExternalBaseSessionGuard.php +++ b/app/Access/Guards/AsyncExternalBaseSessionGuard.php @@ -3,23 +3,18 @@ namespace BookStack\Access\Guards; /** - * Saml2 Session Guard. + * External Auth Session Guard. * - * The saml2 login process is async in nature meaning it does not fit very well - * into the default laravel 'Guard' auth flow. Instead most of the logic is done - * via the Saml2 controller & Saml2Service. This class provides a safer, thin - * version of SessionGuard. + * The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well + * into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant + * controller and services. This class provides a safer, thin version of SessionGuard. */ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard { /** * Validate a user's credentials. - * - * @param array $credentials - * - * @return bool */ - public function validate(array $credentials = []) + public function validate(array $credentials = []): bool { return false; } @@ -27,12 +22,9 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard /** * Attempt to authenticate a user using the given credentials. * - * @param array $credentials * @param bool $remember - * - * @return bool */ - public function attempt(array $credentials = [], $remember = false) + public function attempt(array $credentials = [], $remember = false): bool { return false; } diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php index 2d15422a5..91239599b 100644 --- a/app/Access/Guards/ExternalBaseSessionGuard.php +++ b/app/Access/Guards/ExternalBaseSessionGuard.php @@ -4,7 +4,7 @@ namespace BookStack\Access\Guards; use BookStack\Access\RegistrationService; use Illuminate\Auth\GuardHelpers; -use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Session\Session; @@ -24,43 +24,31 @@ class ExternalBaseSessionGuard implements StatefulGuard * The name of the Guard. Typically "session". * * Corresponds to guard name in authentication configuration. - * - * @var string */ - protected $name; + protected readonly string $name; /** * The user we last attempted to retrieve. - * - * @var \Illuminate\Contracts\Auth\Authenticatable */ - protected $lastAttempted; + protected Authenticatable|null $lastAttempted; /** * The session used by the guard. - * - * @var \Illuminate\Contracts\Session\Session */ - protected $session; + protected Session $session; /** * Indicates if the logout method has been called. - * - * @var bool */ - protected $loggedOut = false; + protected bool $loggedOut = false; /** * Service to handle common registration actions. - * - * @var RegistrationService */ - protected $registrationService; + protected RegistrationService $registrationService; /** * Create a new authentication guard. - * - * @return void */ public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService) { @@ -72,13 +60,11 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Get the currently authenticated user. - * - * @return \Illuminate\Contracts\Auth\Authenticatable|null */ - public function user() + public function user(): Authenticatable|null { if ($this->loggedOut) { - return; + return null; } // If we've already retrieved the user for the current request we can just @@ -101,13 +87,11 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Get the ID for the currently authenticated user. - * - * @return int|null */ - public function id() + public function id(): int|null { if ($this->loggedOut) { - return; + return null; } return $this->user() @@ -117,12 +101,8 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Log a user into the application without sessions or cookies. - * - * @param array $credentials - * - * @return bool */ - public function once(array $credentials = []) + public function once(array $credentials = []): bool { if ($this->validate($credentials)) { $this->setUser($this->lastAttempted); @@ -135,12 +115,8 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Log the given user ID into the application without sessions or cookies. - * - * @param mixed $id - * - * @return \Illuminate\Contracts\Auth\Authenticatable|false */ - public function onceUsingId($id) + public function onceUsingId($id): Authenticatable|false { if (!is_null($user = $this->provider->retrieveById($id))) { $this->setUser($user); @@ -153,38 +129,26 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Validate a user's credentials. - * - * @param array $credentials - * - * @return bool */ - public function validate(array $credentials = []) + public function validate(array $credentials = []): bool { return false; } /** * Attempt to authenticate a user using the given credentials. - * - * @param array $credentials - * @param bool $remember - * - * @return bool + * @param bool $remember */ - public function attempt(array $credentials = [], $remember = false) + public function attempt(array $credentials = [], $remember = false): bool { return false; } /** * Log the given user ID into the application. - * - * @param mixed $id * @param bool $remember - * - * @return \Illuminate\Contracts\Auth\Authenticatable|false */ - public function loginUsingId($id, $remember = false) + public function loginUsingId(mixed $id, $remember = false): Authenticatable|false { // Always return false as to disable this method, // Logins should route through LoginService. @@ -194,12 +158,9 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Log a user into the application. * - * @param \Illuminate\Contracts\Auth\Authenticatable $user - * @param bool $remember - * - * @return void + * @param bool $remember */ - public function login(AuthenticatableContract $user, $remember = false) + public function login(Authenticatable $user, $remember = false): void { $this->updateSession($user->getAuthIdentifier()); @@ -208,12 +169,8 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Update the session with the given ID. - * - * @param string $id - * - * @return void */ - protected function updateSession($id) + protected function updateSession(string|int $id): void { $this->session->put($this->getName(), $id); @@ -222,10 +179,8 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Log the user out of the application. - * - * @return void */ - public function logout() + public function logout(): void { $this->clearUserDataFromStorage(); @@ -239,62 +194,48 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Remove the user data from the session and cookies. - * - * @return void */ - protected function clearUserDataFromStorage() + protected function clearUserDataFromStorage(): void { $this->session->remove($this->getName()); } /** * Get the last user we attempted to authenticate. - * - * @return \Illuminate\Contracts\Auth\Authenticatable */ - public function getLastAttempted() + public function getLastAttempted(): Authenticatable { return $this->lastAttempted; } /** * Get a unique identifier for the auth session value. - * - * @return string */ - public function getName() + public function getName(): string { return 'login_' . $this->name . '_' . sha1(static::class); } /** * Determine if the user was authenticated via "remember me" cookie. - * - * @return bool */ - public function viaRemember() + public function viaRemember(): bool { return false; } /** * Return the currently cached user. - * - * @return \Illuminate\Contracts\Auth\Authenticatable|null */ - public function getUser() + public function getUser(): Authenticatable|null { return $this->user; } /** * Set the current user. - * - * @param \Illuminate\Contracts\Auth\Authenticatable $user - * - * @return $this */ - public function setUser(AuthenticatableContract $user) + public function setUser(Authenticatable $user): self { $this->user = $user; diff --git a/app/Access/Guards/LdapSessionGuard.php b/app/Access/Guards/LdapSessionGuard.php index bd020f70b..9455d530d 100644 --- a/app/Access/Guards/LdapSessionGuard.php +++ b/app/Access/Guards/LdapSessionGuard.php @@ -35,13 +35,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard /** * Validate a user's credentials. * - * @param array $credentials - * * @throws LdapException - * - * @return bool */ - public function validate(array $credentials = []) + public function validate(array $credentials = []): bool { $userDetails = $this->ldapService->getUserDetails($credentials['username']); diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index 6607969af..c81e95572 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -9,6 +9,7 @@ use BookStack\Exceptions\LoginAttemptInvalidUserException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Facades\Activity; use BookStack\Facades\Theme; +use BookStack\Permissions\Permission; use BookStack\Theming\ThemeEvents; use BookStack\Users\Models\User; use Exception; @@ -50,7 +51,7 @@ class LoginService Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user); // Authenticate on all session guards if a likely admin - if ($user->can('users-manage') && $user->can('user-roles-manage')) { + if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) { $guards = ['standard', 'ldap', 'saml2', 'oidc']; foreach ($guards as $guard) { auth($guard)->login($user); @@ -95,7 +96,7 @@ class LoginService { $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY); if (!$value) { - return ['user_id' => null, 'method' => null]; + return ['user_id' => null, 'method' => null, 'remember' => false]; } [$id, $method, $remember, $time] = explode(':', $value); @@ -103,18 +104,18 @@ class LoginService if ($time < $hourAgo) { $this->clearLastLoginAttempted(); - return ['user_id' => null, 'method' => null]; + return ['user_id' => null, 'method' => null, 'remember' => false]; } return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)]; } /** - * Set the last login attempted user. + * Set the last login-attempted user. * Must be only used when credentials are correct and a login could be - * achieved but a secondary factor has stopped the login. + * achieved, but a secondary factor has stopped the login. */ - protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember) + protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void { session()->put( self::LAST_LOGIN_ATTEMPTED_SESSION_KEY, diff --git a/app/Access/Mfa/MfaSession.php b/app/Access/Mfa/MfaSession.php index 09b9e53b8..b12853412 100644 --- a/app/Access/Mfa/MfaSession.php +++ b/app/Access/Mfa/MfaSession.php @@ -11,7 +11,6 @@ class MfaSession */ public function isRequiredForUser(User $user): bool { - // TODO - Test both these cases return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user); } diff --git a/app/Access/Mfa/MfaValue.php b/app/Access/Mfa/MfaValue.php index 64d20eb18..dd3e04618 100644 --- a/app/Access/Mfa/MfaValue.php +++ b/app/Access/Mfa/MfaValue.php @@ -4,6 +4,7 @@ namespace BookStack\Access\Mfa; use BookStack\Users\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; /** @@ -16,6 +17,8 @@ use Illuminate\Database\Eloquent\Model; */ class MfaValue extends Model { + use HasFactory; + protected static $unguarded = true; const METHOD_TOTP = 'totp'; diff --git a/app/Access/Mfa/TotpService.php b/app/Access/Mfa/TotpService.php index 1d1af451a..f13637a3f 100644 --- a/app/Access/Mfa/TotpService.php +++ b/app/Access/Mfa/TotpService.php @@ -14,10 +14,9 @@ use PragmaRX\Google2FA\Support\Constants; class TotpService { - protected $google2fa; - - public function __construct(Google2FA $google2fa) - { + public function __construct( + protected Google2FA $google2fa + ) { $this->google2fa = $google2fa; // Use SHA1 as a default, Personal testing of other options in 2021 found // many apps lack support for other algorithms yet still will scan @@ -35,7 +34,7 @@ class TotpService } /** - * Generate a TOTP URL from secret key. + * Generate a TOTP URL from a secret key. */ public function generateUrl(string $secret, User $user): string { diff --git a/app/Access/Saml2Service.php b/app/Access/Saml2Service.php index bb7e9b572..106a7a229 100644 --- a/app/Access/Saml2Service.php +++ b/app/Access/Saml2Service.php @@ -51,7 +51,7 @@ class Saml2Service * Returns the SAML2 request ID, and the URL to redirect the user to. * * @throws Error - * @returns array{url: string, id: ?string} + * @return array{url: string, id: ?string} */ public function logout(User $user): array { diff --git a/app/Access/SocialAccount.php b/app/Access/SocialAccount.php index b76dbb9ec..f52f74cc4 100644 --- a/app/Access/SocialAccount.php +++ b/app/Access/SocialAccount.php @@ -5,18 +5,23 @@ namespace BookStack\Access; use BookStack\Activity\Models\Loggable; use BookStack\App\Model; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * Class SocialAccount. - * * @property string $driver * @property User $user */ class SocialAccount extends Model implements Loggable { - protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps']; + use HasFactory; - public function user() + protected $fillable = ['user_id', 'driver', 'driver_id']; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/app/Access/SocialDriverManager.php b/app/Access/SocialDriverManager.php index dafc0e82d..efafab560 100644 --- a/app/Access/SocialDriverManager.php +++ b/app/Access/SocialDriverManager.php @@ -55,7 +55,7 @@ class SocialDriverManager /** * Gets the names of the active social drivers, keyed by driver id. - * @returns array + * @return array */ public function getActive(): array { diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 7005f8fcf..1802e3905 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -4,10 +4,11 @@ namespace BookStack\Activity; use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; use BookStack\Exceptions\NotifyException; -use BookStack\Exceptions\PrettyException; use BookStack\Facades\Activity as ActivityService; use BookStack\Util\HtmlDescriptionFilter; +use Illuminate\Database\Eloquent\Builder; class CommentRepo { @@ -19,11 +20,46 @@ class CommentRepo return Comment::query()->findOrFail($id); } + /** + * Get a comment by ID, ensuring it is visible to the user based upon access to the page + * which the comment is attached to. + */ + public function getVisibleById(int $id): Comment + { + return $this->getQueryForVisible()->findOrFail($id); + } + + /** + * Start a query for comments visible to the user. + * @return Builder + */ + public function getQueryForVisible(): Builder + { + return Comment::query()->scopes('visible'); + } + /** * Create a new comment on an entity. */ public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment { + // Prevent comments being added to draft pages + if ($entity instanceof Page && $entity->draft) { + throw new \Exception(trans('errors.cannot_add_comment_to_draft')); + } + + // Validate parent ID + if ($parentId !== null) { + $parentCommentExists = Comment::query() + ->where('commentable_id', '=', $entity->id) + ->where('commentable_type', '=', $entity->getMorphClass()) + ->where('local_id', '=', $parentId) + ->exists(); + if (!$parentCommentExists) { + $parentId = null; + } + } + $userId = user()->id; $comment = new Comment(); @@ -38,6 +74,7 @@ class CommentRepo ActivityService::add(ActivityType::COMMENT_CREATE, $comment); ActivityService::add(ActivityType::COMMENTED_ON, $entity); + $comment->refresh()->unsetRelations(); return $comment; } @@ -59,7 +96,7 @@ class CommentRepo /** * Archive an existing comment. */ - public function archive(Comment $comment): Comment + public function archive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be archived.', '/', 400); @@ -68,7 +105,9 @@ class CommentRepo $comment->archived = true; $comment->save(); - ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + if ($log) { + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + } return $comment; } @@ -76,7 +115,7 @@ class CommentRepo /** * Un-archive an existing comment. */ - public function unarchive(Comment $comment): Comment + public function unarchive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); @@ -85,7 +124,9 @@ class CommentRepo $comment->archived = false; $comment->save(); - ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + if ($log) { + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + } return $comment; } diff --git a/app/Activity/Controllers/AuditLogApiController.php b/app/Activity/Controllers/AuditLogApiController.php index 650d17446..0cb4d9cb6 100644 --- a/app/Activity/Controllers/AuditLogApiController.php +++ b/app/Activity/Controllers/AuditLogApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\Models\Activity; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; class AuditLogApiController extends ApiController { @@ -16,8 +17,8 @@ class AuditLogApiController extends ApiController */ public function list() { - $this->checkPermission('settings-manage'); - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::UsersManage); $query = Activity::query()->with(['user']); diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index 66ca30197..c4f9b91ed 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Activity; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Sorting\SortUrl; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -13,8 +14,8 @@ class AuditLogController extends Controller { public function index(Request $request) { - $this->checkPermission('settings-manage'); - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::UsersManage); $sort = $request->get('sort', 'activity_date'); $order = $request->get('order', 'desc'); diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php new file mode 100644 index 000000000..6c60de9da --- /dev/null +++ b/app/Activity/Controllers/CommentApiController.php @@ -0,0 +1,148 @@ + [ + 'page_id' => ['required', 'integer'], + 'reply_to' => ['nullable', 'integer'], + 'html' => ['required', 'string'], + 'content_ref' => ['string'], + ], + 'update' => [ + 'html' => ['string'], + 'archived' => ['boolean'], + ] + ]; + + public function __construct( + protected CommentRepo $commentRepo, + protected PageQueries $pageQueries, + ) { + } + + /** + * Get a listing of comments visible to the user. + */ + public function list(): JsonResponse + { + $query = $this->commentRepo->getQueryForVisible(); + + return $this->apiListingResponse($query, [ + 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' + ]); + } + + /** + * Create a new comment on a page. + * If commenting as a reply to an existing comment, the 'reply_to' parameter + * should be provided, set to the 'local_id' of the comment being replied to. + */ + public function create(Request $request): JsonResponse + { + $this->checkPermission(Permission::CommentCreateAll); + + $input = $this->validate($request, $this->rules()['create']); + $page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']); + + $comment = $this->commentRepo->create( + $page, + $input['html'], + $input['reply_to'] ?? null, + $input['content_ref'] ?? '', + ); + + return response()->json($comment); + } + + /** + * Read the details of a single comment, along with its direct replies. + */ + public function read(string $id): JsonResponse + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $comment->load('createdBy', 'updatedBy'); + + $replies = $this->commentRepo->getQueryForVisible() + ->where('parent_id', '=', $comment->local_id) + ->where('commentable_id', '=', $comment->commentable_id) + ->where('commentable_type', '=', $comment->commentable_type) + ->get(); + + /** @var Comment[] $toProcess */ + $toProcess = [$comment, ...$replies]; + foreach ($toProcess as $commentToProcess) { + $commentToProcess->setAttribute('html', $commentToProcess->safeHtml()); + $commentToProcess->makeVisible('html'); + } + + $comment->setRelation('replies', $replies); + + return response()->json($comment); + } + + + /** + * Update the content or archived status of an existing comment. + * + * Only provide a new archived status if needing to actively change the archive state. + * Only top-level comments (non-replies) can be archived or unarchived. + */ + public function update(Request $request, string $id): JsonResponse + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $this->checkOwnablePermission(Permission::CommentUpdate, $comment); + + $input = $this->validate($request, $this->rules()['update']); + $hasHtml = isset($input['html']); + + if (isset($input['archived'])) { + if ($input['archived']) { + $this->commentRepo->archive($comment, !$hasHtml); + } else { + $this->commentRepo->unarchive($comment, !$hasHtml); + } + } + + if ($hasHtml) { + $comment = $this->commentRepo->update($comment, $input['html']); + } + + return response()->json($comment); + } + + /** + * Delete a single comment from the system. + */ + public function delete(string $id): Response + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $this->checkOwnablePermission(Permission::CommentDelete, $comment); + + $this->commentRepo->delete($comment); + + return response('', 204); + } +} diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index 479d57c4d..f61a2c8df 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -7,6 +7,7 @@ use BookStack\Activity\Tools\CommentTree; use BookStack\Activity\Tools\CommentTreeNode; use BookStack\Entities\Queries\PageQueries; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -21,7 +22,7 @@ class CommentController extends Controller /** * Save a new comment for a Page. * - * @throws ValidationException + * @throws ValidationException|\Exception */ public function savePageComment(Request $request, int $pageId) { @@ -36,13 +37,8 @@ class CommentController extends Controller return response('Not found', 404); } - // Prevent adding comments to draft pages - if ($page->draft) { - return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400); - } - // Create a new comment. - $this->checkPermission('comment-create-all'); + $this->checkPermission(Permission::CommentCreateAll); $contentRef = $input['content_ref'] ?? ''; $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef); @@ -64,8 +60,8 @@ class CommentController extends Controller ]); $comment = $this->commentRepo->getById($commentId); - $this->checkOwnablePermission('page-view', $comment->entity); - $this->checkOwnablePermission('comment-update', $comment); + $this->checkOwnablePermission(Permission::PageView, $comment->entity); + $this->checkOwnablePermission(Permission::CommentUpdate, $comment); $comment = $this->commentRepo->update($comment, $input['html']); @@ -81,8 +77,8 @@ class CommentController extends Controller public function archive(int $id) { $comment = $this->commentRepo->getById($id); - $this->checkOwnablePermission('page-view', $comment->entity); - if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { + $this->checkOwnablePermission(Permission::PageView, $comment->entity); + if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { $this->showPermissionError(); } @@ -101,8 +97,8 @@ class CommentController extends Controller public function unarchive(int $id) { $comment = $this->commentRepo->getById($id); - $this->checkOwnablePermission('page-view', $comment->entity); - if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { + $this->checkOwnablePermission(Permission::PageView, $comment->entity); + if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { $this->showPermissionError(); } @@ -121,7 +117,7 @@ class CommentController extends Controller public function destroy(int $id) { $comment = $this->commentRepo->getById($id); - $this->checkOwnablePermission('comment-delete', $comment); + $this->checkOwnablePermission(Permission::CommentDelete, $comment); $this->commentRepo->delete($comment); diff --git a/app/Activity/Controllers/WatchController.php b/app/Activity/Controllers/WatchController.php index 5df75da39..b77a893ea 100644 --- a/app/Activity/Controllers/WatchController.php +++ b/app/Activity/Controllers/WatchController.php @@ -5,13 +5,14 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Tools\MixedEntityRequestHelper; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use Illuminate\Http\Request; class WatchController extends Controller { public function update(Request $request, MixedEntityRequestHelper $entityHelper) { - $this->checkPermission('receive-notifications'); + $this->checkPermission(Permission::ReceiveNotifications); $this->preventGuestAccess(); $requestData = $this->validate($request, array_merge([ diff --git a/app/Activity/Controllers/WebhookController.php b/app/Activity/Controllers/WebhookController.php index dcca1dc41..6a65b8363 100644 --- a/app/Activity/Controllers/WebhookController.php +++ b/app/Activity/Controllers/WebhookController.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Webhook; use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -14,7 +15,7 @@ class WebhookController extends Controller public function __construct() { $this->middleware([ - 'can:settings-manage', + Permission::SettingsManage->middleware() ]); } diff --git a/app/Activity/Models/Activity.php b/app/Activity/Models/Activity.php index ac9fec517..898a6c93a 100644 --- a/app/Activity/Models/Activity.php +++ b/app/Activity/Models/Activity.php @@ -6,6 +6,7 @@ use BookStack\App\Model; use BookStack\Entities\Models\Entity; use BookStack\Permissions\Models\JointPermission; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -24,6 +25,8 @@ use Illuminate\Support\Str; */ class Activity extends Model { + use HasFactory; + /** * Get the loggable model related to this activity. * Currently only used for entities (previously entity_[id/type] columns). diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 91cea4fe0..0f8e5d785 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -3,48 +3,68 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; +use BookStack\Permissions\Models\JointPermission; +use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; +use BookStack\Users\Models\OwnableInterface; use BookStack\Util\HtmlContentFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property int $id - * @property string $text - Deprecated & now unused (#4821) * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id - * @property string $entity_type - * @property int $entity_id - * @property int $created_by - * @property int $updated_by + * @property string $commentable_type + * @property int $commentable_id * @property string $content_ref * @property bool $archived */ -class Comment extends Model implements Loggable +class Comment extends Model implements Loggable, OwnableInterface { use HasFactory; use HasCreatorAndUpdater; protected $fillable = ['parent_id']; + protected $hidden = ['html']; + + protected $casts = [ + 'archived' => 'boolean', + ]; /** * Get the entity that this comment belongs to. */ public function entity(): MorphTo { - return $this->morphTo('entity'); + // We specifically define null here to avoid the different name (commentable) + // being used by Laravel eager loading instead of the method name, which it was doing + // in some scenarios like when deserialized when going through the queue system. + // So we instead specify the type and id column names to use. + // Related to: + // https://github.com/laravel/framework/pull/24815 + // https://github.com/laravel/framework/issues/27342 + // https://github.com/laravel/framework/issues/47953 + // (and probably more) + + // Ultimately, we could just align the method name to 'commentable' but that would be a potential + // breaking change and not really worthwhile in a patch due to the risk of creating extra problems. + return $this->morphTo(null, 'commentable_type', 'commentable_id'); } /** * Get the parent comment this is in reply to (if existing). + * @return BelongsTo */ public function parent(): BelongsTo { return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent') - ->where('entity_type', '=', $this->entity_type) - ->where('entity_id', '=', $this->entity_id); + ->where('commentable_type', '=', $this->commentable_type) + ->where('commentable_id', '=', $this->commentable_id); } /** @@ -57,11 +77,27 @@ class Comment extends Model implements Loggable public function logDescriptor(): string { - return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})"; + return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})"; } public function safeHtml(): string { return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? ''); } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type'); + } + + /** + * Scope the query to just the comments visible to the user based upon the + * user visibility of what has been commented on. + */ + public function scopeVisible(Builder $query): Builder + { + return app()->make(PermissionApplicator::class) + ->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type'); + } } diff --git a/app/Activity/Models/Favourite.php b/app/Activity/Models/Favourite.php index 6f6079b07..6b5e97dee 100644 --- a/app/Activity/Models/Favourite.php +++ b/app/Activity/Models/Favourite.php @@ -4,11 +4,14 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; use BookStack\Permissions\Models\JointPermission; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; class Favourite extends Model { + use HasFactory; + protected $fillable = ['user_id']; /** diff --git a/app/Activity/Models/MentionHistory.php b/app/Activity/Models/MentionHistory.php new file mode 100644 index 000000000..7386a4d74 --- /dev/null +++ b/app/Activity/Models/MentionHistory.php @@ -0,0 +1,20 @@ +whereIn('id', array_unique($userIds))->get(); + /** @var User $user */ foreach ($users as $user) { // Prevent sending to the user that initiated the activity if ($user->id === $initiator->id) { @@ -26,7 +28,7 @@ abstract class BaseNotificationHandler implements NotificationHandler } // Prevent sending of the user does not have notification permissions - if (!$user->can('receive-notifications')) { + if (!$user->can(Permission::ReceiveNotifications)) { continue; } diff --git a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php index bc12c8566..daacfba56 100644 --- a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php @@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler $watcherIds = $watchers->getWatcherUserIds(); // Page owner if user preferences allow - if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { + if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); if ($userNotificationPrefs->notifyOnOwnPageComments()) { $watcherIds[] = $page->owned_by; @@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler // Parent comment creator if preferences allow $parentComment = $detail->parent()->first(); - if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { + if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy); if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) { $watcherIds[] = $parentComment->created_by; diff --git a/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php new file mode 100644 index 000000000..50c8cb8ab --- /dev/null +++ b/app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php @@ -0,0 +1,85 @@ +entity instanceof Page)) { + throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page"); + } + + /** @var Page $page */ + $page = $detail->entity; + + $parser = new MentionParser(); + $mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html); + $realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get(); + + $receivingNotifications = $realMentionedUsers->filter(function (User $user) { + $prefs = new UserNotificationPreferences($user); + return $prefs->notifyOnCommentMentions(); + }); + $receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray(); + + $userMentionsToLog = $realMentionedUsers; + + // When an edit, we check our history to see if we've already notified the user about this comment before + // so that we can filter them out to avoid double notifications. + if ($activity->type === ActivityType::COMMENT_UPDATE) { + $previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail); + $receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds)); + $userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) { + return !in_array($user->id, $previouslyNotifiedUserIds); + }); + } + + $this->logMentions($userMentionsToLog, $detail, $user); + $this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page); + } + + /** + * @param Collection $mentionedUsers + */ + protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void + { + $mentions = []; + $now = Carbon::now(); + + foreach ($mentionedUsers as $mentionedUser) { + $mentions[] = [ + 'mentionable_type' => $comment->getMorphClass(), + 'mentionable_id' => $comment->id, + 'from_user_id' => $fromUser->id, + 'to_user_id' => $mentionedUser->id, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + MentionHistory::query()->insert($mentions); + } + + protected function getPreviouslyNotifiedUserIds(Comment $comment): array + { + return MentionHistory::query() + ->where('mentionable_id', $comment->id) + ->where('mentionable_type', $comment->getMorphClass()) + ->pluck('to_user_id') + ->toArray(); + } +} diff --git a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php index 20dc0fc4d..c9489d70e 100644 --- a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php @@ -39,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler $watchers = new EntityWatchers($detail, WatchLevels::UPDATES); $watcherIds = $watchers->getWatcherUserIds(); - // Add page owner if preferences allow - if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { + // Add the page owner if preferences allow + if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); if ($userNotificationPrefs->notifyOnOwnPageChanges()) { $watcherIds[] = $detail->owned_by; diff --git a/app/Activity/Notifications/Messages/CommentMentionNotification.php b/app/Activity/Notifications/Messages/CommentMentionNotification.php new file mode 100644 index 000000000..de9e71963 --- /dev/null +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -0,0 +1,37 @@ +detail; + /** @var Page $page */ + $page = $comment->entity; + + $locale = $notifiable->getLocale(); + + $listLines = array_filter([ + $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), + $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), + $locale->trans('notifications.detail_commenter') => $this->user->name, + $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + ]); + + return $this->newMailMessage($locale) + ->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()])) + ->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine($listLines)) + ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine($locale)); + } +} diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php index 294f56ebb..8a6c26ffb 100644 --- a/app/Activity/Notifications/NotificationManager.php +++ b/app/Activity/Notifications/NotificationManager.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Loggable; use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler; +use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler; use BookStack\Activity\Notifications\Handlers\NotificationHandler; use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler; @@ -48,5 +49,7 @@ class NotificationManager $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class); } } diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index a05a9d247..68f4a94d3 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -4,6 +4,7 @@ namespace BookStack\Activity\Tools; use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Page; +use BookStack\Permissions\Permission; class CommentTree { @@ -12,6 +13,11 @@ class CommentTree * @var CommentTreeNode[] */ protected array $tree; + + /** + * A linear array of loaded comments. + * @var Comment[] + */ protected array $comments; public function __construct( @@ -38,7 +44,7 @@ class CommentTree public function getActive(): array { - return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived); + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); } public function activeThreadCount(): int @@ -48,7 +54,7 @@ class CommentTree public function getArchived(): array { - return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived); + return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); } public function archivedThreadCount(): int @@ -70,7 +76,7 @@ class CommentTree public function canUpdateAny(): bool { foreach ($this->comments as $comment) { - if (userCan('comment-update', $comment)) { + if (userCan(Permission::CommentUpdate, $comment)) { return true; } } @@ -78,6 +84,14 @@ class CommentTree return false; } + public function loadVisibleHtml(): void + { + foreach ($this->comments as $comment) { + $comment->setAttribute('html', $comment->safeHtml()); + $comment->makeVisible('html'); + } + } + /** * @param Comment[] $comments * @return CommentTreeNode[] @@ -122,6 +136,9 @@ class CommentTree return new CommentTreeNode($byId[$id], $depth, $children); } + /** + * @return Comment[] + */ protected function loadComments(): array { if (!$this->enabled()) { diff --git a/app/Activity/Tools/MentionParser.php b/app/Activity/Tools/MentionParser.php new file mode 100644 index 000000000..d7bcac5e6 --- /dev/null +++ b/app/Activity/Tools/MentionParser.php @@ -0,0 +1,28 @@ +queryXPath('//a[@data-mention-user-id]'); + + foreach ($mentionLinks as $link) { + if ($link instanceof DOMElement) { + $id = intval($link->getAttribute('data-mention-user-id')); + if ($id > 0) { + $ids[] = $id; + } + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/app/Activity/Tools/TagClassGenerator.php b/app/Activity/Tools/TagClassGenerator.php index 5bcb44113..0f7aa1fe0 100644 --- a/app/Activity/Tools/TagClassGenerator.php +++ b/app/Activity/Tools/TagClassGenerator.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Permissions\Permission; class TagClassGenerator { @@ -26,14 +27,14 @@ class TagClassGenerator array_push($classes, ...$this->generateClassesForTag($tag)); } - if ($this->entity instanceof BookChild && userCan('view', $this->entity->book)) { + if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) { $bookTags = $this->entity->book->tags; foreach ($bookTags as $bookTag) { array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-')); } } - if ($this->entity instanceof Page && $this->entity->chapter && userCan('view', $this->entity->chapter)) { + if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) { $chapterTags = $this->entity->chapter->tags; foreach ($chapterTags as $chapterTag) { array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-')); diff --git a/app/Activity/Tools/UserEntityWatchOptions.php b/app/Activity/Tools/UserEntityWatchOptions.php index 559d7903d..8e5f70758 100644 --- a/app/Activity/Tools/UserEntityWatchOptions.php +++ b/app/Activity/Tools/UserEntityWatchOptions.php @@ -7,6 +7,7 @@ use BookStack\Activity\WatchLevels; use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; @@ -22,7 +23,7 @@ class UserEntityWatchOptions public function canWatch(): bool { - return $this->user->can('receive-notifications') && !$this->user->isGuest(); + return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest(); } public function getWatchLevel(): string diff --git a/app/Activity/WatchLevels.php b/app/Activity/WatchLevels.php index de3c5e122..edbece2d3 100644 --- a/app/Activity/WatchLevels.php +++ b/app/Activity/WatchLevels.php @@ -36,7 +36,7 @@ class WatchLevels /** * Get all the possible values as an option_name => value array. - * @returns array + * @return array */ public static function all(): array { @@ -50,7 +50,7 @@ class WatchLevels /** * Get the watch options suited for the given entity. - * @returns array + * @return array */ public static function allSuitedFor(Entity $entity): array { diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 287c83877..eb8f5508c 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -83,11 +83,19 @@ class ApiDocsGenerator protected function loadDetailsFromControllers(Collection $routes): Collection { return $routes->map(function (array $route) { + $class = $this->getReflectionClass($route['controller']); $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); $comment = $method->getDocComment(); - $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null; + $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); + // Load class description for the model + // Not ideal to have it here on each route, but adding it in a more structured manner would break + // docs resulting JSON format and therefore be an API break. + // Save refactoring for a more significant set of changes. + $classComment = $class->getDocComment(); + $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null; + return $route; }); } @@ -140,7 +148,7 @@ class ApiDocsGenerator /** * Parse out the description text from a class method comment. */ - protected function parseDescriptionFromMethodComment(string $comment): string + protected function parseDescriptionFromDocBlockComment(string $comment): string { $matches = []; preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); @@ -155,6 +163,16 @@ class ApiDocsGenerator * @throws ReflectionException */ protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod + { + return $this->getReflectionClass($className)->getMethod($methodName); + } + + /** + * Get a reflection class from the given class name. + * + * @throws ReflectionException + */ + protected function getReflectionClass(string $className): ReflectionClass { $class = $this->reflectionClasses[$className] ?? null; if ($class === null) { @@ -162,7 +180,7 @@ class ApiDocsGenerator $this->reflectionClasses[$className] = $class; } - return $class->getMethod($methodName); + return $class; } /** diff --git a/app/Api/ApiTokenGuard.php b/app/Api/ApiTokenGuard.php index 6302884a9..9f4537b29 100644 --- a/app/Api/ApiTokenGuard.php +++ b/app/Api/ApiTokenGuard.php @@ -4,6 +4,7 @@ namespace BookStack\Api; use BookStack\Access\LoginService; use BookStack\Exceptions\ApiAuthException; +use BookStack\Permissions\Permission; use Illuminate\Auth\GuardHelpers; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Guard; @@ -146,7 +147,7 @@ class ApiTokenGuard implements Guard throw new ApiAuthException(trans('errors.api_user_token_expired'), 403); } - if (!$token->user->can('access-api')) { + if (!$token->user->can(Permission::AccessApi)) { throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); } } diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php index 3606e8260..2ca9e2235 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -4,6 +4,7 @@ namespace BookStack\Api; use BookStack\Activity\ActivityType; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -16,8 +17,8 @@ class UserApiTokenController extends Controller */ public function create(Request $request, int $userId) { - $this->checkPermission('access-api'); - $this->checkPermissionOrCurrentUser('users-manage', $userId); + $this->checkPermission(Permission::AccessApi); + $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->updateContext($request); $user = User::query()->findOrFail($userId); @@ -35,8 +36,8 @@ class UserApiTokenController extends Controller */ public function store(Request $request, int $userId) { - $this->checkPermission('access-api'); - $this->checkPermissionOrCurrentUser('users-manage', $userId); + $this->checkPermission(Permission::AccessApi); + $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->validate($request, [ 'name' => ['required', 'max:250'], @@ -143,8 +144,8 @@ class UserApiTokenController extends Controller */ protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array { - $this->checkPermissionOr('users-manage', function () use ($userId) { - return $userId === user()->id && userCan('access-api'); + $this->checkPermissionOr(Permission::UsersManage, function () use ($userId) { + return $userId === user()->id && userCan(Permission::AccessApi); }); $user = User::query()->findOrFail($userId); diff --git a/app/App/HomeController.php b/app/App/HomeController.php index 0585e0af5..00e2db3df 100644 --- a/app/App/HomeController.php +++ b/app/App/HomeController.php @@ -83,7 +83,7 @@ class HomeController extends Controller if ($homepageOption === 'bookshelves') { $shelves = $this->queries->shelves->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) - ->paginate(18); + ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); $data = array_merge($commonData, ['shelves' => $shelves]); return view('home.shelves', $data); @@ -92,7 +92,7 @@ class HomeController extends Controller if ($homepageOption === 'books') { $books = $this->queries->books->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) - ->paginate(18); + ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); $data = array_merge($commonData, ['books' => $books]); return view('home.books', $data); diff --git a/app/App/Model.php b/app/App/Model.php index 8de5a2762..e1c7511c1 100644 --- a/app/App/Model.php +++ b/app/App/Model.php @@ -8,7 +8,7 @@ class Model extends EloquentModel { /** * Provides public access to get the raw attribute value from the model. - * Used in areas where no mutations are required but performance is critical. + * Used in areas where no mutations are required, but performance is critical. * * @return mixed */ diff --git a/app/App/Providers/AppServiceProvider.php b/app/App/Providers/AppServiceProvider.php index 9012a07eb..debba7944 100644 --- a/app/App/Providers/AppServiceProvider.php +++ b/app/App/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Access\SocialDriverManager; +use BookStack\Activity\Models\Comment; use BookStack\Activity\Tools\ActivityLogger; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; @@ -73,6 +74,7 @@ class AppServiceProvider extends ServiceProvider 'book' => Book::class, 'chapter' => Chapter::class, 'page' => Page::class, + 'comment' => Comment::class, ]); } } diff --git a/app/App/Providers/AuthServiceProvider.php b/app/App/Providers/AuthServiceProvider.php index 23c339079..6a8162521 100644 --- a/app/App/Providers/AuthServiceProvider.php +++ b/app/App/Providers/AuthServiceProvider.php @@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider */ public function register(): void { - Auth::provider('external-users', function ($app, array $config) { - return new ExternalBaseUserProvider($config['model']); + Auth::provider('external-users', function () { + return new ExternalBaseUserProvider(); }); // Bind and provide the default system user as a singleton to the app instance when needed. diff --git a/app/App/Providers/EventServiceProvider.php b/app/App/Providers/EventServiceProvider.php index 34ab7cfef..60e78efe0 100644 --- a/app/App/Providers/EventServiceProvider.php +++ b/app/App/Providers/EventServiceProvider.php @@ -15,7 +15,7 @@ class EventServiceProvider extends ServiceProvider /** * The event listener mappings for the application. * - * @var array> + * @var array> */ protected $listen = [ SocialiteWasCalled::class => [ diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 7115dcb51..6771e513f 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Entities\BreadcrumbsViewComposer; +use BookStack\Util\DateFormatter; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider; class ViewTweaksServiceProvider extends ServiceProvider { + public function register() + { + $this->app->singleton(DateFormatter::class, function ($app) { + return new DateFormatter( + $app['config']->get('app.display_timezone'), + ); + }); + } + /** * Bootstrap services. */ @@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider // View Composers View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); + // View Globals + View::share('dates', $this->app->make(DateFormatter::class)); + // Custom blade view directives Blade::directive('icon', function ($expression) { return "toHtml(); ?>"; diff --git a/app/App/Sluggable.php b/app/App/Sluggable.php deleted file mode 100644 index f8da2e265..000000000 --- a/app/App/Sluggable.php +++ /dev/null @@ -1,18 +0,0 @@ -can($permission); @@ -55,7 +56,7 @@ function userCan(string $permission, ?Model $ownable = null): bool * Check if the current user can perform the given action on any items in the system. * Can be provided the class name of an entity to filter ability to that specific entity type. */ -function userCanOnAny(string $action, string $entityClass = ''): bool +function userCanOnAny(string|Permission $action, string $entityClass = ''): bool { $permissions = app()->make(PermissionApplicator::class); diff --git a/app/Config/app.php b/app/Config/app.php index b96d0bdb7..40e542d3e 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -70,8 +70,8 @@ return [ // A list of the sources/hostnames that can be reached by application SSR calls. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // Host-specific functionality (usually controlled via other options) like auth - // or user avatars for example, won't use this list. - // Space seperated if multiple. Can use '*' as a wildcard. + // or user avatars, for example, won't use this list. + // Space separated if multiple. Can use '*' as a wildcard. // Values will be compared prefix-matched, case-insensitive, against called SSR urls. // Defaults to allow all hosts. 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), @@ -80,8 +80,10 @@ return [ // Integer value between 0 (IP hidden) to 4 (Full IP usage) 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), - // Application timezone for back-end date functions. + // Application timezone for stored date/time values. 'timezone' => env('APP_TIMEZONE', 'UTC'), + // Application timezone for displayed date/time values in the UI. + 'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')), // Default locale to use // A default variant is also stored since Laravel can overwrite diff --git a/app/Config/cache.php b/app/Config/cache.php index 9a0be8eab..01c822a65 100644 --- a/app/Config/cache.php +++ b/app/Config/cache.php @@ -85,6 +85,6 @@ return [ | */ - 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'), + 'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'), ]; diff --git a/app/Config/database.php b/app/Config/database.php index 8d38a86df..6fc861312 100644 --- a/app/Config/database.php +++ b/app/Config/database.php @@ -75,13 +75,14 @@ return [ 'collation' => 'utf8mb4_unicode_ci', // Prefixes are only semi-supported and may be unstable // since they are not tested as part of our automated test suite. - // If used, the prefix should not be changed otherwise you will likely receive errors. + // If used, the prefix should not be changed; otherwise you will likely receive errors. 'prefix' => env('DB_TABLE_PREFIX', ''), 'prefix_indexes' => true, 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + // @phpstan-ignore class.notFound + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -103,9 +104,7 @@ return [ ], // Migration Repository Table - // This table keeps track of all the migrations that have already run for - // your application. Using this information, we can determine which of - // the migrations on disk haven't actually been run in the database. + // This table keeps track of all the migrations that have already run for the application. 'migrations' => 'migrations', // Redis configuration to use if set diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index ab73fec29..facf5f2df 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -11,7 +11,7 @@ return [ // Default Filesystem Disk - // Options: local, local_secure, s3 + // Options: local, local_secure, local_secure_restricted, s3 'default' => env('STORAGE_TYPE', 'local'), // Filesystem to use specifically for image uploads. diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index 88c4612ca..2f270b283 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -41,6 +41,7 @@ return [ 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), + 'notifications#comment-mentions' => true, ], ]; diff --git a/app/Console/Commands/CreateAdminCommand.php b/app/Console/Commands/CreateAdminCommand.php index 82b6e50aa..bf72553f7 100644 --- a/app/Console/Commands/CreateAdminCommand.php +++ b/app/Console/Commands/CreateAdminCommand.php @@ -8,7 +8,6 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Illuminate\Validation\Rules\Password; -use Illuminate\Validation\Rules\Unique; class CreateAdminCommand extends Command { @@ -21,7 +20,9 @@ class CreateAdminCommand extends Command {--email= : The email address for the new admin user} {--name= : The name of the new admin user} {--password= : The password to assign to the new admin user} - {--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}'; + {--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)} + {--generate-password : Generate a random password for the new admin user} + {--initial : Indicate if this should set/update the details of the initial admin user}'; /** * The console command description. @@ -35,26 +36,12 @@ class CreateAdminCommand extends Command */ public function handle(UserRepo $userRepo): int { - $details = $this->snakeCaseOptions(); - - if (empty($details['email'])) { - $details['email'] = $this->ask('Please specify an email address for the new admin user'); - } - - if (empty($details['name'])) { - $details['name'] = $this->ask('Please specify a name for the new admin user'); - } - - if (empty($details['password'])) { - if (empty($details['external_auth_id'])) { - $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)'); - } else { - $details['password'] = Str::random(32); - } - } + $initialAdminOnly = $this->option('initial'); + $shouldGeneratePassword = $this->option('generate-password'); + $details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly); $validator = Validator::make($details, [ - 'email' => ['required', 'email', 'min:5', new Unique('users', 'email')], + 'email' => ['required', 'email', 'min:5'], 'name' => ['required', 'min:2'], 'password' => ['required_without:external_auth_id', Password::default()], 'external_auth_id' => ['required_without:password'], @@ -68,16 +55,101 @@ class CreateAdminCommand extends Command return 1; } + $adminRole = Role::getSystemRole('admin'); + + if ($initialAdminOnly) { + $handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole); + if ($handled !== null) { + return $handled; + } + } + + $emailUsed = $userRepo->getByEmail($details['email']) !== null; + if ($emailUsed) { + $this->error("Could not create admin account."); + $this->error("An account with the email address \"{$details['email']}\" already exists."); + return 1; + } + $user = $userRepo->createWithoutActivity($validator->validated()); - $user->attachRole(Role::getSystemRole('admin')); + $user->attachRole($adminRole); $user->email_confirmed = true; $user->save(); - $this->info("Admin account with email \"{$user->email}\" successfully created!"); + if ($shouldGeneratePassword) { + $this->line($details['password']); + } else { + $this->info("Admin account with email \"{$user->email}\" successfully created!"); + } return 0; } + /** + * Handle updates to the original admin account if it exists. + * Returns an int return status if handled, otherwise returns null if not handled (new user to be created). + */ + protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null + { + $defaultAdmin = $userRepo->getByEmail('admin@admin.com'); + if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) { + if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) { + $this->error("Could not create admin account."); + $this->error("An account with the email address \"{$data['email']}\" already exists."); + return 1; + } + + $userRepo->updateWithoutActivity($defaultAdmin, $data, true); + if ($generatePassword) { + $this->line($data['password']); + } else { + $this->info("The default admin user has been updated with the provided details!"); + } + + return 0; + } else if ($adminRole->users()->count() > 0) { + $this->warn('Non-default admin user already exists. Skipping creation of new admin user.'); + return 2; + } + + return null; + } + + protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array + { + $details = $this->snakeCaseOptions(); + + if (empty($details['email'])) { + if ($initialAdmin) { + $details['email'] = 'admin@example.com'; + } else { + $details['email'] = $this->ask('Please specify an email address for the new admin user'); + } + } + + if (empty($details['name'])) { + if ($initialAdmin) { + $details['name'] = 'Admin'; + } else { + $details['name'] = $this->ask('Please specify a name for the new admin user'); + } + } + + if (empty($details['password'])) { + if (empty($details['external_auth_id'])) { + if ($generatePassword) { + $details['password'] = Str::random(32); + } else { + $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)'); + } + } else { + $details['password'] = Str::random(32); + } + } + + return $details; + } + protected function snakeCaseOptions(): array { $returnOpts = []; diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php index e155878d3..fd86e0706 100644 --- a/app/Console/Commands/UpdateUrlCommand.php +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -45,14 +45,12 @@ class UpdateUrlCommand extends Command $columnsToUpdateByTable = [ 'attachments' => ['path'], - 'pages' => ['html', 'text', 'markdown'], - 'chapters' => ['description_html'], - 'books' => ['description_html'], - 'bookshelves' => ['description_html'], + 'entity_page_data' => ['html', 'text', 'markdown'], + 'entity_container_data' => ['description_html'], 'page_revisions' => ['html', 'text', 'markdown'], 'images' => ['url'], 'settings' => ['value'], - 'comments' => ['html', 'text'], + 'comments' => ['html'], ]; foreach ($columnsToUpdateByTable as $table => $columns) { diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index a617ee2da..325f0583c 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -11,6 +11,7 @@ use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -47,7 +48,7 @@ class BookApiController extends ApiController */ public function create(Request $request) { - $this->checkPermission('book-create-all'); + $this->checkPermission(Permission::BookCreateAll); $requestData = $this->validate($request, $this->rules()['create']); $book = $this->bookRepo->create($requestData); @@ -57,7 +58,7 @@ class BookApiController extends ApiController /** * View the details of a single book. - * The response data will contain 'content' property listing the chapter and pages directly within, in + * The response data will contain a 'content' property listing the chapter and pages directly within, in * the same structure as you'd see within the BookStack interface when viewing a book. Top-level * contents will have a 'type' property to distinguish between pages & chapters. */ @@ -92,7 +93,7 @@ class BookApiController extends ApiController public function update(Request $request, string $id) { $book = $this->queries->findVisibleByIdOrFail(intval($id)); - $this->checkOwnablePermission('book-update', $book); + $this->checkOwnablePermission(Permission::BookUpdate, $book); $requestData = $this->validate($request, $this->rules()['update']); $book = $this->bookRepo->update($book, $requestData); @@ -109,7 +110,7 @@ class BookApiController extends ApiController public function delete(string $id) { $book = $this->queries->findVisibleByIdOrFail(intval($id)); - $this->checkOwnablePermission('book-delete', $book); + $this->checkOwnablePermission(Permission::BookDelete, $book); $this->bookRepo->destroy($book); @@ -121,9 +122,10 @@ class BookApiController extends ApiController $book = clone $book; $book->unsetRelations()->refresh(); - $book->load(['tags', 'cover']); - $book->makeVisible('description_html') - ->setAttribute('description_html', $book->descriptionHtml()); + $book->load(['tags']); + $book->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $book->descriptionInfo()->getHtml()) + ->setAttribute('cover', $book->coverInfo()->getImage()); return $book; } diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 5d3d67f64..c94057fa9 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -8,6 +8,7 @@ use BookStack\Activity\Models\View; use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\Cloner; @@ -17,6 +18,7 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\NotFoundException; use BookStack\Facades\Activity; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\References\ReferenceFetcher; use BookStack\Util\DatabaseTransaction; use BookStack\Util\SimpleListOptions; @@ -30,6 +32,7 @@ class BookController extends Controller protected ShelfContext $shelfContext, protected BookRepo $bookRepo, protected BookQueries $queries, + protected EntityQueries $entityQueries, protected BookshelfQueries $shelfQueries, protected ReferenceFetcher $referenceFetcher, ) { @@ -49,7 +52,7 @@ class BookController extends Controller $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) - ->paginate(18); + ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $popular = $this->queries->popularForList()->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); @@ -73,12 +76,12 @@ class BookController extends Controller */ public function create(?string $shelfSlug = null) { - $this->checkPermission('book-create-all'); + $this->checkPermission(Permission::BookCreateAll); $bookshelf = null; if ($shelfSlug !== null) { $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); - $this->checkOwnablePermission('bookshelf-update', $bookshelf); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); } $this->setPageTitle(trans('entities.books_create')); @@ -96,7 +99,7 @@ class BookController extends Controller */ public function store(Request $request, ?string $shelfSlug = null) { - $this->checkPermission('book-create-all'); + $this->checkPermission(Permission::BookCreateAll); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], @@ -108,7 +111,7 @@ class BookController extends Controller $bookshelf = null; if ($shelfSlug !== null) { $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); - $this->checkOwnablePermission('bookshelf-update', $bookshelf); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); } $book = $this->bookRepo->create($validated); @@ -126,7 +129,16 @@ class BookController extends Controller */ public function show(Request $request, ActivityQueries $activities, string $slug) { - $book = $this->queries->findVisibleBySlugOrFail($slug); + try { + $book = $this->queries->findVisibleBySlugOrFail($slug); + } catch (NotFoundException $exception) { + $book = $this->entityQueries->findVisibleByOldSlugs('book', $slug); + if (is_null($book)) { + throw $exception; + } + return redirect($book->getUrl()); + } + $bookChildren = (new BookContents($book))->getTree(true); $bookParentShelves = $book->shelves()->scopes('visible')->get(); @@ -154,7 +166,7 @@ class BookController extends Controller public function edit(string $slug) { $book = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('book-update', $book); + $this->checkOwnablePermission(Permission::BookUpdate, $book); $this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()])); return view('books.edit', ['book' => $book, 'current' => $book]); @@ -170,7 +182,7 @@ class BookController extends Controller public function update(Request $request, string $slug) { $book = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('book-update', $book); + $this->checkOwnablePermission(Permission::BookUpdate, $book); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], @@ -197,7 +209,7 @@ class BookController extends Controller public function showDelete(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-delete', $book); + $this->checkOwnablePermission(Permission::BookDelete, $book); $this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()])); return view('books.delete', ['book' => $book, 'current' => $book]); @@ -211,7 +223,7 @@ class BookController extends Controller public function destroy(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-delete', $book); + $this->checkOwnablePermission(Permission::BookDelete, $book); $this->bookRepo->destroy($book); @@ -226,7 +238,7 @@ class BookController extends Controller public function showCopy(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-view', $book); + $this->checkOwnablePermission(Permission::BookView, $book); session()->flashInput(['name' => $book->name]); @@ -243,8 +255,8 @@ class BookController extends Controller public function copy(Request $request, Cloner $cloner, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-view', $book); - $this->checkPermission('book-create-all'); + $this->checkOwnablePermission(Permission::BookView, $book); + $this->checkPermission(Permission::BookCreateAll); $newName = $request->get('name') ?: $book->name; $bookCopy = $cloner->cloneBook($book, $newName); @@ -259,10 +271,10 @@ class BookController extends Controller public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-update', $book); - $this->checkOwnablePermission('book-delete', $book); - $this->checkPermission('bookshelf-create-all'); - $this->checkPermission('book-create-all'); + $this->checkOwnablePermission(Permission::BookUpdate, $book); + $this->checkOwnablePermission(Permission::BookDelete, $book); + $this->checkPermission(Permission::BookshelfCreateAll); + $this->checkPermission(Permission::BookCreateAll); $shelf = (new DatabaseTransaction(function () use ($book, $transformer) { return $transformer->transformBookToShelf($book); diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index b512f2d05..735742060 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\Request; @@ -45,7 +46,7 @@ class BookshelfApiController extends ApiController */ public function create(Request $request) { - $this->checkPermission('bookshelf-create-all'); + $this->checkPermission(Permission::BookshelfCreateAll); $requestData = $this->validate($request, $this->rules()['create']); $bookIds = $request->get('books', []); @@ -84,7 +85,7 @@ class BookshelfApiController extends ApiController public function update(Request $request, string $id) { $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); - $this->checkOwnablePermission('bookshelf-update', $shelf); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $requestData = $this->validate($request, $this->rules()['update']); $bookIds = $request->get('books', null); @@ -103,7 +104,7 @@ class BookshelfApiController extends ApiController public function delete(string $id) { $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); - $this->checkOwnablePermission('bookshelf-delete', $shelf); + $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); $this->bookshelfRepo->destroy($shelf); @@ -115,9 +116,10 @@ class BookshelfApiController extends ApiController $shelf = clone $shelf; $shelf->unsetRelations()->refresh(); - $shelf->load(['tags', 'cover']); - $shelf->makeVisible('description_html') - ->setAttribute('description_html', $shelf->descriptionHtml()); + $shelf->load(['tags']); + $shelf->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) + ->setAttribute('cover', $shelf->coverInfo()->getImage()); return $shelf; } diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index 6cedd23e7..f5f4a90bf 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -6,11 +6,13 @@ use BookStack\Activity\ActivityQueries; use BookStack\Activity\Models\View; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\NotFoundException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\References\ReferenceFetcher; use BookStack\Util\SimpleListOptions; use Exception; @@ -22,6 +24,7 @@ class BookshelfController extends Controller public function __construct( protected BookshelfRepo $shelfRepo, protected BookshelfQueries $queries, + protected EntityQueries $entityQueries, protected BookQueries $bookQueries, protected ShelfContext $shelfContext, protected ReferenceFetcher $referenceFetcher, @@ -42,7 +45,7 @@ class BookshelfController extends Controller $shelves = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) - ->paginate(18); + ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; $popular = $this->queries->popularForList()->get(); $new = $this->queries->visibleForList() @@ -68,7 +71,7 @@ class BookshelfController extends Controller */ public function create() { - $this->checkPermission('bookshelf-create-all'); + $this->checkPermission(Permission::BookshelfCreateAll); $books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']); $this->setPageTitle(trans('entities.shelves_create')); @@ -83,7 +86,7 @@ class BookshelfController extends Controller */ public function store(Request $request) { - $this->checkPermission('bookshelf-create-all'); + $this->checkPermission(Permission::BookshelfCreateAll); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], @@ -104,8 +107,17 @@ class BookshelfController extends Controller */ public function show(Request $request, ActivityQueries $activities, string $slug) { - $shelf = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('bookshelf-view', $shelf); + try { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + } catch (NotFoundException $exception) { + $shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug); + if (is_null($shelf)) { + throw $exception; + } + return redirect($shelf->getUrl()); + } + + $this->checkOwnablePermission(Permission::BookshelfView, $shelf); $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([ 'default' => trans('common.sort_default'), @@ -115,6 +127,7 @@ class BookshelfController extends Controller ]); $sort = $listOptions->getSort(); + $sortedVisibleShelfBooks = $shelf->visibleBooks() ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) ->get() @@ -143,7 +156,7 @@ class BookshelfController extends Controller public function edit(string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('bookshelf-update', $shelf); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $shelfBookIds = $shelf->books()->get(['id'])->pluck('id'); $books = $this->bookQueries->visibleForList() @@ -169,7 +182,7 @@ class BookshelfController extends Controller public function update(Request $request, string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('bookshelf-update', $shelf); + $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], @@ -195,7 +208,7 @@ class BookshelfController extends Controller public function showDelete(string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('bookshelf-delete', $shelf); + $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()])); @@ -210,7 +223,7 @@ class BookshelfController extends Controller public function destroy(string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('bookshelf-delete', $shelf); + $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); $this->shelfRepo->destroy($shelf); diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 6ba2e9fd2..6aa62f887 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -9,6 +9,7 @@ use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Exceptions\PermissionsException; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Exception; use Illuminate\Http\Request; @@ -65,7 +66,7 @@ class ChapterApiController extends ApiController $bookId = $request->get('book_id'); $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId)); - $this->checkOwnablePermission('chapter-create', $book); + $this->checkOwnablePermission(Permission::ChapterCreate, $book); $chapter = $this->chapterRepo->create($requestData, $book); @@ -101,10 +102,10 @@ class ChapterApiController extends ApiController { $requestData = $this->validate($request, $this->rules()['update']); $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); - $this->checkOwnablePermission('chapter-update', $chapter); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); - if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) { - $this->checkOwnablePermission('chapter-delete', $chapter); + if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); try { $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}"); @@ -129,7 +130,7 @@ class ChapterApiController extends ApiController public function delete(string $id) { $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); - $this->checkOwnablePermission('chapter-delete', $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->chapterRepo->destroy($chapter); @@ -143,7 +144,7 @@ class ChapterApiController extends ApiController $chapter->load(['tags']); $chapter->makeVisible('description_html'); - $chapter->setAttribute('description_html', $chapter->descriptionHtml()); + $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); /** @var Book $book */ $book = $chapter->book()->first(); diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 677745500..878ee42b5 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -17,6 +17,7 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\References\ReferenceFetcher; use BookStack\Util\DatabaseTransaction; use Illuminate\Http\Request; @@ -39,7 +40,7 @@ class ChapterController extends Controller public function create(string $bookSlug) { $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('chapter-create', $book); + $this->checkOwnablePermission(Permission::ChapterCreate, $book); $this->setPageTitle(trans('entities.chapters_create')); @@ -64,7 +65,7 @@ class ChapterController extends Controller ]); $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('chapter-create', $book); + $this->checkOwnablePermission(Permission::ChapterCreate, $book); $chapter = $this->chapterRepo->create($validated, $book); @@ -76,8 +77,15 @@ class ChapterController extends Controller */ public function show(string $bookSlug, string $chapterSlug) { - $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-view', $chapter); + try { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + } catch (NotFoundException $exception) { + $chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug); + if (is_null($chapter)) { + throw $exception; + } + return redirect($chapter->getUrl()); + } $sidebarTree = (new BookContents($chapter->book))->getTree(); $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get(); @@ -106,7 +114,7 @@ class ChapterController extends Controller public function edit(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-update', $chapter); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()])); @@ -128,9 +136,9 @@ class ChapterController extends Controller ]); $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-update', $chapter); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); - $this->chapterRepo->update($chapter, $validated); + $chapter = $this->chapterRepo->update($chapter, $validated); return redirect($chapter->getUrl()); } @@ -143,7 +151,7 @@ class ChapterController extends Controller public function showDelete(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-delete', $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()])); @@ -159,7 +167,7 @@ class ChapterController extends Controller public function destroy(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-delete', $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->chapterRepo->destroy($chapter); @@ -175,8 +183,8 @@ class ChapterController extends Controller { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()])); - $this->checkOwnablePermission('chapter-update', $chapter); - $this->checkOwnablePermission('chapter-delete', $chapter); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); return view('chapters.move', [ 'chapter' => $chapter, @@ -192,8 +200,8 @@ class ChapterController extends Controller public function move(Request $request, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-update', $chapter); - $this->checkOwnablePermission('chapter-delete', $chapter); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $entitySelection = $request->get('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { @@ -221,7 +229,6 @@ class ChapterController extends Controller public function showCopy(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-view', $chapter); session()->flashInput(['name' => $chapter->name]); @@ -240,7 +247,6 @@ class ChapterController extends Controller public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-view', $chapter); $entitySelection = $request->get('entity_selection') ?: null; $newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent(); @@ -251,7 +257,7 @@ class ChapterController extends Controller return redirect($chapter->getUrl('/copy')); } - $this->checkOwnablePermission('chapter-create', $newParentBook); + $this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook); $newName = $request->get('name') ?: $chapter->name; $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); @@ -266,9 +272,9 @@ class ChapterController extends Controller public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('chapter-update', $chapter); - $this->checkOwnablePermission('chapter-delete', $chapter); - $this->checkPermission('book-create-all'); + $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); + $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); + $this->checkPermission(Permission::BookCreateAll); $book = (new DatabaseTransaction(function () use ($chapter, $transformer) { return $transformer->transformChapterToBook($chapter); diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 8fcba3dc6..197018cca 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -2,11 +2,13 @@ namespace BookStack\Entities\Controllers; +use BookStack\Activity\Tools\CommentTree; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\PermissionsException; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Exception; use Illuminate\Http\Request; @@ -76,7 +78,7 @@ class PageApiController extends ApiController } else { $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); } - $this->checkOwnablePermission('page-create', $parent); + $this->checkOwnablePermission(Permission::PageCreate, $parent); $draft = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create']))); @@ -87,21 +89,32 @@ class PageApiController extends ApiController /** * View the details of a single page. * Pages will always have HTML content. They may have markdown content - * if the markdown editor was used to last update the page. + * if the Markdown editor was used to last update the page. * - * The 'html' property is the fully rendered & escaped HTML content that BookStack + * The 'html' property is the fully rendered and escaped HTML content that BookStack * would show on page view, with page includes handled. * The 'raw_html' property is the direct database stored HTML content, which would be * what BookStack shows on page edit. * * See the "Content Security" section of these docs for security considerations when using * the page content returned from this endpoint. + * + * Comments for the page are provided in a tree-structure representing the hierarchy of top-level + * comments and replies, for both archived and active comments. */ public function read(string $id) { $page = $this->queries->findVisibleByIdOrFail($id); - return response()->json($page->forJsonDisplay()); + $page = $page->forJsonDisplay(); + $commentTree = (new CommentTree($page)); + $commentTree->loadVisibleHtml(); + $page->setAttribute('comments', [ + 'active' => $commentTree->getActive(), + 'archived' => $commentTree->getArchived(), + ]); + + return response()->json($page); } /** @@ -116,7 +129,7 @@ class PageApiController extends ApiController $requestData = $this->validate($request, $this->rules['update']); $page = $this->queries->findVisibleByIdOrFail($id); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $parent = null; if ($request->has('chapter_id')) { @@ -126,7 +139,7 @@ class PageApiController extends ApiController } if ($parent && !$parent->matches($page->getParent())) { - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); try { $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id); @@ -151,7 +164,7 @@ class PageApiController extends ApiController public function delete(string $id) { $page = $this->queries->findVisibleByIdOrFail($id); - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); $this->pageRepo->destroy($page); diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index de3aed7d9..a648bc298 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -17,9 +17,9 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditActivity; use BookStack\Entities\Tools\PageEditorData; use BookStack\Exceptions\NotFoundException; -use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\References\ReferenceFetcher; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -50,7 +50,7 @@ class PageController extends Controller $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); } - $this->checkOwnablePermission('page-create', $parent); + $this->checkOwnablePermission(Permission::PageCreate, $parent); // Redirect to draft edit screen if signed in if ($this->isSignedIn()) { @@ -82,7 +82,7 @@ class PageController extends Controller $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); } - $this->checkOwnablePermission('page-create', $parent); + $this->checkOwnablePermission(Permission::PageCreate, $parent); $page = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($page, [ @@ -100,7 +100,7 @@ class PageController extends Controller public function editDraft(Request $request, string $bookSlug, int $pageId) { $draft = $this->queries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-create', $draft->getParent()); + $this->checkOwnablePermission(Permission::PageCreate, $draft->getParent()); $editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', '')); $this->setPageTitle(trans('entities.pages_edit_draft')); @@ -119,8 +119,9 @@ class PageController extends Controller $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], ]); + $draftPage = $this->queries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-create', $draftPage->getParent()); + $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); $page = $this->pageRepo->publishDraft($draftPage, $request->all()); @@ -138,9 +139,7 @@ class PageController extends Controller try { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); } catch (NotFoundException $e) { - $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug); - $page = $revision->page ?? null; - + $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug); if (is_null($page)) { throw $e; } @@ -148,8 +147,6 @@ class PageController extends Controller return redirect($page->getUrl()); } - $this->checkOwnablePermission('page-view', $page); - $pageContent = (new PageContent($page)); $page->html = $pageContent->render(); $pageNav = $pageContent->getNavigation($page->html); @@ -197,7 +194,7 @@ class PageController extends Controller public function edit(Request $request, string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-update', $page, $page->getUrl()); + $this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl()); $editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', '')); if ($editorData->getWarnings()) { @@ -221,7 +218,7 @@ class PageController extends Controller 'name' => ['required', 'string', 'max:255'], ]); $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->pageRepo->update($page, $request->all()); @@ -236,7 +233,7 @@ class PageController extends Controller public function saveDraft(Request $request, int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); if (!$this->isSignedIn()) { return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500); @@ -273,7 +270,7 @@ class PageController extends Controller public function showDelete(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); $this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()])); $usedAsTemplate = $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || @@ -295,7 +292,7 @@ class PageController extends Controller public function showDeleteDraft(string $bookSlug, int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()])); $usedAsTemplate = $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || @@ -318,7 +315,7 @@ class PageController extends Controller public function destroy(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); $parent = $page->getParent(); $this->pageRepo->destroy($page); @@ -337,13 +334,13 @@ class PageController extends Controller $page = $this->queries->findVisibleByIdOrFail($pageId); $book = $page->book; $chapter = $page->chapter; - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->pageRepo->destroy($page); $this->showSuccessNotification(trans('entities.pages_delete_draft_success')); - if ($chapter && userCan('view', $chapter)) { + if ($chapter && userCan(Permission::ChapterView, $chapter)) { return redirect($chapter->getUrl()); } @@ -384,8 +381,8 @@ class PageController extends Controller public function showMove(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-update', $page); - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); return view('pages.move', [ 'book' => $page->book, @@ -402,8 +399,8 @@ class PageController extends Controller public function move(Request $request, string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-update', $page); - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); $entitySelection = $request->get('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { @@ -431,7 +428,6 @@ class PageController extends Controller public function showCopy(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-view', $page); session()->flashInput(['name' => $page->name]); return view('pages.copy', [ @@ -449,7 +445,7 @@ class PageController extends Controller public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-view', $page); + $this->checkOwnablePermission(Permission::PageView, $page); $entitySelection = $request->get('entity_selection') ?: null; $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent(); @@ -460,7 +456,7 @@ class PageController extends Controller return redirect($page->getUrl('/copy')); } - $this->checkOwnablePermission('page-create', $newParent); + $this->checkOwnablePermission(Permission::PageCreate, $newParent); $newName = $request->get('name') ?: $page->name; $pageCopy = $cloner->clonePage($page, $newParent, $newName); diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 4985c39f3..35f1e8daf 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exceptions\NotFoundException; use BookStack\Facades\Activity; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; use Ssddanbrown\HtmlDiff\Diff; @@ -98,7 +99,7 @@ class PageRevisionController extends Controller throw new NotFoundException(); } - $prev = $revision->getPrevious(); + $prev = $revision->getPreviousRevision(); $prevContent = $prev->html ?? ''; $diff = Diff::excecute($prevContent, $revision->html); @@ -124,7 +125,7 @@ class PageRevisionController extends Controller public function restore(string $bookSlug, string $pageSlug, int $revisionId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $page = $this->pageRepo->restoreRevision($page, $revisionId); @@ -139,7 +140,7 @@ class PageRevisionController extends Controller public function destroy(string $bookSlug, string $pageSlug, int $revId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('page-delete', $page); + $this->checkOwnablePermission(Permission::PageDelete, $page); $revision = $page->revisions()->where('id', '=', $revId)->first(); if ($revision === null) { diff --git a/app/Entities/Controllers/RecycleBinApiController.php b/app/Entities/Controllers/RecycleBinApiController.php index bf22d7dcd..614685136 100644 --- a/app/Entities/Controllers/RecycleBinApiController.php +++ b/app/Entities/Controllers/RecycleBinApiController.php @@ -6,18 +6,20 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; +use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\DeletionRepo; use BookStack\Http\ApiController; -use Closure; +use BookStack\Permissions\Permission; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasMany; class RecycleBinApiController extends ApiController { public function __construct() { $this->middleware(function ($request, $next) { - $this->checkPermission('settings-manage'); - $this->checkPermission('restrictions-manage-all'); + $this->checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::RestrictionsManageAll); return $next($request); }); @@ -40,7 +42,7 @@ class RecycleBinApiController extends ApiController 'updated_at', 'deletable_type', 'deletable_id', - ], [Closure::fromCallable([$this, 'listFormatter'])]); + ], [$this->listFormatter(...)]); } /** @@ -69,10 +71,9 @@ class RecycleBinApiController extends ApiController /** * Load some related details for the deletion listing. */ - protected function listFormatter(Deletion $deletion) + protected function listFormatter(Deletion $deletion): void { $deletable = $deletion->deletable; - $withTrashedQuery = fn (Builder $query) => $query->withTrashed(); if ($deletable instanceof BookChild) { $parent = $deletable->getParent(); @@ -81,11 +82,19 @@ class RecycleBinApiController extends ApiController } if ($deletable instanceof Book || $deletable instanceof Chapter) { - $countsToLoad = ['pages' => $withTrashedQuery]; + $countsToLoad = ['pages' => static::withTrashedQuery(...)]; if ($deletable instanceof Book) { - $countsToLoad['chapters'] = $withTrashedQuery; + $countsToLoad['chapters'] = static::withTrashedQuery(...); } $deletable->loadCount($countsToLoad); } } + + /** + * @param Builder $query + */ + protected static function withTrashedQuery(Builder $query): void + { + $query->withTrashed(); + } } diff --git a/app/Entities/Controllers/RecycleBinController.php b/app/Entities/Controllers/RecycleBinController.php index d11dde4dd..f3c2b6a01 100644 --- a/app/Entities/Controllers/RecycleBinController.php +++ b/app/Entities/Controllers/RecycleBinController.php @@ -8,6 +8,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Repos\DeletionRepo; use BookStack\Entities\Tools\TrashCan; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; class RecycleBinController extends Controller { @@ -20,8 +21,8 @@ class RecycleBinController extends Controller public function __construct() { $this->middleware(function ($request, $next) { - $this->checkPermission('settings-manage'); - $this->checkPermission('restrictions-manage-all'); + $this->checkPermission(Permission::SettingsManage); + $this->checkPermission(Permission::RestrictionsManageAll); return $next($request); }); diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php new file mode 100644 index 000000000..da2105446 --- /dev/null +++ b/app/Entities/EntityExistsRule.php @@ -0,0 +1,20 @@ +where('type', $this->type); + return $existsRule->__toString(); + } +} diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index ede4fc7d5..1909dbd56 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,9 +2,10 @@ namespace BookStack\Entities\Models; +use BookStack\Entities\Tools\EntityCover; +use BookStack\Entities\Tools\EntityDefaultTemplate; use BookStack\Sorting\SortRule; use BookStack\Uploads\Image; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -15,26 +16,25 @@ use Illuminate\Support\Collection; * Class Book. * * @property string $description + * @property string $description_html * @property int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id - * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves - * @property ?Page $defaultTemplate - * @property ?SortRule $sortRule + * @property ?SortRule $sortRule */ -class Book extends Entity implements HasCoverImage +class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface { use HasFactory; - use HasHtmlDescription; + use ContainerTrait; public float $searchFactor = 1.2; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority']; protected $fillable = ['name']; - protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html']; /** * Get the url for this book. @@ -44,57 +44,9 @@ class Book extends Entity implements HasCoverImage return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } - /** - * Returns book cover image, if book cover not exists return default cover image. - */ - public function getBookCover(int $width = 440, int $height = 250): string - { - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id || !$this->cover) { - return $default; - } - - try { - return $this->cover->getThumb($width, $height, false) ?? $default; - } catch (Exception $err) { - return $default; - } - } - - /** - * Get the cover image of the book. - */ - public function cover(): BelongsTo - { - return $this->belongsTo(Image::class, 'image_id'); - } - - /** - * Get the type of the image model that is used when storing a cover image. - */ - public function coverImageTypeKey(): string - { - return 'cover_book'; - } - - /** - * Get the Page that is used as default template for newly created pages within this Book. - */ - public function defaultTemplate(): BelongsTo - { - return $this->belongsTo(Page::class, 'default_template_id'); - } - - /** - * Get the sort set assigned to this book, if existing. - */ - public function sortRule(): BelongsTo - { - return $this->belongsTo(SortRule::class); - } - /** * Get all pages within this book. + * @return HasMany */ public function pages(): HasMany { @@ -106,11 +58,12 @@ class Book extends Entity implements HasCoverImage */ public function directPages(): HasMany { - return $this->pages()->where('chapter_id', '=', '0'); + return $this->pages()->whereNull('chapter_id'); } /** * Get all chapters within this book. + * @return HasMany */ public function chapters(): HasMany { @@ -135,4 +88,27 @@ class Book extends Entity implements HasCoverImage return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); + } + + public function coverInfo(): EntityCover + { + return new EntityCover($this); + } + + /** + * Get the sort rule assigned to this container, if existing. + */ + public function sortRule(): BelongsTo + { + return $this->belongsTo(SortRule::class); + } } diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index ad54fb926..9a8493c3a 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -2,8 +2,6 @@ namespace BookStack\Entities\Models; -use BookStack\References\ReferenceUpdater; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -18,34 +16,10 @@ abstract class BookChild extends Entity { /** * Get the book this page sits in. + * @return BelongsTo */ public function book(): BelongsTo { return $this->belongsTo(Book::class)->withTrashed(); } - - /** - * Change the book that this entity belongs to. - */ - public function changeBook(int $newBookId): Entity - { - $oldUrl = $this->getUrl(); - $this->book_id = $newBookId; - $this->refreshSlug(); - $this->save(); - $this->refresh(); - - if ($oldUrl !== $this->getUrl()) { - app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl); - } - - // Update all child pages if a chapter - if ($this instanceof Chapter) { - foreach ($this->pages()->withTrashed()->get() as $page) { - $page->changeBook($newBookId); - } - } - - return $this; - } } diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index 9ffa0ea9c..42dcc8f8f 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -2,34 +2,34 @@ namespace BookStack\Entities\Models; +use BookStack\Entities\Tools\EntityCover; use BookStack\Uploads\Image; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -class Bookshelf extends Entity implements HasCoverImage +/** + * @property string $description + * @property string $description_html + */ +class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface { use HasFactory; - use HasHtmlDescription; - - protected $table = 'bookshelves'; + use ContainerTrait; public float $searchFactor = 1.2; - protected $fillable = ['name', 'description', 'image_id']; - - protected $hidden = ['image_id', 'deleted_at', 'description_html']; + protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; + protected $fillable = ['name']; /** * Get the books in this shelf. - * Should not be used directly since does not take into account permissions. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * Should not be used directly since it does not take into account permissions. */ - public function books() + public function books(): BelongsToMany { return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') + ->select(['entities.*', 'entity_container_data.*']) ->withPivot('order') ->orderBy('order', 'asc'); } @@ -50,40 +50,6 @@ class Bookshelf extends Entity implements HasCoverImage return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } - /** - * Returns shelf cover image, if cover not exists return default cover image. - */ - public function getBookCover(int $width = 440, int $height = 250): string - { - // TODO - Make generic, focused on books right now, Perhaps set-up a better image - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id || !$this->cover) { - return $default; - } - - try { - return $this->cover->getThumb($width, $height, false) ?? $default; - } catch (Exception $err) { - return $default; - } - } - - /** - * Get the cover image of the shelf. - */ - public function cover(): BelongsTo - { - return $this->belongsTo(Image::class, 'image_id'); - } - - /** - * Get the type of the image model that is used when storing a cover image. - */ - public function coverImageTypeKey(): string - { - return 'cover_bookshelf'; - } - /** * Check if this shelf contains the given book. */ @@ -95,7 +61,7 @@ class Bookshelf extends Entity implements HasCoverImage /** * Add a book to the end of this shelf. */ - public function appendBook(Book $book) + public function appendBook(Book $book): void { if ($this->contains($book)) { return; @@ -105,12 +71,13 @@ class Bookshelf extends Entity implements HasCoverImage $this->books()->attach($book->id, ['order' => $maxOrder + 1]); } - /** - * Get a visible shelf by its slug. - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public static function getBySlug(string $slug): self + public function coverInfo(): EntityCover { - return static::visible()->where('slug', '=', $slug)->firstOrFail(); + return new EntityCover($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); } } diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index 088d199da..2dd4cb77f 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -2,32 +2,30 @@ namespace BookStack\Entities\Models; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use BookStack\Entities\Tools\EntityDefaultTemplate; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; /** - * Class Chapter. - * * @property Collection $pages * @property ?int $default_template_id - * @property ?Page $defaultTemplate + * @property string $description + * @property string $description_html */ -class Chapter extends BookChild +class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface { use HasFactory; - use HasHtmlDescription; + use ContainerTrait; public float $searchFactor = 1.2; - - protected $fillable = ['name', 'description', 'priority']; - protected $hidden = ['pivot', 'deleted_at', 'description_html']; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id']; + protected $fillable = ['name', 'priority']; /** * Get the pages that this chapter contains. * - * @return HasMany + * @return HasMany */ public function pages(string $dir = 'ASC'): HasMany { @@ -50,17 +48,9 @@ class Chapter extends BookChild return url('/' . implode('/', $parts)); } - /** - * Get the Page that is used as default template for newly created pages within this Chapter. - */ - public function defaultTemplate(): BelongsTo - { - return $this->belongsTo(Page::class, 'default_template_id'); - } - /** * Get the visible pages in this chapter. - * @returns Collection + * @return Collection */ public function getVisiblePages(): Collection { @@ -70,4 +60,9 @@ class Chapter extends BookChild ->orderBy('priority', 'asc') ->get(); } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } } diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php new file mode 100644 index 000000000..9ef5ca8d4 --- /dev/null +++ b/app/Entities/Models/ContainerTrait.php @@ -0,0 +1,26 @@ + + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityContainerData::class, 'entity_id', 'id') + ->where('entity_type', '=', $this->getMorphClass()); + } +} diff --git a/app/Entities/Models/Deletable.php b/app/Entities/Models/DeletableInterface.php similarity index 90% rename from app/Entities/Models/Deletable.php rename to app/Entities/Models/DeletableInterface.php index a2c7fad81..f771d9c69 100644 --- a/app/Entities/Models/Deletable.php +++ b/app/Entities/Models/DeletableInterface.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; * A model that can be deleted in a manner that deletions * are tracked to be part of the recycle bin system. */ -interface Deletable +interface DeletableInterface { public function deletions(): MorphMany; } diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php index a73437c94..c24c72d44 100644 --- a/app/Entities/Models/Deletion.php +++ b/app/Entities/Models/Deletion.php @@ -4,6 +4,7 @@ namespace BookStack\Entities\Models; use BookStack\Activity\Models\Loggable; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,10 +14,12 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; * @property int $deleted_by * @property string $deletable_type * @property int $deletable_id - * @property Deletable $deletable + * @property DeletableInterface $deletable */ class Deletion extends Model implements Loggable { + use HasFactory; + protected $hidden = []; /** diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 1ef4e618d..47e134626 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -12,8 +12,7 @@ use BookStack\Activity\Models\View; use BookStack\Activity\Models\Viewable; use BookStack\Activity\Models\Watch; use BookStack\App\Model; -use BookStack\App\Sluggable; -use BookStack\Entities\Tools\SlugGenerator; +use BookStack\App\SluggableInterface; use BookStack\Permissions\JointPermissionBuilder; use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\JointPermission; @@ -22,38 +21,47 @@ use BookStack\References\Reference; use BookStack\Search\SearchIndex; use BookStack\Search\SearchTerm; use BookStack\Users\Models\HasCreatorAndUpdater; -use BookStack\Users\Models\HasOwner; +use BookStack\Users\Models\OwnableInterface; +use BookStack\Users\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; /** * Class Entity - * The base class for book-like items such as pages, chapters & books. + * The base class for book-like items such as pages, chapters and books. * This is not a database model in itself but extended. * * @property int $id + * @property string $type * @property string $name * @property string $slug * @property Carbon $created_at * @property Carbon $updated_at * @property Carbon $deleted_at - * @property int $created_by - * @property int $updated_by + * @property int|null $created_by + * @property int|null $updated_by + * @property int|null $owned_by * @property Collection $tags * * @method static Entity|Builder visible() * @method static Builder withLastView() * @method static Builder withViewCount() */ -abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable +abstract class Entity extends Model implements + SluggableInterface, + Favouritable, + Viewable, + DeletableInterface, + OwnableInterface, + Loggable { use SoftDeletes; use HasCreatorAndUpdater; - use HasOwner; /** * @var string - Name of property where the main text content is found @@ -70,6 +78,72 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable */ public float $searchFactor = 1.0; + /** + * Set the table to be that used by all entities. + */ + protected $table = 'entities'; + + /** + * Set a custom query builder for entities. + */ + protected static string $builder = EntityQueryBuilder::class; + + public static array $commonFields = [ + 'id', + 'type', + 'name', + 'slug', + 'book_id', + 'chapter_id', + 'priority', + 'created_at', + 'updated_at', + 'deleted_at', + 'created_by', + 'updated_by', + 'owned_by', + ]; + + /** + * Override the save method to also save the contents for convenience. + */ + public function save(array $options = []): bool + { + /** @var EntityPageData|EntityContainerData $contents */ + $contents = $this->relatedData()->firstOrNew(); + $contentFields = $this->getContentsAttributes(); + + foreach ($contentFields as $key => $value) { + $contents->setAttribute($key, $value); + unset($this->attributes[$key]); + } + + $this->setAttribute('type', $this->getMorphClass()); + $result = parent::save($options); + $contentsResult = true; + + if ($result && $contents->isDirty()) { + $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()]; + $contents->forceFill($contentsFillData); + $contentsResult = $contents->save(); + $this->touch(); + } + + $this->forceFill($contentFields); + + return $result && $contentsResult; + } + + /** + * Check if this item is a container item. + */ + public function isContainer(): bool + { + return $this instanceof Bookshelf || + $this instanceof Book || + $this instanceof Chapter; + } + /** * Get the entities that are visible to the current user. */ @@ -84,8 +158,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable public function scopeWithLastView(Builder $query) { $viewedAtQuery = View::query()->select('updated_at') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass()) + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') ->where('user_id', '=', user()->id) ->take(1); @@ -95,11 +169,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable /** * Query scope to get the total view count of the entities. */ - public function scopeWithViewCount(Builder $query) + public function scopeWithViewCount(Builder $query): void { $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass())->take(1); + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') + ->take(1); $query->addSelect(['view_count' => $viewCountQuery]); } @@ -155,15 +230,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable */ public function tags(): MorphMany { - return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); + return $this->morphMany(Tag::class, 'entity') + ->orderBy('order', 'asc'); } /** * Get the comments for an entity. + * @return MorphMany */ public function comments(bool $orderByCreated = true): MorphMany { - $query = $this->morphMany(Comment::class, 'entity'); + $query = $this->morphMany(Comment::class, 'commentable'); return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; } @@ -177,7 +254,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable } /** - * Get this entities restrictions. + * Get this entities assigned permissions. */ public function permissions(): MorphMany { @@ -200,6 +277,20 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable return $this->morphMany(JointPermission::class, 'entity'); } + /** + * Get the user who owns this entity. + * @return BelongsTo + */ + public function ownedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'owned_by'); + } + + public function getOwnerFieldName(): string + { + return 'owned_by'; + } + /** * Get the related delete records for this entity. */ @@ -246,7 +337,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable } /** - * Gets a limited-length version of the entities name. + * Gets a limited-length version of the entity name. */ public function getShortName(int $length = 25): string { @@ -313,16 +404,6 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable app()->make(SearchIndex::class)->indexEntity(clone $this); } - /** - * {@inheritdoc} - */ - public function refreshSlug(): string - { - $this->slug = app()->make(SlugGenerator::class)->generate($this); - - return $this->slug; - } - /** * {@inheritdoc} */ @@ -349,6 +430,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable return $this->morphMany(Watch::class, 'watchable'); } + /** + * Get the related slug history for this entity. + */ + public function slugHistory(): MorphMany + { + return $this->morphMany(SlugHistory::class, 'sluggable'); + } + /** * {@inheritdoc} */ @@ -356,4 +445,40 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable { return "({$this->id}) {$this->name}"; } + + /** + * @return HasOne + */ + abstract public function relatedData(): HasOne; + + /** + * Get the attributes that are intended for the related contents model. + * @return array + */ + protected function getContentsAttributes(): array + { + $contentFields = []; + $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class; + + foreach ($this->attributes as $key => $value) { + if (in_array($key, $contentModel::$fields)) { + $contentFields[$key] = $value; + } + } + + return $contentFields; + } + + /** + * Create a new instance for the given entity type. + */ + public static function instanceFromType(string $type): self + { + return match ($type) { + 'page' => new Page(), + 'chapter' => new Chapter(), + 'book' => new Book(), + 'bookshelf' => new Bookshelf(), + }; + } } diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php new file mode 100644 index 000000000..21bace751 --- /dev/null +++ b/app/Entities/Models/EntityContainerData.php @@ -0,0 +1,52 @@ +where($this->getKeyName(), '=', $this->getKeyForSaveQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } + + /** + * Override the default set keys for a select query method to make it work with composite keys. + */ + protected function setKeysForSelectQuery($query): Builder + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } +} diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php new file mode 100644 index 000000000..a98b1a982 --- /dev/null +++ b/app/Entities/Models/EntityPageData.php @@ -0,0 +1,25 @@ +withGlobalScope('entity', new EntityScope()); + } + + public function withoutGlobalScope($scope): static + { + // Prevent removal of the entity scope + if ($scope === 'entity') { + return $this; + } + + return parent::withoutGlobalScope($scope); + } + + /** + * Override the default forceDelete method to add type filter onto the query + * since it specifically ignores scopes by default. + */ + public function forceDelete() + { + return $this->query->where('type', '=', $this->model->getMorphClass())->delete(); + } +} diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php new file mode 100644 index 000000000..c77b75a16 --- /dev/null +++ b/app/Entities/Models/EntityScope.php @@ -0,0 +1,28 @@ +where('type', '=', $model->getMorphClass()); + $table = $model->getTable(); + if ($model instanceof Page) { + $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id"); + } else { + $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) { + $join->on('entity_container_data.entity_id', '=', "{$table}.id") + ->where('entity_container_data.entity_type', '=', $model->getMorphClass()); + }); + } + } +} diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php new file mode 100644 index 000000000..5780162d1 --- /dev/null +++ b/app/Entities/Models/EntityTable.php @@ -0,0 +1,69 @@ +make(PermissionApplicator::class)->restrictEntityQuery($query); + } + + /** + * Get the entity jointPermissions this is connected to. + */ + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the Tags that have been assigned to entities. + */ + public function tags(): HasMany + { + return $this->hasMany(Tag::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the assigned permissions. + */ + public function permissions(): HasMany + { + return $this->hasMany(EntityPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get View objects for this entity. + */ + public function views(): HasMany + { + return $this->hasMany(View::class, 'viewable_id') + ->whereColumn('viewable_type', '=', 'entities.type'); + } +} diff --git a/app/Entities/Models/HasCoverImage.php b/app/Entities/Models/HasCoverImage.php deleted file mode 100644 index f665efce6..000000000 --- a/app/Entities/Models/HasCoverImage.php +++ /dev/null @@ -1,18 +0,0 @@ - + */ + public function cover(): BelongsTo; +} diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php new file mode 100644 index 000000000..f3af0da48 --- /dev/null +++ b/app/Entities/Models/HasDefaultTemplateInterface.php @@ -0,0 +1,10 @@ +description_html ?: '

' . nl2br(e($this->description)) . '

'; - return HtmlContentFilter::removeScriptsFromHtmlString($html); - } -} diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index 499ef4d72..a1d3fc7b4 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -3,7 +3,6 @@ namespace BookStack\Entities\Models; use BookStack\Entities\Tools\PageContent; -use BookStack\Entities\Tools\PageEditorType; use BookStack\Permissions\PermissionApplicator; use BookStack\Uploads\Attachment; use Illuminate\Database\Eloquent\Builder; @@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; /** * Class Page. - * + * @property EntityPageData $pageData * @property int $chapter_id * @property string $html * @property string $markdown @@ -33,12 +32,10 @@ class Page extends BookChild { use HasFactory; - protected $fillable = ['name', 'priority']; - public string $textField = 'text'; public string $htmlField = 'html'; - - protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at']; + protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type']; + protected $fillable = ['name', 'priority']; protected $casts = [ 'draft' => 'boolean', @@ -57,10 +54,8 @@ class Page extends BookChild /** * Get the chapter that this page is in, If applicable. - * - * @return BelongsTo */ - public function chapter() + public function chapter(): BelongsTo { return $this->belongsTo(Chapter::class); } @@ -107,10 +102,8 @@ class Page extends BookChild /** * Get the attachments assigned to this page. - * - * @return HasMany */ - public function attachments() + public function attachments(): HasMany { return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); } @@ -131,6 +124,14 @@ class Page extends BookChild return url('/' . implode('/', $parts)); } + /** + * Get the ID-based permalink for this page. + */ + public function getPermalink(): string + { + return url("/link/{$this->id}"); + } + /** * Get this page for JSON display. */ @@ -139,8 +140,16 @@ class Page extends BookChild $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->setAttribute('raw_html', $refreshed->html); - $refreshed->html = (new PageContent($refreshed))->render(); + $refreshed->setAttribute('html', (new PageContent($refreshed))->render()); return $refreshed; } + + /** + * @return HasOne + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityPageData::class, 'page_id', 'id'); + } } diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 10ff6d901..4409afdc2 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\App\Model; use BookStack\Users\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; */ class PageRevision extends Model implements Loggable { + use HasFactory; + protected $fillable = ['name', 'text', 'summary']; protected $hidden = ['html', 'markdown', 'text']; @@ -60,7 +63,7 @@ class PageRevision extends Model implements Loggable /** * Get the previous revision for the same page if existing. */ - public function getPrevious(): ?PageRevision + public function getPreviousRevision(): ?PageRevision { $id = static::newQuery()->where('page_id', '=', $this->page_id) ->where('id', '<', $this->id) diff --git a/app/Entities/Models/SlugHistory.php b/app/Entities/Models/SlugHistory.php new file mode 100644 index 000000000..4041cedd9 --- /dev/null +++ b/app/Entities/Models/SlugHistory.php @@ -0,0 +1,28 @@ +hasMany(JointPermission::class, 'entity_id', 'sluggable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type'); + } +} diff --git a/app/Entities/Queries/BookQueries.php b/app/Entities/Queries/BookQueries.php index 534640621..a466f37bc 100644 --- a/app/Entities/Queries/BookQueries.php +++ b/app/Entities/Queries/BookQueries.php @@ -6,6 +6,9 @@ use BookStack\Entities\Models\Book; use BookStack\Exceptions\NotFoundException; use Illuminate\Database\Eloquent\Builder; +/** + * @implements ProvidesEntityQueries + */ class BookQueries implements ProvidesEntityQueries { protected static array $listAttributes = [ @@ -13,6 +16,9 @@ class BookQueries implements ProvidesEntityQueries 'created_at', 'updated_at', 'image_id', 'owned_by', ]; + /** + * @return Builder + */ public function start(): Builder { return Book::query(); @@ -49,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries ->select(static::$listAttributes); } + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); diff --git a/app/Entities/Queries/BookshelfQueries.php b/app/Entities/Queries/BookshelfQueries.php index 19717fb7c..3fe0a2afc 100644 --- a/app/Entities/Queries/BookshelfQueries.php +++ b/app/Entities/Queries/BookshelfQueries.php @@ -6,6 +6,9 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Exceptions\NotFoundException; use Illuminate\Database\Eloquent\Builder; +/** + * @implements ProvidesEntityQueries + */ class BookshelfQueries implements ProvidesEntityQueries { protected static array $listAttributes = [ @@ -13,6 +16,9 @@ class BookshelfQueries implements ProvidesEntityQueries 'created_at', 'updated_at', 'image_id', 'owned_by', ]; + /** + * @return Builder + */ public function start(): Builder { return Bookshelf::query(); @@ -54,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries return $this->start()->scopes('visible')->select(static::$listAttributes); } + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); diff --git a/app/Entities/Queries/ChapterQueries.php b/app/Entities/Queries/ChapterQueries.php index 53c5bc9d8..9ddeb9b58 100644 --- a/app/Entities/Queries/ChapterQueries.php +++ b/app/Entities/Queries/ChapterQueries.php @@ -6,6 +6,9 @@ use BookStack\Entities\Models\Chapter; use BookStack\Exceptions\NotFoundException; use Illuminate\Database\Eloquent\Builder; +/** + * @implements ProvidesEntityQueries + */ class ChapterQueries implements ProvidesEntityQueries { protected static array $listAttributes = [ @@ -62,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries ->scopes('visible') ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) { $builder->select('slug') - ->from('books') - ->whereColumn('books.id', '=', 'chapters.book_id'); + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); }])); } + + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } } diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index 36dc6c0bc..3ffa0adf3 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -3,7 +3,12 @@ namespace BookStack\Entities\Queries; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\EntityTable; +use BookStack\Entities\Tools\SlugHistory; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Facades\DB; use InvalidArgumentException; class EntityQueries @@ -14,6 +19,7 @@ class EntityQueries public ChapterQueries $chapters, public PageQueries $pages, public PageRevisionQueries $revisions, + protected SlugHistory $slugHistory, ) { } @@ -27,24 +33,81 @@ class EntityQueries $explodedId = explode(':', $identifier); $entityType = $explodedId[0]; $entityId = intval($explodedId[1]); - $queries = $this->getQueriesForType($entityType); - return $queries->findVisibleById($entityId); + return $this->findVisibleById($entityType, $entityId); + } + + /** + * Find an entity by its ID. + */ + public function findVisibleById(string $type, int $id): ?Entity + { + $queries = $this->getQueriesForType($type); + return $queries->findVisibleById($id); + } + + /** + * Find an entity by looking up old slugs in the slug history. + */ + public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity + { + $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug); + if ($id === null) { + return null; + } + + return $this->findVisibleById($type, $id); + } + + /** + * Start a query across all entity types. + * Combines the description/text fields into a single 'description' field. + * @return Builder + */ + public function visibleForList(): Builder + { + $rawDescriptionField = DB::raw('COALESCE(description, text) as description'); + $bookSlugSelect = function (QueryBuilder $query) { + return $query->select('slug')->from('entities as books') + ->whereColumn('books.id', '=', 'entities.book_id') + ->where('type', '=', 'book'); + }; + + return EntityTable::query()->scopes('visible') + ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField]) + ->leftJoin('entity_container_data', function (JoinClause $join) { + $join->on('entity_container_data.entity_id', '=', 'entities.id') + ->on('entity_container_data.entity_type', '=', 'entities.type'); + })->leftJoin('entity_page_data', function (JoinClause $join) { + $join->on('entity_page_data.page_id', '=', 'entities.id') + ->where('entities.type', '=', 'page'); + }); } /** * Start a query of visible entities of the given type, * suitable for listing display. + * @return Builder */ - public function visibleForList(string $entityType): Builder + public function visibleForListForType(string $entityType): Builder { $queries = $this->getQueriesForType($entityType); return $queries->visibleForList(); } + /** + * Start a query of visible entities of the given type, + * suitable for using the contents of the items. + * @return Builder + */ + public function visibleForContentForType(string $entityType): Builder + { + $queries = $this->getQueriesForType($entityType); + return $queries->visibleForContent(); + } + protected function getQueriesForType(string $type): ProvidesEntityQueries { - /** @var ?ProvidesEntityQueries $queries */ $queries = match ($type) { 'page' => $this->pages, 'chapter' => $this->chapters, diff --git a/app/Entities/Queries/PageQueries.php b/app/Entities/Queries/PageQueries.php index 06298f470..f4ecee2dc 100644 --- a/app/Entities/Queries/PageQueries.php +++ b/app/Entities/Queries/PageQueries.php @@ -6,11 +6,14 @@ use BookStack\Entities\Models\Page; use BookStack\Exceptions\NotFoundException; use Illuminate\Database\Eloquent\Builder; +/** + * @implements ProvidesEntityQueries + */ class PageQueries implements ProvidesEntityQueries { protected static array $contentAttributes = [ 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', - 'template', 'html', 'text', 'created_at', 'updated_at', 'priority', + 'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority', 'created_by', 'updated_by', 'owned_by', ]; protected static array $listAttributes = [ @@ -18,6 +21,9 @@ class PageQueries implements ProvidesEntityQueries 'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by', ]; + /** + * @return Builder + */ public function start(): Builder { return Page::query(); @@ -66,6 +72,9 @@ class PageQueries implements ProvidesEntityQueries }); } + /** + * @return Builder + */ public function visibleForList(): Builder { return $this->start() @@ -73,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries ->select($this->mergeBookSlugForSelect(static::$listAttributes)); } + /** + * @return Builder + */ + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForChapterList(int $chapterId): Builder { return $this->visibleForList() @@ -95,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries ->where('created_by', '=', user()->id); } - public function visibleTemplates(): Builder + public function visibleTemplates(bool $includeContents = false): Builder { - return $this->visibleForList() - ->where('template', '=', true); + $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList(); + return $base->where('template', '=', true); } protected function mergeBookSlugForSelect(array $columns): array { return array_merge($columns, ['book_slug' => function ($builder) { $builder->select('slug') - ->from('books') - ->whereColumn('books.id', '=', 'pages.book_id'); + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); }]); } } diff --git a/app/Entities/Queries/ProvidesEntityQueries.php b/app/Entities/Queries/ProvidesEntityQueries.php index 611d0ae52..674e96afa 100644 --- a/app/Entities/Queries/ProvidesEntityQueries.php +++ b/app/Entities/Queries/ProvidesEntityQueries.php @@ -7,28 +7,39 @@ use Illuminate\Database\Eloquent\Builder; /** * Interface for our classes which provide common queries for our - * entity objects. Ideally all queries for entities should run through + * entity objects. Ideally, all queries for entities should run through * these classes. * Any added methods should return a builder instances to allow extension * via building on the query, unless the method starts with 'find' * in which case an entity object should be returned. * (nullable unless it's a *OrFail method). + * + * @template TModel of Entity */ interface ProvidesEntityQueries { /** * Start a new query for this entity type. + * @return Builder */ public function start(): Builder; /** - * Find the entity of the given ID, or return null if not found. + * Find the entity of the given ID or return null if not found. */ public function findVisibleById(int $id): ?Entity; /** * Start a query for items that are visible, with selection * configured for list display of this item. + * @return Builder */ public function visibleForList(): Builder; + + /** + * Start a query for items that are visible, with selection + * configured for using the content of the items found. + * @return Builder + */ + public function visibleForContent(): Builder; } diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index ac5a44e67..717e9c9f8 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -3,13 +3,13 @@ namespace BookStack\Entities\Repos; use BookStack\Activity\TagRepo; -use BookStack\Entities\Models\Book; use BookStack\Entities\Models\BookChild; -use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\HasCoverInterface; +use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HasCoverImage; -use BookStack\Entities\Models\HasHtmlDescription; use BookStack\Entities\Queries\PageQueries; +use BookStack\Entities\Tools\SlugGenerator; +use BookStack\Entities\Tools\SlugHistory; use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; @@ -27,22 +27,32 @@ class BaseRepo protected ReferenceStore $referenceStore, protected PageQueries $pageQueries, protected BookSorter $bookSorter, + protected SlugGenerator $slugGenerator, + protected SlugHistory $slugHistory, ) { } /** * Create a new entity in the system. + * @template T of Entity + * @param T $entity + * @return T */ - public function create(Entity $entity, array $input) + public function create(Entity $entity, array $input): Entity { + $entity = (clone $entity)->refresh(); $entity->fill($input); - $this->updateDescription($entity, $input); $entity->forceFill([ 'created_by' => user()->id, 'updated_by' => user()->id, 'owned_by' => user()->id, ]); - $entity->refreshSlug(); + $this->refreshSlug($entity); + + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + $entity->save(); if (isset($input['tags'])) { @@ -52,22 +62,31 @@ class BaseRepo $entity->refresh(); $entity->rebuildPermissions(); $entity->indexForSearch(); + $this->referenceStore->updateForEntity($entity); + + return $entity; } /** * Update the given entity. + * @template T of Entity + * @param T $entity + * @return T */ - public function update(Entity $entity, array $input) + public function update(Entity $entity, array $input): Entity { $oldUrl = $entity->getUrl(); $entity->fill($input); - $this->updateDescription($entity, $input); $entity->updated_by = user()->id; if ($entity->isDirty('name') || empty($entity->slug)) { - $entity->refreshSlug(); + $this->refreshSlug($entity); + } + + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); } $entity->save(); @@ -83,61 +102,35 @@ class BaseRepo if ($oldUrl !== $entity->getUrl()) { $this->referenceUpdater->updateEntityReferences($entity, $oldUrl); } + + return $entity; } /** - * Update the given items' cover image, or clear it. - * - * @param Entity&HasCoverImage $entity + * Update the given items' cover image or clear it. * * @throws ImageUploadException * @throws \Exception */ - public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void { if ($coverImage) { - $imageType = $entity->coverImageTypeKey(); - $this->imageRepo->destroyImage($entity->cover()->first()); + $imageType = 'cover_' . $entity->type; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true); - $entity->cover()->associate($image); + $entity->coverInfo()->setImage($image); $entity->save(); } if ($removeImage) { - $this->imageRepo->destroyImage($entity->cover()->first()); - $entity->image_id = 0; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $entity->coverInfo()->setImage(null); $entity->save(); } } /** - * Update the default page template used for this item. - * Checks that, if changing, the provided value is a valid template and the user - * has visibility of the provided page template id. - */ - public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void - { - $changing = $templateId !== intval($entity->default_template_id); - if (!$changing) { - return; - } - - if ($templateId === 0) { - $entity->default_template_id = null; - $entity->save(); - return; - } - - $templateExists = $this->pageQueries->visibleTemplates() - ->where('id', '=', $templateId) - ->exists(); - - $entity->default_template_id = $templateExists ? $templateId : null; - $entity->save(); - } - - /** - * Sort the parent of the given entity, if any auto sort actions are set for it. + * Sort the parent of the given entity if any auto sort actions are set for it. * Typically ran during create/update/insert events. */ public function sortParent(Entity $entity): void @@ -148,20 +141,31 @@ class BaseRepo } } + /** + * Update the description of the given entity from input data. + */ protected function updateDescription(Entity $entity, array $input): void { - if (!in_array(HasHtmlDescription::class, class_uses($entity))) { + if (!$entity instanceof HasDescriptionInterface) { return; } - /** @var HasHtmlDescription $entity */ if (isset($input['description_html'])) { - $entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']); - $entity->description = html_entity_decode(strip_tags($input['description_html'])); + $entity->descriptionInfo()->set( + HtmlDescriptionFilter::filterFromString($input['description_html']), + html_entity_decode(strip_tags($input['description_html'])) + ); } else if (isset($input['description'])) { - $entity->description = $input['description']; - $entity->description_html = ''; - $entity->description_html = $entity->descriptionHtml(); + $entity->descriptionInfo()->set('', $input['description']); } } + + /** + * Refresh the slug for the given entity. + */ + public function refreshSlug(Entity $entity): void + { + $this->slugHistory->recordForEntity($entity); + $this->slugGenerator->regenerateForEntity($entity); + } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 6d28d5d6a..b4244b9bb 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -30,19 +30,18 @@ class BookRepo public function create(array $input): Book { return (new DatabaseTransaction(function () use ($input) { - $book = new Book(); - - $this->baseRepo->create($book, $input); + $book = $this->baseRepo->create(new Book(), $input); $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { $book->sort_rule_id = $defaultBookSortSetting; - $book->save(); } + $book->save(); + return $book; }))->run(); } @@ -52,28 +51,29 @@ class BookRepo */ public function update(Book $book, array $input): Book { - $this->baseRepo->update($book, $input); + $book = $this->baseRepo->update($book, $input); if (array_key_exists('default_template_id', $input)) { - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'])); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'])); } if (array_key_exists('image', $input)) { $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); } + $book->save(); Activity::add(ActivityType::BOOK_UPDATE, $book); return $book; } /** - * Update the given book's cover image, or clear it. + * Update the given book's cover image or clear it. * * @throws ImageUploadException * @throws Exception */ - public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void { $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } @@ -83,7 +83,7 @@ class BookRepo * * @throws Exception */ - public function destroy(Book $book) + public function destroy(Book $book): void { $this->trashCan->softDestroyBook($book); Activity::add(ActivityType::BOOK_DELETE, $book); diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index 8e60f58c4..bb84b51fd 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -25,8 +25,7 @@ class BookshelfRepo public function create(array $input, array $bookIds): Bookshelf { return (new DatabaseTransaction(function () use ($input, $bookIds) { - $shelf = new Bookshelf(); - $this->baseRepo->create($shelf, $input); + $shelf = $this->baseRepo->create(new Bookshelf(), $input); $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->updateBooks($shelf, $bookIds); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); @@ -39,7 +38,7 @@ class BookshelfRepo */ public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf { - $this->baseRepo->update($shelf, $input); + $shelf = $this->baseRepo->update($shelf, $input); if (!is_null($bookIds)) { $this->updateBooks($shelf, $bookIds); @@ -56,20 +55,37 @@ class BookshelfRepo /** * Update which books are assigned to this shelf by syncing the given book ids. - * Function ensures the books are visible to the current user and existing. + * Function ensures the managed books are visible to the current user and existing, + * and that the user does not alter the assignment of books that are not visible to them. */ - protected function updateBooks(Bookshelf $shelf, array $bookIds) + protected function updateBooks(Bookshelf $shelf, array $bookIds): void { $numericIDs = collect($bookIds)->map(function ($id) { return intval($id); }); - $syncData = $this->bookQueries->visibleForList() + $existingBookIds = $shelf->books()->pluck('id')->toArray(); + $visibleExistingBookIds = $this->bookQueries->visibleForList() + ->whereIn('id', $existingBookIds) + ->pluck('id') + ->toArray(); + $nonVisibleExistingBookIds = array_values(array_diff($existingBookIds, $visibleExistingBookIds)); + + $newIdsToAssign = $this->bookQueries->visibleForList() ->whereIn('id', $bookIds) ->pluck('id') - ->mapWithKeys(function ($bookId) use ($numericIDs) { - return [$bookId => ['order' => $numericIDs->search($bookId)]]; - }); + ->toArray(); + + $maxNewIndex = max($numericIDs->keys()->toArray() ?: [0]); + + $syncData = []; + foreach ($newIdsToAssign as $id) { + $syncData[$id] = ['order' => $numericIDs->search($id)]; + } + + foreach ($nonVisibleExistingBookIds as $index => $id) { + $syncData[$id] = ['order' => $maxNewIndex + ($index + 1)]; + } $shelf->books()->sync($syncData); } @@ -79,7 +95,7 @@ class BookshelfRepo * * @throws Exception */ - public function destroy(Bookshelf $shelf) + public function destroy(Bookshelf $shelf): void { $this->trashCan->softDestroyShelf($shelf); Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 6503e63cf..a528eece0 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -7,10 +7,12 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\ParentChanger; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; +use BookStack\Permissions\Permission; use BookStack\Util\DatabaseTransaction; use Exception; @@ -20,6 +22,7 @@ class ChapterRepo protected BaseRepo $baseRepo, protected EntityQueries $entityQueries, protected TrashCan $trashCan, + protected ParentChanger $parentChanger, ) { } @@ -32,8 +35,11 @@ class ChapterRepo $chapter = new Chapter(); $chapter->book_id = $parentBook->id; $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; - $this->baseRepo->create($chapter, $input); - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); + + $chapter = $this->baseRepo->create($chapter, $input); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); + + $chapter->save(); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); $this->baseRepo->sortParent($chapter); @@ -47,12 +53,13 @@ class ChapterRepo */ public function update(Chapter $chapter, array $input): Chapter { - $this->baseRepo->update($chapter, $input); + $chapter = $this->baseRepo->update($chapter, $input); if (array_key_exists('default_template_id', $input)) { - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'])); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'])); } + $chapter->save(); Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); $this->baseRepo->sortParent($chapter); @@ -65,7 +72,7 @@ class ChapterRepo * * @throws Exception */ - public function destroy(Chapter $chapter) + public function destroy(Chapter $chapter): void { $this->trashCan->softDestroyChapter($chapter); Activity::add(ActivityType::CHAPTER_DELETE, $chapter); @@ -87,12 +94,12 @@ class ChapterRepo throw new MoveOperationException('Book to move chapter into not found'); } - if (!userCan('chapter-create', $parent)) { + if (!userCan(Permission::ChapterCreate, $parent)) { throw new PermissionsException('User does not have permission to create a chapter within the chosen book'); } return (new DatabaseTransaction(function () use ($chapter, $parent) { - $chapter->changeBook($parent->id); + $this->parentChanger->changeBook($chapter, $parent->id); $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php index e47192cc2..5b67e5e6b 100644 --- a/app/Entities/Repos/DeletionRepo.php +++ b/app/Entities/Repos/DeletionRepo.php @@ -9,11 +9,9 @@ use BookStack\Facades\Activity; class DeletionRepo { - private TrashCan $trashCan; - - public function __construct(TrashCan $trashCan) - { - $this->trashCan = $trashCan; + public function __construct( + protected TrashCan $trashCan + ) { } public function restore(int $id): int diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 63e8b8370..bc590785d 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -12,10 +12,12 @@ use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditorType; +use BookStack\Entities\Tools\ParentChanger; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; +use BookStack\Permissions\Permission; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; use BookStack\Util\DatabaseTransaction; @@ -30,13 +32,14 @@ class PageRepo protected ReferenceStore $referenceStore, protected ReferenceUpdater $referenceUpdater, protected TrashCan $trashCan, + protected ParentChanger $parentChanger, ) { } /** * Get a new draft page belonging to the given parent entity. */ - public function getNewDraftPage(Entity $parent) + public function getNewDraftPage(Entity $parent): Page { $page = (new Page())->forceFill([ 'name' => trans('entities.pages_initial_name'), @@ -45,6 +48,9 @@ class PageRepo 'updated_by' => user()->id, 'draft' => true, 'editor' => PageEditorType::getSystemDefault()->value, + 'html' => '', + 'markdown' => '', + 'text' => '', ]); if ($parent instanceof Chapter) { @@ -54,17 +60,18 @@ class PageRepo $page->book_id = $parent->id; } - $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate; - if ($defaultTemplate && userCan('view', $defaultTemplate)) { + $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get(); + if ($defaultTemplate) { $page->forceFill([ 'html' => $defaultTemplate->html, 'markdown' => $defaultTemplate->markdown, ]); + $page->text = (new PageContent($page))->toPlainText(); } (new DatabaseTransaction(function () use ($page) { $page->save(); - $page->refresh()->rebuildPermissions(); + $page->rebuildPermissions(); }))->run(); return $page; @@ -80,7 +87,8 @@ class PageRepo $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); $this->updateTemplateStatusAndContentFromInput($draft, $input); - $this->baseRepo->update($draft, $input); + + $draft = $this->baseRepo->update($draft, $input); $draft->rebuildPermissions(); $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); @@ -111,12 +119,12 @@ class PageRepo public function update(Page $page, array $input): Page { // Hold the old details to compare later - $oldHtml = $page->html; $oldName = $page->name; + $oldHtml = $page->html; $oldMarkdown = $page->markdown; $this->updateTemplateStatusAndContentFromInput($page, $input); - $this->baseRepo->update($page, $input); + $page = $this->baseRepo->update($page, $input); // Update with new details $page->revision_count++; @@ -142,7 +150,7 @@ class PageRepo protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void { - if (isset($input['template']) && userCan('templates-manage')) { + if (isset($input['template']) && userCan(Permission::TemplatesManage)) { $page->template = ($input['template'] === 'true'); } @@ -165,7 +173,7 @@ class PageRepo $pageContent->setNewHTML($input['html'], user()); } - if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) { + if (($newEditor !== $currentEditor || empty($page->editor)) && userCan(Permission::EditorChange)) { $page->editor = $newEditor->value; } elseif (empty($page->editor)) { $page->editor = $defaultEditor->value; @@ -175,12 +183,12 @@ class PageRepo /** * Save a page update draft. */ - public function updatePageDraft(Page $page, array $input) + public function updatePageDraft(Page $page, array $input): Page|PageRevision { - // If the page itself is a draft simply update that + // If the page itself is a draft, simply update that if ($page->draft) { $this->updateTemplateStatusAndContentFromInput($page, $input); - $page->fill($input); + $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save(); $page->save(); return $page; @@ -208,7 +216,7 @@ class PageRepo * * @throws Exception */ - public function destroy(Page $page) + public function destroy(Page $page): void { $this->trashCan->softDestroyPage($page); Activity::add(ActivityType::PAGE_DELETE, $page); @@ -236,7 +244,7 @@ class PageRepo } $page->updated_by = user()->id; - $page->refreshSlug(); + $this->baseRepo->refreshSlug($page); $page->save(); $page->indexForSearch(); $this->referenceStore->updateForEntity($page); @@ -271,14 +279,14 @@ class PageRepo throw new MoveOperationException('Book or chapter to move page into not found'); } - if (!userCan('page-create', $parent)) { + if (!userCan(Permission::PageCreate, $parent)) { throw new PermissionsException('User does not have permission to create a page within the new parent'); } return (new DatabaseTransaction(function () use ($page, $parent) { $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; - $page->changeBook($newBookId); + $this->parentChanger->changeBook($page, $newBookId); $page->rebuildPermissions(); Activity::add(ActivityType::PAGE_MOVE, $page); diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php index d5549a0f1..2d1371b63 100644 --- a/app/Entities/Repos/RevisionRepo.php +++ b/app/Entities/Repos/RevisionRepo.php @@ -23,7 +23,7 @@ class RevisionRepo /** * Get a user update_draft page revision to update for the given page. - * Checks for an existing revisions before providing a fresh one. + * Checks for an existing revision before providing a fresh one. */ public function getNewDraftForCurrentUser(Page $page): PageRevision { @@ -72,7 +72,7 @@ class RevisionRepo /** * Delete old revisions, for the given page, from the system. */ - protected function deleteOldRevisions(Page $page) + protected function deleteOldRevisions(Page $page): void { $revisionLimit = config('app.revision_limit'); if ($revisionLimit === false) { diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7dd3f3e11..4bbab6265 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -3,13 +3,10 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Book; -use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; -use BookStack\Sorting\BookSortMap; -use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -29,7 +26,7 @@ class BookContents { $maxPage = $this->book->pages() ->where('draft', '=', false) - ->where('chapter_id', '=', 0) + ->whereDoesntHave('chapter') ->max('priority'); $maxChapter = $this->book->chapters() @@ -80,11 +77,11 @@ class BookContents protected function bookChildSortFunc(): callable { return function (Entity $entity) { - if (isset($entity['draft']) && $entity['draft']) { + if ($entity->getAttribute('draft') ?? false) { return -100; } - return $entity['priority'] ?? 0; + return $entity->getAttribute('priority') ?? 0; }; } diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 2be6083e3..64c48c351 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -6,36 +6,54 @@ use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; +use BookStack\Permissions\Permission; +use BookStack\References\ReferenceChangeContext; +use BookStack\References\ReferenceUpdater; use BookStack\Uploads\Image; use BookStack\Uploads\ImageService; use Illuminate\Http\UploadedFile; class Cloner { + protected ReferenceChangeContext $referenceChangeContext; + public function __construct( protected PageRepo $pageRepo, protected ChapterRepo $chapterRepo, protected BookRepo $bookRepo, protected ImageService $imageService, + protected ReferenceUpdater $referenceUpdater, ) { + $this->referenceChangeContext = new ReferenceChangeContext(); } /** * Clone the given page into the given parent using the provided name. */ public function clonePage(Page $original, Entity $parent, string $newName): Page + { + $context = $this->newReferenceChangeContext(); + $page = $this->createPageClone($original, $parent, $newName); + $this->referenceUpdater->changeReferencesUsingContext($context); + return $page; + } + + protected function createPageClone(Page $original, Entity $parent, string $newName): Page { $copyPage = $this->pageRepo->getNewDraftPage($parent); $pageData = $this->entityToInputData($original); $pageData['name'] = $newName; - return $this->pageRepo->publishDraft($copyPage, $pageData); + $newPage = $this->pageRepo->publishDraft($copyPage, $pageData); + $this->referenceChangeContext->add($original, $newPage); + + return $newPage; } /** @@ -43,27 +61,45 @@ class Cloner * Clones all child pages. */ public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter + { + $context = $this->newReferenceChangeContext(); + $chapter = $this->createChapterClone($original, $parent, $newName); + $this->referenceUpdater->changeReferencesUsingContext($context); + return $chapter; + } + + protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter { $chapterDetails = $this->entityToInputData($original); $chapterDetails['name'] = $newName; $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); - if (userCan('page-create', $copyChapter)) { + if (userCan(Permission::PageCreate, $copyChapter)) { /** @var Page $page */ foreach ($original->getVisiblePages() as $page) { - $this->clonePage($page, $copyChapter, $page->name); + $this->createPageClone($page, $copyChapter, $page->name); } } + $this->referenceChangeContext->add($original, $copyChapter); + return $copyChapter; } /** * Clone the given book. - * Clones all child chapters & pages. + * Clones all child chapters and pages. */ public function cloneBook(Book $original, string $newName): Book + { + $context = $this->newReferenceChangeContext(); + $book = $this->createBookClone($original, $newName); + $this->referenceUpdater->changeReferencesUsingContext($context); + return $book; + } + + protected function createBookClone(Book $original, string $newName): Book { $bookDetails = $this->entityToInputData($original); $bookDetails['name'] = $newName; @@ -74,23 +110,25 @@ class Cloner // Clone contents $directChildren = $original->getDirectVisibleChildren(); foreach ($directChildren as $child) { - if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) { - $this->cloneChapter($child, $copyBook, $child->name); + if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) { + $this->createChapterClone($child, $copyBook, $child->name); } - if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) { - $this->clonePage($child, $copyBook, $child->name); + if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) { + $this->createPageClone($child, $copyBook, $child->name); } } // Clone bookshelf relationships /** @var Bookshelf $shelf */ foreach ($original->shelves as $shelf) { - if (userCan('bookshelf-update', $shelf)) { + if (userCan(Permission::BookshelfUpdate, $shelf)) { $shelf->appendBook($copyBook); } } + $this->referenceChangeContext->add($original, $copyBook); + return $copyBook; } @@ -105,8 +143,8 @@ class Cloner $inputData['tags'] = $this->entityTagsToInputArray($entity); // Add a cover to the data if existing on the original entity - if ($entity instanceof HasCoverImage) { - $cover = $entity->cover()->first(); + if ($entity instanceof HasCoverInterface) { + $cover = $entity->coverInfo()->getImage(); if ($cover) { $inputData['image'] = $this->imageToUploadedFile($cover); } @@ -154,4 +192,10 @@ class Cloner return $tags; } + + protected function newReferenceChangeContext(): ReferenceChangeContext + { + $this->referenceChangeContext = new ReferenceChangeContext(); + return $this->referenceChangeContext; + } } diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php new file mode 100644 index 000000000..1e8fce201 --- /dev/null +++ b/app/Entities/Tools/EntityCover.php @@ -0,0 +1,75 @@ +where('id', '=', $this->entity->image_id); + } + + /** + * Check if a cover image exists for this entity. + */ + public function exists(): bool + { + return $this->entity->image_id !== null && $this->imageQuery()->exists(); + } + + /** + * Get the assigned cover image model. + */ + public function getImage(): Image|null + { + if ($this->entity->image_id === null) { + return null; + } + + $cover = $this->imageQuery()->first(); + if ($cover instanceof Image) { + return $cover; + } + + return null; + } + + /** + * Returns a cover image URL, or the given default if none assigned/existing. + */ + public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null + { + if (!$this->entity->image_id) { + return $default; + } + + try { + return $this->getImage()?->getThumb($width, $height, false) ?? $default; + } catch (Exception $err) { + return $default; + } + } + + /** + * Set the image to use as the cover for this entity. + */ + public function setImage(Image|null $image): void + { + if ($image === null) { + $this->entity->image_id = null; + } else { + $this->entity->image_id = $image->id; + } + } +} diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php new file mode 100644 index 000000000..d36c3f270 --- /dev/null +++ b/app/Entities/Tools/EntityDefaultTemplate.php @@ -0,0 +1,60 @@ +entity->default_template_id); + if (!$changing) { + return; + } + + if ($templateId === 0) { + $this->entity->default_template_id = null; + return; + } + + $pageQueries = app()->make(PageQueries::class); + $templateExists = $pageQueries->visibleTemplates() + ->where('id', '=', $templateId) + ->exists(); + + $this->entity->default_template_id = $templateExists ? $templateId : null; + } + + /** + * Get the default template for this entity (if visible). + */ + public function get(): Page|null + { + if (!$this->entity->default_template_id) { + return null; + } + + $pageQueries = app()->make(PageQueries::class); + $page = $pageQueries->visibleTemplates(true) + ->where('id', '=', $this->entity->default_template_id) + ->first(); + + if ($page instanceof Page) { + return $page; + } + + return null; + } +} diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php new file mode 100644 index 000000000..335703c36 --- /dev/null +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -0,0 +1,60 @@ +html = $this->entity->description_html ?? ''; + $this->plain = $this->entity->description ?? ''; + } + + /** + * Update the description from HTML code. + * Optionally takes plaintext to use for the model also. + */ + public function set(string $html, string|null $plaintext = null): void + { + $this->html = $html; + $this->entity->description_html = $this->html; + + if ($plaintext !== null) { + $this->plain = $plaintext; + $this->entity->description = $this->plain; + } + + if (empty($html) && !empty($plaintext)) { + $this->html = $this->getHtml(); + $this->entity->description_html = $this->html; + } + } + + /** + * Get the description as HTML. + * Optionally returns the raw HTML if requested. + */ + public function getHtml(bool $raw = false): string + { + $html = $this->html ?: '

' . nl2br(e($this->plain)) . '

'; + if ($raw) { + return $html; + } + + return HtmlContentFilter::removeScriptsFromHtmlString($html); + } + + public function getPlain(): string + { + return $this->plain; + } +} diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php new file mode 100644 index 000000000..87e39d222 --- /dev/null +++ b/app/Entities/Tools/EntityHydrator.php @@ -0,0 +1,140 @@ +getRawOriginal(); + $instance = Entity::instanceFromType($entity->type); + + if ($instance instanceof Page) { + $data['text'] = $data['description']; + unset($data['description']); + } + + $instance = $instance->setRawAttributes($data, true); + $hydrated[] = $instance; + } + + if ($loadTags) { + $this->loadTagsIntoModels($hydrated); + } + + if ($loadParents) { + $this->loadParentsIntoModels($hydrated); + } + + return $hydrated; + } + + /** + * @param Entity[] $entities + */ + protected function loadTagsIntoModels(array $entities): void + { + $idsByType = []; + $entityMap = []; + foreach ($entities as $entity) { + if (!isset($idsByType[$entity->type])) { + $idsByType[$entity->type] = []; + } + $idsByType[$entity->type][] = $entity->id; + $entityMap[$entity->type . ':' . $entity->id] = $entity; + } + + $query = Tag::query(); + foreach ($idsByType as $type => $ids) { + $query->orWhere(function ($query) use ($type, $ids) { + $query->where('entity_type', '=', $type) + ->whereIn('entity_id', $ids); + }); + } + + $tags = empty($idsByType) ? [] : $query->get()->all(); + $tagMap = []; + foreach ($tags as $tag) { + $key = $tag->entity_type . ':' . $tag->entity_id; + if (!isset($tagMap[$key])) { + $tagMap[$key] = []; + } + $tagMap[$key][] = $tag; + } + + foreach ($entityMap as $key => $entity) { + $entityTags = new Collection($tagMap[$key] ?? []); + $entity->setRelation('tags', $entityTags); + } + } + + /** + * @param Entity[] $entities + */ + protected function loadParentsIntoModels(array $entities): void + { + $parentsByType = ['book' => [], 'chapter' => []]; + + foreach ($entities as $entity) { + if ($entity->getAttribute('book_id') !== null) { + $parentsByType['book'][] = $entity->getAttribute('book_id'); + } + if ($entity->getAttribute('chapter_id') !== null) { + $parentsByType['chapter'][] = $entity->getAttribute('chapter_id'); + } + } + + $parentQuery = $this->entityQueries->visibleForList(); + $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0; + $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) { + foreach ($parentsByType as $type => $ids) { + if (count($ids) > 0) { + $query = $query->orWhere(function ($query) use ($type, $ids) { + $query->where('type', '=', $type) + ->whereIn('id', $ids); + }); + } + } + }); + + $parentModels = $filtered ? $parentQuery->get()->all() : []; + $parents = $this->hydrate($parentModels); + $parentMap = []; + foreach ($parents as $parent) { + $parentMap[$parent->type . ':' . $parent->id] = $parent; + } + + foreach ($entities as $entity) { + if ($entity instanceof Page || $entity instanceof Chapter) { + $key = 'book:' . $entity->getRawAttribute('book_id'); + $entity->setRelation('book', $parentMap[$key] ?? null); + } + if ($entity instanceof Page) { + $key = 'chapter:' . $entity->getRawAttribute('chapter_id'); + $entity->setRelation('chapter', $parentMap[$key] ?? null); + } + } + } +} diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index b0d8880f4..c58d29bd0 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -17,7 +17,8 @@ class HierarchyTransformer protected BookRepo $bookRepo, protected BookshelfRepo $shelfRepo, protected Cloner $cloner, - protected TrashCan $trashCan + protected TrashCan $trashCan, + protected ParentChanger $parentChanger, ) { } @@ -34,7 +35,8 @@ class HierarchyTransformer /** @var Page $page */ foreach ($chapter->pages as $page) { $page->chapter_id = 0; - $page->changeBook($book->id); + $page->save(); + $this->parentChanger->changeBook($page, $book->id); } $this->trashCan->destroyEntity($chapter); diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php index f9a940b98..9987cc061 100644 --- a/app/Entities/Tools/MixedEntityListLoader.php +++ b/app/Entities/Tools/MixedEntityListLoader.php @@ -19,7 +19,7 @@ class MixedEntityListLoader * This will look for a model id and type via 'name_id' and 'name_type'. * @param Model[] $relations */ - public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void + public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void { $idsByType = []; foreach ($relations as $relation) { @@ -33,7 +33,7 @@ class MixedEntityListLoader $idsByType[$type][] = $id; } - $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents); + $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents); foreach ($relations as $relation) { $type = $relation->getAttribute($relationName . '_type'); @@ -49,13 +49,13 @@ class MixedEntityListLoader * @param array $idsByType * @return array> */ - protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array + protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array { $modelMap = []; foreach ($idsByType as $type => $ids) { - $models = $this->queries->visibleForList($type) - ->whereIn('id', $ids) + $base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type); + $models = $base->whereIn('id', $ids) ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : []) ->get(); diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index d2f5de65c..c7a59216a 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -7,6 +7,7 @@ use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Tools\Markdown\MarkdownToHtml; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Theme; +use BookStack\Permissions\Permission; use BookStack\Theming\ThemeEvents; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageService; @@ -122,7 +123,7 @@ class PageContent $imageInfo = $this->parseBase64ImageUri($uri); // Validate user has permission to create images - if (!$updater->can('image-create-all')) { + if (!$updater->can(Permission::ImageCreateAll)) { return ''; } @@ -283,7 +284,7 @@ class PageContent /** * Get a plain-text visualisation of this page. */ - protected function toPlainText(): string + public function toPlainText(): string { $html = $this->render(true); diff --git a/app/Entities/Tools/PageEditActivity.php b/app/Entities/Tools/PageEditActivity.php index 646b200f1..22f89bf62 100644 --- a/app/Entities/Tools/PageEditActivity.php +++ b/app/Entities/Tools/PageEditActivity.php @@ -4,19 +4,15 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Page; use BookStack\Entities\Models\PageRevision; +use BookStack\Util\DateFormatter; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; class PageEditActivity { - protected Page $page; - - /** - * PageEditActivity constructor. - */ - public function __construct(Page $page) - { - $this->page = $page; + public function __construct( + protected Page $page + ) { } /** @@ -50,11 +46,9 @@ class PageEditActivity /** * Get any editor clash warning messages to show for the given draft revision. * - * @param PageRevision|Page $draft - * * @return string[] */ - public function getWarningMessagesForDraft($draft): array + public function getWarningMessagesForDraft(Page|PageRevision $draft): array { $warnings = []; @@ -82,7 +76,8 @@ class PageEditActivity */ public function getEditingActiveDraftMessage(PageRevision $draft): string { - $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); + $formatter = resolve(DateFormatter::class); + $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $formatter->relative($draft->updated_at)]); if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) { return $message; } diff --git a/app/Entities/Tools/PageEditorData.php b/app/Entities/Tools/PageEditorData.php index e4fe2fd25..b41b31909 100644 --- a/app/Entities/Tools/PageEditorData.php +++ b/app/Entities/Tools/PageEditorData.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\Markdown\HtmlToMarkdown; use BookStack\Entities\Tools\Markdown\MarkdownToHtml; +use BookStack\Permissions\Permission; class PageEditorData { @@ -98,9 +99,9 @@ class PageEditorData { $editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault(); - // Use requested editor if valid and if we have permission + // Use the requested editor if valid and if we have permission $requestedType = PageEditorType::fromRequestValue($this->requestedEditor); - if ($requestedType && userCan('editor-change')) { + if ($requestedType && userCan(Permission::EditorChange)) { $editorType = $requestedType; } diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php index e0b89f158..af7ed4fc6 100644 --- a/app/Entities/Tools/PageIncludeParser.php +++ b/app/Entities/Tools/PageIncludeParser.php @@ -7,15 +7,14 @@ use Closure; use DOMDocument; use DOMElement; use DOMNode; -use DOMText; class PageIncludeParser { protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/"; /** - * Elements to clean up and remove if left empty after a parsing operation. - * @var DOMElement[] + * Nodes to clean up and remove if left empty after a parsing operation. + * @var DOMNode[] */ protected array $toCleanup = []; @@ -159,7 +158,7 @@ class PageIncludeParser /** * Splits the given $parentNode at the location of the $domNode within it. - * Attempts replicate the original $parentNode, moving some of their parent + * Attempts to replicate the original $parentNode, moving some of their parent * children in where needed, before adding the $domNode between. */ protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void @@ -171,6 +170,10 @@ class PageIncludeParser } $parentClone = $parentNode->cloneNode(); + if (!($parentClone instanceof DOMElement)) { + return; + } + $parentNode->parentNode->insertBefore($parentClone, $parentNode); $parentClone->removeAttribute('id'); @@ -203,7 +206,7 @@ class PageIncludeParser } /** - * Cleanup after a parse operation. + * Clean up after a parse operation. * Removes stranded elements we may have left during the parse. */ protected function cleanup(): void diff --git a/app/Entities/Tools/ParentChanger.php b/app/Entities/Tools/ParentChanger.php new file mode 100644 index 000000000..00ce42aae --- /dev/null +++ b/app/Entities/Tools/ParentChanger.php @@ -0,0 +1,40 @@ +getUrl(); + + $child->book_id = $newBookId; + $child->unsetRelation('book'); + $this->slugGenerator->regenerateForEntity($child); + $child->save(); + + if ($oldUrl !== $child->getUrl()) { + $this->referenceUpdater->updateEntityReferences($child, $oldUrl); + } + + // Update all child pages if a chapter + if ($child instanceof Chapter) { + foreach ($child->pages()->withTrashed()->get() as $page) { + $this->changeBook($page, $newBookId); + } + } + } +} diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index 9f3b8f952..fa9ae753c 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -8,6 +8,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Entity; use BookStack\Facades\Activity; use BookStack\Permissions\Models\EntityPermission; +use BookStack\Permissions\Permission; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; use Illuminate\Http\Request; @@ -93,8 +94,9 @@ class PermissionsUpdater foreach ($permissions as $roleId => $info) { $entityPermissionData = ['role_id' => $roleId]; - foreach (EntityPermission::PERMISSIONS as $permission) { - $entityPermissionData[$permission] = (($info[$permission] ?? false) === "true"); + foreach (Permission::genericForEntity() as $permission) { + $permName = $permission->value; + $entityPermissionData[$permName] = (($info[$permName] ?? false) === "true"); } $formatted[] = $entityPermissionData; } @@ -108,8 +110,9 @@ class PermissionsUpdater foreach ($permissions as $requestPermissionData) { $entityPermissionData = ['role_id' => $requestPermissionData['role_id']]; - foreach (EntityPermission::PERMISSIONS as $permission) { - $entityPermissionData[$permission] = boolval($requestPermissionData[$permission] ?? false); + foreach (Permission::genericForEntity() as $permission) { + $permName = $permission->value; + $entityPermissionData[$permName] = boolval($requestPermissionData[$permName] ?? false); } $formatted[] = $entityPermissionData; } @@ -147,7 +150,7 @@ class PermissionsUpdater /** @var Book $book */ foreach ($shelfBooks as $book) { - if ($checkUserPermissions && !userCan('restrictions-manage', $book)) { + if ($checkUserPermissions && !userCan(Permission::RestrictionsManage, $book)) { continue; } $book->permissions()->delete(); diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php index 5df300bb0..6eec84a91 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -3,19 +3,21 @@ namespace BookStack\Entities\Tools; use BookStack\App\Model; -use BookStack\App\Sluggable; +use BookStack\App\SluggableInterface; use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Models\Entity; +use BookStack\Users\Models\User; use Illuminate\Support\Str; class SlugGenerator { /** - * Generate a fresh slug for the given entity. + * Generate a fresh slug for the given item. * The slug will be generated so that it doesn't conflict within the same parent item. */ - public function generate(Sluggable $model): string + public function generate(SluggableInterface&Model $model, string $slugSource): string { - $slug = $this->formatNameAsSlug($model->name); + $slug = $this->formatNameAsSlug($slugSource); while ($this->slugInUse($slug, $model)) { $slug .= '-' . Str::random(3); } @@ -24,7 +26,27 @@ class SlugGenerator } /** - * Format a name as a url slug. + * Regenerate the slug for the given entity. + */ + public function regenerateForEntity(Entity $entity): string + { + $entity->slug = $this->generate($entity, $entity->name); + + return $entity->slug; + } + + /** + * Regenerate the slug for a user. + */ + public function regenerateForUser(User $user): string + { + $user->slug = $this->generate($user, $user->name); + + return $user->slug; + } + + /** + * Format a name as a URL slug. */ protected function formatNameAsSlug(string $name): string { @@ -39,10 +61,8 @@ class SlugGenerator /** * Check if a slug is already in-use for this * type of model within the same parent. - * - * @param Sluggable&Model $model */ - protected function slugInUse(string $slug, Sluggable $model): bool + protected function slugInUse(string $slug, SluggableInterface&Model $model): bool { $query = $model->newQuery()->where('slug', '=', $slug); diff --git a/app/Entities/Tools/SlugHistory.php b/app/Entities/Tools/SlugHistory.php new file mode 100644 index 000000000..2c8d88129 --- /dev/null +++ b/app/Entities/Tools/SlugHistory.php @@ -0,0 +1,97 @@ +id || !$entity->slug) { + return; + } + + $parentSlug = null; + if ($entity instanceof BookChild) { + $parentSlug = $entity->book()->first()?->slug; + } + + $latest = $this->getLatestEntryForEntity($entity); + if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) { + return; + } + + $info = [ + 'sluggable_type' => $entity->getMorphClass(), + 'sluggable_id' => $entity->id, + 'slug' => $entity->slug, + 'parent_slug' => $parentSlug, + ]; + + $entry = new SlugHistoryModel(); + $entry->forceFill($info); + $entry->save(); + + if ($entity instanceof Book) { + $this->recordForBookChildren($entity); + } + } + + protected function recordForBookChildren(Book $book): void + { + $query = EntityTable::query() + ->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')]) + ->where('book_id', '=', $book->id) + ->whereNotNull('book_id'); + + SlugHistoryModel::query()->insertUsing( + ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'], + $query + ); + } + + /** + * Find the latest visible entry for an entity which uses the given slug(s) in the history. + */ + public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int + { + $query = SlugHistoryModel::query() + ->where('sluggable_type', '=', $type) + ->where('slug', '=', $slug); + + if ($parentSlug) { + $query->where('parent_slug', '=', $parentSlug); + } + + $query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type'); + + /** @var SlugHistoryModel|null $result */ + $result = $query->orderBy('created_at', 'desc')->first(); + + return $result?->sluggable_id; + } + + protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null + { + return SlugHistoryModel::query() + ->where('sluggable_type', '=', $entity->getMorphClass()) + ->where('sluggable_id', '=', $entity->id) + ->orderBy('created_at', 'desc') + ->first(); + } +} diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index 5e8a93719..96645aebf 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -6,14 +6,16 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\EntityContainerData; +use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; use BookStack\Uploads\AttachmentService; +use BookStack\Uploads\Image; use BookStack\Uploads\ImageService; use BookStack\Util\DatabaseTransaction; use Exception; @@ -140,6 +142,7 @@ class TrashCan protected function destroyShelf(Bookshelf $shelf): int { $this->destroyCommonRelations($shelf); + $shelf->books()->detach(); $shelf->forceDelete(); return 1; @@ -167,6 +170,7 @@ class TrashCan } $this->destroyCommonRelations($book); + $book->shelves()->detach(); $book->forceDelete(); return $count + 1; @@ -209,15 +213,16 @@ class TrashCan $attachmentService->deleteFile($attachment); } - // Remove book template usages - $this->queries->books->start() + // Remove use as a template + EntityContainerData::query() ->where('default_template_id', '=', $page->id) ->update(['default_template_id' => null]); - // Remove chapter template usages - $this->queries->chapters->start() - ->where('default_template_id', '=', $page->id) - ->update(['default_template_id' => null]); + // Nullify uploaded image relations + Image::query() + ->whereIn('type', ['gallery', 'drawio']) + ->where('uploaded_to', '=', $page->id) + ->update(['uploaded_to' => null]); $page->forceDelete(); @@ -268,8 +273,8 @@ class TrashCan // exists in the event it has already been destroyed during this request. $entity = $deletion->deletable()->first(); $count = 0; - if ($entity) { - $count = $this->destroyEntity($deletion->deletable); + if ($entity instanceof Entity) { + $count = $this->destroyEntity($entity); } $deletion->delete(); @@ -383,7 +388,7 @@ class TrashCan /** * Update entity relations to remove or update outstanding connections. */ - protected function destroyCommonRelations(Entity $entity) + protected function destroyCommonRelations(Entity $entity): void { Activity::removeEntity($entity); $entity->views()->delete(); @@ -397,10 +402,13 @@ class TrashCan $entity->watches()->delete(); $entity->referencesTo()->delete(); $entity->referencesFrom()->delete(); + $entity->slugHistory()->delete(); - if ($entity instanceof HasCoverImage && $entity->cover()->exists()) { + if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) { $imageService = app()->make(ImageService::class); - $imageService->destroy($entity->cover()->first()); + $imageService->destroy($entity->coverInfo()->getImage()); } + + $entity->relatedData()->delete(); } } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 61e126327..08d326ad8 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,7 +2,6 @@ namespace BookStack\Exceptions; -use Exception; use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; @@ -12,6 +11,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Validation\ValidationException; use Symfony\Component\ErrorHandler\Error\FatalError; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; @@ -20,7 +20,7 @@ class Handler extends ExceptionHandler /** * A list of the exception types that are not reported. * - * @var array> + * @var array> */ protected $dontReport = [ NotFoundException::class, @@ -50,11 +50,11 @@ class Handler extends ExceptionHandler /** * Report or log an exception. * - * @param \Throwable $exception - * - * @throws \Throwable + * @param Throwable $exception * * @return void + *@throws Throwable + * */ public function report(Throwable $exception) { @@ -64,12 +64,9 @@ class Handler extends ExceptionHandler /** * Render an exception into an HTTP response. * - * @param \Illuminate\Http\Request $request - * @param Exception $e - * - * @return \Illuminate\Http\Response + * @param Request $request */ - public function render($request, Throwable $e) + public function render($request, Throwable $e): SymfonyResponse { if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) { $response = call_user_func($this->onOutOfMemory); @@ -94,7 +91,7 @@ class Handler extends ExceptionHandler * If the callable returns a response, this response will be returned * to the request upon error. */ - public function prepareForOutOfMemory(callable $onOutOfMemory) + public function prepareForOutOfMemory(callable $onOutOfMemory): void { $this->onOutOfMemory = $onOutOfMemory; } @@ -102,7 +99,7 @@ class Handler extends ExceptionHandler /** * Forget the current out of memory handler, if existing. */ - public function forgetOutOfMemoryHandler() + public function forgetOutOfMemoryHandler(): void { $this->onOutOfMemory = null; } @@ -152,12 +149,9 @@ class Handler extends ExceptionHandler /** * Convert an authentication exception into an unauthenticated response. * - * @param \Illuminate\Http\Request $request - * @param \Illuminate\Auth\AuthenticationException $exception - * - * @return \Illuminate\Http\Response + * @param Request $request */ - protected function unauthenticated($request, AuthenticationException $exception) + protected function unauthenticated($request, AuthenticationException $exception): SymfonyResponse { if ($request->expectsJson()) { return response()->json(['error' => 'Unauthenticated.'], 401); @@ -169,12 +163,9 @@ class Handler extends ExceptionHandler /** * Convert a validation exception into a JSON response. * - * @param \Illuminate\Http\Request $request - * @param \Illuminate\Validation\ValidationException $exception - * - * @return \Illuminate\Http\JsonResponse + * @param Request $request */ - protected function invalidJson($request, ValidationException $exception) + protected function invalidJson($request, ValidationException $exception): JsonResponse { return response()->json($exception->errors(), $exception->status); } diff --git a/app/Exports/Controllers/BookExportApiController.php b/app/Exports/Controllers/BookExportApiController.php index 87f1d7eef..21f276f8a 100644 --- a/app/Exports/Controllers/BookExportApiController.php +++ b/app/Exports/Controllers/BookExportApiController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Queries\BookQueries; use BookStack\Exports\ExportFormatter; use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Throwable; class BookExportApiController extends ApiController @@ -14,7 +15,7 @@ class BookExportApiController extends ApiController protected ExportFormatter $exportFormatter, protected BookQueries $queries, ) { - $this->middleware('can:content-export'); + $this->middleware(Permission::ContentExport->middleware()); } /** diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php index 67247598c..f6bb66666 100644 --- a/app/Exports/Controllers/BookExportController.php +++ b/app/Exports/Controllers/BookExportController.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use Throwable; class BookExportController extends Controller @@ -15,7 +16,7 @@ class BookExportController extends Controller protected BookQueries $queries, protected ExportFormatter $exportFormatter, ) { - $this->middleware('can:content-export'); + $this->middleware(Permission::ContentExport->middleware()); $this->middleware('throttle:exports'); } diff --git a/app/Exports/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php index bccd414af..7e5a23c70 100644 --- a/app/Exports/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Queries\ChapterQueries; use BookStack\Exports\ExportFormatter; use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Throwable; class ChapterExportApiController extends ApiController @@ -14,7 +15,7 @@ class ChapterExportApiController extends ApiController protected ExportFormatter $exportFormatter, protected ChapterQueries $queries, ) { - $this->middleware('can:content-export'); + $this->middleware(Permission::ContentExport->middleware()); } /** diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index 849024343..fdb2bba94 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use Throwable; class ChapterExportController extends Controller @@ -15,7 +16,7 @@ class ChapterExportController extends Controller protected ChapterQueries $queries, protected ExportFormatter $exportFormatter, ) { - $this->middleware('can:content-export'); + $this->middleware(Permission::ContentExport->middleware()); $this->middleware('throttle:exports'); } diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php index cac155c7c..f8eaea5a1 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -8,6 +8,7 @@ use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ImportRepo; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use BookStack\Uploads\AttachmentService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; @@ -18,7 +19,7 @@ class ImportApiController extends ApiController public function __construct( protected ImportRepo $imports, ) { - $this->middleware('can:content-import'); + $this->middleware(Permission::ContentImport->middleware()); } /** diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 0d3e2414b..7ecc09a41 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -8,6 +8,7 @@ use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ImportRepo; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Uploads\AttachmentService; use Illuminate\Http\Request; @@ -16,7 +17,7 @@ class ImportController extends Controller public function __construct( protected ImportRepo $imports, ) { - $this->middleware('can:content-import'); + $this->middleware(Permission::ContentImport->middleware()); } /** diff --git a/app/Exports/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php index 73af01afa..c5b186289 100644 --- a/app/Exports/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Queries\PageQueries; use BookStack\Exports\ExportFormatter; use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Throwable; class PageExportApiController extends ApiController @@ -14,7 +15,7 @@ class PageExportApiController extends ApiController protected ExportFormatter $exportFormatter, protected PageQueries $queries, ) { - $this->middleware('can:content-export'); + $this->middleware(Permission::ContentExport->middleware()); } /** diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index 145dce9dd..9bc79f247 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -8,6 +8,7 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use Throwable; class PageExportController extends Controller @@ -16,7 +17,7 @@ class PageExportController extends Controller protected PageQueries $queries, protected ExportFormatter $exportFormatter, ) { - $this->middleware('can:content-export'); + $this->middleware(Permission::ContentExport->middleware()); $this->middleware('throttle:exports'); } diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index 85ac7d2c9..ad489aba1 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -284,7 +284,7 @@ class ExportFormatter public function bookToPlainText(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); - $text = $book->name . "\n" . $book->description; + $text = $book->name . "\n" . $book->descriptionInfo()->getPlain(); $text = rtrim($text) . "\n\n"; $parts = []; @@ -318,7 +318,7 @@ class ExportFormatter { $text = '# ' . $chapter->name . "\n\n"; - $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert(); + $description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert(); if ($description) { $text .= $description . "\n\n"; } @@ -338,7 +338,7 @@ class ExportFormatter $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; - $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert(); + $description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert(); if ($description) { $text .= $description . "\n\n"; } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index e030a88d2..79db69fca 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -16,6 +16,7 @@ use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Facades\Activity; +use BookStack\Permissions\Permission; use BookStack\Uploads\FileStorage; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -39,11 +40,14 @@ class ImportRepo return $this->queryVisible()->get(); } + /** + * @return Builder + */ public function queryVisible(): Builder { $query = Import::query(); - if (!userCan('settings-manage')) { + if (!userCan(Permission::SettingsManage)) { $query->where('created_by', user()->id); } @@ -54,7 +58,7 @@ class ImportRepo { $query = Import::query(); - if (!userCan('settings-manage')) { + if (!userCan(Permission::SettingsManage)) { $query->where('created_by', user()->id); } diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 4f5b2f236..97995738f 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -6,7 +6,7 @@ use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Attachment; -class ZipExportAttachment extends ZipExportModel +final class ZipExportAttachment extends ZipExportModel { public ?int $id = null; public string $name; @@ -52,9 +52,9 @@ class ZipExportAttachment extends ZipExportModel return $context->validateData($data, $rules); } - public static function fromArray(array $data): self + public static function fromArray(array $data): static { - $model = new self(); + $model = new static(); $model->id = $data['id'] ?? null; $model->name = $data['name']; diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 39176ded4..ab3fd90ec 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -8,7 +8,7 @@ use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipValidationHelper; -class ZipExportBook extends ZipExportModel +final class ZipExportBook extends ZipExportModel { public ?int $id = null; public string $name; @@ -55,10 +55,10 @@ class ZipExportBook extends ZipExportModel $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->description_html = $model->descriptionHtml(); + $instance->description_html = $model->descriptionInfo()->getHtml(); - if ($model->cover) { - $instance->cover = $files->referenceForImage($model->cover); + if ($model->coverInfo()->exists()) { + $instance->cover = $files->referenceForImage($model->coverInfo()->getImage()); } $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); @@ -101,9 +101,9 @@ class ZipExportBook extends ZipExportModel return $errors; } - public static function fromArray(array $data): self + public static function fromArray(array $data): static { - $model = new self(); + $model = new static(); $model->id = $data['id'] ?? null; $model->name = $data['name']; diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index bf2dc78f8..906ce3d81 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -7,7 +7,7 @@ use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipValidationHelper; -class ZipExportChapter extends ZipExportModel +final class ZipExportChapter extends ZipExportModel { public ?int $id = null; public string $name; @@ -40,7 +40,7 @@ class ZipExportChapter extends ZipExportModel $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->description_html = $model->descriptionHtml(); + $instance->description_html = $model->descriptionInfo()->getHtml(); $instance->priority = $model->priority; $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); @@ -79,9 +79,9 @@ class ZipExportChapter extends ZipExportModel return $errors; } - public static function fromArray(array $data): self + public static function fromArray(array $data): static { - $model = new self(); + $model = new static(); $model->id = $data['id'] ?? null; $model->name = $data['name']; diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index e0e7d1198..4c71af0c3 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -7,7 +7,7 @@ use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Image; use Illuminate\Validation\Rule; -class ZipExportImage extends ZipExportModel +final class ZipExportImage extends ZipExportModel { public ?int $id = null; public string $name; @@ -43,9 +43,9 @@ class ZipExportImage extends ZipExportModel return $context->validateData($data, $rules); } - public static function fromArray(array $data): self + public static function fromArray(array $data): static { - $model = new self(); + $model = new static(); $model->id = $data['id'] ?? null; $model->name = $data['name']; diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php index d3a8c3567..38001c628 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -30,12 +30,12 @@ abstract class ZipExportModel implements JsonSerializable /** * Decode the array of data into this export model. */ - abstract public static function fromArray(array $data): self; + abstract public static function fromArray(array $data): static; /** * Decode an array of array data into an array of export models. * @param array[] $data - * @return self[] + * @return static[] */ public static function fromManyArray(array $data): array { diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 097443df0..6de7f9446 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -7,7 +7,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipValidationHelper; -class ZipExportPage extends ZipExportModel +final class ZipExportPage extends ZipExportModel { public ?int $id = null; public string $name; @@ -86,9 +86,9 @@ class ZipExportPage extends ZipExportModel return $errors; } - public static function fromArray(array $data): self + public static function fromArray(array $data): static { - $model = new self(); + $model = new static(); $model->id = $data['id'] ?? null; $model->name = $data['name']; diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index 6b4720fca..8ac7f3c4d 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -5,7 +5,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Activity\Models\Tag; use BookStack\Exports\ZipExports\ZipValidationHelper; -class ZipExportTag extends ZipExportModel +final class ZipExportTag extends ZipExportModel { public string $name; public ?string $value = null; @@ -39,9 +39,9 @@ class ZipExportTag extends ZipExportModel return $context->validateData($data, $rules); } - public static function fromArray(array $data): self + public static function fromArray(array $data): static { - $model = new self(); + $model = new static(); $model->name = $data['name']; $model->value = $data['value'] ?? null; diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index c3d5c23cf..28b830167 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -58,6 +58,16 @@ class ZipExportReader { $this->open(); + $info = $this->zip->statName('data.json'); + if ($info === false) { + throw new ZipExportException(trans('errors.import_zip_cant_decode_data')); + } + + $maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000; + if ($info['size'] > $maxSize) { + throw new ZipExportException(trans('errors.import_zip_data_too_large')); + } + // Validate json data exists, including metadata $jsonData = $this->zip->getFromName('data.json') ?: ''; $importData = json_decode($jsonData, true); @@ -73,6 +83,17 @@ class ZipExportReader return $this->zip->statName("files/{$fileName}") !== false; } + public function fileWithinSizeLimit(string $fileName): bool + { + $fileInfo = $this->zip->statName("files/{$fileName}"); + if ($fileInfo === false) { + return false; + } + + $maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000; + return $fileInfo['size'] <= $maxSize; + } + /** * @return false|resource */ diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index b21248ffd..a79988d44 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -12,8 +12,10 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; +use BookStack\Permissions\Permission; use BookStack\Uploads\Attachment; use BookStack\Uploads\Image; +use BookStack\Uploads\ImageService; class ZipExportReferences { @@ -32,6 +34,7 @@ class ZipExportReferences public function __construct( protected ZipReferenceParser $parser, + protected ImageService $imageService, ) { } @@ -132,10 +135,17 @@ class ZipExportReferences return "[[bsexport:image:{$model->id}]]"; } - // Find and include images if in visibility + // Get the page which we'll reference this image upon $page = $model->getPage(); - $pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null); - if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan('view', $page))) { + $pageExportModel = null; + if ($page && isset($this->pages[$page->id])) { + $pageExportModel = $this->pages[$page->id]; + } elseif ($exportModel instanceof ZipExportPage) { + $pageExportModel = $exportModel; + } + + // Add the image to the export if it's accessible or just return the existing reference if already added + if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) { if (!isset($this->images[$model->id])) { $exportImage = ZipExportImage::fromModel($model, $files); $this->images[$model->id] = $exportImage; @@ -143,6 +153,7 @@ class ZipExportReferences } return "[[bsexport:image:{$model->id}]]"; } + return null; } diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index 90e78c060..01d703a1d 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -13,7 +13,6 @@ class ZipFileReferenceRule implements ValidationRule ) { } - /** * @inheritDoc */ @@ -23,6 +22,13 @@ class ZipFileReferenceRule implements ValidationRule $fail('validation.zip_file')->translate(); } + if (!$this->context->zipReader->fileWithinSizeLimit($value)) { + $fail('validation.zip_file_size')->translate([ + 'attribute' => $value, + 'size' => config('app.upload_limit'), + ]); + } + if (!empty($this->acceptedMimes)) { $fileMime = $this->context->zipReader->sniffFileMime($value); if (!in_array($fileMime, $this->acceptedMimes)) { diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index da0581df6..662dfdf6c 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -29,7 +29,10 @@ class ZipImportReferences /** @var Image[] */ protected array $images = []; - /** @var array */ + /** + * Mapping keyed by "type:old-reference-id" with values being the new imported equivalent model. + * @var array + */ protected array $referenceMap = []; /** @var array */ @@ -108,6 +111,22 @@ class ZipImportReferences return null; } + protected function replaceDrawingIdReferences(string $content): string + { + $referenceRegex = '/\sdrawio-diagram=[\'"](\d+)[\'"]/'; + + $result = preg_replace_callback($referenceRegex, function ($matches) { + $key = 'image:' . $matches[1]; + $model = $this->referenceMap[$key] ?? null; + if ($model instanceof Image && $model->type === 'drawio') { + return ' drawio-diagram="' . $model->id . '"'; + } + return $matches[0]; + }, $content); + + return $result ?: $content; + } + public function replaceReferences(): void { foreach ($this->books as $book) { @@ -134,7 +153,9 @@ class ZipImportReferences $exportPage = $this->zipExportPageMap[$page->id]; $contentType = $exportPage->markdown ? 'markdown' : 'html'; $content = $exportPage->markdown ?: ($exportPage->html ?: ''); + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + $parsed = $this->replaceDrawingIdReferences($parsed); $this->pageRepo->setContentFromInput($page, [ $contentType => $parsed, diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index d25a1621f..382e4073e 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -18,6 +18,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\Models\ZipExportTag; +use BookStack\Permissions\Permission; use BookStack\Uploads\Attachment; use BookStack\Uploads\AttachmentService; use BookStack\Uploads\FileStorage; @@ -134,8 +135,8 @@ class ZipImportRunner 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), ]); - if ($book->cover) { - $this->references->addImage($book->cover, null); + if ($book->coverInfo()->getImage()) { + $this->references->addImage($book->coverInfo()->getImage(), null); } $children = [ @@ -196,8 +197,8 @@ class ZipImportRunner $this->pageRepo->publishDraft($page, [ 'name' => $exportPage->name, - 'markdown' => $exportPage->markdown, - 'html' => $exportPage->html, + 'markdown' => $exportPage->markdown ?? '', + 'html' => $exportPage->html ?? '', 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), ]); @@ -264,6 +265,12 @@ class ZipImportRunner protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile { + if (!$reader->fileWithinSizeLimit($fileName)) { + throw new ZipImportException([ + "File $fileName exceeds app upload limit." + ]); + } + $tempPath = tempnam(sys_get_temp_dir(), 'bszipextract'); $fileStream = $reader->streamFile($fileName); $tempStream = fopen($tempPath, 'wb'); @@ -288,7 +295,7 @@ class ZipImportRunner $attachments = []; if ($exportModel instanceof ZipExportBook) { - if (!userCan('book-create-all')) { + if (!userCan(Permission::BookCreateAll)) { $errors[] = trans('errors.import_perms_books'); } array_push($pages, ...$exportModel->pages); @@ -317,11 +324,11 @@ class ZipImportRunner if (count($pages) > 0) { if ($parent) { - if (!userCan('page-create', $parent)) { + if (!userCan(Permission::PageCreate, $parent)) { $errors[] = trans('errors.import_perms_pages'); } } else { - $hasPermission = userCan('page-create-all') || userCan('page-create-own'); + $hasPermission = userCan(Permission::PageCreateAll) || userCan(Permission::PageCreateOwn); if (!$hasPermission) { $errors[] = trans('errors.import_perms_pages'); } @@ -329,13 +336,13 @@ class ZipImportRunner } if (count($images) > 0) { - if (!userCan('image-create-all')) { + if (!userCan(Permission::ImageCreateAll)) { $errors[] = trans('errors.import_perms_images'); } } if (count($attachments) > 0) { - if (!userCan('attachment-create-all')) { + if (!userCan(Permission::AttachmentCreateAll)) { $errors[] = trans('errors.import_perms_attachments'); } } diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 1a92afa33..8c0f206d0 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse; abstract class ApiController extends Controller { + /** + * The validation rules for this controller. + * Can alternative be defined in a rules() method is they need to be dynamic. + * + * @var array> + */ protected array $rules = []; /** diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 7f2134dd8..5d3be4951 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\App\Model; use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; +use BookStack\Permissions\Permission; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; @@ -27,10 +28,9 @@ abstract class Controller extends BaseController } /** - * Stops the application and shows a permission error if - * the application is in demo mode. + * Stops the application and shows a permission error if the application is in demo mode. */ - protected function preventAccessInDemoMode() + protected function preventAccessInDemoMode(): void { if (config('app.env') === 'demo') { $this->showPermissionError(); @@ -40,14 +40,13 @@ abstract class Controller extends BaseController /** * Adds the page title into the view. */ - public function setPageTitle(string $title) + public function setPageTitle(string $title): void { view()->share('pageTitle', $title); } /** - * On a permission error redirect to home and display. - * the error as a notification. + * On a permission error redirect to home and display the error as a notification. * * @throws NotifyException */ @@ -61,7 +60,7 @@ abstract class Controller extends BaseController /** * Checks that the current user has the given permission otherwise throw an exception. */ - protected function checkPermission(string $permission): void + protected function checkPermission(string|Permission $permission): void { if (!user() || !user()->can($permission)) { $this->showPermissionError(); @@ -81,7 +80,7 @@ abstract class Controller extends BaseController /** * Check the current user's permissions against an ownable item otherwise throw an exception. */ - protected function checkOwnablePermission(string $permission, Model $ownable, string $redirectLocation = '/'): void + protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void { if (!userCan($permission, $ownable)) { $this->showPermissionError($redirectLocation); @@ -92,7 +91,7 @@ abstract class Controller extends BaseController * Check if a user has a permission or bypass the permission * check if the given callback resolves true. */ - protected function checkPermissionOr(string $permission, callable $callback): void + protected function checkPermissionOr(string|Permission $permission, callable $callback): void { if ($callback() !== true) { $this->checkPermission($permission); @@ -103,7 +102,7 @@ abstract class Controller extends BaseController * Check if the current user has a permission or bypass if the provided user * id matches the current user. */ - protected function checkPermissionOrCurrentUser(string $permission, int $userId): void + protected function checkPermissionOrCurrentUser(string|Permission $permission, int $userId): void { $this->checkPermissionOr($permission, function () use ($userId) { return $userId === user()->id; @@ -111,7 +110,7 @@ abstract class Controller extends BaseController } /** - * Send back a json error message. + * Send back a JSON error message. */ protected function jsonError(string $messageText = '', int $statusCode = 500): JsonResponse { @@ -127,7 +126,7 @@ abstract class Controller extends BaseController } /** - * Show a positive, successful notification to the user on next view load. + * Show a positive, successful notification to the user on the next view load. */ protected function showSuccessNotification(string $message): void { @@ -135,7 +134,7 @@ abstract class Controller extends BaseController } /** - * Show a warning notification to the user on next view load. + * Show a warning notification to the user on the next view load. */ protected function showWarningNotification(string $message): void { @@ -143,7 +142,7 @@ abstract class Controller extends BaseController } /** - * Show an error notification to the user on next view load. + * Show an error notification to the user on the next view load. */ protected function showErrorNotification(string $message): void { diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 30714e2ac..00bf8cbe1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -9,6 +9,8 @@ class Kernel extends HttpKernel /** * The application's global HTTP middleware stack. * These middleware are run during every request to your application. + * + * @var list */ protected $middleware = [ \BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class, @@ -21,7 +23,7 @@ class Kernel extends HttpKernel /** * The application's route middleware groups. * - * @var array + * @var array> */ protected $middlewareGroups = [ 'web' => [ @@ -47,7 +49,7 @@ class Kernel extends HttpKernel /** * The application's middleware aliases. * - * @var array + * @var array */ protected $middlewareAliases = [ 'auth' => \BookStack\Http\Middleware\Authenticate::class, diff --git a/app/Http/Middleware/ApiAuthenticate.php b/app/Http/Middleware/ApiAuthenticate.php index 5f3ad3168..15b5a325a 100644 --- a/app/Http/Middleware/ApiAuthenticate.php +++ b/app/Http/Middleware/ApiAuthenticate.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Middleware; use BookStack\Exceptions\ApiAuthException; +use BookStack\Permissions\Permission; use Closure; use Illuminate\Http\Request; @@ -51,7 +52,7 @@ class ApiAuthenticate */ protected function sessionUserHasApiAccess(): bool { - $hasApiPermission = user()->can('access-api'); + $hasApiPermission = user()->can(Permission::AccessApi); return $hasApiPermission && user()->hasAppAccess(); } diff --git a/app/Http/Middleware/CheckUserHasPermission.php b/app/Http/Middleware/CheckUserHasPermission.php index b5678e734..ea4ff652a 100644 --- a/app/Http/Middleware/CheckUserHasPermission.php +++ b/app/Http/Middleware/CheckUserHasPermission.php @@ -2,6 +2,7 @@ namespace BookStack\Http\Middleware; +use BookStack\Permissions\Permission; use Closure; use Illuminate\Http\Request; @@ -10,13 +11,9 @@ class CheckUserHasPermission /** * Handle an incoming request. * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @param string $permission - * * @return mixed */ - public function handle($request, Closure $next, $permission) + public function handle(Request $request, Closure $next, string|Permission $permission) { if (!user()->can($permission)) { return $this->errorResponse($request); diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php index 484c15ae8..2d7d5d9d4 100644 --- a/app/Http/Middleware/EncryptCookies.php +++ b/app/Http/Middleware/EncryptCookies.php @@ -9,7 +9,7 @@ class EncryptCookies extends Middleware /** * The names of the cookies that should not be encrypted. * - * @var array + * @var array */ protected $except = [ // diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php index dfb9592e1..c1a02c8cc 100644 --- a/app/Http/Middleware/PreventRequestsDuringMaintenance.php +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -9,7 +9,7 @@ class PreventRequestsDuringMaintenance extends Middleware /** * The URIs that should be reachable while maintenance mode is enabled. * - * @var array + * @var array */ protected $except = [ // diff --git a/app/Http/Middleware/StartSessionExtended.php b/app/Http/Middleware/StartSessionExtended.php index 26cd250ac..8c5c7cf18 100644 --- a/app/Http/Middleware/StartSessionExtended.php +++ b/app/Http/Middleware/StartSessionExtended.php @@ -14,7 +14,10 @@ use Illuminate\Session\Middleware\StartSession as Middleware; class StartSessionExtended extends Middleware { protected static array $pathPrefixesExcludedFromHistory = [ - 'uploads/images/' + 'uploads/images/', + 'dist/', + 'manifest.json', + 'opensearch.xml', ]; /** diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php index cbdc88fb9..ec5725d67 100644 --- a/app/Http/Middleware/TrimStrings.php +++ b/app/Http/Middleware/TrimStrings.php @@ -9,7 +9,7 @@ class TrimStrings extends Middleware /** * The names of the attributes that should not be trimmed. * - * @var array + * @var array */ protected $except = [ 'password', diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 0fe0247b8..ecf6cb18a 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -11,7 +11,7 @@ class TrustProxies extends Middleware /** * The trusted proxies for this application. * - * @var array + * @var array|string|null */ protected $proxies; diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 804a22bc0..7c8689b8b 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -16,7 +16,7 @@ class VerifyCsrfToken extends Middleware /** * The URIs that should be excluded from CSRF verification. * - * @var array + * @var array */ protected $except = [ 'saml2/*', diff --git a/app/Permissions/ContentPermissionApiController.php b/app/Permissions/ContentPermissionApiController.php index bddbc2c7d..0720cc478 100644 --- a/app/Permissions/ContentPermissionApiController.php +++ b/app/Permissions/ContentPermissionApiController.php @@ -51,7 +51,7 @@ class ContentPermissionApiController extends ApiController $entity = $this->entities->get($contentType) ->newQuery()->scopes(['visible'])->findOrFail($contentId); - $this->checkOwnablePermission('restrictions-manage', $entity); + $this->checkOwnablePermission(Permission::RestrictionsManage, $entity); return response()->json($this->formattedPermissionDataForEntity($entity)); } @@ -71,7 +71,7 @@ class ContentPermissionApiController extends ApiController $entity = $this->entities->get($contentType) ->newQuery()->scopes(['visible'])->findOrFail($contentId); - $this->checkOwnablePermission('restrictions-manage', $entity); + $this->checkOwnablePermission(Permission::RestrictionsManage, $entity); $data = $this->validate($request, $this->rules()['update']); $this->permissionsUpdater->updateFromApiRequestData($entity, $data); diff --git a/app/Permissions/EntityPermissionEvaluator.php b/app/Permissions/EntityPermissionEvaluator.php index 98ec03306..30c97b700 100644 --- a/app/Permissions/EntityPermissionEvaluator.php +++ b/app/Permissions/EntityPermissionEvaluator.php @@ -30,7 +30,7 @@ class EntityPermissionEvaluator } /** - * @param array> $permitsByType + * @param array> $permitsByType */ protected function evaluatePermitsByType(array $permitsByType): ?int { @@ -50,7 +50,7 @@ class EntityPermissionEvaluator /** * @param string[] $typeIdChain * @param array $permissionMapByTypeId - * @return array> + * @return array> */ protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array { diff --git a/app/Permissions/Models/EntityPermission.php b/app/Permissions/Models/EntityPermission.php index 1ef9c2886..efad2ba39 100644 --- a/app/Permissions/Models/EntityPermission.php +++ b/app/Permissions/Models/EntityPermission.php @@ -18,8 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; */ class EntityPermission extends Model { - public const PERMISSIONS = ['view', 'create', 'update', 'delete']; - protected $fillable = ['role_id', 'view', 'create', 'update', 'delete']; public $timestamps = false; protected $hidden = ['entity_id', 'entity_type', 'id']; diff --git a/app/Permissions/Models/RolePermission.php b/app/Permissions/Models/RolePermission.php index d43313ea0..67b1c55f5 100644 --- a/app/Permissions/Models/RolePermission.php +++ b/app/Permissions/Models/RolePermission.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** * @property int $id * @property string $name - * @property string $display_name */ class RolePermission extends Model { diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php new file mode 100644 index 000000000..04878ada0 --- /dev/null +++ b/app/Permissions/Permission.php @@ -0,0 +1,142 @@ +value; + } +} diff --git a/app/Permissions/PermissionApplicator.php b/app/Permissions/PermissionApplicator.php index ce4a543fd..c44a18a4d 100644 --- a/app/Permissions/PermissionApplicator.php +++ b/app/Permissions/PermissionApplicator.php @@ -7,8 +7,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\EntityPermission; -use BookStack\Users\Models\HasCreatorAndUpdater; -use BookStack\Users\Models\HasOwner; +use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Builder as QueryBuilder; @@ -24,14 +23,13 @@ class PermissionApplicator /** * Checks if an entity has a restriction set upon it. - * - * @param Model&(HasCreatorAndUpdater|HasOwner) $ownable */ - public function checkOwnableUserAccess(Model $ownable, string $permission): bool + public function checkOwnableUserAccess(Model&OwnableInterface $ownable, string|Permission $permission): bool { - $explodedPermission = explode('-', $permission); + $permissionName = is_string($permission) ? $permission : $permission->value; + $explodedPermission = explode('-', $permissionName); $action = $explodedPermission[1] ?? $explodedPermission[0]; - $fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission; + $fullPermission = count($explodedPermission) > 1 ? $permissionName : $ownable->getMorphClass() . '-' . $permissionName; $user = $this->currentUser(); $userRoleIds = $this->getCurrentUserRoleIds(); @@ -39,21 +37,21 @@ class PermissionApplicator $allRolePermission = $user->can($fullPermission . '-all'); $ownRolePermission = $user->can($fullPermission . '-own'); $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment']; - $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by'; + $ownerField = $ownable->getOwnerFieldName(); $ownableFieldVal = $ownable->getAttribute($ownerField); - if (is_null($ownableFieldVal)) { - throw new InvalidArgumentException("{$ownerField} field used but has not been loaded"); - } - $isOwner = $user->id === $ownableFieldVal; $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission); - // Handle non entity specific jointPermissions + // Handle non-entity-specific jointPermissions if (in_array($explodedPermission[0], $nonJointPermissions)) { return $hasRolePermission; } + if (!($ownable instanceof Entity)) { + return false; + } + $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action); return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions; @@ -74,12 +72,13 @@ class PermissionApplicator * Checks if a user has the given permission for any items in the system. * Can be passed an entity instance to filter on a specific type. */ - public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool + public function checkUserHasEntityPermissionOnAny(string|Permission $action, string $entityClass = ''): bool { - $this->ensureValidEntityAction($action); + $permissionName = is_string($action) ? $action : $action->value; + $this->ensureValidEntityAction($permissionName); $permissionQuery = EntityPermission::query() - ->where($action, '=', true) + ->where($permissionName, '=', true) ->whereIn('role_id', $this->getCurrentUserRoleIds()); if (!empty($entityClass)) { @@ -141,10 +140,10 @@ class PermissionApplicator /** @var Builder $query */ $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) + $query->select('page_id')->from('entity_page_data') + ->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass) - ->where('pages.draft', '=', false); + ->where('entity_page_data.draft', '=', false); }); }); } @@ -194,18 +193,18 @@ class PermissionApplicator { $fullPageIdColumn = $tableName . '.' . $pageIdColumn; return $this->restrictEntityQuery($query) - ->where(function ($query) use ($fullPageIdColumn) { - /** @var Builder $query */ - $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where('pages.draft', '=', false); - })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where('pages.draft', '=', true) - ->where('pages.created_by', '=', $this->currentUser()->id); - }); + ->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { + $query->select('id')->from('entities') + ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') + ->whereColumn('entities.id', '=', $fullPageIdColumn) + ->where('entities.type', '=', 'page') + ->where(function (QueryBuilder $query) { + $query->where('entity_page_data.draft', '=', false) + ->orWhere(function (QueryBuilder $query) { + $query->where('entity_page_data.draft', '=', true) + ->where('entities.created_by', '=', $this->currentUser()->id); + }); + }); }); } @@ -234,8 +233,13 @@ class PermissionApplicator */ protected function ensureValidEntityAction(string $action): void { - if (!in_array($action, EntityPermission::PERMISSIONS)) { - throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission'); + $allowed = Permission::genericForEntity(); + foreach ($allowed as $permission) { + if ($permission->value === $action) { + return; + } } + + throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission'); } } diff --git a/app/Permissions/PermissionsController.php b/app/Permissions/PermissionsController.php index 9dcfe242e..d93d9e718 100644 --- a/app/Permissions/PermissionsController.php +++ b/app/Permissions/PermissionsController.php @@ -24,7 +24,7 @@ class PermissionsController extends Controller public function showForPage(string $bookSlug, string $pageSlug) { $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('restrictions-manage', $page); + $this->checkOwnablePermission(Permission::RestrictionsManage, $page); $this->setPageTitle(trans('entities.pages_permissions')); return view('pages.permissions', [ @@ -39,7 +39,7 @@ class PermissionsController extends Controller public function updateForPage(Request $request, string $bookSlug, string $pageSlug) { $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug); - $this->checkOwnablePermission('restrictions-manage', $page); + $this->checkOwnablePermission(Permission::RestrictionsManage, $page); (new DatabaseTransaction(function () use ($page, $request) { $this->permissionsUpdater->updateFromPermissionsForm($page, $request); @@ -56,7 +56,7 @@ class PermissionsController extends Controller public function showForChapter(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('restrictions-manage', $chapter); + $this->checkOwnablePermission(Permission::RestrictionsManage, $chapter); $this->setPageTitle(trans('entities.chapters_permissions')); return view('chapters.permissions', [ @@ -71,7 +71,7 @@ class PermissionsController extends Controller public function updateForChapter(Request $request, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $this->checkOwnablePermission('restrictions-manage', $chapter); + $this->checkOwnablePermission(Permission::RestrictionsManage, $chapter); (new DatabaseTransaction(function () use ($chapter, $request) { $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request); @@ -88,7 +88,7 @@ class PermissionsController extends Controller public function showForBook(string $slug) { $book = $this->queries->books->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('restrictions-manage', $book); + $this->checkOwnablePermission(Permission::RestrictionsManage, $book); $this->setPageTitle(trans('entities.books_permissions')); return view('books.permissions', [ @@ -103,7 +103,7 @@ class PermissionsController extends Controller public function updateForBook(Request $request, string $slug) { $book = $this->queries->books->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('restrictions-manage', $book); + $this->checkOwnablePermission(Permission::RestrictionsManage, $book); (new DatabaseTransaction(function () use ($book, $request) { $this->permissionsUpdater->updateFromPermissionsForm($book, $request); @@ -120,7 +120,7 @@ class PermissionsController extends Controller public function showForShelf(string $slug) { $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('restrictions-manage', $shelf); + $this->checkOwnablePermission(Permission::RestrictionsManage, $shelf); $this->setPageTitle(trans('entities.shelves_permissions')); return view('shelves.permissions', [ @@ -135,7 +135,7 @@ class PermissionsController extends Controller public function updateForShelf(Request $request, string $slug) { $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('restrictions-manage', $shelf); + $this->checkOwnablePermission(Permission::RestrictionsManage, $shelf); (new DatabaseTransaction(function () use ($shelf, $request) { $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request); @@ -152,7 +152,7 @@ class PermissionsController extends Controller public function copyShelfPermissionsToBooks(string $slug) { $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug); - $this->checkOwnablePermission('restrictions-manage', $shelf); + $this->checkOwnablePermission(Permission::RestrictionsManage, $shelf); $updateCount = (new DatabaseTransaction(function () use ($shelf) { return $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf); @@ -168,7 +168,7 @@ class PermissionsController extends Controller */ public function formRowForRole(string $entityType, string $roleId) { - $this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own')); + $this->checkPermissionOr(Permission::RestrictionsManageAll, fn() => userCan(Permission::RestrictionsManageOwn)); $role = Role::query()->findOrFail($roleId); diff --git a/app/References/CrossLinkParser.php b/app/References/CrossLinkParser.php index 1fd4c1b3e..3fb00be84 100644 --- a/app/References/CrossLinkParser.php +++ b/app/References/CrossLinkParser.php @@ -48,7 +48,7 @@ class CrossLinkParser /** * Get a list of href values from the given document. * - * @returns string[] + * @return string[] */ protected function getLinksFromContent(string $html): array { diff --git a/app/References/ReferenceChangeContext.php b/app/References/ReferenceChangeContext.php new file mode 100644 index 000000000..27de0e2d2 --- /dev/null +++ b/app/References/ReferenceChangeContext.php @@ -0,0 +1,45 @@ + + */ + protected array $changes = []; + + public function add(Entity $oldEntity, Entity $newEntity): void + { + $this->changes[] = [$oldEntity, $newEntity]; + } + + /** + * Get all the new entities from the changes. + */ + public function getNewEntities(): array + { + return array_column($this->changes, 1); + } + + /** + * Get all the old entities from the changes. + */ + public function getOldEntities(): array + { + return array_column($this->changes, 0); + } + + public function getNewForOld(Entity $oldEntity): ?Entity + { + foreach ($this->changes as [$old, $new]) { + if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) { + return $new; + } + } + return null; + } +} diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index 1c9664f45..8588c6e2c 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -20,10 +20,10 @@ class ReferenceFetcher * Query and return the references pointing to the given entity. * Loads the commonly required relations while taking permissions into account. */ - public function getReferencesToEntity(Entity $entity): Collection + public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection { $references = $this->queryReferencesToEntity($entity)->get(); - $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true); + $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); return $references; } diff --git a/app/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php index db355f211..42de72fde 100644 --- a/app/References/ReferenceUpdater.php +++ b/app/References/ReferenceUpdater.php @@ -3,8 +3,8 @@ namespace BookStack\References; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HasHtmlDescription; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\RevisionRepo; use BookStack\Util\HtmlDocument; @@ -29,13 +29,54 @@ class ReferenceUpdater } } + /** + * Change existing references for a range of entities using the given context. + */ + public function changeReferencesUsingContext(ReferenceChangeContext $context): void + { + $bindings = []; + foreach ($context->getOldEntities() as $old) { + $bindings[] = $old->getMorphClass(); + $bindings[] = $old->id; + } + + // No targets to update within the context, so no need to continue. + if (count($bindings) < 2) { + return; + } + + $toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')'; + + // Cycle each new entity in the context + foreach ($context->getNewEntities() as $new) { + // For each, get all references from it which lead to other items within the context of the change + $newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get(); + // For each reference, update the URL and the reference entry + foreach ($newReferencesInContext as $reference) { + $oldToEntity = $reference->to; + $newToEntity = $context->getNewForOld($oldToEntity); + if ($newToEntity === null) { + continue; + } + + $this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl()); + if ($newToEntity instanceof Page && $oldToEntity instanceof Page) { + $this->updateReferencesWithinEntity($new, $oldToEntity->getPermalink(), $newToEntity->getPermalink()); + } + $reference->to_id = $newToEntity->id; + $reference->to_type = $newToEntity->getMorphClass(); + $reference->save(); + } + } + } + /** * @return Reference[] */ protected function getReferencesToUpdate(Entity $entity): array { /** @var Reference[] $references */ - $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all(); + $references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all(); if ($entity instanceof Book) { $pages = $entity->pages()->get(['id']); @@ -43,7 +84,7 @@ class ReferenceUpdater $children = $pages->concat($chapters); foreach ($children as $bookChild) { /** @var Reference[] $childRefs */ - $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all(); + $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all(); array_push($references, ...$childRefs); } } @@ -61,20 +102,18 @@ class ReferenceUpdater { if ($entity instanceof Page) { $this->updateReferencesWithinPage($entity, $oldLink, $newLink); - return; } - if (in_array(HasHtmlDescription::class, class_uses($entity))) { + if ($entity instanceof HasDescriptionInterface) { $this->updateReferencesWithinDescription($entity, $oldLink, $newLink); } } - protected function updateReferencesWithinDescription(Entity $entity, string $oldLink, string $newLink): void + protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void { - /** @var HasHtmlDescription&Entity $entity */ - $entity = (clone $entity)->refresh(); - $html = $this->updateLinksInHtml($entity->description_html ?: '', $oldLink, $newLink); - $entity->description_html = $html; + $description = $entity->descriptionInfo(); + $html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink); + $description->set($html); $entity->save(); } diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index cd4a14a39..5de7a5110 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -1,10 +1,13 @@ validate($request, $this->rules['all']); diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php index 9050f65f5..125a9095b 100644 --- a/app/Search/SearchController.php +++ b/app/Search/SearchController.php @@ -8,6 +8,7 @@ use BookStack\Entities\Tools\SiblingFetcher; use BookStack\Http\Controller; use BookStack\Search\Queries\VectorSearchRunner; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; class SearchController extends Controller { @@ -24,20 +25,22 @@ class SearchController extends Controller { $searchOpts = SearchOptions::fromRequest($request); $fullSearchString = $searchOpts->toString(); - $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString])); - $page = intval($request->get('page', '0')) ?: 1; - $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1)); + $count = setting()->getInteger('lists-page-count-search', 18, 1, 1000); - $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20); + $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count); $formatter->format($results['results']->all(), $searchOpts); + $paginator = new LengthAwarePaginator($results['results'], $results['total'], $count, $page); + $paginator->setPath(url('/search')); + $paginator->appends($request->except('page')); + + $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString])); return view('search.all', [ 'entities' => $results['results'], 'totalResults' => $results['total'], + 'paginator' => $paginator, 'searchTerm' => $fullSearchString, - 'hasNextPage' => $results['has_more'], - 'nextPageLink' => $nextPageLink, 'options' => $searchOpts, ]); } @@ -76,8 +79,9 @@ class SearchController extends Controller // Search for entities otherwise show most popular if ($searchTerm !== false) { - $searchTerm .= ' {type:' . implode('|', $entityTypes) . '}'; - $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results']; + $options = SearchOptions::fromString($searchTerm); + $options->setFilter('type', implode('|', $entityTypes)); + $entities = $this->searchRunner->searchEntities($options, 'all', 1, 20)['results']; } else { $entities = $queryPopular->run(20, 0, $entityTypes); } diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index aaee97fe7..5925b7139 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -131,14 +131,14 @@ class SearchIndex * Create a scored term array from the given text, where the keys are the terms * and the values are their scores. * - * @returns array + * @return array */ protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array { $termMap = $this->textToTermCountMap($text); foreach ($termMap as $term => $count) { - $termMap[$term] = floor($count * $scoreAdjustment); + $termMap[$term] = intval($count * $scoreAdjustment); } return $termMap; @@ -148,7 +148,7 @@ class SearchIndex * Create a scored term array from the given HTML, where the keys are the terms * and the values are their scores. * - * @returns array + * @return array */ protected function generateTermScoreMapFromHtml(string $html): array { @@ -189,7 +189,7 @@ class SearchIndex * * @param Tag[] $tags * - * @returns array + * @return array */ protected function generateTermScoreMapFromTags(array $tags): array { @@ -211,7 +211,7 @@ class SearchIndex * For the given text, return an array where the keys are the unique term words * and the values are the frequency of that term. * - * @returns array + * @return array */ protected function textToTermCountMap(string $text): array { @@ -255,7 +255,7 @@ class SearchIndex * For the given entity, Generate an array of term data details. * Is the raw term data, not instances of SearchTerm models. * - * @returns array{term: string, score: float, entity_id: int, entity_type: string}[] + * @return array{term: string, score: float, entity_id: int, entity_type: string}[] */ protected function entityToTermDataArray(Entity $entity): array { @@ -291,7 +291,7 @@ class SearchIndex * * @param array[] ...$scoreMaps * - * @returns array + * @return array */ protected function mergeTermScoreMaps(...$scoreMaps): array { diff --git a/app/Search/SearchOptionSet.php b/app/Search/SearchOptionSet.php index bd5e5a5b2..19f1c5509 100644 --- a/app/Search/SearchOptionSet.php +++ b/app/Search/SearchOptionSet.php @@ -14,6 +14,9 @@ class SearchOptionSet */ protected array $options = []; + /** + * @param T[] $options + */ public function __construct(array $options = []) { $this->options = $options; @@ -79,4 +82,12 @@ class SearchOptionSet $values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated)); return new self($values); } + + /** + * @return self + */ + public function limit(int $limit): self + { + return new self(array_slice(array_values($this->options), 0, $limit)); + } } diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index bf527d9c3..83af2d043 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -35,6 +35,7 @@ class SearchOptions { $instance = new self(); $instance->addOptionsFromString($search); + $instance->limitOptions(); return $instance; } @@ -87,6 +88,8 @@ class SearchOptions $instance->filters = $instance->filters->merge($extras->filters); } + $instance->limitOptions(); + return $instance; } @@ -147,6 +150,25 @@ class SearchOptions $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters'])); } + /** + * Limit the amount of search options to reasonable levels. + * Provides higher limits to logged-in users since that signals a slightly + * higher level of trust. + */ + protected function limitOptions(): void + { + $userLoggedIn = !user()->isGuest(); + $searchLimit = $userLoggedIn ? 10 : 5; + $exactLimit = $userLoggedIn ? 4 : 2; + $tagLimit = $userLoggedIn ? 8 : 4; + $filterLimit = $userLoggedIn ? 10 : 5; + + $this->searches = $this->searches->limit($searchLimit); + $this->exacts = $this->exacts->limit($exactLimit); + $this->tags = $this->tags->limit($tagLimit); + $this->filters = $this->filters->limit($filterLimit); + } + /** * Decode backslash escaping within the input string. */ diff --git a/app/Search/SearchResultsFormatter.php b/app/Search/SearchResultsFormatter.php index b06f81e0e..7bb5e03cc 100644 --- a/app/Search/SearchResultsFormatter.php +++ b/app/Search/SearchResultsFormatter.php @@ -91,7 +91,7 @@ class SearchResultsFormatter $offset = 0; $term = mb_strtolower($term); $pos = mb_strpos($text, $term, $offset); - while ($pos !== false) { + while ($pos !== false && count($matchRefs) < 25) { $end = $pos + mb_strlen($term); $matchRefs[$pos] = $end; $offset = $end; diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 9716f8053..bfb65cf0f 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -4,16 +4,15 @@ namespace BookStack\Search; use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Entities\Tools\EntityHydrator; use BookStack\Permissions\PermissionApplicator; use BookStack\Search\Options\TagSearchOption; use BookStack\Users\Models\User; use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -22,7 +21,7 @@ use WeakMap; class SearchRunner { /** - * Retain a cache of score adjusted terms for specific search options. + * Retain a cache of score-adjusted terms for specific search options. */ protected WeakMap $termAdjustmentCache; @@ -30,16 +29,15 @@ class SearchRunner protected EntityProvider $entityProvider, protected PermissionApplicator $permissions, protected EntityQueries $entityQueries, + protected EntityHydrator $entityHydrator, ) { $this->termAdjustmentCache = new WeakMap(); } /** * Search all entities in the system. - * The provided count is for each entity to search, - * Total returned could be larger and not guaranteed. * - * @return array{total: int, count: int, has_more: bool, results: Collection} + * @return array{total: int, results: Collection} */ public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array { @@ -53,32 +51,13 @@ class SearchRunner $entityTypesToSearch = explode('|', $filterMap['type']); } - $results = collect(); - $total = 0; - $hasMore = false; - - foreach ($entityTypesToSearch as $entityType) { - if (!in_array($entityType, $entityTypes)) { - continue; - } - - $searchQuery = $this->buildQuery($searchOpts, $entityType); - $entityTotal = $searchQuery->count(); - $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count); - - if ($entityTotal > ($page * $count)) { - $hasMore = true; - } - - $total += $entityTotal; - $results = $results->merge($searchResults); - } + $searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch); + $total = $searchQuery->count(); + $results = $this->getPageOfDataFromQuery($searchQuery, $page, $count); return [ 'total' => $total, - 'count' => count($results), - 'has_more' => $hasMore, - 'results' => $results->sortByDesc('score')->values(), + 'results' => $results->values(), ]; } @@ -92,17 +71,10 @@ class SearchRunner $filterMap = $opts->filters->toValueMap(); $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes; - $results = collect(); - foreach ($entityTypesToSearch as $entityType) { - if (!in_array($entityType, $entityTypes)) { - continue; - } + $filteredTypes = array_intersect($entityTypesToSearch, $entityTypes); + $query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId); - $search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get(); - $results = $results->merge($search); - } - - return $results->sortByDesc('score')->take(20); + return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); } /** @@ -111,54 +83,45 @@ class SearchRunner public function searchChapter(int $chapterId, string $searchString): Collection { $opts = SearchOptions::fromString($searchString); - $pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); + $query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId); - return $pages->sortByDesc('score'); + return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); } /** * Get a page of result data from the given query based on the provided page parameters. */ - protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection + protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection { - $relations = ['tags']; - - if ($entityType === 'page' || $entityType === 'chapter') { - $relations['book'] = function (BelongsTo $query) { - $query->scopes('visible'); - }; - } - - if ($entityType === 'page') { - $relations['chapter'] = function (BelongsTo $query) { - $query->scopes('visible'); - }; - } - - return $query->clone() - ->with(array_filter($relations)) + $entities = $query->clone() ->skip(($page - 1) * $count) ->take($count) ->get(); + + $hydrated = $this->entityHydrator->hydrate($entities->all(), true, true); + + return collect($hydrated); } /** * Create a search query for an entity. + * @param string[] $entityTypes */ - protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder + protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder { - $entityModelInstance = $this->entityProvider->get($entityType); - $entityQuery = $this->entityQueries->visibleForList($entityType); + $entityQuery = $this->entityQueries->visibleForList() + ->whereIn('type', $entityTypes); // Handle normal search terms - $this->applyTermSearch($entityQuery, $searchOpts, $entityType); + $this->applyTermSearch($entityQuery, $searchOpts, $entityTypes); // Handle exact term matching foreach ($searchOpts->exacts->all() as $exact) { - $filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) { + $filter = function (EloquentBuilder $query) use ($exact) { $inputTerm = str_replace('\\', '\\\\', $exact->value); $query->where('name', 'like', '%' . $inputTerm . '%') - ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%'); + ->orWhere('description', 'like', '%' . $inputTerm . '%') + ->orWhere('text', 'like', '%' . $inputTerm . '%'); }; $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); @@ -173,7 +136,7 @@ class SearchRunner foreach ($searchOpts->filters->all() as $filterOption) { $functionName = Str::camel('filter_' . $filterOption->getKey()); if (method_exists($this, $functionName)) { - $this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated); + $this->$functionName($entityQuery, $filterOption->value, $filterOption->negated); } } @@ -183,7 +146,7 @@ class SearchRunner /** * For the given search query, apply the queries for handling the regular search terms. */ - protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void + protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void { $terms = $options->searches->toValueArray(); if (count($terms) === 0) { @@ -200,8 +163,6 @@ class SearchRunner ]); $subQuery->addBinding($scoreSelect['bindings'], 'select'); - - $subQuery->where('entity_type', '=', $entityType); $subQuery->where(function (Builder $query) use ($terms) { foreach ($terms as $inputTerm) { $escapedTerm = str_replace('\\', '\\\\', $inputTerm); @@ -210,7 +171,10 @@ class SearchRunner }); $subQuery->groupBy('entity_type', 'entity_id'); - $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id'); + $entityQuery->joinSub($subQuery, 's', function (JoinClause $join) { + $join->on('s.entity_id', '=', 'entities.id') + ->on('s.entity_type', '=', 'entities.type'); + }); $entityQuery->addSelect('s.score'); $entityQuery->orderBy('score', 'desc'); } @@ -285,7 +249,7 @@ class SearchRunner * * @param array $termCounts * - * @return array + * @return array */ protected function rawTermCountsToAdjustments(array $termCounts): array { @@ -338,7 +302,7 @@ class SearchRunner $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter); } - protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void + protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void { if ($negated) { $query->whereNot($column, $operator, $value); @@ -350,31 +314,31 @@ class SearchRunner /** * Custom entity search filters. */ - protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void + protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date); } - protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void + protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date); } - protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void + protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date); } - protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated) { $date = date_create($input); $this->applyNegatableWhere($query, $negated, 'created_at', '<', $date); } - protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); @@ -383,7 +347,7 @@ class SearchRunner } } - protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); @@ -392,7 +356,7 @@ class SearchRunner } } - protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated) { $userSlug = $input === 'me' ? user()->slug : trim($input); $user = User::query()->where('slug', '=', $userSlug)->first(['id']); @@ -401,27 +365,30 @@ class SearchRunner } } - protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterInName(EloquentBuilder $query, string $input, bool $negated) { $this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%'); } - protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated) { - $this->filterInName($query, $model, $input, $negated); + $this->filterInName($query, $input, $negated); } - protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterInBody(EloquentBuilder $query, string $input, bool $negated) { - $this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%'); + $this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) { + $query->where('description', 'like', '%' . $input . '%') + ->orWhere('text', 'like', '%' . $input . '%'); + }, null, null); } - protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated) { $negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions'); } - protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated) { $filter = function ($query) { $query->where('user_id', '=', user()->id); @@ -430,7 +397,7 @@ class SearchRunner $negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter); } - protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated) { $filter = function ($query) { $query->where('user_id', '=', user()->id); @@ -439,31 +406,30 @@ class SearchRunner $negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter); } - protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated) { - if ($model instanceof Page) { - $this->applyNegatableWhere($query, $negated, 'template', '=', true); - } + $this->applyNegatableWhere($query, $negated, 'template', '=', true); } - protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated) + protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated) { $functionName = Str::camel('sort_by_' . $input); if (method_exists($this, $functionName)) { - $this->$functionName($query, $model, $negated); + $this->$functionName($query, $negated); } } /** * Sorting filter options. */ - protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated) + protected function sortByLastCommented(EloquentBuilder $query, bool $negated) { $commentsTable = DB::getTablePrefix() . 'comments'; - $morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); - $commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); + $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c2.created_at IS NULL) as comments'); - $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id')) - ->orderBy('last_commented', $negated ? 'asc' : 'desc'); + $query->join($commentQuery, function (JoinClause $join) { + $join->on('entities.id', '=', 'comments.commentable_id') + ->on('entities.type', '=', 'comments.commentable_type'); + })->orderBy('last_commented', $negated ? 'asc' : 'desc'); } } diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php index e6fc466ba..e098d87f8 100644 --- a/app/Settings/AppSettingsStore.php +++ b/app/Settings/AppSettingsStore.php @@ -14,7 +14,7 @@ class AppSettingsStore ) { } - public function storeFromUpdateRequest(Request $request, string $category) + public function storeFromUpdateRequest(Request $request, string $category): void { $this->storeSimpleSettings($request); if ($category === 'customization') { @@ -76,7 +76,7 @@ class AppSettingsStore protected function storeSimpleSettings(Request $request): void { foreach ($request->all() as $name => $value) { - if (strpos($name, 'setting-') !== 0) { + if (!str_starts_with($name, 'setting-')) { continue; } @@ -85,7 +85,7 @@ class AppSettingsStore } } - protected function destroyExistingSettingImage(string $settingKey) + protected function destroyExistingSettingImage(string $settingKey): void { $existingVal = setting()->get($settingKey); if ($existingVal) { diff --git a/app/Settings/MaintenanceController.php b/app/Settings/MaintenanceController.php index ac9dd20cc..b2b2226bf 100644 --- a/app/Settings/MaintenanceController.php +++ b/app/Settings/MaintenanceController.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\App\AppVersion; use BookStack\Entities\Tools\TrashCan; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\References\ReferenceStore; use BookStack\Uploads\ImageService; use Illuminate\Http\Request; @@ -17,7 +18,7 @@ class MaintenanceController extends Controller */ public function index(TrashCan $trashCan) { - $this->checkPermission('settings-manage'); + $this->checkPermission(Permission::SettingsManage); $this->setPageTitle(trans('settings.maint')); // Recycle bin details @@ -34,7 +35,7 @@ class MaintenanceController extends Controller */ public function cleanupImages(Request $request, ImageService $imageService) { - $this->checkPermission('settings-manage'); + $this->checkPermission(Permission::SettingsManage); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images'); $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true'); @@ -62,7 +63,7 @@ class MaintenanceController extends Controller */ public function sendTestEmail() { - $this->checkPermission('settings-manage'); + $this->checkPermission(Permission::SettingsManage); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email'); try { @@ -81,7 +82,7 @@ class MaintenanceController extends Controller */ public function regenerateReferences(ReferenceStore $referenceStore) { - $this->checkPermission('settings-manage'); + $this->checkPermission(Permission::SettingsManage); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references'); try { diff --git a/app/Settings/SettingController.php b/app/Settings/SettingController.php index 3b7ba74d5..f89bc6dab 100644 --- a/app/Settings/SettingController.php +++ b/app/Settings/SettingController.php @@ -5,6 +5,7 @@ namespace BookStack\Settings; use BookStack\Activity\ActivityType; use BookStack\App\AppVersion; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; use Illuminate\Http\Request; @@ -24,7 +25,7 @@ class SettingController extends Controller public function category(string $category) { $this->ensureCategoryExists($category); - $this->checkPermission('settings-manage'); + $this->checkPermission(Permission::SettingsManage); $this->setPageTitle(trans('settings.settings')); return view('settings.categories.' . $category, [ @@ -41,7 +42,7 @@ class SettingController extends Controller { $this->ensureCategoryExists($category); $this->preventAccessInDemoMode(); - $this->checkPermission('settings-manage'); + $this->checkPermission(Permission::SettingsManage); $this->validate($request, [ 'app_logo' => ['nullable', ...$this->getImageValidationRules()], 'app_icon' => ['nullable', ...$this->getImageValidationRules()], diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index 31debdaea..e0b136180 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -28,6 +28,21 @@ class SettingService return $this->formatValue($value, $default); } + /** + * Get a setting from the database as an integer. + * Returns the default value if not found or not an integer, and clamps the value to the given min/max range. + */ + public function getInteger(string $key, int $default, int $min = 0, int $max = PHP_INT_MAX): int + { + $value = $this->get($key, $default); + if (!is_numeric($value)) { + return $default; + } + + $int = intval($value); + return max($min, min($max, $int)); + } + /** * Get a value from the session instead of the main store option. */ diff --git a/app/Settings/UserNotificationPreferences.php b/app/Settings/UserNotificationPreferences.php index 5b267b533..752d92de6 100644 --- a/app/Settings/UserNotificationPreferences.php +++ b/app/Settings/UserNotificationPreferences.php @@ -26,9 +26,14 @@ class UserNotificationPreferences return $this->getNotificationSetting('comment-replies'); } + public function notifyOnCommentMentions(): bool + { + return $this->getNotificationSetting('comment-mentions'); + } + public function updateFromSettingsArray(array $settings) { - $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies']; + $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies', 'comment-mentions']; foreach ($settings as $setting => $status) { if (!in_array($setting, $allowList)) { continue; diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index d70d0e656..7e2ee5465 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -7,6 +7,7 @@ use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Tools\BookContents; use BookStack\Facades\Activity; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Util\DatabaseTransaction; use Illuminate\Http\Request; @@ -23,7 +24,7 @@ class BookSortController extends Controller public function show(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-update', $book); + $this->checkOwnablePermission(Permission::BookUpdate, $book); $bookChildren = (new BookContents($book))->getTree(false); @@ -51,7 +52,7 @@ class BookSortController extends Controller public function update(Request $request, BookSorter $sorter, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); - $this->checkOwnablePermission('book-update', $book); + $this->checkOwnablePermission(Permission::BookUpdate, $book); $loggedActivityForBook = false; // Sort via map diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index cf41a6a94..b4f93d47b 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -8,11 +8,14 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Entities\Tools\ParentChanger; +use BookStack\Permissions\Permission; class BookSorter { public function __construct( protected EntityQueries $queries, + protected ParentChanger $parentChanger, ) { } @@ -32,22 +35,22 @@ class BookSorter */ public function runBookAutoSort(Book $book): void { - $set = $book->sortRule; - if (!$set) { + $rule = $book->sortRule()->first(); + if (!($rule instanceof SortRule)) { return; } $sortFunctions = array_map(function (SortRuleOperation $op) { return $op->getSortFunction(); - }, $set->getOperations()); + }, $rule->getOperations()); $chapters = $book->chapters() - ->with('pages:id,name,priority,created_at,updated_at,chapter_id') + ->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at') ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); /** @var (Chapter|Book)[] $topItems */ $topItems = [ - ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']), + ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']), ...$chapters, ]; @@ -78,7 +81,7 @@ class BookSorter * Sort the books content using the given sort map. * Returns a list of books that were involved in the operation. * - * @returns Book[] + * @return Book[] */ public function sortUsingMap(BookSortMap $sortMap): array { @@ -154,11 +157,12 @@ class BookSorter // Action the required changes if ($bookChanged) { - $model->changeBook($newBook->id); + $this->parentChanger->changeBook($model, $newBook->id); } if ($model instanceof Page && $chapterChanged) { $model->chapter_id = $newChapter->id ?? 0; + $model->unsetRelation('chapter'); } if ($priorityChanged) { @@ -187,11 +191,11 @@ class BookSorter $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); if ($model instanceof Chapter) { - $hasPermission = userCan('book-update', $currentParent) - && userCan('book-update', $newBook) - && userCan('chapter-update', $model) - && (!$hasNewParent || userCan('chapter-create', $newBook)) - && (!$hasNewParent || userCan('chapter-delete', $model)); + $hasPermission = userCan(Permission::BookUpdate, $currentParent) + && userCan(Permission::BookUpdate, $newBook) + && userCan(Permission::ChapterUpdate, $model) + && (!$hasNewParent || userCan(Permission::ChapterCreate, $newBook)) + && (!$hasNewParent || userCan(Permission::ChapterDelete, $model)); if (!$hasPermission) { return false; @@ -210,13 +214,13 @@ class BookSorter return false; } - $hasPageEditPermission = userCan('page-update', $model); + $hasPageEditPermission = userCan(Permission::PageUpdate, $model); $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; $hasNewParentPermission = userCan($newParentPermission, $newParent); - $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); - $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); + $hasDeletePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageDelete, $model)); + $hasCreatePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageCreate, $newParent)); $hasPermission = $hasCurrentParentPermission && $newParentInRightLocation diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php index 45e5514fd..bf53365a2 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable public function books(): HasMany { - return $this->hasMany(Book::class); + return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); } public static function allByName(): Collection diff --git a/app/Sorting/SortRuleController.php b/app/Sorting/SortRuleController.php index 96b8e8ef5..65e1cba09 100644 --- a/app/Sorting/SortRuleController.php +++ b/app/Sorting/SortRuleController.php @@ -3,14 +3,16 @@ namespace BookStack\Sorting; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\EntityContainerData; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use Illuminate\Http\Request; class SortRuleController extends Controller { public function __construct() { - $this->middleware('can:settings-manage'); + $this->middleware(Permission::SettingsManage->middleware()); } public function create() @@ -29,7 +31,7 @@ class SortRuleController extends Controller $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect('/settings/sorting/rules/new')->withInput()->withErrors(['sequence' => 'No operations set.']); } $rule = new SortRule(); @@ -87,7 +89,9 @@ class SortRuleController extends Controller if ($booksAssigned > 0) { if ($confirmed) { - $rule->books()->update(['sort_rule_id' => null]); + EntityContainerData::query() + ->where('sort_rule_id', $rule->id) + ->update(['sort_rule_id' => null]); } else { $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); } diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index 2d4900c96..44630acae 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -62,7 +62,7 @@ class ThemeEvents * * @param string $authSystem * @param array $userData - * @returns bool|null + * @return bool|null */ const AUTH_PRE_REGISTER = 'auth_pre_register'; @@ -83,7 +83,7 @@ class ThemeEvents * If the listener returns a non-null value, that will be used as an environment instead. * * @param \League\CommonMark\Environment\Environment $environment - * @returns \League\CommonMark\Environment\Environment|null + * @return \League\CommonMark\Environment\Environment|null */ const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure'; @@ -96,7 +96,7 @@ class ThemeEvents * * @param array $idTokenData * @param array $accessTokenData - * @returns array|null + * @return array|null */ const OIDC_ID_TOKEN_PRE_VALIDATE = 'oidc_id_token_pre_validate'; @@ -142,7 +142,7 @@ class ThemeEvents * Return values, if provided, will be used as a new response to use. * * @param \Illuminate\Http\Request $request - * @returns \Illuminate\Http\Response|null + * @return \Illuminate\Http\Response|null */ const WEB_MIDDLEWARE_BEFORE = 'web_middleware_before'; @@ -154,7 +154,7 @@ class ThemeEvents * * @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\BinaryFileResponse $response - * @returns \Illuminate\Http\Response|null + * @return \Illuminate\Http\Response|null */ const WEB_MIDDLEWARE_AFTER = 'web_middleware_after'; @@ -173,7 +173,7 @@ class ThemeEvents * @param string|\BookStack\Activity\Models\Loggable $detail * @param \BookStack\Users\Models\User $initiator * @param int $initiatedTime - * @returns array|null + * @return array|null */ const WEBHOOK_CALL_BEFORE = 'webhook_call_before'; } diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 57d7cb334..05227243a 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -8,6 +8,7 @@ use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; +use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -27,7 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * * @method static Entity|Builder visible() */ -class Attachment extends Model +class Attachment extends Model implements OwnableInterface { use HasCreatorAndUpdater; use HasFactory; diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index 87e00257c..ea3c4a962 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -2,9 +2,11 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use BookStack\Uploads\Attachment; use BookStack\Uploads\AttachmentService; use Exception; @@ -45,12 +47,12 @@ class AttachmentApiController extends ApiController */ public function create(Request $request) { - $this->checkPermission('attachment-create-all'); + $this->checkPermission(Permission::AttachmentCreateAll); $requestData = $this->validate($request, $this->rules()['create']); $pageId = $request->get('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); if ($request->hasFile('file')) { $uploadedFile = $request->file('file'); @@ -137,9 +139,9 @@ class AttachmentApiController extends ApiController $attachment->uploaded_to = $requestData['uploaded_to']; } - $this->checkOwnablePermission('page-view', $page); - $this->checkOwnablePermission('page-update', $page); - $this->checkOwnablePermission('attachment-update', $attachment); + $this->checkOwnablePermission(Permission::PageView, $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); + $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); if ($request->hasFile('file')) { $uploadedFile = $request->file('file'); @@ -160,7 +162,7 @@ class AttachmentApiController extends ApiController { /** @var Attachment $attachment */ $attachment = Attachment::visible()->findOrFail($id); - $this->checkOwnablePermission('attachment-delete', $attachment); + $this->checkOwnablePermission(Permission::AttachmentDelete, $attachment); $this->attachmentService->deleteFile($attachment); @@ -172,13 +174,13 @@ class AttachmentApiController extends ApiController return [ 'create' => [ 'name' => ['required', 'string', 'min:1', 'max:255'], - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()), 'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'], ], 'update' => [ 'name' => ['string', 'min:1', 'max:255'], - 'uploaded_to' => ['integer', 'exists:pages,id'], + 'uploaded_to' => ['integer', new EntityExistsRule('page')], 'file' => $this->attachmentService->getFileValidationRules(), 'link' => ['string', 'min:1', 'max:2000', 'safe_url'], ], diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 809cdfa58..9c60fa415 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -2,11 +2,13 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\NotFoundException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Uploads\Attachment; use BookStack\Uploads\AttachmentService; use Exception; @@ -33,15 +35,15 @@ class AttachmentController extends Controller public function upload(Request $request) { $this->validate($request, [ - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()), ]); $pageId = $request->get('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - $this->checkPermission('attachment-create-all'); - $this->checkOwnablePermission('page-update', $page); + $this->checkPermission(Permission::AttachmentCreateAll); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $uploadedFile = $request->file('file'); @@ -67,9 +69,9 @@ class AttachmentController extends Controller /** @var Attachment $attachment */ $attachment = Attachment::query()->findOrFail($attachmentId); - $this->checkOwnablePermission('view', $attachment->page); - $this->checkOwnablePermission('page-update', $attachment->page); - $this->checkOwnablePermission('attachment-create', $attachment); + $this->checkOwnablePermission(Permission::PageView, $attachment->page); + $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page); + $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); $uploadedFile = $request->file('file'); @@ -90,8 +92,8 @@ class AttachmentController extends Controller /** @var Attachment $attachment */ $attachment = Attachment::query()->findOrFail($attachmentId); - $this->checkOwnablePermission('page-update', $attachment->page); - $this->checkOwnablePermission('attachment-create', $attachment); + $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page); + $this->checkOwnablePermission(Permission::AttachmentCreate, $attachment); return view('attachments.manager-edit-form', [ 'attachment' => $attachment, @@ -118,9 +120,9 @@ class AttachmentController extends Controller ]), 422); } - $this->checkOwnablePermission('page-view', $attachment->page); - $this->checkOwnablePermission('page-update', $attachment->page); - $this->checkOwnablePermission('attachment-update', $attachment); + $this->checkOwnablePermission(Permission::PageView, $attachment->page); + $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page); + $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); $attachment = $this->attachmentService->updateFile($attachment, [ 'name' => $request->get('attachment_edit_name'), @@ -143,7 +145,7 @@ class AttachmentController extends Controller try { $this->validate($request, [ - 'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'], 'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'], ]); @@ -156,8 +158,8 @@ class AttachmentController extends Controller $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - $this->checkPermission('attachment-create-all'); - $this->checkOwnablePermission('page-update', $page); + $this->checkPermission(Permission::AttachmentCreateAll); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $attachmentName = $request->get('attachment_link_name'); $link = $request->get('attachment_link_url'); @@ -176,7 +178,6 @@ class AttachmentController extends Controller public function listForPage(int $pageId) { $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-view', $page); return view('attachments.manager-list', [ 'attachments' => $page->attachments->all(), @@ -195,7 +196,7 @@ class AttachmentController extends Controller 'order' => ['required', 'array'], ]); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission(Permission::PageUpdate, $page); $attachmentOrder = $request->get('order'); $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId); @@ -220,7 +221,7 @@ class AttachmentController extends Controller throw new NotFoundException(trans('errors.attachment_not_found')); } - $this->checkOwnablePermission('page-view', $page); + $this->checkOwnablePermission(Permission::PageView, $page); if ($attachment->external) { return redirect($attachment->path); @@ -246,7 +247,7 @@ class AttachmentController extends Controller { /** @var Attachment $attachment */ $attachment = Attachment::query()->findOrFail($attachmentId); - $this->checkOwnablePermission('attachment-delete', $attachment); + $this->checkOwnablePermission(Permission::AttachmentDelete, $attachment); $this->attachmentService->deleteFile($attachment); return response()->json(['message' => trans('entities.attachments_deleted')]); diff --git a/app/Uploads/Controllers/DrawioImageController.php b/app/Uploads/Controllers/DrawioImageController.php index 6293da4f7..f44acd997 100644 --- a/app/Uploads/Controllers/DrawioImageController.php +++ b/app/Uploads/Controllers/DrawioImageController.php @@ -4,6 +4,7 @@ namespace BookStack\Uploads\Controllers; use BookStack\Exceptions\ImageUploadException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; use BookStack\Util\OutOfMemoryHandler; @@ -57,7 +58,7 @@ class DrawioImageController extends Controller 'uploaded_to' => ['required', 'integer'], ]); - $this->checkPermission('image-create-all'); + $this->checkPermission(Permission::ImageCreateAll); $imageBase64Data = $request->get('image'); try { @@ -81,7 +82,7 @@ class DrawioImageController extends Controller return $this->jsonError(trans('errors.drawing_data_not_found'), 404); } - if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) { + if ($image->type !== 'drawio' || !userCan(Permission::PageView, $image->getPage())) { return $this->jsonError(trans('errors.drawing_data_not_found'), 404); } diff --git a/app/Uploads/Controllers/GalleryImageController.php b/app/Uploads/Controllers/GalleryImageController.php index 1bc9da2d7..745efcde8 100644 --- a/app/Uploads/Controllers/GalleryImageController.php +++ b/app/Uploads/Controllers/GalleryImageController.php @@ -4,6 +4,7 @@ namespace BookStack\Uploads\Controllers; use BookStack\Exceptions\ImageUploadException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; use BookStack\Util\OutOfMemoryHandler; @@ -52,7 +53,7 @@ class GalleryImageController extends Controller */ public function create(Request $request) { - $this->checkPermission('image-create-all'); + $this->checkPermission(Permission::ImageCreateAll); try { $this->validate($request, [ diff --git a/app/Uploads/Controllers/ImageController.php b/app/Uploads/Controllers/ImageController.php index c68ffdf6b..da67639c1 100644 --- a/app/Uploads/Controllers/ImageController.php +++ b/app/Uploads/Controllers/ImageController.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotifyException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Uploads\Image; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; @@ -50,7 +51,7 @@ class ImageController extends Controller $image = $this->imageRepo->getById($id); $this->checkImagePermission($image); - $this->checkOwnablePermission('image-update', $image); + $this->checkOwnablePermission(Permission::ImageUpdate, $image); $image = $this->imageRepo->updateImageDetails($image, $data); @@ -71,7 +72,7 @@ class ImageController extends Controller $image = $this->imageRepo->getById($id); $this->checkImagePermission($image); - $this->checkOwnablePermission('image-update', $image); + $this->checkOwnablePermission(Permission::ImageUpdate, $image); $file = $request->file('file'); new OutOfMemoryHandler(function () { @@ -125,7 +126,7 @@ class ImageController extends Controller public function destroy(string $id) { $image = $this->imageRepo->getById($id); - $this->checkOwnablePermission('image-delete', $image); + $this->checkOwnablePermission(Permission::ImageDelete, $image); $this->checkImagePermission($image); $this->imageRepo->destroyImage($image); @@ -140,7 +141,7 @@ class ImageController extends Controller { $image = $this->imageRepo->getById($id); $this->checkImagePermission($image); - $this->checkOwnablePermission('image-update', $image); + $this->checkOwnablePermission(Permission::ImageUpdate, $image); new OutOfMemoryHandler(function () { return $this->jsonError(trans('errors.image_thumbnail_memory_limit')); @@ -163,7 +164,7 @@ class ImageController extends Controller $relatedPage = $image->getPage(); if ($relatedPage) { - $this->checkOwnablePermission('page-view', $relatedPage); + $this->checkOwnablePermission(Permission::PageView, $relatedPage); } } } diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index 6d4657a7a..c4168a77e 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -3,10 +3,13 @@ namespace BookStack\Uploads\Controllers; use BookStack\Entities\Queries\PageQueries; +use BookStack\Exceptions\NotFoundException; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use BookStack\Uploads\Image; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; +use BookStack\Uploads\ImageService; use Illuminate\Http\Request; class ImageGalleryApiController extends ApiController @@ -19,6 +22,7 @@ class ImageGalleryApiController extends ApiController protected ImageRepo $imageRepo, protected ImageResizer $imageResizer, protected PageQueries $pageQueries, + protected ImageService $imageService, ) { } @@ -31,6 +35,9 @@ class ImageGalleryApiController extends ApiController 'image' => ['required', 'file', ...$this->getImageValidationRules()], 'name' => ['string', 'max:180'], ], + 'readDataForUrl' => [ + 'url' => ['required', 'string', 'url'], + ], 'update' => [ 'name' => ['string', 'max:180'], 'image' => ['file', ...$this->getImageValidationRules()], @@ -65,7 +72,7 @@ class ImageGalleryApiController extends ApiController */ public function create(Request $request) { - $this->checkPermission('image-create-all'); + $this->checkPermission(Permission::ImageCreateAll); $data = $this->validate($request, $this->rules()['create']); $page = $this->pageQueries->findVisibleByIdOrFail($data['uploaded_to']); @@ -84,7 +91,8 @@ class ImageGalleryApiController extends ApiController * The "thumbs" response property contains links to scaled variants that BookStack may use in its UI. * The "content" response property provides HTML and Markdown content, in the format that BookStack * would typically use by default to add the image in page content, as a convenience. - * Actual image file data is not provided but can be fetched via the "url" response property. + * Actual image file data is not provided but can be fetched via the "url" response property or by + * using the "read-data" endpoint. */ public function read(string $id) { @@ -93,6 +101,37 @@ class ImageGalleryApiController extends ApiController return response()->json($this->formatForSingleResponse($image)); } + /** + * Read the image file data for a single image in the system. + * The returned response will be a stream of image data instead of a JSON response. + */ + public function readData(string $id) + { + $image = Image::query()->scopes(['visible'])->findOrFail($id); + + return $this->imageService->streamImageFromStorageResponse('gallery', $image->path); + } + + /** + * Read the image file data for a single image in the system, using the provided URL + * to identify the image instead of its ID, which is provided as a "URL" query parameter. + * The returned response will be a stream of image data instead of a JSON response. + */ + public function readDataForUrl(Request $request) + { + $data = $this->validate($request, $this->rules()['readDataForUrl']); + $basePath = url('/uploads/images/'); + $imagePath = str_replace($basePath, '', $data['url']); + + if (!$this->imageService->pathAccessible($imagePath)) { + throw (new NotFoundException(trans('errors.image_not_found'))) + ->setSubtitle(trans('errors.image_not_found_subtitle')) + ->setDetails(trans('errors.image_not_found_details')); + } + + return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath); + } + /** * Update the details of an existing image in the system. * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a @@ -102,8 +141,8 @@ class ImageGalleryApiController extends ApiController { $data = $this->validate($request, $this->rules()['update']); $image = $this->imageRepo->getById($id); - $this->checkOwnablePermission('page-view', $image->getPage()); - $this->checkOwnablePermission('image-update', $image); + $this->checkOwnablePermission(Permission::PageView, $image->getPage()); + $this->checkOwnablePermission(Permission::ImageUpdate, $image); $this->imageRepo->updateImageDetails($image, $data); if (isset($data['image'])) { @@ -121,8 +160,8 @@ class ImageGalleryApiController extends ApiController public function delete(string $id) { $image = $this->imageRepo->getById($id); - $this->checkOwnablePermission('page-view', $image->getPage()); - $this->checkOwnablePermission('image-delete', $image); + $this->checkOwnablePermission(Permission::PageView, $image->getPage()); + $this->checkOwnablePermission(Permission::ImageDelete, $image); $this->imageRepo->destroyImage($image); return response('', 204); @@ -146,10 +185,10 @@ class ImageGalleryApiController extends ApiController $data['content']['html'] = "
id}\">
"; $data['content']['markdown'] = $data['content']['html']; } else { - $escapedDisplayThumb = htmlentities($image->thumbs['display']); + $escapedDisplayThumb = htmlentities($image->getAttribute('thumbs')['display']); $data['content']['html'] = "\"{$escapedName}\""; $mdEscapedName = str_replace(']', '', str_replace('[', '', $image->name)); - $mdEscapedThumb = str_replace(']', '', str_replace('[', '', $image->thumbs['display'])); + $mdEscapedThumb = str_replace(']', '', str_replace('[', '', $image->getAttribute('thumbs')['display'])); $data['content']['markdown'] = "![{$mdEscapedName}]({$mdEscapedThumb})"; } diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 0a267a644..81b6db6fd 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -7,21 +7,22 @@ use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; +use BookStack\Users\Models\OwnableInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; /** - * @property int $id - * @property string $name - * @property string $url - * @property string $path - * @property string $type - * @property int $uploaded_to - * @property int $created_by - * @property int $updated_by + * @property int $id + * @property string $name + * @property string $url + * @property string $path + * @property string $type + * @property int|null $uploaded_to + * @property int $created_by + * @property int $updated_by */ -class Image extends Model +class Image extends Model implements OwnableInterface { use HasFactory; use HasCreatorAndUpdater; @@ -41,7 +42,9 @@ class Image extends Model */ public function scopeVisible(Builder $query): Builder { - return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to'); + return app()->make(PermissionApplicator::class) + ->restrictPageRelationQuery($query, 'images', 'uploaded_to') + ->whereIn('type', ['gallery', 'drawio']); } /** diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php index 8d7571c82..9ed0d0f75 100644 --- a/app/Uploads/ImageResizer.php +++ b/app/Uploads/ImageResizer.php @@ -55,7 +55,7 @@ class ImageResizer /** * Get the thumbnail for an image. - * If $keepRatio is true only the width will be used. + * If $keepRatio is true, only the width will be used. * Checks the cache then storage to avoid creating / accessing the filesystem on every check. * * @throws Exception @@ -84,7 +84,7 @@ class ImageResizer return $this->storage->getPublicUrl($cachedThumbPath); } - // If thumbnail has already been generated, serve that and cache path + // If a thumbnail has already been generated, serve that and cache path $disk = $this->storage->getDisk($image->type); if (!$shouldCreate && $disk->exists($thumbFilePath)) { Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME); @@ -110,7 +110,7 @@ class ImageResizer } /** - * Resize the image of given data to the specified size, and return the new image data. + * Resize the image of given data to the specified size and return the new image data. * Format will remain the same as the input format, unless specified. * * @throws ImageUploadException @@ -125,6 +125,7 @@ class ImageResizer try { $thumb = $this->interventionFromImageData($imageData, $format); } catch (Exception $e) { + Log::error('Failed to resize image with error:' . $e->getMessage()); throw new ImageUploadException(trans('errors.cannot_create_thumbs')); } @@ -154,17 +155,21 @@ class ImageResizer /** * Create an intervention image instance from the given image data. - * Performs some manual library usage to ensure image is specifically loaded + * Performs some manual library usage to ensure the image is specifically loaded * from given binary data instead of data being misinterpreted. */ protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage { + if (!extension_loaded('gd')) { + throw new ImageUploadException('The PHP "gd" extension is required to resize images, but is missing.'); + } + $manager = new ImageManager( new Driver(), autoOrientation: false, ); - // Ensure gif images are decoded natively instead of deferring to intervention GIF + // Ensure GIF images are decoded natively instead of deferring to intervention GIF // handling since we don't need the added animation support. $isGif = $fileType === 'gif'; $decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class; @@ -223,7 +228,7 @@ class ImageResizer } /** - * Checks if the image is a gif. Returns true if it is, else false. + * Checks if the image is a GIF. Returns true if it is, else false. */ protected function isGif(Image $image): bool { @@ -250,7 +255,7 @@ class ImageResizer /** * Check if the given avif image data represents an animated image. - * This is based up the answer here: https://stackoverflow.com/a/79457313 + * This is based upon the answer here: https://stackoverflow.com/a/79457313 */ protected function isAnimatedAvifData(string &$imageData): bool { diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index a8f144517..ed640913e 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -138,7 +138,7 @@ class ImageService * Get the raw data content from an image. * * @throws Exception - * @returns ?resource + * @return ?resource */ public function getImageStream(Image $image): mixed { @@ -148,7 +148,7 @@ class ImageService } /** - * Destroy an image along with its revisions, thumbnails and remaining folders. + * Destroy an image along with its revisions, thumbnails, and remaining folders. * * @throws Exception */ @@ -184,7 +184,7 @@ class ImageService /** @var Image $image */ foreach ($images as $image) { $searchQuery = '%' . basename($image->path) . '%'; - $inPage = DB::table('pages') + $inPage = DB::table('entity_page_data') ->where('html', 'like', $searchQuery)->count() > 0; $inRevision = false; @@ -252,16 +252,59 @@ class ImageService { $disk = $this->storage->getDisk('gallery'); + return $disk->usingSecureImages() && $this->pathAccessible($imagePath); + } + + /** + * Check if the given path exists and is accessible depending on the current settings. + */ + public function pathAccessible(string $imagePath): bool + { if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { return false; } - // Check local_secure is active - return $disk->usingSecureImages() - // Check the image file exists - && $disk->exists($imagePath) - // Check the file is likely an image file - && str_starts_with($disk->mimeType($imagePath), 'image/'); + if ($this->blockedBySecureImages()) { + return false; + } + + return $this->imageFileExists($imagePath, 'gallery'); + } + + /** + * Check if the given image should be accessible to the current user. + */ + public function imageAccessible(Image $image): bool + { + if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) { + return false; + } + + if ($this->blockedBySecureImages()) { + return false; + } + + return $this->imageFileExists($image->path, $image->type); + } + + /** + * Check if the current user should be blocked from accessing images based on if secure images are enabled + * and if public access is enabled for the application. + */ + protected function blockedBySecureImages(): bool + { + $enforced = $this->storage->usingSecureImages() && !setting('app-public'); + + return $enforced && user()->isGuest(); + } + + /** + * Check if the given image path exists for the given image type and that it is likely an image file. + */ + protected function imageFileExists(string $imagePath, string $imageType): bool + { + $disk = $this->storage->getDisk($imageType); + return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/'); } /** @@ -290,6 +333,11 @@ class ImageService return false; } + return $this->checkUserHasAccessToRelationOfImage($image); + } + + protected function checkUserHasAccessToRelationOfImage(Image $image): bool + { $imageType = $image->type; // Allow user or system (logo) images diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php index ddaa26a94..abf2b429b 100644 --- a/app/Uploads/ImageStorage.php +++ b/app/Uploads/ImageStorage.php @@ -34,6 +34,15 @@ class ImageStorage return config('filesystems.images') === 'local_secure_restricted'; } + /** + * Check if "local secure" (Fetched behind auth, either with or without permissions enforced) + * is currently active in the instance. + */ + public function usingSecureImages(): bool + { + return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages(); + } + /** * Clean up an image file name to be both URL and storage safe. */ @@ -65,7 +74,7 @@ class ImageStorage return 'local'; } - // Rename local_secure options to get our image specific storage driver which + // Rename local_secure options to get our image-specific storage driver, which // is scoped to the relevant image directories. if ($localSecureInUse) { return 'local_secure_images'; diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index f2667d993..36d6721de 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -61,7 +61,7 @@ class ImageStorageDisk /** * Get a stream to the file at the given path. - * @returns ?resource + * @return ?resource */ public function stream(string $path): mixed { diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 2f3638cd3..93ecc549b 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -3,6 +3,7 @@ namespace BookStack\Users\Controllers; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use BookStack\Permissions\PermissionsRepo; use BookStack\Users\Models\Role; use Illuminate\Http\Request; @@ -10,8 +11,6 @@ use Illuminate\Support\Facades\DB; class RoleApiController extends ApiController { - protected PermissionsRepo $permissionsRepo; - protected array $fieldsToExpose = [ 'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at', ]; @@ -35,13 +34,12 @@ class RoleApiController extends ApiController ] ]; - public function __construct(PermissionsRepo $permissionsRepo) - { - $this->permissionsRepo = $permissionsRepo; - + public function __construct( + protected PermissionsRepo $permissionsRepo + ) { // Checks for all endpoints in this controller $this->middleware(function ($request, $next) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); return $next($request); }); @@ -125,9 +123,9 @@ class RoleApiController extends ApiController } /** - * Format the given role model for single-result display. + * Format the given role model for a single-result display. */ - protected function singleFormatter(Role $role) + protected function singleFormatter(Role $role): void { $role->load('users:id,name,slug'); $role->unsetRelation('permissions'); diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index 0a7fdcc9b..549f6e0ac 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -4,6 +4,7 @@ namespace BookStack\Users\Controllers; use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Permissions\PermissionsRepo; use BookStack\Users\Models\Role; use BookStack\Users\Queries\RolesAllPaginatedAndSorted; @@ -23,7 +24,7 @@ class RoleController extends Controller */ public function index(Request $request) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); $listOptions = SimpleListOptions::fromRequest($request, 'roles')->withSortOptions([ 'display_name' => trans('common.sort_name'), @@ -49,7 +50,7 @@ class RoleController extends Controller */ public function create(Request $request) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); /** @var ?Role $role */ $role = null; @@ -71,7 +72,7 @@ class RoleController extends Controller */ public function store(Request $request) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); $data = $this->validate($request, [ 'display_name' => ['required', 'min:3', 'max:180'], 'description' => ['max:180'], @@ -92,7 +93,7 @@ class RoleController extends Controller */ public function edit(string $id) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); $role = $this->permissionsRepo->getRoleById($id); $this->setPageTitle(trans('settings.role_edit')); @@ -105,7 +106,7 @@ class RoleController extends Controller */ public function update(Request $request, string $id) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); $data = $this->validate($request, [ 'display_name' => ['required', 'min:3', 'max:180'], 'description' => ['max:180'], @@ -127,7 +128,7 @@ class RoleController extends Controller */ public function showDelete(string $id) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); $role = $this->permissionsRepo->getRoleById($id); $roles = $this->permissionsRepo->getAllRolesExcept($role); $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]); @@ -146,7 +147,7 @@ class RoleController extends Controller */ public function delete(Request $request, string $id) { - $this->checkPermission('user-roles-manage'); + $this->checkPermission(Permission::UserRolesManage); try { $migrateRoleId = intval($request->get('migrate_role_id') ?: "0"); diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index 708a91e9d..a8baba529 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -4,6 +4,7 @@ namespace BookStack\Users\Controllers; use BookStack\Access\SocialDriverManager; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\UserNotificationPreferences; use BookStack\Settings\UserShortcutMap; @@ -62,9 +63,9 @@ class UserAccountController extends Controller 'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()), ]); - $this->userRepo->update($user, $validated, userCan('users-manage')); + $this->userRepo->update($user, $validated, userCan(Permission::UsersManage)); - // Save profile image if in request + // Save the profile image if in request if ($request->hasFile('profile_image')) { $imageUpload = $request->file('profile_image'); $imageRepo->destroyImage($user->avatar); @@ -73,7 +74,7 @@ class UserAccountController extends Controller $user->save(); } - // Delete the profile image if reset option is in request + // Delete the profile image if the reset option is in request if ($request->has('profile_image_reset')) { $imageRepo->destroyImage($user->avatar); $user->image_id = 0; @@ -122,7 +123,7 @@ class UserAccountController extends Controller */ public function showNotifications(PermissionApplicator $permissions) { - $this->checkPermission('receive-notifications'); + $this->checkPermission(Permission::ReceiveNotifications); $preferences = (new UserNotificationPreferences(user())); @@ -145,7 +146,7 @@ class UserAccountController extends Controller public function updateNotifications(Request $request) { $this->preventAccessInDemoMode(); - $this->checkPermission('receive-notifications'); + $this->checkPermission(Permission::ReceiveNotifications); $data = $this->validate($request, [ 'preferences' => ['required', 'array'], 'preferences.*' => ['required', 'string'], @@ -218,7 +219,7 @@ class UserAccountController extends Controller $this->preventAccessInDemoMode(); $requestNewOwnerId = intval($request->get('new_owner_id')) ?: null; - $newOwnerId = userCan('users-manage') ? $requestNewOwnerId : null; + $newOwnerId = userCan(Permission::UsersManage) ? $requestNewOwnerId : null; $this->userRepo->destroy(user(), $newOwnerId); diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index bb2570b31..25753280f 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -2,11 +2,12 @@ namespace BookStack\Users\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Exceptions\UserUpdateException; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; use BookStack\Users\UserRepo; -use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rules\Password; @@ -26,7 +27,7 @@ class UserApiController extends ApiController // Checks for all endpoints in this controller $this->middleware(function ($request, $next) { - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $this->preventAccessInDemoMode(); return $next($request); @@ -81,7 +82,7 @@ class UserApiController extends ApiController return $this->apiListingResponse($users, [ 'id', 'name', 'slug', 'email', 'external_auth_id', 'created_at', 'updated_at', 'last_activity_at', - ], [Closure::fromCallable([$this, 'listFormatter'])]); + ], [$this->listFormatter(...)]); } /** @@ -125,7 +126,7 @@ class UserApiController extends ApiController { $data = $this->validate($request, $this->rules($id)['update']); $user = $this->userRepo->getById($id); - $this->userRepo->update($user, $data, userCan('users-manage')); + $this->userRepo->update($user, $data, userCan(Permission::UsersManage)); $this->singleFormatter($user); return response()->json($user); diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index c6e4326e9..494221b14 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -7,6 +7,7 @@ use BookStack\Access\UserInviteException; use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\UserUpdateException; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Uploads\ImageRepo; use BookStack\Users\Models\Role; use BookStack\Users\Queries\UsersAllPaginatedAndSorted; @@ -32,7 +33,7 @@ class UserController extends Controller */ public function index(Request $request) { - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $listOptions = SimpleListOptions::fromRequest($request, 'users')->withSortOptions([ 'name' => trans('common.sort_name'), @@ -58,7 +59,7 @@ class UserController extends Controller */ public function create() { - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $authMethod = config('auth.method'); $roles = Role::query()->orderBy('display_name', 'asc')->get(); $this->setPageTitle(trans('settings.users_add_new')); @@ -73,7 +74,7 @@ class UserController extends Controller */ public function store(Request $request) { - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $authMethod = config('auth.method'); $sendInvite = ($request->get('send_invite', 'false') === 'true'); @@ -111,7 +112,7 @@ class UserController extends Controller */ public function edit(int $id, SocialDriverManager $socialDriverManager) { - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $user = $this->userRepo->getById($id); $user->load(['apiTokens', 'mfaValues']); @@ -141,7 +142,7 @@ class UserController extends Controller public function update(Request $request, int $id) { $this->preventAccessInDemoMode(); - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $validated = $this->validate($request, [ 'name' => ['min:1', 'max:100'], @@ -182,7 +183,7 @@ class UserController extends Controller */ public function delete(int $id) { - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $user = $this->userRepo->getById($id); $this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name])); @@ -198,7 +199,7 @@ class UserController extends Controller public function destroy(Request $request, int $id) { $this->preventAccessInDemoMode(); - $this->checkPermission('users-manage'); + $this->checkPermission(Permission::UsersManage); $user = $this->userRepo->getById($id); $newOwnerId = intval($request->get('new_owner_id')) ?: null; diff --git a/app/Users/Controllers/UserSearchController.php b/app/Users/Controllers/UserSearchController.php index b6f37bce0..bc0543cab 100644 --- a/app/Users/Controllers/UserSearchController.php +++ b/app/Users/Controllers/UserSearchController.php @@ -3,7 +3,9 @@ namespace BookStack\Users\Controllers; use BookStack\Http\Controller; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; class UserSearchController extends Controller @@ -15,9 +17,9 @@ class UserSearchController extends Controller public function forSelect(Request $request) { $hasPermission = !user()->isGuest() && ( - userCan('users-manage') - || userCan('restrictions-manage-own') - || userCan('restrictions-manage-all') + userCan(Permission::UsersManage) + || userCan(Permission::RestrictionsManageOwn) + || userCan(Permission::RestrictionsManageAll) ); if (!$hasPermission) { @@ -33,8 +35,43 @@ class UserSearchController extends Controller $query->where('name', 'like', '%' . $search . '%'); } + /** @var Collection $users */ + $users = $query->get(); + return view('form.user-select-list', [ - 'users' => $query->get(), + 'users' => $users, + ]); + } + + /** + * Search users in the system, with the response formatted + * for use in a list of mentions. + */ + public function forMentions(Request $request) + { + $hasPermission = !user()->isGuest() && ( + userCan(Permission::CommentCreateAll) + || userCan(Permission::CommentUpdate) + ); + + if (!$hasPermission) { + $this->showPermissionError(); + } + + $search = $request->get('search', ''); + $query = User::query() + ->orderBy('name', 'asc') + ->take(20); + + if (!empty($search)) { + $query->where('name', 'like', '%' . $search . '%'); + } + + /** @var Collection $users */ + $users = $query->get(); + + return view('form.user-mention-list', [ + 'users' => $users, ]); } } diff --git a/app/Users/Models/HasCreatorAndUpdater.php b/app/Users/Models/HasCreatorAndUpdater.php index 9fb24ead9..bb05de11a 100644 --- a/app/Users/Models/HasCreatorAndUpdater.php +++ b/app/Users/Models/HasCreatorAndUpdater.php @@ -27,4 +27,9 @@ trait HasCreatorAndUpdater { return $this->belongsTo(User::class, 'updated_by'); } + + public function getOwnerFieldName(): string + { + return 'created_by'; + } } diff --git a/app/Users/Models/HasOwner.php b/app/Users/Models/HasOwner.php deleted file mode 100644 index eb830ef6f..000000000 --- a/app/Users/Models/HasOwner.php +++ /dev/null @@ -1,19 +0,0 @@ -belongsTo(User::class, 'owned_by'); - } -} diff --git a/app/Users/Models/OwnableInterface.php b/app/Users/Models/OwnableInterface.php new file mode 100644 index 000000000..8f738487f --- /dev/null +++ b/app/Users/Models/OwnableInterface.php @@ -0,0 +1,8 @@ + */ public function permissions(): BelongsToMany { - return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id'); + return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id') + ->select(['id', 'name']); } /** diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 0d437418b..50efdcdad 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -10,8 +10,8 @@ use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Watch; use BookStack\Api\ApiToken; use BookStack\App\Model; -use BookStack\App\Sluggable; -use BookStack\Entities\Tools\SlugGenerator; +use BookStack\App\SluggableInterface; +use BookStack\Permissions\Permission; use BookStack\Translation\LocaleDefinition; use BookStack\Translation\LocaleManager; use BookStack\Uploads\Image; @@ -30,8 +30,6 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; /** - * Class User. - * * @property int $id * @property string $name * @property string $slug @@ -47,7 +45,7 @@ use Illuminate\Support\Collection; * @property Collection $mfaValues * @property ?Image $avatar */ -class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable +class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, SluggableInterface { use HasFactory; use Authenticatable; @@ -64,7 +62,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * The attributes that are mass assignable. * - * @var array + * @var list */ protected $fillable = ['name', 'email']; @@ -73,7 +71,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * The attributes excluded from the model's JSON form. * - * @var array + * @var list */ protected $hidden = [ 'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email', @@ -118,14 +116,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * The roles that belong to the user. * - * @return BelongsToMany + * @return BelongsToMany */ - public function roles() + public function roles(): BelongsToMany { - if ($this->id === 0) { - return; - } - return $this->belongsToMany(Role::class); } @@ -159,8 +153,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * Check if the user has a particular permission. */ - public function can(string $permissionName): bool + public function can(string|Permission $permission): bool { + $permissionName = is_string($permission) ? $permission : $permission->value; return $this->permissions()->contains($permissionName); } @@ -184,9 +179,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon } /** - * Clear any cached permissions on this instance. + * Clear any cached permissions in this instance. */ - public function clearPermissionCache() + public function clearPermissionCache(): void { $this->permissions = null; } @@ -194,7 +189,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * Attach a role to this user. */ - public function attachRole(Role $role) + public function attachRole(Role $role): void { $this->roles()->attach($role->id); $this->unsetRelation('roles'); @@ -210,15 +205,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * Check if the user has a social account, - * If a driver is passed it checks for that single account type. - * - * @param bool|string $socialDriver - * - * @return bool + * If a driver is passed, it checks for that single account type. */ - public function hasSocialAccount($socialDriver = false) + public function hasSocialAccount(string $socialDriver = ''): bool { - if ($socialDriver === false) { + if (empty($socialDriver)) { return $this->socialAccounts()->count() > 0; } @@ -366,14 +357,4 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon { return "({$this->id}) {$this->name}"; } - - /** - * {@inheritdoc} - */ - public function refreshSlug(): string - { - $this->slug = app()->make(SlugGenerator::class)->generate($this); - - return $this->slug; - } } diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 5c8ace8fa..2c0897cef 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -5,13 +5,14 @@ namespace BookStack\Users; use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; -use BookStack\Entities\EntityProvider; +use BookStack\Entities\Tools\SlugGenerator; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\UserUpdateException; use BookStack\Facades\Activity; use BookStack\Uploads\UserAvatars; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use DB; use Exception; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; @@ -21,11 +22,11 @@ class UserRepo { public function __construct( protected UserAvatars $userAvatar, - protected UserInviteService $inviteService + protected UserInviteService $inviteService, + protected SlugGenerator $slugGenerator, ) { } - /** * Get a user by their email address. */ @@ -64,7 +65,7 @@ class UserRepo $user->email_confirmed = $emailConfirmed; $user->external_auth_id = $data['external_auth_id'] ?? ''; - $user->refreshSlug(); + $this->slugGenerator->regenerateForUser($user); $user->save(); if (!empty($data['language'])) { @@ -100,17 +101,17 @@ class UserRepo } /** - * Update the given user with the given data. + * Update the given user with the given data, but do not create an activity. * * @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array, language: ?string} $data * * @throws UserUpdateException */ - public function update(User $user, array $data, bool $manageUsersAllowed): User + public function updateWithoutActivity(User $user, array $data, bool $manageUsersAllowed): User { if (!empty($data['name'])) { $user->name = $data['name']; - $user->refreshSlug(); + $this->slugGenerator->regenerateForUser($user); } if (!empty($data['email']) && $manageUsersAllowed) { @@ -134,6 +135,21 @@ class UserRepo } $user->save(); + + return $user; + } + + /** + * Update the given user with the given data. + * + * @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array, language: ?string} $data + * + * @throws UserUpdateException + */ + public function update(User $user, array $data, bool $manageUsersAllowed): User + { + $user = $this->updateWithoutActivity($user, $data, $manageUsersAllowed); + Activity::add(ActivityType::USER_UPDATE, $user); return $user; @@ -144,15 +160,12 @@ class UserRepo * * @throws Exception */ - public function destroy(User $user, ?int $newOwnerId = null) + public function destroy(User $user, ?int $newOwnerId = null): void { $this->ensureDeletable($user); - $user->socialAccounts()->delete(); - $user->apiTokens()->delete(); - $user->favourites()->delete(); - $user->mfaValues()->delete(); - $user->watches()->delete(); + $this->removeUserDependantRelations($user); + $this->nullifyUserNonDependantRelations($user); $user->delete(); // Delete user profile images @@ -161,16 +174,52 @@ class UserRepo // Delete related activities setting()->deleteUserSettings($user->id); + // Migrate or nullify ownership + $newOwner = null; if (!empty($newOwnerId)) { $newOwner = User::query()->find($newOwnerId); - if (!is_null($newOwner)) { - $this->migrateOwnership($user, $newOwner); - } } + $this->migrateOwnership($user, $newOwner); Activity::add(ActivityType::USER_DELETE, $user); } + protected function removeUserDependantRelations(User $user): void + { + $user->apiTokens()->delete(); + $user->socialAccounts()->delete(); + $user->favourites()->delete(); + $user->mfaValues()->delete(); + $user->watches()->delete(); + + $tables = ['email_confirmations', 'user_invites', 'views']; + foreach ($tables as $table) { + DB::table($table)->where('user_id', '=', $user->id)->delete(); + } + } + protected function nullifyUserNonDependantRelations(User $user): void + { + $toNullify = [ + 'attachments' => ['created_by', 'updated_by'], + 'comments' => ['created_by', 'updated_by'], + 'deletions' => ['deleted_by'], + 'entities' => ['created_by', 'updated_by'], + 'images' => ['created_by', 'updated_by'], + 'imports' => ['created_by'], + 'joint_permissions' => ['owner_id'], + 'page_revisions' => ['created_by'], + 'sessions' => ['user_id'], + ]; + + foreach ($toNullify as $table => $columns) { + foreach ($columns as $column) { + DB::table($table) + ->where($column, '=', $user->id) + ->update([$column => null]); + } + } + } + /** * @throws NotifyException */ @@ -188,13 +237,12 @@ class UserRepo /** * Migrate ownership of items in the system from one user to another. */ - protected function migrateOwnership(User $fromUser, User $toUser) + protected function migrateOwnership(User $fromUser, User|null $toUser): void { - $entities = (new EntityProvider())->all(); - foreach ($entities as $instance) { - $instance->newQuery()->where('owned_by', '=', $fromUser->id) - ->update(['owned_by' => $toUser->id]); - } + $newOwnerValue = $toUser ? $toUser->id : null; + DB::table('entities') + ->where('owned_by', '=', $fromUser->id) + ->update(['owned_by' => $newOwnerValue]); } /** @@ -232,7 +280,7 @@ class UserRepo * * @throws UserUpdateException */ - protected function setUserRoles(User $user, array $roles) + protected function setUserRoles(User $user, array $roles): void { $roles = array_filter(array_values($roles)); @@ -245,7 +293,7 @@ class UserRepo /** * Check if the given user is the last admin and their new roles no longer - * contains the admin role. + * contain the admin role. */ protected function demotingLastAdmin(User $user, array $newRoles): bool { diff --git a/app/Util/DateFormatter.php b/app/Util/DateFormatter.php new file mode 100644 index 000000000..c6e60bd53 --- /dev/null +++ b/app/Util/DateFormatter.php @@ -0,0 +1,26 @@ +clone()->setTimezone($this->displayTimezone); + + return $withDisplayTimezone->format('Y-m-d H:i:s T'); + } + + public function relative(Carbon $date, bool $includeSuffix = true): string + { + return $date->diffForHumans(null, $includeSuffix ? null : CarbonInterface::DIFF_ABSOLUTE); + } +} diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index d4f7d2c8f..1baa11ffc 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -19,7 +19,7 @@ class HtmlDescriptionFilter */ protected static array $allowedAttrsByElements = [ 'p' => [], - 'a' => ['href', 'title', 'target'], + 'a' => ['href', 'title', 'target', 'data-mention-user-id'], 'ol' => [], 'ul' => [], 'li' => [], diff --git a/app/Util/OutOfMemoryHandler.php b/app/Util/OutOfMemoryHandler.php index 88e9581f4..6632d22a5 100644 --- a/app/Util/OutOfMemoryHandler.php +++ b/app/Util/OutOfMemoryHandler.php @@ -42,7 +42,7 @@ class OutOfMemoryHandler } /** - * Forget the handler so no action is taken place on out of memory. + * Forget the handler, so no action is taken place on out of memory. */ public function forget(): void { @@ -53,6 +53,11 @@ class OutOfMemoryHandler protected function getHandler(): Handler { + /** + * We want to resolve our specific BookStack handling via the set app handler + * singleton, but phpstan will only infer based on the interface. + * @phpstan-ignore return.type + */ return app()->make(ExceptionHandler::class); } } diff --git a/bookstack-system-cli b/bookstack-system-cli index c55c5a8a7..1a518e644 100755 Binary files a/bookstack-system-cli and b/bookstack-system-cli differ diff --git a/composer.json b/composer.json index 2ea1d802b..9d47f5548 100644 --- a/composer.json +++ b/composer.json @@ -22,17 +22,17 @@ "guzzlehttp/guzzle": "^7.4", "intervention/image": "^3.5", "knplabs/knp-snappy": "^1.5", - "laravel/framework": "^v11.37", + "laravel/framework": "^v12.26.4", "laravel/socialite": "^5.10", "laravel/tinker": "^2.8", "league/commonmark": "^2.3", "league/flysystem-aws-s3-v3": "^3.0", "league/html-to-markdown": "^5.0.0", "league/oauth2-client": "^2.6", - "onelogin/php-saml": "^4.0", + "onelogin/php-saml": "^4.3.1", "phpseclib/phpseclib": "^3.0", - "pragmarx/google2fa": "^8.0", - "predis/predis": "^2.1", + "pragmarx/google2fa": "^9.0", + "predis/predis": "^3.2", "socialiteproviders/discord": "^4.1", "socialiteproviders/gitlab": "^4.1", "socialiteproviders/microsoft-azure": "^5.1", @@ -44,10 +44,10 @@ "fakerphp/faker": "^1.21", "itsgoingd/clockwork": "^5.1", "mockery/mockery": "^1.5", - "nunomaduro/collision": "^8.1", + "nunomaduro/collision": "^8.6", "larastan/larastan": "^v3.0", "phpunit/phpunit": "^11.5", - "squizlabs/php_codesniffer": "^3.7", + "squizlabs/php_codesniffer": "^4.0.1", "ssddanbrown/asserthtml": "^3.1" }, "autoload": { @@ -93,6 +93,7 @@ "@php artisan view:clear" ], "refresh-test-database": [ + "@putenv APP_TIMEZONE=UTC", "@php artisan migrate:refresh --database=mysql_testing", "@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing" ] diff --git a/composer.lock b/composer.lock index 54ea99275..06ef01bdd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7695cb9945ec550970c67da96934daf", + "content-hash": "556613432c8fb7d8f96bcf637c8c07a9", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.354.0", + "version": "3.369.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "014ce3465277cf78a05e60c04ce04c9893733bf2" + "reference": "2aa1ef195e90140d733382e4341732ce113024f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/014ce3465277cf78a05e60c04ce04c9893733bf2", - "reference": "014ce3465277cf78a05e60c04ce04c9893733bf2", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2aa1ef195e90140d733382e4341732ce113024f5", + "reference": "2aa1ef195e90140d733382e4341732ce113024f5", "shasum": "" }, "require": { @@ -84,7 +84,8 @@ "guzzlehttp/psr7": "^2.4.5", "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", - "psr/http-message": "^2.0" + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -95,13 +96,11 @@ "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", - "ext-pcntl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "phpunit/phpunit": "^9.6", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", - "symfony/filesystem": "^v6.4.0 || ^v7.1.0", "yoast/phpunit-polyfills": "^2.0" }, "suggest": { @@ -109,6 +108,7 @@ "doctrine/cache": "To use the DoctrineCacheAdapter", "ext-curl": "To send requests using cURL", "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", "ext-sockets": "To use client-side monitoring" }, "type": "library", @@ -153,22 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.354.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.4" }, - "time": "2025-08-14T18:10:08+00:00" + "time": "2025-12-29T19:07:47+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.1", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" + "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", - "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", "shasum": "" }, "require": { @@ -178,8 +178,9 @@ }, "require-dev": { "phly/keep-a-changelog": "^2.12", - "phpunit/phpunit": "^10.5.11 || 11.0.4", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", "squizlabs/php_codesniffer": "^3.9" }, "suggest": { @@ -207,31 +208,31 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" }, - "time": "2024-10-01T13:55:55+00:00" + "time": "2025-11-19T17:15:36+00:00" }, { "name": "brick/math", - "version": "0.12.3", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -261,7 +262,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -269,7 +270,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -342,16 +343,16 @@ }, { "name": "dasprid/enum", - "version": "1.0.6", + "version": "1.0.7", "source": { "type": "git", "url": "https://github.com/DASPRiD/Enum.git", - "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90" + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90", - "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", "shasum": "" }, "require": { @@ -386,9 +387,9 @@ ], "support": { "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.6" + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" }, - "time": "2024-08-09T14:30:48+00:00" + "time": "2025-09-16T12:23:56+00:00" }, { "name": "dflydev/dot-access-data", @@ -634,16 +635,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.0", + "version": "v3.1.4", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "a51bd7a063a65499446919286fb18b518177155a" + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a", - "reference": "a51bd7a063a65499446919286fb18b518177155a", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", "shasum": "" }, "require": { @@ -692,9 +693,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.0" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" }, - "time": "2025-01-15T14:09:04+00:00" + "time": "2025-10-29T12:43:30+00:00" }, { "name": "dompdf/php-font-lib", @@ -789,29 +790,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -842,7 +842,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -850,7 +850,7 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -984,31 +984,31 @@ }, { "name": "fruitcake/php-cors", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -1039,7 +1039,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -1051,28 +1051,28 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -1101,7 +1101,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -1113,26 +1113,26 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -1223,7 +1223,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -1239,20 +1239,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -1260,7 +1260,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -1306,7 +1306,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.2.0" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -1322,20 +1322,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:27:01+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -1351,7 +1351,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1422,7 +1422,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -1438,20 +1438,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.4", + "version": "v1.0.5", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", "shasum": "" }, "require": { @@ -1460,7 +1460,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1508,7 +1508,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" }, "funding": [ { @@ -1524,7 +1524,7 @@ "type": "tidelift" } ], - "time": "2025-02-03T10:55:03+00:00" + "time": "2025-08-22T14:27:06+00:00" }, { "name": "intervention/gif", @@ -1596,16 +1596,16 @@ }, { "name": "intervention/image", - "version": "3.11.4", + "version": "3.11.6", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" + "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", + "url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1", + "reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1", "shasum": "" }, "require": { @@ -1637,11 +1637,11 @@ { "name": "Oliver Vogel", "email": "oliver@intervention.io", - "homepage": "https://intervention.io/" + "homepage": "https://intervention.io" } ], - "description": "PHP image manipulation", - "homepage": "https://image.intervention.io/", + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", "keywords": [ "gd", "image", @@ -1652,7 +1652,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.4" + "source": "https://github.com/Intervention/image/tree/3.11.6" }, "funding": [ { @@ -1668,7 +1668,7 @@ "type": "ko_fi" } ], - "time": "2025-07-30T13:13:19+00:00" + "time": "2025-12-17T13:38:29+00:00" }, { "name": "knplabs/knp-snappy", @@ -1739,20 +1739,20 @@ }, { "name": "laravel/framework", - "version": "v11.45.2", + "version": "v12.44.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "d134bf11e2208c0c5bd488cf19e612ca176b820a" + "reference": "592bbf1c036042958332eb98e3e8131b29102f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/d134bf11e2208c0c5bd488cf19e612ca176b820a", - "reference": "d134bf11e2208c0c5bd488cf19e612ca176b820a", + "url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33", + "reference": "592bbf1c036042958332eb98e3e8131b29102f33", "shasum": "" }, "require": { - "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1767,32 +1767,34 @@ "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", + "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", "monolog/monolog": "^3.0", - "nesbot/carbon": "^2.72.6|^3.8.4", + "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^7.0.3", - "symfony/error-handler": "^7.0.3", - "symfony/finder": "^7.0.3", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", "symfony/http-foundation": "^7.2.0", - "symfony/http-kernel": "^7.0.3", - "symfony/mailer": "^7.0.3", - "symfony/mime": "^7.0.3", - "symfony/polyfill-php83": "^1.31", - "symfony/process": "^7.0.3", - "symfony/routing": "^7.0.3", - "symfony/uid": "^7.0.3", - "symfony/var-dumper": "^7.0.3", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" @@ -1824,6 +1826,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", "illuminate/mail": "self.version", @@ -1833,6 +1836,7 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1856,17 +1860,18 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^9.16.0", - "pda/pheanstalk": "^5.0.6", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.8.1", + "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5.35|^11.3.6|^12.0.1", - "predis/predis": "^2.3", - "resend/resend-php": "^0.10.0", - "symfony/cache": "^7.0.3", - "symfony/http-client": "^7.0.3", - "symfony/psr-http-message-bridge": "^7.0.3", - "symfony/translation": "^7.0.3" + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", @@ -1881,7 +1886,7 @@ "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", - "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "laravel/tinker": "Required to use the tinker console command (^2.0).", "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", @@ -1892,22 +1897,22 @@ "mockery/mockery": "Required to use mocking (^1.6).", "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", - "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.3.6|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^7.0).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)." + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "11.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -1918,6 +1923,7 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1926,7 +1932,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1950,20 +1957,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-13T20:28:00+00:00" + "time": "2025-12-23T15:29:43+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.6", + "version": "v0.3.8", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "86a8b692e8661d0fb308cec64f3d176821323077" + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", - "reference": "86a8b692e8661d0fb308cec64f3d176821323077", + "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", + "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", "shasum": "" }, "require": { @@ -1979,9 +1986,9 @@ "require-dev": { "illuminate/collections": "^10.0|^11.0|^12.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3|^3.4", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-mockery": "^1.1" + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" }, "suggest": { "ext-pcntl": "Required for the spinner to be animated." @@ -2007,22 +2014,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.6" + "source": "https://github.com/laravel/prompts/tree/v0.3.8" }, - "time": "2025-07-07T14:17:42+00:00" + "time": "2025-11-21T20:52:52+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.4", + "version": "v2.0.7", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841" + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841", - "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", "shasum": "" }, "require": { @@ -2031,7 +2038,7 @@ "require-dev": { "illuminate/support": "^10.0|^11.0|^12.0", "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", "symfony/var-dumper": "^6.2.0|^7.0.0" }, @@ -2070,20 +2077,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-03-19T13:51:03+00:00" + "time": "2025-11-21T20:52:36+00:00" }, { "name": "laravel/socialite", - "version": "v5.23.0", + "version": "v5.24.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" + "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", - "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd", + "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd", "shasum": "" }, "require": { @@ -2099,9 +2106,9 @@ }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", "phpstan/phpstan": "^1.12.23", - "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5" + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" }, "type": "library", "extra": { @@ -2142,20 +2149,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-07-23T14:16:08+00:00" + "time": "2025-12-09T15:37:06+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.1", + "version": "v2.10.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", "shasum": "" }, "require": { @@ -2206,22 +2213,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.1" + "source": "https://github.com/laravel/tinker/tree/v2.10.2" }, - "time": "2025-01-27T14:24:01+00:00" + "time": "2025-11-20T16:29:12+00:00" }, { "name": "league/commonmark", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", "shasum": "" }, "require": { @@ -2258,7 +2265,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.8-dev" + "dev-main": "2.9-dev" } }, "autoload": { @@ -2315,7 +2322,7 @@ "type": "tidelift" } ], - "time": "2025-07-20T12:47:49+00:00" + "time": "2025-11-26T21:48:24+00:00" }, { "name": "league/config", @@ -2401,16 +2408,16 @@ }, { "name": "league/flysystem", - "version": "3.30.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", "shasum": "" }, "require": { @@ -2478,22 +2485,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" }, - "time": "2025-06-25T13:29:59+00:00" + "time": "2025-11-10T17:13:11+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.29.0", + "version": "3.30.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9" + "reference": "d286e896083bed3190574b8b088b557b59eb66f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9", - "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5", + "reference": "d286e896083bed3190574b8b088b557b59eb66f5", "shasum": "" }, "require": { @@ -2533,22 +2540,22 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1" }, - "time": "2024-08-17T13:10:48+00:00" + "time": "2025-10-20T15:27:33+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", "shasum": "" }, "require": { @@ -2582,9 +2589,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" }, - "time": "2025-05-21T10:34:19+00:00" + "time": "2025-11-10T11:23:37+00:00" }, { "name": "league/html-to-markdown", @@ -2809,22 +2816,22 @@ }, { "name": "league/oauth2-client", - "version": "2.8.1", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "9df2924ca644736c835fc60466a3a60390d334f9" + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/9df2924ca644736c835fc60466a3a60390d334f9", - "reference": "9df2924ca644736c835fc60466a3a60390d334f9", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", - "php": "^7.1 || >=8.0.0 <8.5.0" + "php": "^7.1 || >=8.0.0 <8.6.0" }, "require-dev": { "mockery/mockery": "^1.3.5", @@ -2868,39 +2875,44 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.1" + "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" }, - "time": "2025-02-26T04:37:30+00:00" + "time": "2025-11-25T22:17:17+00:00" }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.7", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", "league/uri-components": "Needed to easily manipulate URI objects components", + "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2928,6 +2940,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -2940,9 +2953,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -2952,7 +2967,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.7.0" }, "funding": [ { @@ -2960,26 +2975,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2025-12-07T16:02:06+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -2987,6 +3001,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -3011,7 +3026,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -3036,7 +3051,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" }, "funding": [ { @@ -3044,7 +3059,7 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2025-12-07T16:03:21+00:00" }, { "name": "masterminds/html5", @@ -3284,16 +3299,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.2", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" + "reference": "bdb375400dcd162624531666db4799b36b64e4a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1", "shasum": "" }, "require": { @@ -3301,9 +3316,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3.12 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -3311,13 +3326,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.75.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", "kylekatarnls/multi-tester": "^2.5.3", "phpmd/phpmd": "^2.15.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", - "phpunit/phpunit": "^10.5.46", - "squizlabs/php_codesniffer": "^3.13.0" + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" }, "bin": [ "bin/carbon" @@ -3385,29 +3400,29 @@ "type": "tidelift" } ], - "time": "2025-08-02T09:36:06+00:00" + "time": "2025-12-02T21:04:28+00:00" }, { "name": "nette/schema", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3417,6 +3432,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -3445,26 +3463,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.2" + "source": "https://github.com/nette/schema/tree/v1.3.3" }, - "time": "2024-10-06T23:10:23+00:00" + "time": "2025-10-30T22:57:59+00:00" }, { "name": "nette/utils", - "version": "v4.0.8", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -3487,7 +3505,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -3534,22 +3552,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.8" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2025-08-06T21:43:34+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -3592,37 +3610,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.1", + "version": "v2.3.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", - "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.2.6" + "symfony/console": "^7.3.6" }, "require-dev": { - "illuminate/console": "^11.44.7", - "laravel/pint": "^1.22.0", + "illuminate/console": "^11.46.1", + "laravel/pint": "^1.25.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.2", - "phpstan/phpstan": "^1.12.25", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.2.6", + "symfony/var-dumper": "^7.3.5", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3665,7 +3683,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" }, "funding": [ { @@ -3681,25 +3699,25 @@ "type": "github" } ], - "time": "2025-05-08T08:14:37+00:00" + "time": "2025-11-20T02:34:59+00:00" }, { "name": "onelogin/php-saml", - "version": "4.3.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/SAML-Toolkits/php-saml.git", - "reference": "bf5efce9f2df5d489d05e78c27003a0fc8bc50f0" + "reference": "b009f160e4ac11f49366a45e0d45706b48429353" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/bf5efce9f2df5d489d05e78c27003a0fc8bc50f0", - "reference": "bf5efce9f2df5d489d05e78c27003a0fc8bc50f0", + "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/b009f160e4ac11f49366a45e0d45706b48429353", + "reference": "b009f160e4ac11f49366a45e0d45706b48429353", "shasum": "" }, "require": { "php": ">=7.3", - "robrichards/xmlseclibs": "^3.1" + "robrichards/xmlseclibs": ">=3.1.4" }, "require-dev": { "pdepend/pdepend": "^2.8.0", @@ -3745,28 +3763,30 @@ "type": "github" } ], - "time": "2025-05-25T14:28:00+00:00" + "time": "2025-12-09T10:50:49+00:00" }, { "name": "paragonie/constant_time_encoding", - "version": "v3.0.0", + "version": "v3.1.3", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", - "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", "shasum": "" }, "require": { "php": "^8" }, "require-dev": { - "phpunit/phpunit": "^9", - "vimeo/psalm": "^4|^5" + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" }, "type": "library", "autoload": { @@ -3812,7 +3832,7 @@ "issues": "https://github.com/paragonie/constant_time_encoding/issues", "source": "https://github.com/paragonie/constant_time_encoding" }, - "time": "2024-05-08T12:36:18+00:00" + "time": "2025-09-24T15:06:41+00:00" }, { "name": "paragonie/random_compat", @@ -3866,16 +3886,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.3", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3883,7 +3903,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" }, "type": "library", "extra": { @@ -3925,7 +3945,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3937,20 +3957,20 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:41:07+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.46", + "version": "3.0.48", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", - "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", "shasum": "" }, "require": { @@ -4031,7 +4051,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" }, "funding": [ { @@ -4047,20 +4067,20 @@ "type": "tidelift" } ], - "time": "2025-06-26T16:29:55+00:00" + "time": "2025-12-15T11:51:42+00:00" }, { "name": "pragmarx/google2fa", - "version": "v8.0.3", + "version": "v9.0.0", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", - "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", "shasum": "" }, "require": { @@ -4097,32 +4117,33 @@ ], "support": { "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" }, - "time": "2024-09-05T11:56:40+00:00" + "time": "2025-09-19T22:51:08+00:00" }, { "name": "predis/predis", - "version": "v2.4.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "f49e13ee3a2a825631562aa0223ac922ec5d058b" + "reference": "153097374b39a2f737fe700ebcd725642526cdec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/f49e13ee3a2a825631562aa0223ac922ec5d058b", - "reference": "f49e13ee3a2a825631562aa0223ac922ec5d058b", + "url": "https://api.github.com/repos/predis/predis/zipball/153097374b39a2f737fe700ebcd725642526cdec", + "reference": "153097374b39a2f737fe700ebcd725642526cdec", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.3", "phpstan/phpstan": "^1.9", "phpunit/phpcov": "^6.0 || ^8.0", - "phpunit/phpunit": "^8.0 || ^9.4" + "phpunit/phpunit": "^8.0 || ~9.4.4" }, "suggest": { "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" @@ -4153,7 +4174,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v2.4.0" + "source": "https://github.com/predis/predis/tree/v3.3.0" }, "funding": [ { @@ -4161,7 +4182,7 @@ "type": "github" } ], - "time": "2025-04-30T15:16:02+00:00" + "time": "2025-11-24T17:48:50+00:00" }, { "name": "psr/clock", @@ -4577,16 +4598,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.10", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", - "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -4594,18 +4615,19 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" }, "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, "bin": [ @@ -4649,9 +4671,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2025-08-04T12:39:37+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -4775,20 +4797,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4847,22 +4869,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "robrichards/xmlseclibs", - "version": "3.1.3", + "version": "3.1.4", "source": { "type": "git", "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07" + "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07", - "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/bc87389224c6de95802b505e5265b0ec2c5bcdbd", + "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd", "shasum": "" }, "require": { @@ -4889,9 +4911,9 @@ ], "support": { "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3" + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.4" }, - "time": "2024-11-20T21:13:56+00:00" + "time": "2025-12-08T11:57:53+00:00" }, { "name": "sabberworm/php-css-parser", @@ -5329,16 +5351,16 @@ }, { "name": "symfony/clock", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { @@ -5383,7 +5405,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.3.0" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -5394,25 +5416,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/console", - "version": "v7.3.2", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", + "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", "shasum": "" }, "require": { @@ -5420,7 +5446,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -5434,16 +5460,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5477,7 +5503,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.2" + "source": "https://github.com/symfony/console/tree/v7.4.1" }, "funding": [ { @@ -5497,20 +5523,20 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:13:41+00:00" + "time": "2025-12-05T15:23:39+00:00" }, { "name": "symfony/css-selector", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", "shasum": "" }, "require": { @@ -5546,7 +5572,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.0" + "source": "https://github.com/symfony/css-selector/tree/v7.4.0" }, "funding": [ { @@ -5557,12 +5583,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-10-30T13:39:42+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5633,32 +5663,33 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -5690,7 +5721,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -5710,20 +5741,20 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:57+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", "shasum": "" }, "require": { @@ -5740,13 +5771,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5774,7 +5806,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" }, "funding": [ { @@ -5785,12 +5817,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-22T09:11:45+00:00" + "time": "2025-10-28T09:38:46+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5869,24 +5905,94 @@ "time": "2024-09-25T14:21:43+00:00" }, { - "name": "symfony/finder", - "version": "v7.3.2", + "name": "symfony/filesystem", + "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "url": "https://github.com/symfony/filesystem.git", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-27T13:27:24+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5914,7 +6020,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.4.0" }, "funding": [ { @@ -5934,27 +6040,26 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-11-05T05:42:40+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.2", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -5963,13 +6068,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/clock": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5997,7 +6102,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" }, "funding": [ { @@ -6017,29 +6122,29 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-12-07T11:13:10+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.2", + "version": "v7.4.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", - "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^7.3", - "symfony/http-foundation": "^7.3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -6049,6 +6154,7 @@ "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", "symfony/form": "<6.4", "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", @@ -6066,27 +6172,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/css-selector": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^7.1", - "symfony/routing": "^6.4|^7.0", - "symfony/serializer": "^7.1", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", - "symfony/var-exporter": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "type": "library", @@ -6115,7 +6221,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" }, "funding": [ { @@ -6135,20 +6241,20 @@ "type": "tidelift" } ], - "time": "2025-07-31T10:45:04+00:00" + "time": "2025-12-08T07:43:37+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", - "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", "shasum": "" }, "require": { @@ -6156,8 +6262,8 @@ "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -6168,10 +6274,10 @@ "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6199,7 +6305,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.2" + "source": "https://github.com/symfony/mailer/tree/v7.4.0" }, "funding": [ { @@ -6219,24 +6325,25 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-11-21T15:26:00+00:00" }, { "name": "symfony/mime", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -6251,11 +6358,11 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -6287,7 +6394,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.2" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -6307,11 +6414,11 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -6370,7 +6477,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -6381,6 +6488,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -6390,16 +6501,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -6448,7 +6559,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -6459,16 +6570,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -6531,7 +6646,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -6542,6 +6657,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -6551,7 +6670,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -6612,7 +6731,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -6623,6 +6742,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -6632,7 +6755,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -6693,7 +6816,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -6704,6 +6827,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -6713,7 +6840,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -6773,7 +6900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -6784,6 +6911,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -6793,16 +6924,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -6849,7 +6980,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -6860,16 +6991,180 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -6928,7 +7223,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" }, "funding": [ { @@ -6939,6 +7234,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -6948,16 +7247,16 @@ }, { "name": "symfony/process", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", "shasum": "" }, "require": { @@ -6989,7 +7288,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.0" + "source": "https://github.com/symfony/process/tree/v7.4.0" }, "funding": [ { @@ -7000,25 +7299,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-17T09:11:12+00:00" + "time": "2025-10-16T11:21:06+00:00" }, { "name": "symfony/routing", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + "reference": "4720254cb2644a0b876233d258a32bf017330db7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", + "reference": "4720254cb2644a0b876233d258a32bf017330db7", "shasum": "" }, "require": { @@ -7032,11 +7335,11 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/yaml": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7070,7 +7373,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.2" + "source": "https://github.com/symfony/routing/tree/v7.4.0" }, "funding": [ { @@ -7090,20 +7393,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -7157,7 +7460,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -7168,31 +7471,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -7200,12 +7508,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7244,7 +7551,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.2" + "source": "https://github.com/symfony/string/tree/v7.4.0" }, "funding": [ { @@ -7264,27 +7571,27 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", - "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "url": "https://api.github.com/repos/symfony/translation/zipball/2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { "nikic/php-parser": "<5.0", @@ -7303,17 +7610,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7344,7 +7651,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.2" + "source": "https://github.com/symfony/translation/tree/v7.4.0" }, "funding": [ { @@ -7364,20 +7671,20 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:31:46+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -7426,7 +7733,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -7437,25 +7744,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/uid", - "version": "v7.3.1", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", "shasum": "" }, "require": { @@ -7463,7 +7774,7 @@ "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7500,7 +7811,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.3.1" + "source": "https://github.com/symfony/uid/tree/v7.4.0" }, "funding": [ { @@ -7511,25 +7822,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-09-25T11:02:55+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "53205bea27450dc5c65377518b3275e126d45e75" + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", - "reference": "53205bea27450dc5c65377518b3275e126d45e75", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", "shasum": "" }, "require": { @@ -7541,10 +7856,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -7583,7 +7898,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" }, "funding": [ { @@ -7603,27 +7918,27 @@ "type": "tidelift" } ], - "time": "2025-07-29T20:02:46+00:00" + "time": "2025-10-27T20:36:44+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -7656,32 +7971,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -7730,7 +8045,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -7742,7 +8057,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -7817,64 +8132,6 @@ } ], "time": "2024-11-21T01:49:47+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" - }, - "time": "2022-06-03T18:03:27+00:00" } ], "packages-dev": [ @@ -8106,16 +8363,16 @@ }, { "name": "itsgoingd/clockwork", - "version": "v5.3.4", + "version": "v5.3.5", "source": { "type": "git", "url": "https://github.com/itsgoingd/clockwork.git", - "reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5" + "reference": "d928483e231f042dbff9258795cb17aadaebc7d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/c27ad77a08a9e58bf0049de46969fa4fe3b506e5", - "reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/d928483e231f042dbff9258795cb17aadaebc7d0", + "reference": "d928483e231f042dbff9258795cb17aadaebc7d0", "shasum": "" }, "require": { @@ -8170,7 +8427,7 @@ ], "support": { "issues": "https://github.com/itsgoingd/clockwork/issues", - "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.4" + "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.5" }, "funding": [ { @@ -8178,20 +8435,20 @@ "type": "github" } ], - "time": "2025-02-09T15:57:21+00:00" + "time": "2025-09-14T15:34:49+00:00" }, { "name": "larastan/larastan", - "version": "v3.6.0", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "6431d010dd383a9279eb8874a76ddb571738564a" + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a", - "reference": "6431d010dd383a9279eb8874a76ddb571738564a", + "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", "shasum": "" }, "require": { @@ -8205,7 +8462,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1", "php": "^8.2", - "phpstan/phpstan": "^2.1.11" + "phpstan/phpstan": "^2.1.32" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8218,7 +8475,8 @@ "phpunit/phpunit": "^10.5.35 || ^11.5.15" }, "suggest": { - "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" }, "type": "phpstan-extension", "extra": { @@ -8259,7 +8517,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.6.0" + "source": "https://github.com/larastan/larastan/tree/v3.8.1" }, "funding": [ { @@ -8267,7 +8525,7 @@ "type": "github" } ], - "time": "2025-07-11T06:52:52+00:00" + "time": "2025-12-11T16:37:35+00:00" }, { "name": "mockery/mockery", @@ -8414,16 +8672,16 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.2", + "version": "v8.8.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", - "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", "shasum": "" }, "require": { @@ -8445,7 +8703,7 @@ "laravel/sanctum": "^4.1.1", "laravel/tinker": "^2.10.1", "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2", + "pestphp/pest": "^3.8.2 || ^4.0.0", "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", @@ -8509,7 +8767,7 @@ "type": "patreon" } ], - "time": "2025-06-25T02:12:12+00:00" + "time": "2025-11-20T02:55:25+00:00" }, { "name": "phar-io/manifest", @@ -8631,16 +8889,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.22", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" - }, + "version": "2.1.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", + "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", "shasum": "" }, "require": { @@ -8685,39 +8938,39 @@ "type": "github" } ], - "time": "2025-08-04T19:17:37+00:00" + "time": "2025-12-05T10:24:31+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.10", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", - "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8755,7 +9008,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -8775,7 +9028,7 @@ "type": "tidelift" } ], - "time": "2025-06-18T08:56:18+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9024,16 +9277,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.32", + "version": "11.5.46", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "101e132dcf9e74a1eb3a309b4f686114ae8f7f36" + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/101e132dcf9e74a1eb3a309b4f686114ae8f7f36", - "reference": "101e132dcf9e74a1eb3a309b4f686114ae8f7f36", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", "shasum": "" }, "require": { @@ -9047,7 +9300,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.11", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", @@ -9057,7 +9310,7 @@ "sebastian/comparator": "^6.3.2", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.0", + "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.3", @@ -9105,7 +9358,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.32" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" }, "funding": [ { @@ -9129,7 +9382,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T07:32:49+00:00" + "time": "2025-12-06T08:01:15+00:00" }, { "name": "sebastian/cli-parser", @@ -9596,16 +9849,16 @@ }, { "name": "sebastian/exporter", - "version": "6.3.0", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", - "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { @@ -9619,7 +9872,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -9662,15 +9915,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-12-05T09:17:50+00:00" + "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", @@ -10107,37 +10372,32 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "php": ">=7.2.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, "bin": [ "bin/phpcbf", "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -10156,7 +10416,7 @@ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", @@ -10187,7 +10447,7 @@ "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" + "time": "2025-11-10T16:43:36+00:00" }, { "name": "ssddanbrown/asserthtml", @@ -10287,26 +10547,27 @@ }, { "name": "symfony/dom-crawler", - "version": "v7.3.1", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "8b2ee2e06ab99fa5f067b6699296d4e35c156bb9" + "reference": "0c5e8f20c74c78172a8ee72b125909b505033597" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8b2ee2e06ab99fa5f067b6699296d4e35c156bb9", - "reference": "8b2ee2e06ab99fa5f067b6699296d4e35c156bb9", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/0c5e8f20c74c78172a8ee72b125909b505033597", + "reference": "0c5e8f20c74c78172a8ee72b125909b505033597", "shasum": "" }, "require": { "masterminds/html5": "^2.6", "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { - "symfony/css-selector": "^6.4|^7.0" + "symfony/css-selector": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -10334,7 +10595,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.3.1" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.1" }, "funding": [ { @@ -10345,25 +10606,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-15T10:07:06+00:00" + "time": "2025-12-06T15:47:47+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -10392,7 +10657,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -10400,7 +10665,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], diff --git a/crowdin.yml b/crowdin.yml index 32f2ad052..b803b07ee 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,3 +1,4 @@ +project_id: "377219" project_identifier: bookstack base_path: . preserve_hierarchy: false diff --git a/database/factories/Access/Mfa/MfaValueFactory.php b/database/factories/Access/Mfa/MfaValueFactory.php new file mode 100644 index 000000000..03036b608 --- /dev/null +++ b/database/factories/Access/Mfa/MfaValueFactory.php @@ -0,0 +1,28 @@ + + */ +class MfaValueFactory extends Factory +{ + protected $model = \BookStack\Access\Mfa\MfaValue::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'method' => 'totp', + 'value' => '123456', + ]; + } +} diff --git a/database/factories/Access/SocialAccountFactory.php b/database/factories/Access/SocialAccountFactory.php new file mode 100644 index 000000000..814f47b58 --- /dev/null +++ b/database/factories/Access/SocialAccountFactory.php @@ -0,0 +1,29 @@ + + */ +class SocialAccountFactory extends Factory +{ + protected $model = \BookStack\Access\SocialAccount::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'driver' => 'github', + 'driver_id' => '123456', + 'avatar' => '', + ]; + } +} diff --git a/database/factories/Activity/Models/ActivityFactory.php b/database/factories/Activity/Models/ActivityFactory.php new file mode 100644 index 000000000..06ec07ced --- /dev/null +++ b/database/factories/Activity/Models/ActivityFactory.php @@ -0,0 +1,34 @@ + + */ +class ActivityFactory extends Factory +{ + protected $model = \BookStack\Activity\Models\Activity::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $activities = ActivityType::all(); + $activity = $activities[array_rand($activities)]; + return [ + 'type' => $activity, + 'detail' => 'Activity detail for ' . $activity, + 'user_id' => User::factory(), + 'ip' => $this->faker->ipv4(), + 'loggable_id' => null, + 'loggable_type' => null, + ]; + } +} diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 844bc3993..2b7fb9ac9 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories\Activity\Models; +use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class CommentFactory extends Factory @@ -13,6 +14,11 @@ class CommentFactory extends Factory */ protected $model = \BookStack\Activity\Models\Comment::class; + /** + * A static counter to provide a unique local_id for each comment. + */ + protected static int $nextLocalId = 1000; + /** * Define the model's default state. * @@ -22,13 +28,18 @@ class CommentFactory extends Factory { $text = $this->faker->paragraph(1); $html = '

' . $text . '

'; + $nextLocalId = static::$nextLocalId++; + + $user = User::query()->first(); return [ 'html' => $html, 'parent_id' => null, - 'local_id' => 1, + 'local_id' => $nextLocalId, 'content_ref' => '', 'archived' => false, + 'created_by' => $user ?? User::factory(), + 'updated_by' => $user ?? User::factory(), ]; } } diff --git a/database/factories/Activity/Models/FavouriteFactory.php b/database/factories/Activity/Models/FavouriteFactory.php new file mode 100644 index 000000000..75fba86b3 --- /dev/null +++ b/database/factories/Activity/Models/FavouriteFactory.php @@ -0,0 +1,31 @@ + + */ +class FavouriteFactory extends Factory +{ + protected $model = \BookStack\Activity\Models\Favourite::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $book = Book::query()->first(); + + return [ + 'user_id' => User::factory(), + 'favouritable_id' => $book->id, + 'favouritable_type' => 'book', + ]; + } +} diff --git a/database/factories/Activity/Models/WatchFactory.php b/database/factories/Activity/Models/WatchFactory.php new file mode 100644 index 000000000..0f8b9e6f7 --- /dev/null +++ b/database/factories/Activity/Models/WatchFactory.php @@ -0,0 +1,33 @@ + + */ +class WatchFactory extends Factory +{ + protected $model = \BookStack\Activity\Models\Watch::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $book = Book::factory()->create(); + + return [ + 'user_id' => User::factory(), + 'watchable_id' => $book->id, + 'watchable_type' => 'book', + 'level' => WatchLevels::NEW, + ]; + } +} diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php index 1fc49933e..abf554ac8 100644 --- a/database/factories/Entities/Models/ChapterFactory.php +++ b/database/factories/Entities/Models/ChapterFactory.php @@ -26,7 +26,8 @@ class ChapterFactory extends Factory 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

' . e($description) . '

' + 'description_html' => '

' . e($description) . '

', + 'priority' => 5, ]; } } diff --git a/database/factories/Entities/Models/DeletionFactory.php b/database/factories/Entities/Models/DeletionFactory.php new file mode 100644 index 000000000..2368447cd --- /dev/null +++ b/database/factories/Entities/Models/DeletionFactory.php @@ -0,0 +1,29 @@ + + */ +class DeletionFactory extends Factory +{ + protected $model = \BookStack\Entities\Models\Deletion::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'deleted_by' => User::factory(), + 'deletable_id' => Page::factory(), + 'deletable_type' => 'page', + ]; + } +} diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php index 811570095..538ee5f3e 100644 --- a/database/factories/Entities/Models/PageFactory.php +++ b/database/factories/Entities/Models/PageFactory.php @@ -17,10 +17,8 @@ class PageFactory extends Factory /** * Define the model's default state. - * - * @return array */ - public function definition() + public function definition(): array { $html = '

' . implode('

', $this->faker->paragraphs(5)) . '

'; @@ -31,6 +29,7 @@ class PageFactory extends Factory 'text' => strip_tags($html), 'revision_count' => 1, 'editor' => 'wysiwyg', + 'priority' => 1, ]; } } diff --git a/database/factories/Entities/Models/PageRevisionFactory.php b/database/factories/Entities/Models/PageRevisionFactory.php new file mode 100644 index 000000000..333916931 --- /dev/null +++ b/database/factories/Entities/Models/PageRevisionFactory.php @@ -0,0 +1,40 @@ +' . implode('

', $this->faker->paragraphs(5)) . '

'; + $page = Page::query()->first(); + + return [ + 'page_id' => $page->id, + 'name' => $this->faker->sentence(), + 'html' => $html, + 'text' => strip_tags($html), + 'created_by' => User::factory(), + 'slug' => $page->slug, + 'book_slug' => $page->book->slug, + 'type' => 'version', + 'markdown' => strip_tags($html), + 'summary' => $this->faker->sentence(), + 'revision_number' => rand(1, 4000), + ]; + } +} diff --git a/database/factories/Entities/Models/SlugHistoryFactory.php b/database/factories/Entities/Models/SlugHistoryFactory.php new file mode 100644 index 000000000..c8c57e09c --- /dev/null +++ b/database/factories/Entities/Models/SlugHistoryFactory.php @@ -0,0 +1,29 @@ + + */ +class SlugHistoryFactory extends Factory +{ + protected $model = \BookStack\Entities\Models\SlugHistory::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'sluggable_id' => Book::factory(), + 'sluggable_type' => 'book', + 'slug' => $this->faker->slug(), + 'parent_slug' => null, + ]; + } +} diff --git a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php index 0e25c1d60..a8f1843ed 100644 --- a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php +++ b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php @@ -25,9 +25,6 @@ return new class extends Migration $table->unsignedInteger('owner_id')->nullable()->index(); }); } - - // Rebuild permissions - app(JointPermissionBuilder::class)->rebuildForAll(); } /** diff --git a/database/migrations/2025_09_02_111542_remove_unused_columns.php b/database/migrations/2025_09_02_111542_remove_unused_columns.php new file mode 100644 index 000000000..3a36e5ad6 --- /dev/null +++ b/database/migrations/2025_09_02_111542_remove_unused_columns.php @@ -0,0 +1,38 @@ +dropColumn('text'); + }); + + Schema::table('role_permissions', function (Blueprint $table) { + $table->dropColumn('display_name'); + $table->dropColumn('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->longText('text')->nullable(); + }); + + Schema::table('role_permissions', function (Blueprint $table) { + $table->string('display_name')->nullable(); + $table->string('description')->nullable(); + }); + } +}; diff --git a/database/migrations/2025_09_15_132850_create_entities_table.php b/database/migrations/2025_09_15_132850_create_entities_table.php new file mode 100644 index 000000000..6c890d719 --- /dev/null +++ b/database/migrations/2025_09_15_132850_create_entities_table.php @@ -0,0 +1,71 @@ +bigIncrements('id'); + $table->string('type', 10)->index(); + $table->string('name'); + $table->string('slug')->index(); + + $table->unsignedBigInteger('book_id')->nullable()->index(); + $table->unsignedBigInteger('chapter_id')->nullable()->index(); + $table->unsignedInteger('priority')->nullable(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable()->index(); + $table->timestamp('deleted_at')->nullable()->index(); + + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->unsignedInteger('owned_by')->nullable()->index(); + + $table->primary(['id', 'type'], 'entities_pk'); + }); + + Schema::create('entity_container_data', function (Blueprint $table) { + $table->unsignedBigInteger('entity_id'); + $table->string('entity_type', 10); + $table->text('description'); + $table->text('description_html'); + + $table->unsignedBigInteger('default_template_id')->nullable(); + $table->unsignedInteger('image_id')->nullable(); + $table->unsignedInteger('sort_rule_id')->nullable(); + + $table->primary(['entity_id', 'entity_type'], 'entity_container_data_pk'); + }); + + Schema::create('entity_page_data', function (Blueprint $table) { + $table->unsignedBigInteger('page_id')->primary(); + + $table->boolean('draft')->index(); + $table->boolean('template')->index(); + $table->unsignedInteger('revision_count'); + $table->string('editor', 50); + + $table->longText('html'); + $table->longText('text'); + $table->longText('markdown'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entities'); + Schema::dropIfExists('entity_container_data'); + Schema::dropIfExists('entity_page_data'); + } +}; diff --git a/database/migrations/2025_09_15_134701_migrate_entity_data.php b/database/migrations/2025_09_15_134701_migrate_entity_data.php new file mode 100644 index 000000000..7b4beef06 --- /dev/null +++ b/database/migrations/2025_09_15_134701_migrate_entity_data.php @@ -0,0 +1,90 @@ + 'book', 'bookshelves' => 'bookshelf'] as $table => $type) { + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table($table)->select([ + 'id', DB::raw("'{$type}'"), 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + } + + // Migrate chapter data to entities + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table('chapters')->select([ + 'id', DB::raw("'chapter'"), 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table('pages')->select([ + 'id', DB::raw("'page'"), 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + + // Migrate shelf data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'image_id', + ], DB::table('bookshelves')->select([ + 'id', DB::raw("'bookshelf'"), 'description', 'description_html', 'image_id', + ])); + + // Migrate book data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id' + ], DB::table('books')->select([ + 'id', DB::raw("'book'"), 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id' + ])); + + // Migrate chapter data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', + ], DB::table('chapters')->select([ + 'id', DB::raw("'chapter'"), 'description', 'description_html', 'default_template_id', + ])); + + // Migrate page data to entity_page_data + DB::table('entity_page_data')->insertUsing([ + 'page_id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown', + ], DB::table('pages')->select([ + 'id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown', + ])); + + // Fix up data - Convert 0 id references to null + DB::table('entities')->where('created_by', '=', 0)->update(['created_by' => null]); + DB::table('entities')->where('updated_by', '=', 0)->update(['updated_by' => null]); + DB::table('entities')->where('owned_by', '=', 0)->update(['owned_by' => null]); + DB::table('entities')->where('chapter_id', '=', 0)->update(['chapter_id' => null]); + + // Fix up data - Convert any missing id-based references to null + $userIdQuery = DB::table('users')->select('id'); + DB::table('entities')->whereNotIn('created_by', $userIdQuery)->update(['created_by' => null]); + DB::table('entities')->whereNotIn('updated_by', $userIdQuery)->update(['updated_by' => null]); + DB::table('entities')->whereNotIn('owned_by', $userIdQuery)->update(['owned_by' => null]); + DB::table('entities')->whereNotIn('chapter_id', DB::table('chapters')->select('id'))->update(['chapter_id' => null]); + + // Commit our changes within our transaction + DB::commit(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No action here since the actual data remains in the database for the old tables, + // so data reversion actions are done in a later migration when the old tables are dropped. + } +}; diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php new file mode 100644 index 000000000..f8622d7c6 --- /dev/null +++ b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php @@ -0,0 +1,125 @@ +> $columnByTable + */ + protected static array $columnByTable = [ + 'activities' => 'loggable_id', + 'attachments' => 'uploaded_to', + 'bookshelves_books' => ['bookshelf_id', 'book_id'], + 'comments' => 'entity_id', + 'deletions' => 'deletable_id', + 'entity_permissions' => 'entity_id', + 'favourites' => 'favouritable_id', + 'images' => 'uploaded_to', + 'joint_permissions' => 'entity_id', + 'page_revisions' => 'page_id', + 'references' => ['from_id', 'to_id'], + 'search_terms' => 'entity_id', + 'tags' => 'entity_id', + 'views' => 'viewable_id', + 'watches' => 'watchable_id', + ]; + + protected static array $nullable = [ + 'activities.loggable_id', + 'images.uploaded_to', + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + // Drop foreign key constraints + Schema::table('bookshelves_books', function (Blueprint $table) { + $table->dropForeign(['book_id']); + $table->dropForeign(['bookshelf_id']); + }); + + // Update column types to unsigned big integers + foreach (static::$columnByTable as $table => $column) { + $tableName = $table; + Schema::table($table, function (Blueprint $table) use ($tableName, $column) { + if (is_string($column)) { + $column = [$column]; + } + + foreach ($column as $col) { + if (in_array($tableName . '.' . $col, static::$nullable)) { + $table->unsignedBigInteger($col)->nullable()->change(); + } else { + $table->unsignedBigInteger($col)->change(); + } + } + }); + } + + // Convert image and activity zero values to null + DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]); + DB::table('activities')->where('loggable_id', '=', 0)->update(['loggable_id' => null]); + + // Clean up any orphaned gallery/drawio images to nullify their page relation + DB::table('images') + ->whereIn('type', ['gallery', 'drawio']) + ->whereNotIn('uploaded_to', function (Builder $query) { + $query->select('id') + ->from('entities') + ->where('type', '=', 'page'); + })->update(['uploaded_to' => null]); + + // Rebuild joint permissions if needed + // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes + // made for this release would mean our current logic would not be compatible with + // the database changes being made. This is based on a count since any joint permissions + // would have been truncated in the previous migration. + if (\Illuminate\Support\Facades\DB::table('joint_permissions')->count() === 0) { + app(JointPermissionBuilder::class)->rebuildForAll(); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Convert image null values back to zeros + DB::table('images')->whereNull('uploaded_to')->update(['uploaded_to' => '0']); + + // Revert columns to standard integers + foreach (static::$columnByTable as $table => $column) { + $tableName = $table; + Schema::table($table, function (Blueprint $table) use ($tableName, $column) { + if (is_string($column)) { + $column = [$column]; + } + + foreach ($column as $col) { + if ($tableName . '.' . $col === 'activities.loggable_id') { + $table->unsignedInteger($col)->nullable()->change(); + } else if ($tableName . '.' . $col === 'images.uploaded_to') { + $table->unsignedInteger($col)->default(0)->change(); + } else { + $table->unsignedInteger($col)->change(); + } + } + }); + } + + // Re-add foreign key constraints + Schema::table('bookshelves_books', function (Blueprint $table) { + $table->foreign('bookshelf_id')->references('id')->on('bookshelves') + ->onUpdate('cascade')->onDelete('cascade'); + $table->foreign('book_id')->references('id')->on('books') + ->onUpdate('cascade')->onDelete('cascade'); + }); + } +}; diff --git a/database/migrations/2025_09_15_134813_drop_old_entity_tables.php b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php new file mode 100644 index 000000000..d6360f74d --- /dev/null +++ b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php @@ -0,0 +1,162 @@ +unsignedInteger('id', true)->primary(); + $table->integer('book_id')->index(); + $table->integer('chapter_id')->index(); + $table->string('name'); + $table->string('slug')->index(); + $table->longText('html'); + $table->longText('text'); + $table->integer('priority')->index(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable()->index(); + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->boolean('draft')->default(0)->index(); + $table->longText('markdown'); + $table->integer('revision_count'); + $table->boolean('template')->default(0)->index(); + $table->timestamp('deleted_at')->nullable(); + + $table->unsignedInteger('owned_by')->index(); + $table->string('editor', 50)->default(''); + }); + + Schema::create('chapters', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->integer('book_id')->index(); + $table->string('slug')->index(); + $table->text('name'); + $table->text('description'); + $table->integer('priority')->index(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('owned_by')->index(); + $table->text('description_html'); + $table->integer('default_template_id')->nullable(); + }); + + Schema::create('books', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->string('name'); + $table->string('slug')->index(); + $table->text('description'); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->integer('image_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('owned_by')->index(); + + $table->integer('default_template_id')->nullable(); + $table->text('description_html'); + $table->unsignedInteger('sort_rule_id')->nullable(); + }); + + Schema::create('bookshelves', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->string('name', 180); + $table->string('slug', 180)->index(); + $table->text('description'); + + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + $table->integer('image_id')->nullable(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + + $table->unsignedInteger('owned_by')->index(); + $table->text('description_html'); + }); + + DB::beginTransaction(); + + // Revert nulls back to zeros + DB::table('entities')->whereNull('created_by')->update(['created_by' => 0]); + DB::table('entities')->whereNull('updated_by')->update(['updated_by' => 0]); + DB::table('entities')->whereNull('owned_by')->update(['owned_by' => 0]); + DB::table('entities')->whereNull('chapter_id')->update(['chapter_id' => 0]); + + // Restore data back into pages table + $pageFields = [ + 'id', 'book_id', 'chapter_id', 'name', 'slug', 'html', 'text', 'priority', 'created_at', 'updated_at', + 'created_by', 'updated_by', 'draft', 'markdown', 'revision_count', 'template', 'deleted_at', 'owned_by', 'editor' + ]; + $pageQuery = DB::table('entities')->select($pageFields) + ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') + ->where('type', '=', 'page'); + DB::table('pages')->insertUsing($pageFields, $pageQuery); + + // Restore data back into chapters table + $containerJoinClause = function (JoinClause $join) { + return $join->on('entities.id', '=', 'entity_container_data.entity_id') + ->on('entities.type', '=', 'entity_container_data.entity_type'); + }; + $chapterFields = [ + 'id', 'book_id', 'slug', 'name', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by', + 'deleted_at', 'owned_by', 'description_html', 'default_template_id' + ]; + $chapterQuery = DB::table('entities')->select($chapterFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'chapter'); + DB::table('chapters')->insertUsing($chapterFields, $chapterQuery); + + // Restore data back into books table + $bookFields = [ + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id', + 'deleted_at', 'owned_by', 'default_template_id', 'description_html', 'sort_rule_id' + ]; + $bookQuery = DB::table('entities')->select($bookFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'book'); + DB::table('books')->insertUsing($bookFields, $bookQuery); + + // Restore data back into bookshelves table + $shelfFields = [ + 'id', 'name', 'slug', 'description', 'created_by', 'updated_by', 'image_id', 'created_at', 'updated_at', + 'deleted_at', 'owned_by', 'description_html', + ]; + $shelfQuery = DB::table('entities')->select($shelfFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'bookshelf'); + DB::table('bookshelves')->insertUsing($shelfFields, $shelfQuery); + + DB::commit(); + } +}; diff --git a/database/migrations/2025_10_18_163331_clean_user_id_references.php b/database/migrations/2025_10_18_163331_clean_user_id_references.php new file mode 100644 index 000000000..42e670139 --- /dev/null +++ b/database/migrations/2025_10_18_163331_clean_user_id_references.php @@ -0,0 +1,76 @@ + ['created_by', 'updated_by'], + 'comments' => ['created_by', 'updated_by'], + 'deletions' => ['deleted_by'], + 'entities' => ['created_by', 'updated_by', 'owned_by'], + 'images' => ['created_by', 'updated_by'], + 'imports' => ['created_by'], + 'joint_permissions' => ['owner_id'], + 'page_revisions' => ['created_by'], + ]; + + protected static array $toClean = [ + 'api_tokens' => ['user_id'], + 'email_confirmations' => ['user_id'], + 'favourites' => ['user_id'], + 'mfa_values' => ['user_id'], + 'role_user' => ['user_id'], + 'sessions' => ['user_id'], + 'social_accounts' => ['user_id'], + 'user_invites' => ['user_id'], + 'views' => ['user_id'], + 'watches' => ['user_id'], + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + $idSelectQuery = DB::table('users')->select('id'); + + foreach (self::$toNullify as $tableName => $columns) { + Schema::table($tableName, function (Blueprint $table) use ($columns) { + foreach ($columns as $columnName) { + $table->unsignedInteger($columnName)->nullable()->change(); + } + }); + + foreach ($columns as $columnName) { + DB::table($tableName)->where($columnName, '=', 0)->update([$columnName => null]); + DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->update([$columnName => null]); + } + } + + foreach (self::$toClean as $tableName => $columns) { + foreach ($columns as $columnName) { + DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->delete(); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::$toNullify as $tableName => $columns) { + foreach ($columns as $columnName) { + DB::table($tableName)->whereNull($columnName)->update([$columnName => 0]); + } + + Schema::table($tableName, function (Blueprint $table) use ($columns) { + foreach ($columns as $columnName) { + $table->unsignedInteger($columnName)->nullable(false)->change(); + } + }); + } + } +}; diff --git a/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php new file mode 100644 index 000000000..de13453de --- /dev/null +++ b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php @@ -0,0 +1,30 @@ +renameColumn('entity_id', 'commentable_id'); + $table->renameColumn('entity_type', 'commentable_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->renameColumn('commentable_id', 'entity_id'); + $table->renameColumn('commentable_type', 'entity_type'); + }); + } +}; diff --git a/database/migrations/2025_11_23_161812_create_slug_history_table.php b/database/migrations/2025_11_23_161812_create_slug_history_table.php new file mode 100644 index 000000000..df30bf030 --- /dev/null +++ b/database/migrations/2025_11_23_161812_create_slug_history_table.php @@ -0,0 +1,51 @@ +increments('id'); + $table->string('sluggable_type', 10)->index(); + $table->unsignedBigInteger('sluggable_id')->index(); + $table->string('slug')->index(); + $table->string('parent_slug')->nullable()->index(); + $table->timestamps(); + }); + + // Migrate in slugs from page revisions + $revisionSlugQuery = DB::table('page_revisions') + ->select([ + DB::raw('\'page\' as sluggable_type'), + 'page_id as sluggable_id', + 'slug', + 'book_slug as parent_slug', + DB::raw('min(created_at) as created_at'), + DB::raw('min(updated_at) as updated_at'), + ]) + ->where('type', '=', 'version') + ->groupBy(['sluggable_id', 'slug', 'parent_slug']); + + DB::table('slug_history')->insertUsing( + ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'], + $revisionSlugQuery, + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('slug_history'); + } +}; diff --git a/database/migrations/2025_12_15_140219_create_mention_history_table.php b/database/migrations/2025_12_15_140219_create_mention_history_table.php new file mode 100644 index 000000000..ff7b6a981 --- /dev/null +++ b/database/migrations/2025_12_15_140219_create_mention_history_table.php @@ -0,0 +1,31 @@ +increments('id'); + $table->string('mentionable_type', 50)->index(); + $table->unsignedBigInteger('mentionable_id')->index(); + $table->unsignedInteger('from_user_id'); + $table->unsignedInteger('to_user_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mention_history'); + } +}; diff --git a/database/migrations/2025_12_19_103417_add_views_viewable_type_index.php b/database/migrations/2025_12_19_103417_add_views_viewable_type_index.php new file mode 100644 index 000000000..38d1cb450 --- /dev/null +++ b/database/migrations/2025_12_19_103417_add_views_viewable_type_index.php @@ -0,0 +1,28 @@ +index('viewable_type', 'views_viewable_type_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('views', function (Blueprint $table) { + $table->dropIndex('views_viewable_type_index'); + }); + } +}; diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index a4383be50..845bea8e4 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -12,7 +12,9 @@ use BookStack\Permissions\Models\RolePermission; use BookStack\Search\SearchIndex; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Seeder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -20,10 +22,8 @@ class DummyContentSeeder extends Seeder { /** * Run the database seeds. - * - * @return void */ - public function run() + public function run(): void { // Create an editor user $editorUser = User::factory()->create(); @@ -39,40 +39,58 @@ class DummyContentSeeder extends Seeder $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id]; - Book::factory()->count(5)->create($byData) + Book::factory()->count(5)->make($byData) ->each(function ($book) use ($byData) { + $book->save(); $chapters = Chapter::factory()->count(3)->create($byData) ->each(function ($chapter) use ($book, $byData) { $pages = Page::factory()->count(3)->make(array_merge($byData, ['book_id' => $book->id])); - $chapter->pages()->saveMany($pages); + $this->saveManyOnRelation($pages, $chapter->pages()); }); $pages = Page::factory()->count(3)->make($byData); - $book->chapters()->saveMany($chapters); - $book->pages()->saveMany($pages); + $this->saveManyOnRelation($chapters, $book->chapters()); + $this->saveManyOnRelation($pages, $book->pages()); }); - $largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)])); + $largeBook = Book::factory()->make(array_merge($byData, ['name' => 'Large book' . Str::random(10)])); + $largeBook->save(); + $pages = Page::factory()->count(200)->make($byData); $chapters = Chapter::factory()->count(50)->make($byData); - $largeBook->pages()->saveMany($pages); - $largeBook->chapters()->saveMany($chapters); + $this->saveManyOnRelation($pages, $largeBook->pages()); + $this->saveManyOnRelation($chapters, $largeBook->chapters()); + + $shelves = Bookshelf::factory()->count(10)->make($byData); + foreach ($shelves as $shelf) { + $shelf->save(); + } - $shelves = Bookshelf::factory()->count(10)->create($byData); $largeBook->shelves()->attach($shelves->pluck('id')); // Assign API permission to editor role and create an API key $apiPermission = RolePermission::getByName('access-api'); $editorRole->attachPermission($apiPermission); $token = (new ApiToken())->forceFill([ - 'user_id' => $editorUser->id, - 'name' => 'Testing API key', + 'user_id' => $editorUser->id, + 'name' => 'Testing API key', 'expires_at' => ApiToken::defaultExpiry(), - 'secret' => Hash::make('password'), - 'token_id' => 'apitoken', + 'secret' => Hash::make('password'), + 'token_id' => 'apitoken', ]); $token->save(); app(JointPermissionBuilder::class)->rebuildForAll(); app(SearchIndex::class)->indexAllEntities(); } + + /** + * Inefficient workaround for saving many on a relation since we can't directly insert + * entities since we split them across tables. + */ + protected function saveManyOnRelation(Collection $entities, HasMany $relation): void + { + foreach ($entities as $entity) { + $relation->save($entity); + } + } } diff --git a/dev/api/requests/comments-create.json b/dev/api/requests/comments-create.json new file mode 100644 index 000000000..abc6832d8 --- /dev/null +++ b/dev/api/requests/comments-create.json @@ -0,0 +1,5 @@ +{ + "page_id": 2646, + "html": "

Can the title be updated?

", + "content_ref": "bkmrk-page-title:7341676876991010:3-14" +} \ No newline at end of file diff --git a/dev/api/requests/comments-update.json b/dev/api/requests/comments-update.json new file mode 100644 index 000000000..bbcf114f3 --- /dev/null +++ b/dev/api/requests/comments-update.json @@ -0,0 +1,4 @@ +{ + "html": "

Can this comment be updated??????

", + "archived": true +} \ No newline at end of file diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-readDataForUrl.http new file mode 100644 index 000000000..1892600f4 --- /dev/null +++ b/dev/api/requests/image-gallery-readDataForUrl.http @@ -0,0 +1 @@ +GET /api/image-gallery/url/data?url=https%3A%2F%2Fbookstack.example.com%2Fuploads%2Fimages%2Fgallery%2F2025-10%2Fmy-image.png diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index afeebade6..582744f99 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -52,7 +52,7 @@ "name": "Cool Animals", "slug": "cool-animals", "book_id": 16, - "chapter_id": 0, + "chapter_id": null, "draft": false, "template": false, "created_at": "2021-12-19T18:22:11.000000Z", diff --git a/dev/api/responses/comments-create.json b/dev/api/responses/comments-create.json new file mode 100644 index 000000000..6fec5c101 --- /dev/null +++ b/dev/api/responses/comments-create.json @@ -0,0 +1,13 @@ +{ + "id": 167, + "commentable_id": 2646, + "commentable_type": "page", + "parent_id": null, + "local_id": 29, + "created_by": 1, + "updated_by": 1, + "created_at": "2025-10-24T14:05:41.000000Z", + "updated_at": "2025-10-24T14:05:41.000000Z", + "content_ref": "bkmrk-page-title:7341676876991010:3-14", + "archived": false +} \ No newline at end of file diff --git a/dev/api/responses/comments-list.json b/dev/api/responses/comments-list.json new file mode 100644 index 000000000..a0f638120 --- /dev/null +++ b/dev/api/responses/comments-list.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "id": 1, + "commentable_id": 2607, + "commentable_type": "page", + "parent_id": null, + "local_id": 1, + "content_ref": "", + "created_by": 1, + "updated_by": 1, + "created_at": "2022-04-20T08:43:27.000000Z", + "updated_at": "2022-04-20T08:43:27.000000Z" + }, + { + "id": 18, + "commentable_id": 2607, + "commentable_type": "page", + "parent_id": 1, + "local_id": 2, + "content_ref": "", + "created_by": 3, + "updated_by": 3, + "created_at": "2022-11-15T08:12:35.000000Z", + "updated_at": "2022-11-15T08:12:35.000000Z" + } + ], + "total": 88 +} \ No newline at end of file diff --git a/dev/api/responses/comments-read.json b/dev/api/responses/comments-read.json new file mode 100644 index 000000000..054b8ad19 --- /dev/null +++ b/dev/api/responses/comments-read.json @@ -0,0 +1,38 @@ +{ + "id": 22, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

This page looks great!<\/p>\n", + "parent_id": null, + "local_id": 2, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "created_at": "2023-06-07T07:50:56.000000Z", + "updated_at": "2023-06-07T07:50:56.000000Z", + "content_ref": "", + "archived": false, + "replies": [ + { + "id": 34, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

Thanks for the comment!<\/p>\n", + "parent_id": 2, + "local_id": 10, + "created_by": 2, + "updated_by": 2, + "created_at": "2023-06-07T13:46:25.000000Z", + "updated_at": "2023-06-07T13:46:25.000000Z", + "content_ref": "", + "archived": false + } + ] +} \ No newline at end of file diff --git a/dev/api/responses/comments-update.json b/dev/api/responses/comments-update.json new file mode 100644 index 000000000..ce5ed2644 --- /dev/null +++ b/dev/api/responses/comments-update.json @@ -0,0 +1,13 @@ +{ + "id": 167, + "commentable_id": 2646, + "commentable_type": "page", + "parent_id": null, + "local_id": 29, + "created_by": 1, + "updated_by": 1, + "created_at": "2025-10-24T14:05:41.000000Z", + "updated_at": "2025-10-24T14:09:56.000000Z", + "content_ref": "bkmrk-page-title:7341676876991010:3-14", + "archived": true +} \ No newline at end of file diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json index 11f5ab8c8..705dea6f6 100644 --- a/dev/api/responses/pages-create.json +++ b/dev/api/responses/pages-create.json @@ -1,7 +1,7 @@ { "id": 358, "book_id": 1, - "chapter_id": 0, + "chapter_id": null, "name": "My API Page", "slug": "my-api-page", "html": "

my new API page

", diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json index 2f3538964..e38a9cf92 100644 --- a/dev/api/responses/pages-read.json +++ b/dev/api/responses/pages-read.json @@ -1,7 +1,7 @@ { "id": 306, "book_id": 1, - "chapter_id": 0, + "chapter_id": null, "name": "A page written in markdown", "slug": "a-page-written-in-markdown", "html": "

This is my cool page! With some included text

", @@ -29,6 +29,79 @@ "revision_count": 5, "template": false, "editor": "wysiwyg", + "comments": { + "active": [ + { + "comment": { + "id": 22, + "commentable_id": 306, + "commentable_type": "page", + "html": "

Does this need revising?<\/p>\n", + "parent_id": null, + "local_id": 1, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": 1, + "created_at": "2023-06-07T07:50:56.000000Z", + "updated_at": "2023-06-07T07:50:56.000000Z", + "content_ref": "", + "archived": false + }, + "depth": 0, + "children": [ + { + "comment": { + "id": 34, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

I think it's okay!<\/p>\n", + "parent_id": 1, + "local_id": 2, + "created_by": { + "id": 2, + "name": "Editor", + "slug": "editor" + }, + "updated_by": 1, + "created_at": "2023-06-07T13:46:25.000000Z", + "updated_at": "2023-06-07T13:46:25.000000Z", + "content_ref": "", + "archived": false + }, + "depth": 1, + "children": [] + } + ] + } + ], + "archived": [ + { + "comment": { + "id": 21, + "commentable_id": 2646, + "commentable_type": "page", + "html": "

The title needs to be fixed<\/p>\n", + "parent_id": null, + "local_id": 3, + "created_by": { + "id": 2, + "name": "Editor", + "slug": "editor" + }, + "updated_by": 1, + "created_at": "2023-06-07T07:50:49.000000Z", + "updated_at": "2025-10-24T08:37:22.000000Z", + "content_ref": "", + "archived": true + }, + "depth": 0, + "children": [] + } + ] + }, "tags": [ { "name": "Category", diff --git a/dev/api/responses/recycle-bin-list.json b/dev/api/responses/recycle-bin-list.json index 853070839..19167ec05 100644 --- a/dev/api/responses/recycle-bin-list.json +++ b/dev/api/responses/recycle-bin-list.json @@ -10,7 +10,7 @@ "deletable": { "id": 2582, "book_id": 25, - "chapter_id": 0, + "chapter_id": null, "name": "A Wonderful Page", "slug": "a-wonderful-page", "priority": 9, diff --git a/dev/build/esbuild.js b/dev/build/esbuild.mjs similarity index 52% rename from dev/build/esbuild.js rename to dev/build/esbuild.mjs index 63387d612..fa2231157 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.mjs @@ -1,12 +1,15 @@ #!/usr/bin/env node -const esbuild = require('esbuild'); -const path = require('path'); -const fs = require('fs'); +import * as esbuild from 'esbuild'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as process from "node:process"; // Check if we're building for production // (Set via passing `production` as first argument) -const isProd = process.argv[2] === 'production'; +const mode = process.argv[2]; +const isProd = mode === 'production'; +const __dirname = import.meta.dirname; // Gather our input files const entryPoints = { @@ -17,11 +20,16 @@ const entryPoints = { wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'), }; +// Watch styles so we can reload on change +if (mode === 'watch') { + entryPoints['styles-dummy'] = path.join(__dirname, '../../public/dist/styles.css'); +} + // Locate our output directory const outdir = path.join(__dirname, '../../public/dist'); -// Build via esbuild -esbuild.build({ +// Define the options for esbuild +const options = { bundle: true, metafile: true, entryPoints, @@ -33,6 +41,7 @@ esbuild.build({ minify: isProd, logLevel: 'info', loader: { + '.html': 'copy', '.svg': 'text', }, absWorkingDir: path.join(__dirname, '../..'), @@ -45,6 +54,34 @@ esbuild.build({ js: '// See the "/licenses" URI for full package license details', css: '/* See the "/licenses" URI for full package license details */', }, -}).then(result => { +}; + +if (mode === 'watch') { + options.inject = [ + path.join(__dirname, './livereload.js'), + ]; +} + +const ctx = await esbuild.context(options); + +if (mode === 'watch') { + // Watch for changes and rebuild on change + ctx.watch({}); + let {hosts, port} = await ctx.serve({ + servedir: path.join(__dirname, '../../public'), + cors: { + origin: '*', + } + }); +} else { + // Build with meta output for analysis + const result = await ctx.rebuild(); + const outputs = result.metafile.outputs; + const files = Object.keys(outputs); + for (const file of files) { + const output = outputs[file]; + console.log(`Written: ${file} @ ${Math.round(output.bytes / 1000)}kB`); + } fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile)); -}).catch(() => process.exit(1)); + process.exit(0); +} diff --git a/dev/build/livereload.js b/dev/build/livereload.js new file mode 100644 index 000000000..c2d8ac620 --- /dev/null +++ b/dev/build/livereload.js @@ -0,0 +1,35 @@ +if (!window.__dev_reload_listening) { + listen(); + window.__dev_reload_listening = true; +} + + +function listen() { + console.log('Listening for livereload events...'); + new EventSource("http://127.0.0.1:8000/esbuild").addEventListener('change', e => { + const { added, removed, updated } = JSON.parse(e.data); + + if (!added.length && !removed.length && updated.length > 0) { + const updatedPath = updated.filter(path => path.endsWith('.css'))[0] + if (!updatedPath) return; + + const links = [...document.querySelectorAll("link[rel='stylesheet']")]; + for (const link of links) { + const url = new URL(link.href); + const name = updatedPath.replace('-dummy', ''); + + if (url.pathname.endsWith(name)) { + const next = link.cloneNode(); + next.href = name + '?version=' + Math.random().toString(36).slice(2); + next.onload = function() { + link.remove(); + }; + link.after(next); + return + } + } + } + + location.reload() + }); +} \ No newline at end of file diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index edab90ca1..b64899f79 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -14,6 +14,9 @@ RUN apt-get update && \ wait-for-it && \ rm -rf /var/lib/apt/lists/* +# Mark /app as safe for Git >= 2.35.2 +RUN git config --system --add safe.directory /app + # Install PHP extensions RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \ docker-php-ext-configure gd --with-freetype --with-jpeg && \ diff --git a/dev/docker/db-testing/Dockerfile b/dev/docker/db-testing/Dockerfile new file mode 100644 index 000000000..618f5bb82 --- /dev/null +++ b/dev/docker/db-testing/Dockerfile @@ -0,0 +1,30 @@ +FROM ubuntu:24.04 + +# Install additional dependencies +RUN apt-get update && \ + apt-get install -y \ + git \ + wget \ + zip \ + unzip \ + php \ + php-bcmath php-curl php-mbstring php-gd php-xml php-zip php-mysql php-ldap \ + && \ + rm -rf /var/lib/apt/lists/* + +# Take branch as an argument so we can choose which BookStack +# branch to test against +ARG BRANCH=development + +# Download BookStack & install PHP deps +RUN mkdir -p /var/www && \ + git clone https://github.com/bookstackapp/bookstack.git --branch "$BRANCH" --single-branch /var/www/bookstack && \ + cd /var/www/bookstack && \ + wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet --filename=composer && \ + php composer install + +# Set the BookStack dir as the default working dir +WORKDIR /var/www/bookstack + +# Set the default action as running php +ENTRYPOINT ["/bin/php"] diff --git a/dev/docker/db-testing/readme.md b/dev/docker/db-testing/readme.md new file mode 100644 index 000000000..89892408e --- /dev/null +++ b/dev/docker/db-testing/readme.md @@ -0,0 +1,32 @@ +# Database Testing Suite + +This docker setup is designed to run BookStack's test suite against each major database version we support +across MySQL and MariaDB to ensure compatibility and highlight any potential issues before a release. +This is a fairly slow and heavy process, so is designed to just be run manually before a release which +makes changes to the database schema, or a release which makes significant changes to database queries. + +### Running + +Everything is ran via the `run.sh` script. This will: + +- Optionally, accept a branch of BookStack to use for testing. +- Build the docker image from the `Dockerfile`. + - This will include a built-in copy of the chosen BookStack branch. +- Cycle through each major supported database version: + - Migrate and seed the database. + - Run the full PHP test suite. + +If there's a failure for a database version, the script will prompt if you'd like to continue or stop testing. + +This script should be ran from this `db-testing` directory: + +```bash +# Enter this directory +cd dev/docker/db-testing + +# Runs for the 'development' branch by default +./run.sh + +# Run for a specific branch +./run.sh v25-11 +``` diff --git a/dev/docker/db-testing/run.sh b/dev/docker/db-testing/run.sh new file mode 100755 index 000000000..fe6ee4508 --- /dev/null +++ b/dev/docker/db-testing/run.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +BRANCH=${1:-development} + +# Build the container with a known name +docker build --no-cache --build-arg BRANCH="$BRANCH" -t bookstack:db-testing . +if [ $? -eq 1 ]; then + echo "Failed to build app container for testing" + exit 1 +fi + +# List of database containers to test against +containers=( + "mysql:8.0" + "mysql:8.4" + "mysql:9.5" + "mariadb:10.6" + "mariadb:10.11" + "mariadb:11.4" + "mariadb:11.8" + "mariadb:12.0" +) + +# Pre-clean-up from prior runs +docker stop bs-dbtest-db +docker network rm bs-dbtest-net + +# Cycle over each database image +for img in "${containers[@]}"; do + echo "Starting tests with $img..." + docker network create bs-dbtest-net + docker run -d --rm --name "bs-dbtest-db" \ + -e MYSQL_DATABASE=bookstack-test \ + -e MYSQL_USER=bookstack \ + -e MYSQL_PASSWORD=bookstack \ + -e MYSQL_ROOT_PASSWORD=password \ + --network=bs-dbtest-net \ + "$img" + sleep 20 + APP_RUN='docker run -it --rm --network=bs-dbtest-net -e TEST_DATABASE_URL="mysql://bookstack:bookstack@bs-dbtest-db:3306" bookstack:db-testing' + $APP_RUN artisan migrate --force --database=mysql_testing + $APP_RUN artisan db:seed --force --class=DummyContentSeeder --database=mysql_testing + $APP_RUN vendor/bin/phpunit + if [ $? -eq 0 ]; then + echo "$img - Success" + else + echo "$img - Error" + read -p "Stop script? [y/N] " ans + [[ $ans == [yY] ]] && exit 0 + fi + + docker stop "bs-dbtest-db" + docker network rm bs-dbtest-net +done + diff --git a/dev/docs/development.md b/dev/docs/development.md index ea3e692a1..418e9ead6 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -3,11 +3,11 @@ All development on BookStack is currently done on the `development` branch. When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements: -* [Node.js](https://nodejs.org/en/) v20.0+ +* [Node.js](https://nodejs.org/en/) v22.0+ ## Building CSS & JavaScript Assets -This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks: +This project uses SASS for CSS development which is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks: ``` bash # Install NPM Dependencies @@ -113,4 +113,4 @@ docker-compose run app php vendor/bin/phpunit ### Debugging The docker-compose setup ships with Xdebug, which you can listen to on port 9090. -NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work. +NB: For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work. diff --git a/dev/docs/javascript-code.md b/dev/docs/javascript-code.md index e5f491839..4de6223d8 100644 --- a/dev/docs/javascript-code.md +++ b/dev/docs/javascript-code.md @@ -161,3 +161,7 @@ window.$components.firstOnElement(element, name); There are a range of available events that are emitted as part of a public & supported API for accessing or extending JavaScript libraries & components used in the system. Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md). + +## WYSIWYG Editor API + +Details on the API for our custom-built WYSIWYG editor can be found in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md). \ No newline at end of file diff --git a/dev/docs/javascript-public-events.md b/dev/docs/javascript-public-events.md index 4f68daaeb..e9ea014ef 100644 --- a/dev/docs/javascript-public-events.md +++ b/dev/docs/javascript-public-events.md @@ -60,7 +60,7 @@ This event is called when the markdown editor loads, post configuration but befo #### Event Data -- `markdownIt` - A references to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview). +- `markdownIt` - A reference to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview). - `displayEl` - The IFrame Element that wraps the HTML preview display. - `cmEditorView` - The CodeMirror [EditorView](https://codemirror.net/docs/ref/#view.EditorView) instance used for the markdown input editor. @@ -79,7 +79,7 @@ window.addEventListener('editor-markdown::setup', event => { This event is called as the embedded diagrams.net drawing editor loads, to allow configuration of the diagrams.net interface. See [this diagrams.net page](https://www.diagrams.net/doc/faq/configure-diagram-editor) for details on the available options for the configure event. -If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter. +If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter. #### Event Data @@ -134,6 +134,47 @@ window.addEventListener('editor-tinymce::setup', event => { }); ``` +### `editor-wysiwyg::post-init` + +This is called after the (new custom-built Lexical-based) WYSIWYG editor has been initialised. + +#### Event Data + +- `usage` - A string label to identify the usage type of the WYSIWYG editor in BookStack. +- `api` - An instance to the WYSIWYG editor API, as documented in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md). + +##### Example + +The below example shows how you'd use this API to create a button, with that button added to the main toolbar of the page editor, which inserts bold "Hello!" text on press: + +

+Show Example + +```javascript +window.addEventListener('editor-wysiwyg::post-init', event => { + const {usage, api} = event.detail; + // Check that it's the page editor which is being loaded + if (usage !== 'page-editor') { + return; + } + + // Create a custom button which inserts bold hello text on press + const button = api.ui.createButton({ + label: 'Greet', + action: () => { + api.content.insertHtml(`Hello!`); + } + }); + + // Add the button to the start of the first section within the main toolbar + const toolbar = api.ui.getMainToolbar(); + if (toolbar) { + toolbar.getSections()[0]?.addButton(button, 0); + } +}); +``` +
+ ### `library-cm6::configure-theme` This event is called whenever a CodeMirror instance is loaded, as a method to configure the theme used by CodeMirror. This applies to all CodeMirror instances including in-page code blocks, editors using in BookStack settings, and the Page markdown editor. @@ -142,7 +183,7 @@ This event is called whenever a CodeMirror instance is loaded, as a method to co - `darkModeActive` - A boolean to indicate if the current view/page is being loaded with dark mode active. - `registerViewTheme(builder)` - A method that can be called to register a new view (CodeMirror UI) theme. - - `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec. + - `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec. - `registerHighlightStyle(builder)` - A method that can be called to register a new HighlightStyle (code highlighting) theme. - `builder` - A function, that receives a reference to [Tag.tags](https://lezer.codemirror.net/docs/ref/#highlight.tags) and returns an array of [TagStyle](https://codemirror.net/docs/ref/#language.TagStyle) objects. @@ -301,7 +342,7 @@ This event is called just after any CodeMirror instances are initialised so that ##### Example -The below shows how you'd prepend some default text to all content (page) code blocks. +The below example shows how you'd prepend some default text to all content (page) code blocks.
Show Example @@ -318,4 +359,4 @@ window.addEventListener('library-cm6::post-init', event => { } }); ``` -
\ No newline at end of file + diff --git a/dev/docs/php-testing.md b/dev/docs/php-testing.md index 1bd4a414f..3d3e91fa2 100644 --- a/dev/docs/php-testing.md +++ b/dev/docs/php-testing.md @@ -4,7 +4,7 @@ BookStack has many test cases defined within the `tests/` directory of the app. ## Setup -The application tests are mostly functional, rather than unit tests, meaning they simulate user actions and system components and therefore these require use of the database. To avoid potential conflicts within your development environment, the tests use a separate database. This is defined via a specific `mysql_testing` database connection in our configuration, and expects to use the following database access details: +The application tests are mostly functional, rather than unit tests, meaning they simulate user actions and system components, and therefore these require use of the database. To avoid potential conflicts within your development environment, the tests use a separate database. This is defined via a specific `mysql_testing` database connection in our configuration, and expects to use the following database access details: - Host: `127.0.0.1` - Username: `bookstack-test` diff --git a/dev/docs/wysiwyg-js-api.md b/dev/docs/wysiwyg-js-api.md new file mode 100644 index 000000000..4b4fafe56 --- /dev/null +++ b/dev/docs/wysiwyg-js-api.md @@ -0,0 +1,136 @@ +# WYSIWYG JavaScript API + +**Warning: This API is currently in development and may change without notice.** + +Feedback is very much welcomed via this issue: https://github.com/BookStackApp/BookStack/issues/5937 + +This document covers the JavaScript API for the (newer Lexical-based) WYSIWYG editor. +This API is built and designed to abstract the internals of the editor away +to provide a stable interface for performing common customizations. + +Only the methods and properties documented here are guaranteed to be stable **once this API +is out of initial development**. +Other elements may be accessible but are not designed to be used directly, and therefore may change +without notice. +Stable parts of the API may still change where needed, but such changes would be noted as part of BookStack update advisories. + +The methods shown here are documented using standard TypeScript notation. + +## Overview + +The API is provided as an object, which itself provides a number of modules +via its properties: + +- `ui` - Provides methods related to the UI of the editor, like buttons and toolbars. +- `content` - Provides methods related to the live user content being edited upon. + +Each of these modules, and the relevant types used within, are documented in detail below. + +The API object itself is provided via the [editor-wysiwyg::post-init](./javascript-public-events.md#editor-wysiwygpost-init) +JavaScript public event, so you can access it like so: + +```javascript +window.addEventListener('editor-wysiwyg::post-init', event => { + const {api} = event.detail; +}); +``` + +--- + +## UI Module + +This module provides methods related to the UI of the editor, like buttons and toolbars. + +### Methods + +#### createButton(options: object): EditorApiButton + +Creates a new button which can be used by other methods. +This takes an option object with the following properties: + +- `label` - string, optional - Used for the button text if no icon provided, or the button tooltip if an icon is provided. +- `icon` - string, optional - The icon to use for the button. Expected to be an SVG string. +- `action` - callback, required - The action to perform when the button is clicked. + +The function returns an [EditorApiButton](#editorapibutton) object. + +**Example** + +```javascript +const button = api.ui.createButton({ + label: 'Warn', + icon: '...', + action: () => { + window.alert('You clicked the button!'); + } +}); +``` + +### getMainToolbar(): EditorApiToolbar + +Get the main editor toolbar. This is typically the toolbar at the top of the editor. +The function returns an [EditorApiToolbar](#editorapitoolbar) object, or null if no toolbar is found. + +**Example** + +```javascript +const toolbar = api.ui.getMainToolbar(); +const sections = toolbar?.getSections() || []; +if (sections.length > 0) { + sections[0].addButton(button); +} +``` + +### Types + +These are types which may be provided from UI module methods. + +#### EditorApiButton + +Represents a button created via the `createButton` method. +This has the following methods: + +- `setActive(isActive: boolean): void` - Sets whether the button should be in an active state or not (typically active buttons appear as pressed). + +#### EditorApiToolbar + +Represents a toolbar within the editor. This is a bar typically containing sets of buttons. +This has the following methods: + +- `getSections(): EditorApiToolbarSection[]` - Provides the main [EditorApiToolbarSections](#editorapitoolbarsection) contained within this toolbar. + +#### EditorApiToolbarSection + +Represents a section of the main editor toolbar, which contains a set of buttons. +This has the following methods: + +- `getLabel(): string` - Provides the string label of the section. +- `addButton(button: EditorApiButton, targetIndex: number = -1): void` - Adds a button to the section. + - By default, this will append the button, although a target index can be provided to insert at a specific position. + +--- + +## Content Module + +This module provides methods related to the live user content being edited within the editor. + +### Methods + +#### insertHtml(html: string, position: string = 'selection'): void + +Inserts the given HTML string at the given position string. +The position, if not provided, will default to `'selection'`, replacing any existing selected content (or inserting at the selection if there's no active selection range). +Valid position string values are: `selection`, `start` and `end`. `start` & `end` are relative to the whole editor document. +The HTML is not assured to be added to the editor exactly as provided, since it will be parsed and serialised to fit the editor's internal known model format. Different parts of the HTML content may be handled differently depending on if it's block or inline content. + +The function does not return anything. + +**Example** + +```javascript +// Basic insert at selection +api.content.insertHtml('

Hello world!

'); + +// Insert at the start of the editor content +api.content.insertHtml('

I\'m at the start!

', 'start'); +``` \ No newline at end of file diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index b5fa446a4..d7ad4ecc8 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -1,16 +1,3 @@ -abab -License: BSD-3-Clause -License File: node_modules/abab/LICENSE.md -Source: git+https://github.com/jsdom/abab.git -Link: https://github.com/jsdom/abab#readme ------------ -acorn-globals -License: MIT -License File: node_modules/acorn-globals/LICENSE -Copyright: Copyright (c) 2014 Forbes Lindesay -Source: https://github.com/ForbesLindesay/acorn-globals.git -Link: https://github.com/ForbesLindesay/acorn-globals.git ------------ acorn-jsx License: MIT License File: node_modules/acorn-jsx/LICENSE @@ -34,8 +21,10 @@ Link: https://github.com/acornjs/acorn ----------- agent-base License: MIT -Source: git://github.com/TooTallNate/node-agent-base.git -Link: git://github.com/TooTallNate/node-agent-base.git +License File: node_modules/agent-base/LICENSE +Copyright: Copyright (c) 2013 Nathan Rajlich <******@***********.***> +Source: https://github.com/TooTallNate/proxy-agents.git +Link: https://github.com/TooTallNate/proxy-agents.git ----------- ajv License: MIT @@ -54,7 +43,7 @@ Link: sindresorhus/ansi-escapes ansi-regex License: MIT License File: node_modules/ansi-regex/license -Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) Source: chalk/ansi-regex Link: chalk/ansi-regex ----------- @@ -134,20 +123,6 @@ Copyright: Copyright (c) 2016 EduardoRFS Source: git+https://github.com/ljharb/async-function.git Link: https://github.com/ljharb/async-function#readme ----------- -async -License: MIT -License File: node_modules/async/LICENSE -Copyright: Copyright (c) 2010-2018 Caolan McMahon -Source: https://github.com/caolan/async.git -Link: https://caolan.github.io/async/ ------------ -asynckit -License: MIT -License File: node_modules/asynckit/LICENSE -Copyright: Copyright (c) 2016 Alex Indigo -Source: git+https://github.com/alexindigo/asynckit.git -Link: https://github.com/alexindigo/asynckit#readme ------------ available-typed-arrays License: MIT License File: node_modules/available-typed-arrays/LICENSE @@ -318,7 +293,7 @@ ci-info License: MIT License File: node_modules/ci-info/LICENSE Copyright: Copyright (c) 2016 Thomas Watson Steen -Source: https://github.com/watson/ci-info.git +Source: github:watson/ci-info Link: https://github.com/watson/ci-info ----------- cjs-module-lexer @@ -369,13 +344,6 @@ License File: node_modules/color-name/LICENSE Source: git@github.com:colorjs/color-name.git Link: https://github.com/colorjs/color-name ----------- -combined-stream -License: MIT -License File: node_modules/combined-stream/License -Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***> -Source: git://github.com/felixge/node-combined-stream.git -Link: https://github.com/felixge/node-combined-stream ------------ concat-map License: MIT License File: node_modules/concat-map/LICENSE @@ -390,13 +358,6 @@ All rights reserved. Source: git://github.com/thlorenz/convert-source-map.git Link: https://github.com/thlorenz/convert-source-map ----------- -create-jest -License: MIT -License File: node_modules/create-jest/LICENSE -Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. -Source: https://github.com/jestjs/jest.git -Link: https://github.com/jestjs/jest.git ------------ create-require License: MIT License File: node_modules/create-require/LICENSE @@ -418,13 +379,6 @@ Copyright: Copyright (c) 2018 Made With MOXY Lda <*****@****.******> Source: git@github.com:moxystudio/node-cross-spawn.git Link: https://github.com/moxystudio/node-cross-spawn ----------- -cssom -License: MIT -License File: node_modules/cssom/LICENSE.txt -Copyright: Copyright (c) Nikita Vasilyev -Source: NV/CSSOM -Link: NV/CSSOM ------------ cssstyle License: MIT License File: node_modules/cssstyle/LICENSE @@ -515,13 +469,6 @@ Copyright: Copyright (C) 2015 Jordan Harband Source: git://github.com/ljharb/define-properties.git Link: git://github.com/ljharb/define-properties.git ----------- -delayed-stream -License: MIT -License File: node_modules/delayed-stream/License -Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***> -Source: git://github.com/felixge/node-delayed-stream.git -Link: https://github.com/felixge/node-delayed-stream ------------ detect-libc License: Apache-2.0 License File: node_modules/detect-libc/LICENSE @@ -535,13 +482,6 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/detect-newline Link: sindresorhus/detect-newline ----------- -diff-sequences -License: MIT -License File: node_modules/diff-sequences/LICENSE -Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. -Source: https://github.com/jestjs/jest.git -Link: https://github.com/jestjs/jest.git ------------ diff License: BSD-3-Clause License File: node_modules/diff/LICENSE @@ -555,12 +495,6 @@ License File: node_modules/doctrine/LICENSE Source: eslint/doctrine Link: https://github.com/eslint/doctrine ----------- -domexception -License: MIT -License File: node_modules/domexception/LICENSE.txt -Source: jsdom/domexception -Link: jsdom/domexception ------------ dunder-proto License: MIT License File: node_modules/dunder-proto/LICENSE @@ -568,11 +502,10 @@ Copyright: Copyright (c) 2024 ECMAScript Shims Source: git+https://github.com/es-shims/dunder-proto.git Link: https://github.com/es-shims/dunder-proto#readme ----------- -ejs -License: Apache-2.0 -License File: node_modules/ejs/LICENSE -Source: git://github.com/mde/ejs.git -Link: https://github.com/mde/ejs +eastasianwidth +License: MIT +Source: git://github.com/komagata/eastasianwidth.git +Link: git://github.com/komagata/eastasianwidth.git ----------- electron-to-chromium License: ISC @@ -679,13 +612,6 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/escape-string-regexp Link: sindresorhus/escape-string-regexp ----------- -escodegen -License: BSD-2-Clause -License File: node_modules/escodegen/LICENSE.BSD -Copyright: Copyright (C) 2012 Yusuke Suzuki (twitter: @Constellation) and other contributors. -Source: http://github.com/estools/escodegen.git -Link: http://github.com/estools/escodegen ------------ eslint-import-resolver-node License: MIT License File: node_modules/eslint-import-resolver-node/LICENSE @@ -772,9 +698,10 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/execa Link: sindresorhus/execa ----------- -exit -Source: git://github.com/cowboy/node-exit.git -Link: https://github.com/cowboy/node-exit +exit-x +License: MIT +Source: git://github.com/gruntjs/node-exit-x.git +Link: https://github.com/gruntjs/node-exit-x ----------- expect License: MIT @@ -817,11 +744,6 @@ Copyright: Copyright (c) Roy Riojas & Jared Wray Source: jaredwray/file-entry-cache Link: jaredwray/file-entry-cache ----------- -filelist -License: Apache-2.0 -Source: git://github.com/mde/filelist.git -Link: https://github.com/mde/filelist ------------ fill-range License: MIT License File: node_modules/fill-range/LICENSE @@ -857,12 +779,12 @@ Copyright: Copyright (c) 2012 Raynos. Source: https://github.com/Raynos/for-each.git Link: https://github.com/Raynos/for-each ----------- -form-data -License: MIT -License File: node_modules/form-data/License -Copyright: Copyright (c) 2012 Felix Geisendörfer (*****@**********.***) and contributors -Source: git://github.com/form-data/form-data.git -Link: git://github.com/form-data/form-data.git +foreground-child +License: ISC +License File: node_modules/foreground-child/LICENSE +Copyright: Copyright (c) 2015-2023 Isaac Z. Schlueter and Contributors +Source: git+https://github.com/tapjs/foreground-child.git +Link: git+https://github.com/tapjs/foreground-child.git ----------- fs.realpath License: ISC @@ -951,7 +873,7 @@ Link: gulpjs/glob-parent glob License: ISC License File: node_modules/glob/LICENSE -Copyright: Copyright (c) Isaac Z. Schlueter and Contributors +Copyright: Copyright (c) 2009-2023 Isaac Z. Schlueter and Contributors Source: git://github.com/isaacs/node-glob.git Link: git://github.com/isaacs/node-glob.git ----------- @@ -983,6 +905,13 @@ Copyright: Copyright (c) 2011-2022 Isaac Z. Schlueter, Ben Noordhuis, and Contri Source: https://github.com/isaacs/node-graceful-fs Link: https://github.com/isaacs/node-graceful-fs ----------- +handlebars +License: MIT +License File: node_modules/handlebars/LICENSE +Copyright: Copyright (C) 2011-2019 by Yehuda Katz +Source: https://github.com/handlebars-lang/handlebars.js.git +Link: https://www.handlebarsjs.com/ +----------- has-bigints License: MIT License File: node_modules/has-bigints/LICENSE @@ -1054,13 +983,17 @@ Link: https://github.com/WebReflection/html-escaper ----------- http-proxy-agent License: MIT -Source: git://github.com/TooTallNate/node-http-proxy-agent.git -Link: git://github.com/TooTallNate/node-http-proxy-agent.git +License File: node_modules/http-proxy-agent/LICENSE +Copyright: Copyright (c) 2013 Nathan Rajlich <******@***********.***> +Source: https://github.com/TooTallNate/proxy-agents.git +Link: https://github.com/TooTallNate/proxy-agents.git ----------- https-proxy-agent License: MIT -Source: git://github.com/TooTallNate/node-https-proxy-agent.git -Link: git://github.com/TooTallNate/node-https-proxy-agent.git +License File: node_modules/https-proxy-agent/LICENSE +Copyright: Copyright (c) 2013 Nathan Rajlich <******@***********.***> +Source: https://github.com/TooTallNate/proxy-agents.git +Link: https://github.com/TooTallNate/proxy-agents.git ----------- human-signals License: Apache-2.0 @@ -1398,10 +1331,11 @@ Copyright: Copyright 2012-2015 Yahoo! Inc. Source: git+ssh://git@github.com/istanbuljs/istanbuljs.git Link: https://istanbul.js.org/ ----------- -jake -License: Apache-2.0 -Source: git://github.com/jakejs/jake.git -Link: git://github.com/jakejs/jake.git +jackspeak +License: BlueOak-1.0.0 +License File: node_modules/jackspeak/LICENSE.md +Source: git+https://github.com/isaacs/jackspeak.git +Link: git+https://github.com/isaacs/jackspeak.git ----------- jest-changed-files License: MIT @@ -1466,13 +1400,6 @@ Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. Source: https://github.com/jestjs/jest.git Link: https://github.com/jestjs/jest.git ----------- -jest-get-type -License: MIT -License File: node_modules/jest-get-type/LICENSE -Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. -Source: https://github.com/jestjs/jest.git -Link: https://github.com/jestjs/jest.git ------------ jest-haste-map License: MIT License File: node_modules/jest-haste-map/LICENSE @@ -1608,8 +1535,8 @@ jsdom License: MIT License File: node_modules/jsdom/LICENSE.txt Copyright: Copyright (c) 2010 Elijah Insua -Source: jsdom/jsdom -Link: jsdom/jsdom +Source: git+https://github.com/jsdom/jsdom.git +Link: git+https://github.com/jsdom/jsdom.git ----------- jsesc License: MIT @@ -1664,13 +1591,6 @@ License: MIT Source: git+https://github.com/jaredwray/keyv.git Link: https://github.com/jaredwray/keyv ----------- -kleur -License: MIT -License File: node_modules/kleur/license -Copyright: Copyright (c) Luke Edwards <****.*********@*****.***> (lukeed.com) -Source: lukeed/kleur -Link: lukeed/kleur ------------ leven License: MIT License File: node_modules/leven/license @@ -1699,20 +1619,6 @@ Copyright: Copyright (c) 2015 Vitaly Puzrin. Source: markdown-it/linkify-it Link: markdown-it/linkify-it ----------- -livereload-js -License: MIT -License File: node_modules/livereload-js/LICENSE -Copyright: Copyright (c) 2010-2012 Andrey Tarantsov -Source: git://github.com/livereload/livereload-js.git -Link: https://github.com/livereload/livereload-js ------------ -livereload -License: MIT -License File: node_modules/livereload/LICENSE -Copyright: Copyright (c) 2010 Joshua Peek -Source: http://github.com/napcs/node-livereload.git -Link: http://github.com/napcs/node-livereload.git ------------ load-json-file License: MIT License File: node_modules/load-json-file/license @@ -1827,22 +1733,6 @@ Copyright: Copyright (c) 2014-present, Jon Schlinkert. Source: micromatch/micromatch Link: https://github.com/micromatch/micromatch ----------- -mime-db -License: MIT -License File: node_modules/mime-db/LICENSE -Copyright: Copyright (c) 2014 Jonathan Ong <**@***********.***> -Copyright (c) 2015-2022 Douglas Christopher Wilson <****@*************.***> -Source: jshttp/mime-db -Link: jshttp/mime-db ------------ -mime-types -License: MIT -License File: node_modules/mime-types/LICENSE -Copyright: Copyright (c) 2014 Jonathan Ong <**@***********.***> -Copyright (c) 2015 Douglas Christopher Wilson <****@*************.***> -Source: jshttp/mime-types -Link: jshttp/mime-types ------------ mimic-fn License: MIT License File: node_modules/mimic-fn/license @@ -1863,6 +1753,13 @@ License File: node_modules/minimist/LICENSE Source: git://github.com/minimistjs/minimist.git Link: https://github.com/minimistjs/minimist ----------- +minipass +License: ISC +License File: node_modules/minipass/LICENSE +Copyright: Copyright (c) 2017-2023 npm, Inc., Isaac Z. Schlueter, and Contributors +Source: https://github.com/isaacs/minipass +Link: https://github.com/isaacs/minipass +----------- ms License: MIT License File: node_modules/ms/license.md @@ -1870,11 +1767,26 @@ Copyright: Copyright (c) 2020 Vercel, Inc. Source: vercel/ms Link: vercel/ms ----------- +napi-postinstall +License: MIT +License File: node_modules/napi-postinstall/LICENSE +Copyright: Copyright (c) 2021-present UnTS +Source: git+https://github.com/un-ts/napi-postinstall.git +Link: git+https://github.com/un-ts/napi-postinstall.git +----------- natural-compare License: MIT Source: git://github.com/litejs/natural-compare-lite.git Link: git://github.com/litejs/natural-compare-lite.git ----------- +neo-async +License: MIT +License File: node_modules/neo-async/LICENSE +Copyright: Copyright (c) 2014-2018 Suguru Motegi +Based on Async.js, Copyright Caolan McMahon +Source: git@github.com:suguru03/neo-async.git +Link: https://github.com/suguru03/neo-async +----------- nice-try License: MIT License File: node_modules/nice-try/LICENSE @@ -2002,14 +1914,6 @@ Copyright: Copyright (c) George Zahariev Source: git://github.com/gkz/optionator.git Link: https://github.com/gkz/optionator ----------- -opts -License: BSD-2-Clause -License File: node_modules/opts/LICENSE.txt -Copyright: Copyright (c) 2010, Joey Mazzarelli -All rights reserved. -Source: github:khtdr/opts -Link: http://khtdr.com/opts ------------ own-keys License: MIT License File: node_modules/own-keys/LICENSE @@ -2038,6 +1942,12 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/p-try Link: sindresorhus/p-try ----------- +package-json-from-dist +License: BlueOak-1.0.0 +License File: node_modules/package-json-from-dist/LICENSE.md +Source: git+https://github.com/isaacs/package-json-from-dist.git +Link: git+https://github.com/isaacs/package-json-from-dist.git +----------- parent-module License: MIT License File: node_modules/parent-module/license @@ -2087,6 +1997,12 @@ Copyright: Copyright (c) 2015 Javier Blanco Source: https://github.com/jbgutierrez/path-parse.git Link: https://github.com/jbgutierrez/path-parse#readme ----------- +path-scurry +License: BlueOak-1.0.0 +License File: node_modules/path-scurry/LICENSE.md +Source: git+https://github.com/isaacs/path-scurry +Link: git+https://github.com/isaacs/path-scurry +----------- path-type License: MIT License File: node_modules/path-type/license @@ -2157,20 +2073,6 @@ Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. Source: https://github.com/jestjs/jest.git Link: https://github.com/jestjs/jest.git ----------- -prompts -License: MIT -License File: node_modules/prompts/license -Copyright: Copyright (c) 2018 Terkel Gjervig Nielsen -Source: terkelg/prompts -Link: terkelg/prompts ------------ -psl -License: MIT -License File: node_modules/psl/LICENSE -Copyright: Copyright (c) 2017 Lupo Montero ***********@*****.*** -Source: git@github.com:lupomontero/psl.git -Link: git@github.com:lupomontero/psl.git ------------ punycode.js License: MIT License File: node_modules/punycode.js/LICENSE-MIT.txt @@ -2190,13 +2092,6 @@ Copyright: Copyright (c) 2018 Nicolas DUBIEN Source: git+https://github.com/dubzzz/pure-rand.git Link: https://github.com/dubzzz/pure-rand#readme ----------- -querystringify -License: MIT -License File: node_modules/querystringify/LICENSE -Copyright: Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. -Source: https://github.com/unshiftio/querystringify -Link: https://github.com/unshiftio/querystringify ------------ react-is License: MIT License File: node_modules/react-is/LICENSE @@ -2246,13 +2141,6 @@ Copyright: Copyright (c) 2016, Contributors Source: git+ssh://git@github.com/yargs/require-main-filename.git Link: https://github.com/yargs/require-main-filename#readme ----------- -requires-port -License: MIT -License File: node_modules/requires-port/LICENSE -Copyright: Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. -Source: https://github.com/unshiftio/requires-port -Link: https://github.com/unshiftio/requires-port ------------ resolve-cwd License: MIT License File: node_modules/resolve-cwd/license @@ -2267,13 +2155,6 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/resolve-from Link: sindresorhus/resolve-from ----------- -resolve.exports -License: MIT -License File: node_modules/resolve.exports/license -Copyright: Copyright (c) Luke Edwards <****.*********@*****.***> (lukeed.com) -Source: lukeed/resolve.exports -Link: lukeed/resolve.exports ------------ resolve License: MIT License File: node_modules/resolve/LICENSE @@ -2281,6 +2162,13 @@ Copyright: Copyright (c) 2012 James Halliday Source: git://github.com/browserify/resolve.git Link: git://github.com/browserify/resolve.git ----------- +rrweb-cssom +License: MIT +License File: node_modules/rrweb-cssom/LICENSE.txt +Copyright: Copyright (c) Nikita Vasilyev +Source: rrweb-io/CSSOM +Link: rrweb-io/CSSOM +----------- safe-array-concat License: MIT License File: node_modules/safe-array-concat/LICENSE @@ -2408,16 +2296,9 @@ Link: https://github.com/ljharb/side-channel#readme signal-exit License: ISC License File: node_modules/signal-exit/LICENSE.txt -Copyright: Copyright (c) 2015, Contributors +Copyright: Copyright (c) 2015-2023 Benjamin Coe, Isaac Z. Schlueter, and Contributors Source: https://github.com/tapjs/signal-exit.git -Link: https://github.com/tapjs/signal-exit ------------ -sisteransi -License: MIT -License File: node_modules/sisteransi/license -Copyright: Copyright (c) 2018 Terkel Gjervig Nielsen -Source: https://github.com/terkelg/sisteransi -Link: https://github.com/terkelg/sisteransi +Link: https://github.com/tapjs/signal-exit.git ----------- slash License: MIT @@ -2516,6 +2397,13 @@ Link: sindresorhus/string-length ----------- string-width License: MIT +License File: node_modules/string-width-cjs/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/string-width +Link: sindresorhus/string-width +----------- +string-width +License: MIT License File: node_modules/string-width/license Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) Source: sindresorhus/string-width @@ -2551,11 +2439,18 @@ Link: git://github.com/es-shims/String.prototype.trimStart.git ----------- strip-ansi License: MIT -License File: node_modules/strip-ansi/license +License File: node_modules/strip-ansi-cjs/license Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) Source: chalk/strip-ansi Link: chalk/strip-ansi ----------- +strip-ansi +License: MIT +License File: node_modules/strip-ansi/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: chalk/strip-ansi +Link: chalk/strip-ansi +----------- strip-bom License: MIT License File: node_modules/strip-bom/license @@ -2605,6 +2500,13 @@ Copyright: Copyright (c) 2015 Joris van der Wel Source: https://github.com/jsdom/js-symbol-tree.git Link: https://github.com/jsdom/js-symbol-tree#symbol-tree ----------- +synckit +License: MIT +License File: node_modules/synckit/LICENSE +Copyright: Copyright (c) 2021 UnTS +Source: https://github.com/un-ts/synckit.git +Link: https://github.com/un-ts/synckit.git +----------- test-exclude License: ISC License File: node_modules/test-exclude/LICENSE.txt @@ -2612,6 +2514,20 @@ Copyright: Copyright (c) 2016, Contributors Source: git+https://github.com/istanbuljs/test-exclude.git Link: https://istanbul.js.org/ ----------- +tldts-core +License: MIT +License File: node_modules/tldts-core/LICENSE +Copyright: Copyright (c) 2017 Thomas Parisot, 2018 Rémi Berson +Source: git+ssh://git@github.com/remusao/tldts.git +Link: https://github.com/remusao/tldts#readme +----------- +tldts +License: MIT +License File: node_modules/tldts/LICENSE +Copyright: Copyright (c) 2017 Thomas Parisot, 2018 Rémi Berson +Source: git+ssh://git@github.com/remusao/tldts.git +Link: https://github.com/remusao/tldts#readme +----------- tmpl License: BSD-3-Clause License File: node_modules/tmpl/license @@ -2722,6 +2638,13 @@ License File: node_modules/uc.micro/LICENSE.txt Source: markdown-it/uc.micro Link: markdown-it/uc.micro ----------- +uglify-js +License: BSD-2-Clause +License File: node_modules/uglify-js/LICENSE +Copyright: Copyright 2012-2024 (c) Mihai Bazon <*****.*****@*****.***> +Source: mishoo/UglifyJS +Link: mishoo/UglifyJS +----------- unbox-primitive License: MIT License File: node_modules/unbox-primitive/LICENSE @@ -2736,12 +2659,10 @@ Copyright: Copyright (c) Matteo Collina and Undici contributors Source: git+https://github.com/nodejs/undici.git Link: https://undici.nodejs.org ----------- -universalify +unrs-resolver License: MIT -License File: node_modules/universalify/LICENSE -Copyright: Copyright (c) 2017, Ryan Zimmerman <*******@*******.***> -Source: git+https://github.com/RyanZim/universalify.git -Link: https://github.com/RyanZim/universalify#readme +Source: git+https://github.com/unrs/unrs-resolver.git +Link: https://github.com/unrs/unrs-resolver#readme ----------- update-browserslist-db License: MIT @@ -2757,13 +2678,6 @@ Copyright: Copyright 2011 Gary Court. All rights reserved. Source: http://github.com/garycourt/uri-js Link: https://github.com/garycourt/uri-js ----------- -url-parse -License: MIT -License File: node_modules/url-parse/LICENSE -Copyright: Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. -Source: https://github.com/unshiftio/url-parse.git -Link: https://github.com/unshiftio/url-parse.git ------------ v8-compile-cache-lib License: MIT License File: node_modules/v8-compile-cache-lib/LICENSE @@ -2880,6 +2794,19 @@ Copyright: Copyright (c) 2014-2016, Jon Schlinkert Source: jonschlinkert/word-wrap Link: https://github.com/jonschlinkert/word-wrap ----------- +wordwrap +License: MIT +License File: node_modules/wordwrap/LICENSE +Source: git://github.com/substack/node-wordwrap.git +Link: git://github.com/substack/node-wordwrap.git +----------- +wrap-ansi +License: MIT +License File: node_modules/wrap-ansi-cjs/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: chalk/wrap-ansi +Link: chalk/wrap-ansi +----------- wrap-ansi License: MIT License File: node_modules/wrap-ansi/license @@ -2971,6 +2898,31 @@ License File: node_modules/@ampproject/remapping/LICENSE Source: git+https://github.com/ampproject/remapping.git Link: git+https://github.com/ampproject/remapping.git ----------- +@asamuzakjp/css-color +License: MIT +License File: node_modules/@asamuzakjp/css-color/LICENSE +Copyright: Copyright (c) 2024 asamuzaK (Kazz) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +Source: git+https://github.com/asamuzaK/cssColor.git +Link: https://github.com/asamuzaK/cssColor#readme +----------- @babel/code-frame License: MIT License File: node_modules/@babel/code-frame/LICENSE @@ -3335,6 +3287,54 @@ Copyright: Copyright (c) 2014 Evan Wallace Source: https://github.com/cspotcode/node-source-map-support Link: https://github.com/cspotcode/node-source-map-support ----------- +@csstools/color-helpers +License: MIT-0 +License File: node_modules/@csstools/color-helpers/LICENSE.md +Source: git+https://github.com/csstools/postcss-plugins.git +Link: https://github.com/csstools/postcss-plugins/tree/main/packages/color-helpers#readme +----------- +@csstools/css-calc +License: MIT +License File: node_modules/@csstools/css-calc/LICENSE.md +Copyright: Copyright 2022 Romain Menke, Antonio Laguna <*******@******.**> +Source: git+https://github.com/csstools/postcss-plugins.git +Link: https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc#readme +----------- +@csstools/css-color-parser +License: MIT +License File: node_modules/@csstools/css-color-parser/LICENSE.md +Copyright: Copyright 2022 Romain Menke, Antonio Laguna <*******@******.**> +Source: git+https://github.com/csstools/postcss-plugins.git +Link: https://github.com/csstools/postcss-plugins/tree/main/packages/css-color-parser#readme +----------- +@csstools/css-parser-algorithms +License: MIT +License File: node_modules/@csstools/css-parser-algorithms/LICENSE.md +Copyright: Copyright 2022 Romain Menke, Antonio Laguna <*******@******.**> +Source: git+https://github.com/csstools/postcss-plugins.git +Link: https://github.com/csstools/postcss-plugins/tree/main/packages/css-parser-algorithms#readme +----------- +@csstools/css-tokenizer +License: MIT +License File: node_modules/@csstools/css-tokenizer/LICENSE.md +Copyright: Copyright 2022 Romain Menke, Antonio Laguna <*******@******.**> +Source: git+https://github.com/csstools/postcss-plugins.git +Link: https://github.com/csstools/postcss-plugins/tree/main/packages/css-tokenizer#readme +----------- +@emnapi/core +License: MIT +License File: node_modules/@emnapi/core/LICENSE +Copyright: Copyright (c) 2021-present Toyobayashi +Source: git+https://github.com/toyobayashi/emnapi.git +Link: https://github.com/toyobayashi/emnapi#readme +----------- +@emnapi/runtime +License: MIT +License File: node_modules/@emnapi/runtime/LICENSE +Copyright: Copyright (c) 2021-present Toyobayashi +Source: git+https://github.com/toyobayashi/emnapi.git +Link: https://github.com/toyobayashi/emnapi#readme +----------- @esbuild/linux-x64 License: MIT Source: git+https://github.com/evanw/esbuild.git @@ -3388,7 +3388,7 @@ Link: https://eslint.org License: Apache-2.0 License File: node_modules/@eslint/object-schema/LICENSE Source: git+https://github.com/eslint/rewrite.git -Link: https://github.com/eslint/rewrite#readme +Link: https://github.com/eslint/rewrite/tree/main/packages/object-schema#readme ----------- @eslint/plugin-kit License: Apache-2.0 @@ -3420,6 +3420,13 @@ License File: node_modules/@humanwhocodes/retry/LICENSE Source: git+https://github.com/humanwhocodes/retry.git Link: git+https://github.com/humanwhocodes/retry.git ----------- +@isaacs/cliui +License: ISC +License File: node_modules/@isaacs/cliui/LICENSE.txt +Copyright: Copyright (c) 2015, Contributors +Source: yargs/cliui +Link: yargs/cliui +----------- @istanbuljs/load-nyc-config License: ISC License File: node_modules/@istanbuljs/load-nyc-config/LICENSE @@ -3448,6 +3455,20 @@ Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. Source: https://github.com/jestjs/jest.git Link: https://jestjs.io/ ----------- +@jest/diff-sequences +License: MIT +License File: node_modules/@jest/diff-sequences/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/environment-jsdom-abstract +License: MIT +License File: node_modules/@jest/environment-jsdom-abstract/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- @jest/environment License: MIT License File: node_modules/@jest/environment/LICENSE @@ -3476,6 +3497,13 @@ Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. Source: https://github.com/jestjs/jest.git Link: https://github.com/jestjs/jest.git ----------- +@jest/get-type +License: MIT +License File: node_modules/@jest/get-type/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- @jest/globals License: MIT License File: node_modules/@jest/globals/LICENSE @@ -3483,6 +3511,13 @@ Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. Source: https://github.com/jestjs/jest.git Link: https://github.com/jestjs/jest.git ----------- +@jest/pattern +License: MIT +License File: node_modules/@jest/pattern/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- @jest/reporters License: MIT License File: node_modules/@jest/reporters/LICENSE @@ -3497,6 +3532,13 @@ Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. Source: https://github.com/jestjs/jest.git Link: https://github.com/jestjs/jest.git ----------- +@jest/snapshot-utils +License: MIT +License File: node_modules/@jest/snapshot-utils/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- @jest/source-map License: MIT License File: node_modules/@jest/source-map/LICENSE @@ -3665,6 +3707,17 @@ Copyright: Copyright (c) 2017-present Devon Govett Source: https://github.com/parcel-bundler/watcher.git Link: https://github.com/parcel-bundler/watcher.git ----------- +@pkgjs/parseargs +License: MIT +License File: node_modules/@pkgjs/parseargs/LICENSE +Source: git@github.com:pkgjs/parseargs.git +Link: https://github.com/pkgjs/parseargs#readme +----------- +@pkgr/core +License: MIT +Source: git+https://github.com/un-ts/pkgr.git +Link: https://github.com/un-ts/pkgr/blob/master/packages/core +----------- @rtsao/scc License: MIT License File: node_modules/@rtsao/scc/LICENSE @@ -3690,7 +3743,7 @@ Link: https://github.com/sinonjs/commons#readme License: BSD-3-Clause License File: node_modules/@sinonjs/fake-timers/LICENSE Copyright: Copyright (c) 2010-2014, Christian Johansen, *********@*********.**. All rights reserved. -Source: https://github.com/sinonjs/fake-timers.git +Source: git+https://github.com/sinonjs/fake-timers.git Link: https://github.com/sinonjs/fake-timers ----------- @ssddanbrown/codemirror-lang-smarty @@ -3703,13 +3756,6 @@ License: MIT License File: node_modules/@ssddanbrown/codemirror-lang-twig/LICENSE Copyright: Copyright (C) 2023 by Dan Brown, Marijn Haverbeke and others ----------- -@tootallnate/once -License: MIT -License File: node_modules/@tootallnate/once/LICENSE -Copyright: Copyright (c) 2020 Nathan Rajlich -Source: git://github.com/TooTallNate/once.git -Link: git://github.com/TooTallNate/once.git ------------ @tsconfig/node10 License: MIT License File: node_modules/@tsconfig/node10/LICENSE @@ -3738,6 +3784,11 @@ Copyright: Copyright (c) Microsoft Corporation. Source: https://github.com/tsconfig/bases.git Link: https://github.com/tsconfig/bases.git ----------- +@tybys/wasm-util +License: MIT +Source: https://github.com/toyobayashi/wasm-util.git +Link: https://github.com/toyobayashi/wasm-util.git +----------- @types/babel__core License: MIT License File: node_modules/@types/babel__core/LICENSE @@ -3773,13 +3824,6 @@ Copyright: Copyright (c) Microsoft Corporation. Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/estree ----------- -@types/graceful-fs -License: MIT -License File: node_modules/@types/graceful-fs/LICENSE -Copyright: Copyright (c) Microsoft Corporation. -Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git -Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/graceful-fs ------------ @types/istanbul-lib-coverage License: MIT License File: node_modules/@types/istanbul-lib-coverage/LICENSE @@ -3889,3 +3933,20 @@ License File: node_modules/@types/yargs/LICENSE Copyright: Copyright (c) Microsoft Corporation. Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/yargs +----------- +@ungap/structured-clone +License: ISC +License File: node_modules/@ungap/structured-clone/LICENSE +Copyright: Copyright (c) 2021, Andrea Giammarchi, @WebReflection +Source: git+https://github.com/ungap/structured-clone.git +Link: https://github.com/ungap/structured-clone#readme +----------- +@unrs/resolver-binding-linux-x64-gnu +License: MIT +Source: git+https://github.com/unrs/unrs-resolver.git +Link: https://github.com/unrs/unrs-resolver#readme +----------- +@unrs/resolver-binding-linux-x64-musl +License: MIT +Source: git+https://github.com/unrs/unrs-resolver.git +Link: https://github.com/unrs/unrs-resolver#readme diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index f1ca23c9f..d178e9243 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -13,7 +13,7 @@ Link: http://aws.amazon.com/sdkforphp bacon/bacon-qr-code License: BSD-2-Clause License File: vendor/bacon/bacon-qr-code/LICENSE -Copyright: Copyright (c) 2017, Ben Scholzen 'DASPRiD' +Copyright: Copyright (c) 2017-present, Ben Scholzen 'DASPRiD' All rights reserved. Source: https://github.com/Bacon/BaconQrCode.git Link: https://github.com/Bacon/BaconQrCode @@ -176,7 +176,7 @@ License: MIT License File: vendor/intervention/image/LICENSE Copyright: Copyright (c) 2013-present Oliver Vogel Source: https://github.com/Intervention/image.git -Link: https://image.intervention.io/ +Link: https://image.intervention.io ----------- knplabs/knp-snappy License: MIT @@ -465,7 +465,7 @@ Link: https://github.com/php-fig/simple-cache.git psy/psysh License: MIT License File: vendor/psy/psysh/LICENSE -Copyright: Copyright (c) 2012-2023 Justin Hileman +Copyright: Copyright (c) 2012-2025 Justin Hileman Source: https://github.com/bobthecow/psysh.git Link: https://psysh.org ----------- @@ -592,6 +592,13 @@ Copyright: Copyright (c) 2018-present Fabien Potencier Source: https://github.com/symfony/event-dispatcher-contracts.git Link: https://symfony.com ----------- +symfony/filesystem +License: MIT +License File: vendor/symfony/filesystem/LICENSE +Copyright: Copyright (c) 2004-present Fabien Potencier +Source: https://github.com/symfony/filesystem.git +Link: https://symfony.com +----------- symfony/finder License: MIT License File: vendor/symfony/finder/LICENSE @@ -676,6 +683,20 @@ Copyright: Copyright (c) 2022-present Fabien Potencier Source: https://github.com/symfony/polyfill-php83.git Link: https://symfony.com ----------- +symfony/polyfill-php84 +License: MIT +License File: vendor/symfony/polyfill-php84/LICENSE +Copyright: Copyright (c) 2024-present Fabien Potencier +Source: https://github.com/symfony/polyfill-php84.git +Link: https://symfony.com +----------- +symfony/polyfill-php85 +License: MIT +License File: vendor/symfony/polyfill-php85/LICENSE +Copyright: Copyright (c) 2025-present Fabien Potencier +Source: https://github.com/symfony/polyfill-php85.git +Link: https://symfony.com +----------- symfony/polyfill-uuid License: MIT License File: vendor/symfony/polyfill-uuid/LICENSE @@ -759,10 +780,3 @@ License File: vendor/voku/portable-ascii/LICENSE.txt Copyright: Copyright (C) 2019 Lars Moelleken Source: https://github.com/voku/portable-ascii.git Link: https://github.com/voku/portable-ascii ------------ -webmozart/assert -License: MIT -License File: vendor/webmozart/assert/LICENSE -Copyright: Copyright (c) 2014 Bernhard Schussek -Source: https://github.com/webmozarts/assert.git -Link: https://github.com/webmozarts/assert.git diff --git a/lang/ar/entities.php b/lang/ar/entities.php index 1f1d4e411..8b2882c60 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'سيؤدي هذا إلى حذف مِلَفّ الاستيراد المضغوط ZIP، ولا يمكن التراجع عنه.', 'import_errors' => 'أخطاء الاستيراد', 'import_errors_desc' => 'حدثت الأخطاء التالية خلال محاولة الاستيراد:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'تصفح أشقاء هذه الصفحة', + 'breadcrumb_siblings_for_chapter' => 'تصفح أشقاء هذا الفصل', + 'breadcrumb_siblings_for_book' => 'تصفح أشقاء هذا الكتاب', + 'breadcrumb_siblings_for_bookshelf' => 'تصفح أشقاء هذا الرف', // Permissions and restrictions 'permissions' => 'الأذونات', diff --git a/lang/ar/errors.php b/lang/ar/errors.php index 4c6325cb3..491f04c07 100644 --- a/lang/ar/errors.php +++ b/lang/ar/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'لم أتمكن من قراءة المِلَفّ المضغوط -ZIP-.', 'import_zip_cant_decode_data' => 'لم نتمكن من العثور على محتوى المِلَفّ المضغوط data.json وفك تشفيره.', 'import_zip_no_data' => 'لا تتضمن بيانات المِلَفّ المضغوط أي محتوى متوقع للكتاب أو الفصل أو الصفحة.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'فشل التحقق من صحة استيراد المِلَفّ المضغوط بسبب الأخطاء التالية:', 'import_zip_failed_notification' => 'فشل استيراد المِلَفّ المضغوط.', 'import_perms_books' => 'أنت تفتقر إلى الصلاحيات المطلوبة لإنشاء الكتب.', diff --git a/lang/ar/notifications.php b/lang/ar/notifications.php index 30a49a631..69d5dfdcf 100644 --- a/lang/ar/notifications.php +++ b/lang/ar/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'تم تحديث الصفحة: :pageName', 'updated_page_intro' => 'تم تحديث الصفحة في :appName:', 'updated_page_debounce' => 'لمنع تلقي عدد كبير من الإشعارات، لن يتم إرسال إشعارات إليك لفترة من الوقت لإجراء المزيد من التعديلات على هذه الصفحة بواسطة نفس المحرر.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'اسم الصفحة:', 'detail_page_path' => 'مسار الصفحة:', diff --git a/lang/ar/preferences.php b/lang/ar/preferences.php index 9158b4b37..92733c998 100644 --- a/lang/ar/preferences.php +++ b/lang/ar/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'التحكم في إشعارات البريد الإلكتروني الذي تتلقاها عند إجراء نشاط معين داخل النظام.', 'notifications_opt_own_page_changes' => 'إشعاري عند حدوث تغييرات في الصفحات التي أملكها', 'notifications_opt_own_page_comments' => 'إشعاري بشأن التعليقات على الصفحات التي أملكها', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'إشعاري عند الردود على تعليقاتي', 'notifications_save' => 'حفظ اﻹعدادات', 'notifications_update_success' => 'تم تحديث إعدادات الإشعارات!', diff --git a/lang/ar/settings.php b/lang/ar/settings.php index 9006ac227..dc95ac846 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -75,7 +75,7 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'لم يتم اختيار أي قيود', // Sorting Settings - 'sorting' => 'طريقة الترتيب', + 'sorting' => 'القوائم و الفرز', 'sorting_book_default' => 'ترتيب الكتاب الافتراضي', 'sorting_book_default_desc' => 'حدد قاعدة الترتيب الافتراضية لتطبيقها على الكتب الجديدة. لن يؤثر هذا على الكتب الحالية، ويمكن تجاوزه لكل كتاب على حدة.', 'sorting_rules' => 'قواعد الترتيب', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'تاريخ التحديث', 'sort_rule_op_chapters_first' => 'الفصول الأولى', 'sort_rule_op_chapters_last' => 'الفصول الأخيرة', + 'sorting_page_limits' => 'حدود العرض لكل صفحة', + 'sorting_page_limits_desc' => 'تعيين عدد العناصر لإظهار كل صفحة في قوائم مختلفة داخل النظام. عادةً ما يكون الرقم الأقل هو الأكثر أداء، بينما وضع رقم أعلى يغني عن النقر على صفحات متعددة. يوصى باستخدام مضاعفات رقم ٣ (18 و 24 و 30 و إلخ...).', // Maintenance settings 'maint' => 'الصيانة', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'استيراد المحتوى', 'role_editor_change' => 'تغيير محرر الصفحة', 'role_notifications' => 'تلقي الإشعارات وإدارتها', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'أذونات الأصول', 'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.', 'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.', 'role_asset_admins' => 'يُمنح المسؤولين حق الوصول تلقائيًا إلى جميع المحتويات ولكن هذه الخيارات قد تعرض خيارات واجهة المستخدم أو تخفيها.', 'role_asset_image_view_note' => 'يتعلق هذا بالرؤية داخل مدير الصور. يعتمد الوصول الفعلي لملفات الصور المُحمّلة على خِيار تخزين الصور في النظام.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'الكل', 'role_own' => 'ما يخص', 'role_controlled_by_asset' => 'يتحكم فيها الأصول التي يتم رفعها إلى', diff --git a/lang/ar/validation.php b/lang/ar/validation.php index c27770fe3..813f622a3 100644 --- a/lang/ar/validation.php +++ b/lang/ar/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'تعذر تحميل الملف. قد لا يقبل الخادم ملفات بهذا الحجم.', 'zip_file' => ':attribute بحاجة إلى الرجوع إلى مِلَفّ داخل المِلَفّ المضغوط.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute بحاجة إلى الإشارة إلى مِلَفّ من نوع :validTypes، وجدت :foundType.', 'zip_model_expected' => 'عنصر البيانات المتوقع ولكن ":type" تم العثور عليه.', 'zip_unique' => 'يجب أن يكون :attribute فريداً لنوع الكائن داخل المِلَفّ المضغوط.', diff --git a/lang/bg/errors.php b/lang/bg/errors.php index dd0245180..d8dbd4e11 100644 --- a/lang/bg/errors.php +++ b/lang/bg/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/bg/notifications.php b/lang/bg/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/bg/notifications.php +++ b/lang/bg/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/bg/preferences.php b/lang/bg/preferences.php index f954340e2..8f5aaa07e 100644 --- a/lang/bg/preferences.php +++ b/lang/bg/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index 6dd27d74a..ae770c559 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Няма наложени ограничения', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Поддръжка', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Настройки за достъп до активи', 'roles_system_warning' => 'Важно: Добавянето на потребител в някое от горните три роли може да му позволи да промени собствените си права или правата на другите в системата. Възлагайте тези роли само на доверени потребители.', 'role_asset_desc' => 'Тези настройки за достъп контролират достъпа по подразбиране до активите в системата. Настройките за достъп до книги, глави и страници ще отменят тези настройки.', 'role_asset_admins' => 'Администраторите автоматично получават достъп до цялото съдържание, но тези опции могат да показват или скриват опциите за потребителския интерфейс.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Всички', 'role_own' => 'Собствени', 'role_controlled_by_asset' => 'Контролирани от актива, към който са качени', diff --git a/lang/bg/validation.php b/lang/bg/validation.php index e08eb55de..e2f9bdaa9 100644 --- a/lang/bg/validation.php +++ b/lang/bg/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Файлът не можа да бъде качен. Сървърът може да не приема файлове с такъв размер.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/bn/activities.php b/lang/bn/activities.php index 7e4ba8229..c9268f8ab 100644 --- a/lang/bn/activities.php +++ b/lang/bn/activities.php @@ -131,7 +131,7 @@ return [ 'sort_rule_create' => 'created sort rule', 'sort_rule_create_notification' => 'Sort rule successfully created', 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', + 'sort_rule_update_notification' => 'রোলটি সার্থকভাবে হালনাগাদ করা হয়েছে', 'sort_rule_delete' => 'deleted sort rule', 'sort_rule_delete_notification' => 'Sort rule successfully deleted', diff --git a/lang/bn/auth.php b/lang/bn/auth.php index 06ed376a7..30879fcb6 100644 --- a/lang/bn/auth.php +++ b/lang/bn/auth.php @@ -24,8 +24,8 @@ return [ 'password_hint' => 'ন্যূনতম ৮ অক্ষরের হতে হবে', 'forgot_password' => 'পাসওয়ার্ড ভুলে গেছেন?', 'remember_me' => 'লগইন স্থায়িত্ব ধরে রাখুন', - 'ldap_email_hint' => 'Please enter an email to use for this account.', - 'create_account' => 'Create Account', + 'ldap_email_hint' => 'অনুগ্রহ করে এই অ্যাকাউন্টের জন্য ব্যবহার করার জন্য একটি ইমেইল ঠিকানা লিখুন।', + 'create_account' => 'অ্যাকাউন্ট তৈরি করুন', 'already_have_account' => 'Already have an account?', 'dont_have_account' => 'Don\'t have an account?', 'social_login' => 'Social Login', @@ -39,16 +39,16 @@ return [ 'register_success' => 'Thanks for signing up! You are now registered and signed in.', // Login auto-initiation - 'auto_init_starting' => 'Attempting Login', + 'auto_init_starting' => 'লগইন করার চেষ্টা করা হচ্ছে', 'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.', 'auto_init_start_link' => 'Proceed with authentication', // Password Reset - 'reset_password' => 'Reset Password', + 'reset_password' => 'পাসওয়ার্ড রিসেট করুন', 'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.', 'reset_password_send_button' => 'Send Reset Link', 'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.', - 'reset_password_success' => 'Your password has been successfully reset.', + 'reset_password_success' => 'আপনার পাসওয়ার্ড সফলভাবে রিসেট করা হয়েছে.', 'email_reset_subject' => 'Reset your :appName password', 'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.', 'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.', diff --git a/lang/bn/errors.php b/lang/bn/errors.php index ee2fbfa21..32dac63e2 100644 --- a/lang/bn/errors.php +++ b/lang/bn/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/bn/notifications.php b/lang/bn/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/bn/notifications.php +++ b/lang/bn/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/bn/preferences.php b/lang/bn/preferences.php index 2e47604e4..c59ec62da 100644 --- a/lang/bn/preferences.php +++ b/lang/bn/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/bn/settings.php b/lang/bn/settings.php index 04aca4f2e..6d0f4ab88 100644 --- a/lang/bn/settings.php +++ b/lang/bn/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/bn/validation.php b/lang/bn/validation.php index d9b982d1e..ff028525d 100644 --- a/lang/bn/validation.php +++ b/lang/bn/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/bs/errors.php b/lang/bs/errors.php index f60f92f07..fc1744805 100644 --- a/lang/bs/errors.php +++ b/lang/bs/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/bs/notifications.php b/lang/bs/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/bs/notifications.php +++ b/lang/bs/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/bs/preferences.php b/lang/bs/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/bs/preferences.php +++ b/lang/bs/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index 81c2c0a93..c68605fe1 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/bs/validation.php b/lang/bs/validation.php index 4b026afd2..e7e62f2ab 100644 --- a/lang/bs/validation.php +++ b/lang/bs/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Fajl nije učitan. Server ne prihvata fajlove ove veličine.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/ca/activities.php b/lang/ca/activities.php index 8decaa739..4894c279a 100644 --- a/lang/ca/activities.php +++ b/lang/ca/activities.php @@ -18,35 +18,35 @@ return [ 'page_move_notification' => 'S’ha mogut la pàgina', // Chapters - 'chapter_create' => 'ha creat el capítol', + 'chapter_create' => 'S\'ha creat el capítol', 'chapter_create_notification' => 'S’ha creat el capítol', 'chapter_update' => 'ha actualitzat el capítol', 'chapter_update_notification' => 'S’ha actualitzat el capítol', 'chapter_delete' => 'ha suprimit el capítol', 'chapter_delete_notification' => 'S’ha suprimit el capítol', - 'chapter_move' => 'ha mogut el capítol', + 'chapter_move' => 's\'ha mogut el capítol', 'chapter_move_notification' => 'S’ha mogut el capítol', // Books - 'book_create' => 'ha creat el llibre', + 'book_create' => 'llibre creat', 'book_create_notification' => 'S’ha creat el llibre', 'book_create_from_chapter' => 'ha convertit el capítol a llibre', 'book_create_from_chapter_notification' => 'S’ha convertit el capítol a llibre', - 'book_update' => 'ha actualitzat el llibre', + 'book_update' => 'llibre actualitzat', 'book_update_notification' => 'S’ha actualitzat el llibre', - 'book_delete' => 'ha suprimit el llibre', + 'book_delete' => 'llibre suprimit', 'book_delete_notification' => 'S’ha suprimit el llibre', - 'book_sort' => 'ha ordenat el llibre', + 'book_sort' => 'llibre ordenat', 'book_sort_notification' => 'S’ha tornat a ordenar el llibre', // Bookshelves 'bookshelf_create' => 'ha creat el prestatge', 'bookshelf_create_notification' => 'S’ha creat el prestatge', - 'bookshelf_create_from_book' => 'ha convertit el llibre a prestatge', + 'bookshelf_create_from_book' => 'llibre convertit a prestatge', 'bookshelf_create_from_book_notification' => 'S’ha convertit el llibre a prestatge', - 'bookshelf_update' => 'ha actualitzat el prestatge', + 'bookshelf_update' => 'prestatge actualitzat', 'bookshelf_update_notification' => 'S’ha actualitzat el prestatge', - 'bookshelf_delete' => 'ha suprimit el prestatge', + 'bookshelf_delete' => 'prestatge suprimit', 'bookshelf_delete_notification' => 'S’ha suprimit el prestatge', // Revisions @@ -85,12 +85,12 @@ return [ 'webhook_delete_notification' => 'S’ha suprimit el webhook', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'importació creada', + 'import_create_notification' => 'L\'importació s\'ha carregat correctament', + 'import_run' => 'importació actualitzada', + 'import_run_notification' => 'Contingut importat correctament', + 'import_delete' => 'importació eliminada', + 'import_delete_notification' => 'Importació eliminada correctament', // Users 'user_create' => 'ha creat l’usuari', @@ -128,12 +128,12 @@ return [ 'comment_delete' => 'ha suprimit un comentari', // Sort Rules - 'sort_rule_create' => 'created sort rule', - 'sort_rule_create_notification' => 'Sort rule successfully created', - 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', - 'sort_rule_delete' => 'deleted sort rule', - 'sort_rule_delete_notification' => 'Sort rule successfully deleted', + 'sort_rule_create' => 'crear regla d\'ordenació', + 'sort_rule_create_notification' => 'Regla d\'ordenació creada correctament', + 'sort_rule_update' => 'regla d\'ordenació actualitzada', + 'sort_rule_update_notification' => 'Regla d\'ordenació actualitzada correctament', + 'sort_rule_delete' => 'regla d\'ordenació eliminada', + 'sort_rule_delete_notification' => 'Regla d\'ordenació eliminada correctament', // Other 'permissions_update' => 'ha actualitzat els permisos', diff --git a/lang/ca/common.php b/lang/ca/common.php index 698956601..8976edaa1 100644 --- a/lang/ca/common.php +++ b/lang/ca/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'Crea', 'update' => 'Actualitza', 'edit' => 'Edita', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Arxivar', + 'unarchive' => 'Desarxivar', 'sort' => 'Ordena', 'move' => 'Mou', 'copy' => 'Copia', @@ -111,5 +111,5 @@ return [ 'terms_of_service' => 'Condicions del servei', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Buscar :appName', ]; diff --git a/lang/ca/editor.php b/lang/ca/editor.php index 8cc04c1ca..e19d82f8f 100644 --- a/lang/ca/editor.php +++ b/lang/ca/editor.php @@ -25,7 +25,7 @@ return [ 'width' => 'Amplada', 'height' => 'Altura', 'More' => 'Més', - 'select' => 'Selecciona…', + 'select' => 'Selecciona …', // Toolbar 'formats' => 'Formats', @@ -48,7 +48,7 @@ return [ 'superscript' => 'Superíndex', 'subscript' => 'Subíndex', 'text_color' => 'Color del text', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Color ressaltat', 'custom_color' => 'Color personalitzat', 'remove_color' => 'Elimina el color', 'background_color' => 'Color de fons', @@ -149,7 +149,7 @@ return [ 'url' => 'URL', 'text_to_display' => 'Text per a mostrar', 'title' => 'Títol', - 'browse_links' => 'Browse links', + 'browse_links' => 'Explorar enllaços', 'open_link' => 'Obre l’enllaç', 'open_link_in' => 'Obre l’enllaç…', 'open_link_current' => 'A la finestra actual', @@ -166,8 +166,8 @@ return [ 'about' => 'Quant a l’Editor', 'about_title' => 'Quant a l’Editor WYSIWYG', 'editor_license' => 'Llicència i copyright de l’Editor', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Aquest editor està construït com una bifurcació de :lexicalLink i es distribueix sota la llicència MIT.', + 'editor_lexical_license_link' => 'Tots els detalls complets de la llicència es poden trobar aquí.', 'editor_tiny_license' => 'Aquest editor s’ha creat amb :tinyLink que es proporciona amb la llicència MIT.', 'editor_tiny_license_link' => 'Detalls de la llicència i el copyright de TinyMCE.', 'save_continue' => 'Desa la pàgina i continua', diff --git a/lang/ca/entities.php b/lang/ca/entities.php index 51bb4aecb..108a38c2c 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -39,34 +39,34 @@ return [ 'export_pdf' => 'Fitxer PDF', 'export_text' => 'Fitxer de text sense format', 'export_md' => 'Fitxer Markdown', - 'export_zip' => 'Portable ZIP', - 'default_template' => 'Default Page Template', - 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', - 'default_template_select' => 'Select a template page', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'export_zip' => 'ZIP portable', + 'default_template' => 'Plantilla de pàgina per defecte', + 'default_template_explain' => 'Assigna una plantilla de pàgina que s\'utilitzarà com a contingut predeterminat per a totes les pàgines creades en aquest element. Tingues en compte que això només s\'utilitzarà si el creador de pàgines té accés a la plantilla de pàgina triada.', + 'default_template_select' => 'Seleccioneu una plantilla de pàgina', + 'import' => 'Importar', + 'import_validate' => 'Validar importació', + 'import_desc' => 'Importar llibres, capítols i pàgines utilitzant una exportació ZIP portable de la mateixa o una altra instància. Selecciona un arxiu ZIP per continuar. Després que l\'arxiu s\'hagi penjat i validat, podrà configurar i confirmar l\'importació en la següent vista.', + 'import_zip_select' => 'Seleccioneu un fitxer ZIP per pujar', + 'import_zip_validation_errors' => 'S\'han detectat errors al validar l\'arxiu ZIP proporcionat:', + 'import_pending' => 'Importació pendent', + 'import_pending_none' => 'No s\'han iniciat les importacions.', + 'import_continue' => 'Continuar importació', + 'import_continue_desc' => 'Revisa el contingut que s\'ha d\'importar de l\'arxiu ZIP penjat. Quan estigui llest, executa l\'importació per afegir el seu contingut al sistema. L\'arxiu d\'importació ZIP penjat s\'eliminarà automàticament al finalitzar l\'importació correctament.', + 'import_details' => 'Detalls d\'importació', + 'import_run' => 'Executar importació', + 'import_size' => ':size Mida de l\'arxiu ZIP', + 'import_uploaded_at' => 'Penjat :relativeTime', + 'import_uploaded_by' => 'Actualitzat per', + 'import_location' => 'Importar ubicació', + 'import_location_desc' => 'Selecciona una ubicació de destí pel contingut importat. Necessitarà els permisos pertinents per crear-lo dins de la ubicació triada.', + 'import_delete_confirm' => 'Esteu segur que voleu suplir aquesta importació?', + 'import_delete_desc' => 'Això eliminarà l\'arxiu ZIP d\'importació penjat i no es pot desfer.', + 'import_errors' => 'Importar errors', + 'import_errors_desc' => 'S\'han produït els següents errors durant l\'intent d\'importació:', + 'breadcrumb_siblings_for_page' => 'Navegar entre pàgines del mateix nivell', + 'breadcrumb_siblings_for_chapter' => 'Navegar entre capítols del mateix nivell', + 'breadcrumb_siblings_for_book' => 'Navegar entre llibres del mateix nivell', + 'breadcrumb_siblings_for_bookshelf' => 'Navegar entre llibreries del mateix nivell', // Permissions and restrictions 'permissions' => 'Permisos', @@ -170,9 +170,9 @@ return [ 'books_search_this' => 'Cerca en aquest llibre', 'books_navigation' => 'Navegació del llibre', 'books_sort' => 'Ordena el contingut d’un 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. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.', - 'books_sort_auto_sort' => 'Auto Sort Option', - 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', + 'books_sort_desc' => 'Mou capítols i pàgines dins d\'un llibre per reorganitzar el seu contingut. Es poden afegir altres llibres que permetin moure fàcilment capítols i pàgines entre llibres. De manera opcional, es poden establir regles d\'ordenació automàtica per ordenar automàticament el contingut d\'aquest llibre quan hi hagi canvis.', + 'books_sort_auto_sort' => 'Opció d\'ordenació automàtica', + 'books_sort_auto_sort_active' => 'Opció d\'ordenació activa :sortName', 'books_sort_named' => 'Ordena el llibre «:bookName»', 'books_sort_name' => 'Ordena pel nom', 'books_sort_created' => 'Ordena per la data de creació', @@ -234,7 +234,7 @@ return [ 'pages_delete_draft' => 'Suprimeix l’esborrany de pàgina', 'pages_delete_success' => 'S’ha suprimit la pàgina', 'pages_delete_draft_success' => 'S’ha suprimit l’esborrany de pàgina', - 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_warning_template' => 'Aquesta pàgina està en ús com a plantilla de pàgina predeterminada de llibre o capítol. Aquests llibres o capítols ja no tindran una plantilla de pàgina predeterminada assignada després d\'eliminar aquesta pàgina.', 'pages_delete_confirm' => 'Esteu segur que voleu suprimir aquesta pàgina?', 'pages_delete_draft_confirm' => 'Esteu segur que voleu suprimir aquest esborrany de pàgina?', 'pages_editing_named' => 'Edició de la pàgina «:pageName»', @@ -251,8 +251,8 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(Contingut net)', 'pages_edit_switch_to_markdown_stable' => '(Contingut estable)', 'pages_edit_switch_to_wysiwyg' => 'Canvia a l’editor WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Canviar al nou editor WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(En Beta Test)', 'pages_edit_set_changelog' => 'Registre de canvis', 'pages_edit_enter_changelog_desc' => 'Introduïu una descripció breu dels canvis que heu fet', 'pages_edit_enter_changelog' => 'Registra un canvi', @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'Insereix un dibuix', 'pages_md_show_preview' => 'Mostra la visualització prèvia', 'pages_md_sync_scroll' => 'Sincronitza el desplaçament de la visualització prèvia', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Editor de text pla', 'pages_drawing_unsaved' => 'S’ha trobat un dibuix sense desar', 'pages_drawing_unsaved_confirm' => 'S’han trobat dades d’un dibuix d’un intent anterior de desar un dibuix. Voleu restaurar aquest dibuix no desat per a reprendre’n l’edició?', 'pages_not_in_chapter' => 'La pàgina no és un capítol', @@ -397,11 +397,11 @@ return [ 'comment' => 'Comentari', 'comments' => 'Comentaris', 'comment_add' => 'Afegeix un comentari', - 'comment_none' => 'No comments to display', + 'comment_none' => 'No hi ha comentaris per mostrar', 'comment_placeholder' => 'Deixa un comentari aquí', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_thread_count' => ':count fil de comentaris|:count fils de comentaris', + 'comment_archived_count' => ':count Arxivats', + 'comment_archived_threads' => 'Fils arxivats', 'comment_save' => 'Desa el comentari', 'comment_new' => 'Crea un comentari', 'comment_created' => 'ha comentat :createDiff', @@ -410,14 +410,14 @@ return [ 'comment_deleted_success' => 'S’ha suprimit el comentari', 'comment_created_success' => 'S’ha afegit un comentari', 'comment_updated_success' => 'S’ha actualitzat un comentari', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_archive_success' => 'Comentari arxivat', + 'comment_unarchive_success' => 'Comentari desarxivat', + 'comment_view' => 'Mostra el comentari', + 'comment_jump_to_thread' => 'Anar al fil', 'comment_delete_confirm' => 'Esteu segur que voleu suprimir aquest comentari?', 'comment_in_reply_to' => 'En resposta a :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => 'Referència', + 'comment_reference_outdated' => '(Obsolet)', 'comment_editor_explain' => 'Vet aquí els comentaris que s’han fet en aquesta pàgina. Els comentaris es poden fer i gestionar quan es visualitza la pàgina desada.', // Revision diff --git a/lang/ca/errors.php b/lang/ca/errors.php index 76fb293da..945d6fd0f 100644 --- a/lang/ca/errors.php +++ b/lang/ca/errors.php @@ -6,17 +6,17 @@ return [ // Permissions 'permission' => 'No teniu permís per a accedir a la pàgina sol·licitada.', - 'permissionJson' => 'No teniu permís per a fer l’acció sol·licitada.', + 'permissionJson' => 'No teniu permís per a executar l’acció sol·licitada.', // Auth 'error_user_exists_different_creds' => 'Ja existeix un usuari amb el correu electrònic :email però amb unes credencials diferents.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', - 'email_already_confirmed' => 'Ja s’ha confirmat el correu electrònic. Proveu d’iniciar sessió.', + 'auth_pre_register_theme_prevention' => 'El compte d\'usuari no s\'ha pogut registrar amb els detalls proporcionats', + 'email_already_confirmed' => 'L’adreça electrònica ja està confirmada. Proveu d’iniciar la sessió.', 'email_confirmation_invalid' => 'Aquest testimoni de confirmació no és vàlid o ja s’ha utilitzat. Proveu de tornar-vos a registrar.', 'email_confirmation_expired' => 'Aquest testimoni de confirmació ha caducat. S’ha enviat un altre correu electrònic de confirmació.', - 'email_confirmation_awaiting' => 'Cal confirmar l’adreça electrònica del compte que utilitzeu.', + 'email_confirmation_awaiting' => 'Cal confirmar l’adreça electrònica del compte que utilitzeu', 'ldap_fail_anonymous' => 'L’accés LDAP anònim ha fallat', - 'ldap_fail_authed' => 'L’accés LDAP amb el nom distintiu i la contrasenya proporcionades', + 'ldap_fail_authed' => 'L’accés LDAP amb el nom distintiu i la contrasenya proporcionats ha fallat', 'ldap_extension_not_installed' => 'L’extensió PHP de l’LDAP no està instal·lada', 'ldap_cannot_connect' => 'No s’ha pogut connectar amb el servidor LDAP perquè la connexió inicial ha fallat', 'saml_already_logged_in' => 'Ja heu iniciat sessió', @@ -29,15 +29,15 @@ return [ 'social_no_action_defined' => 'No s’ha definit cap acció', 'social_login_bad_response' => "S’ha produït un error en l’inici de sessió amb :socialAccount: \n:error", 'social_account_in_use' => 'Aquest compte de :socialAccount ja s’està utilitzant. Proveu d’iniciar sessió amb :socialAccount.', - 'social_account_email_in_use' => 'L’adreça electrònica :email ja s’està utilitzant. Si ja teniu uns compte podeu connectar-hi el vostre compte de :socialAccount des de la configuració del vostre perfil.', + 'social_account_email_in_use' => 'L’adreça electrònica :email ja està en ús. Si ja teniu un compte, podeu connectar-hi el vostre compte de :socialAccount a la configuració del vostre perfil.', 'social_account_existing' => 'Aquest compte de :socialAccount ja està associat al vostre perfil.', 'social_account_already_used_existing' => 'Aquest compte de :socialAccount ja està associat a un altre usuari.', 'social_account_not_used' => 'Aquest compte de :socialAccount no està associat a cap usuari. Associeu-lo a la configuració del vostre perfil. ', 'social_account_register_instructions' => 'Si encara no teniu un compte, podeu registrar-vos amb l’opció :socialAccount.', 'social_driver_not_found' => 'No s’ha trobat el controlador social', 'social_driver_not_configured' => 'La configuració de :socialAccount no és correcta.', - 'invite_token_expired' => 'Aquest enllaç d’invitació ha caducat. Proveu de reinicialitzar la contrasenya.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'invite_token_expired' => 'Aquest enllaç d’invitació ha caducat. Podeu provar de restablir la contrasenya del vostre compte.', + 'login_user_not_found' => 'No s\'ha pogut trobar un usuari per aquesta acció.', // System 'path_not_writable' => 'No s’ha pogut pujar a :filePath. Assegureu-vos que teniu permisos d’escriptura al servidor.', @@ -78,7 +78,7 @@ return [ // Users 'users_cannot_delete_only_admin' => 'No podeu suprimir l’administrador únic.', 'users_cannot_delete_guest' => 'No podeu suprimir l’usuari convidat.', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'No s\'ha pogut crear l\'usuari, ja que no s\'ha pogut enviar el correu d\'invitació', // Roles 'role_cannot_be_edited' => 'No es pot editar aquest rol.', @@ -106,16 +106,17 @@ return [ 'back_soon' => 'Aviat ho arreglarem.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'No es pot llegir el fitxer ZIP.', + 'import_zip_cant_decode_data' => 'No s\'ha pogut trobar i descodificar el fitxer data.json en el fitxer ZIP.', + 'import_zip_no_data' => 'Les dades del fitxer ZIP no contenen cap llibre, capítol o contingut de pàgina.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', + 'import_validation_failed' => 'Error en validar la importació del ZIP amb els errors:', + 'import_zip_failed_notification' => 'Error en importar l\'arxiu ZIP.', + 'import_perms_books' => 'Li falten els permisos necessaris per crear llibres.', + 'import_perms_chapters' => 'Li falten els permisos necessaris per crear capítols.', + 'import_perms_pages' => 'Li falten els permisos necessaris per crear pàgines.', + 'import_perms_images' => 'Li falten els permisos necessaris per crear imatges.', + 'import_perms_attachments' => 'Li falten els permisos necessaris per crear adjunts.', // API errors 'api_no_authorization_found' => 'No s’ha trobat cap testimoni d’autorització en aquesta sol·licitud.', diff --git a/lang/ca/notifications.php b/lang/ca/notifications.php index fdea33436..e3fb928a1 100644 --- a/lang/ca/notifications.php +++ b/lang/ca/notifications.php @@ -11,9 +11,11 @@ return [ 'updated_page_subject' => 'S’ha actualitzat la pàgina :pageName', 'updated_page_intro' => 'S’ha actualitzat una pàgina a :appName.', 'updated_page_debounce' => 'Per a evitar que s’acumulin les notificacions, durant un temps no se us notificarà cap canvi fet en aquesta pàgina pel mateix usuari.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nom de la pàgina:', - 'detail_page_path' => 'Camí de la pàgina:', + 'detail_page_path' => 'Ruta de la pàgina:', 'detail_commenter' => 'Autor del comentari:', 'detail_comment' => 'Comentari:', 'detail_created_by' => 'Creada per:', diff --git a/lang/ca/preferences.php b/lang/ca/preferences.php index 25206726e..09fdaa4e5 100644 --- a/lang/ca/preferences.php +++ b/lang/ca/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Gestioneu les notificacions de correu electrònic que rebreu quan es facin certes activitats.', 'notifications_opt_own_page_changes' => 'Notifica’m els canvis a les meves pàgines.', 'notifications_opt_own_page_comments' => 'Notifica’m la creació de comentaris a les meves pàgines.', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notifica’m les respostes als meus comentaris.', 'notifications_save' => 'Desa les preferències', 'notifications_update_success' => 'S’han actualitzat les preferències de notificació', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index e25a4a032..352291fe5 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -8,7 +8,7 @@ return [ // Common Messages 'settings' => 'Configuració', - 'settings_save' => 'Configuració de desat', + 'settings_save' => 'Guardar configuració', 'system_version' => 'Versió de sistema', 'categories' => 'Categories', @@ -19,8 +19,8 @@ return [ 'app_name_desc' => 'El nom es mostra a la capçalera i als correus electrònics enviats pel sistema.', 'app_name_header' => 'Mostra el nom a la capçalera', 'app_public_access' => 'Accés públic', - 'app_public_access_desc' => 'Si activeu aquesta opció les visitants podran accedir a la vostra instància del BookStack sense iniciar sessió.', - 'app_public_access_desc_guest' => 'L’accés per als visitants públics es pot gestionar amb l’usuari «Convidat».', + 'app_public_access_desc' => 'Si activeu aquesta opció permetrà als visitants, accedir a la vostra instància del BookStack sense iniciar sessió.', + 'app_public_access_desc_guest' => 'L’accés per als visitants públics es pot gestionar amb l’usuari Convidat.', 'app_public_access_toggle' => 'Permet l’accés públic', 'app_public_viewing' => 'Esteu segur que voleu permetre l’accés públic?', 'app_secure_images' => 'Pujada d’imatges amb més seguretat', @@ -75,34 +75,36 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No hi ha cap restricció', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', - 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_rules' => 'Sort Rules', - 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_rule_create' => 'Create Sort Rule', - 'sort_rule_edit' => 'Edit Sort Rule', - 'sort_rule_delete' => 'Delete Sort Rule', - 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', - 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', - 'sort_rule_details' => 'Sort Rule Details', - 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', - 'sort_rule_operations' => 'Sort Operations', - 'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.', - 'sort_rule_available_operations' => 'Available Operations', - 'sort_rule_available_operations_empty' => 'No operations remaining', - 'sort_rule_configured_operations' => 'Configured Operations', - 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', + 'sorting_book_default_desc' => 'Selecciona la regla d\'ordenació predeterminada per aplicar a nous llibres. Això no afectarà els llibres existents, i pot ser anul·lat per llibre.', + 'sorting_rules' => 'Regles d\'ordenació', + 'sorting_rules_desc' => 'Són operacions d\'ordenació predefinides que es poden aplicar al contingut en el sistema.', + 'sort_rule_assigned_to_x_books' => 'Assignat a :count llibre | Assignat a :count llibres', + 'sort_rule_create' => 'Crear regla d\'ordenació', + 'sort_rule_edit' => 'Editar regla d\'ordenació', + 'sort_rule_delete' => 'Eliminar regla d\'ordenació', + 'sort_rule_delete_desc' => 'Eliminar aquesta regla d\'ordenació del sistema. Els llibres que utilitzin aquest tipus, es revertiran a l\'ordenació manual.', + 'sort_rule_delete_warn_books' => 'Aquesta regla d\'ordenació s\'utilitza actualment en :count llibre(s). Està segur que vol eliminar-la?', + 'sort_rule_delete_warn_default' => 'Aquesta regla d\'ordenació s\'utilitza actualment com a predeterminada per als llibres. Està segur que vol eliminar-la?', + 'sort_rule_details' => 'Detalls de la regla d\'ordenació', + 'sort_rule_details_desc' => 'Defineix un nom per aquesta regla d\'ordenació, que apareixerà a les llistes quan els usuaris estiguin seleccionant un ordre.', + 'sort_rule_operations' => 'Operacions d\'ordenació', + 'sort_rule_operations_desc' => 'Configura les accions d\'ordenació a realitzar movent-les de la llista d\'operacions disponibles. En utilitzar-se, les operacions s\'aplicaran en ordre, de dalt cap a baix. Qualsevol canvi realitzat aquí, s\'aplicarà a tots els llibres assignats en guardar.', + 'sort_rule_available_operations' => 'Operacions disponibles', + 'sort_rule_available_operations_empty' => 'No hi ha operacions pendents', + 'sort_rule_configured_operations' => 'Operacions configurades', + 'sort_rule_configured_operations_empty' => 'Arrossegar/afegir operacions des de la llista d\'Operacions Disponibles', 'sort_rule_op_asc' => '(Asc)', 'sort_rule_op_desc' => '(Desc)', - 'sort_rule_op_name' => 'Name - Alphabetical', - 'sort_rule_op_name_numeric' => 'Name - Numeric', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', - 'sort_rule_op_chapters_first' => 'Chapters First', - 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sort_rule_op_name' => 'Nom - Alfabètic', + 'sort_rule_op_name_numeric' => 'Nom - Numèric', + 'sort_rule_op_created_date' => 'Data de creació', + 'sort_rule_op_updated_date' => 'Data d\'actualització', + 'sort_rule_op_chapters_first' => 'Capítols a l\'inici', + 'sort_rule_op_chapters_last' => 'Capítols al final', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Manteniment', @@ -139,7 +141,7 @@ return [ 'recycle_bin_contents_empty' => 'La paperera és buida', 'recycle_bin_empty' => 'Buida la paperera', 'recycle_bin_empty_confirm' => 'Se suprimiran permanentment tots els elements que hi ha a la paperera incloent-hi el contingut que hi hagi a cada element. Esteu segur que voleu buidar la paperera?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Aquesta acció suprimirà del sistema de manera permanent aquest element, juntament amb tots els fills que es llisten a sota, i no podreu restaurar aquest contingut. Segur que voleu suprimir de manera permanent aquest element?', 'recycle_bin_destroy_list' => 'Elements per destruir', 'recycle_bin_restore_list' => 'Elements per restaurar', 'recycle_bin_restore_confirm' => 'Aquesta acció restaurarà l’element suprimit, incloent-hi els elements fills, a la seva ubicació original. Si la ubicació original s’ha suprimit i és a la paperera, l’element pare també s’haurà de restaurar.', @@ -192,14 +194,16 @@ return [ 'role_access_api' => 'Accés a l’API del sistema', 'role_manage_settings' => 'Gestió de la configuració de l’aplicació', 'role_export_content' => 'Exportació de contingut', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importar contingut', 'role_editor_change' => 'Canvi de l’editor de pàgina', 'role_notifications' => 'Recepció i gestió de notificacions', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Permisos de recursos', 'roles_system_warning' => 'Tingueu en compte que l’accés a qualsevol dels tres permisos de dalt permeten que l’usuari canviï els seus privilegis i els privilegis d’altres usuaris. Assigneu rols d’usuari amb aquests permisos només a usuaris de confiança.', 'role_asset_desc' => 'Aquests permisos controlen l’accés per defecte als recursos del sistema. El permisos dels llibres, capítols i pàgines sobreescriuran aquests permisos.', 'role_asset_admins' => 'Als administradors se’ls dona accés automàticament a tot el contingut però aquestes opcions mostren o amaguen opcions de la interfície d’usuari.', 'role_asset_image_view_note' => 'Això té relació amb la visibilitat al gestor d’imatges. L’accés a les imatges pujades dependrà de l’opció d’emmagatzematge d’imatges dels sistema.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Tot', 'role_own' => 'Propi', 'role_controlled_by_asset' => 'Controlat pel recurs a què estan pujats', @@ -308,13 +312,13 @@ return [ 'webhooks_last_error_message' => 'Darrer missatge d’error:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'Llicències', + 'licenses_desc' => 'Aquesta pàgina detalla informació sobre la llicència de BookStack a més dels projectes i biblioteques que s\'utilitzen en BookStack. Molts projectes enumerats aquí poden ser utilitzats només en un context de desenvolupament.', + 'licenses_bookstack' => 'Llicència de BookStack', + 'licenses_php' => 'Llicències de Biblioteques de PHP', + 'licenses_js' => 'Llicències de Biblioteques de JavaScript', + 'licenses_other' => 'Altres llicències', + 'license_details' => 'Detalls de la llicència', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/ca/validation.php b/lang/ca/validation.php index e33ae336f..1debc5eb2 100644 --- a/lang/ca/validation.php +++ b/lang/ca/validation.php @@ -105,10 +105,11 @@ return [ 'url' => 'El format :attribute no és vàlid.', 'uploaded' => 'No s’ha pogut pujar el fitxer. És possible que el servidor no admeti fitxers d’aquesta mida.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'El :attribute necessita fer referència a un arxiu dins del ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', + 'zip_file_mime' => 'El :attribute necessita fer referència a un arxiu de tipus :validTyes, trobat :foundType.', + 'zip_model_expected' => 'S\'esperava un objecte de dades, però s\'ha trobat ":type".', + 'zip_unique' => 'El :attribute ha de ser únic pel tipus d\'objecte dins del ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/cs/entities.php b/lang/cs/entities.php index cda6b6c82..d65d85ccb 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Potvrzením odstraníte nahraný ZIP soubor. Tento krok nelze vrátit zpět.', 'import_errors' => 'Chyby importu', 'import_errors_desc' => 'Při pokusu o import došlo k následujícím chybám:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Přejít na jinou stránku', + 'breadcrumb_siblings_for_chapter' => 'Přejít na jinou kapitolu', + 'breadcrumb_siblings_for_book' => 'Přejít na jinou knihu', + 'breadcrumb_siblings_for_bookshelf' => 'Přejít na jinou polici', // Permissions and restrictions 'permissions' => 'Oprávnění', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index b1eeb54e0..2077bd4c4 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Nelze načíst ZIP soubor.', 'import_zip_cant_decode_data' => 'Nelze najít a dekódovat data.json v archivu ZIP.', 'import_zip_no_data' => 'ZIP archiv neobsahuje knihy, kapitoly nebo stránky.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Importování ZIP selhalo s chybami:', 'import_zip_failed_notification' => 'Nepodařilo se naimportovat ZIP soubor.', 'import_perms_books' => 'Chybí vám požadovaná oprávnění k vytvoření knih.', diff --git a/lang/cs/notifications.php b/lang/cs/notifications.php index caf75f179..7e20f654f 100644 --- a/lang/cs/notifications.php +++ b/lang/cs/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Aktualizovaná stránka: :pageName', 'updated_page_intro' => 'V :appName byla aktualizována stránka:', 'updated_page_debounce' => 'Po nějakou dobu neobdržíte další oznámení o aktualizaci této stránky stejným editorem, aby se omezil počet stejných zpráv.', + 'comment_mention_subject' => 'Byli jste zmíněni v komentáři na stránce: :pageName', + 'comment_mention_intro' => 'Byli jste zmíněni v komentáři na webu :appName:', 'detail_page_name' => 'Název stránky:', 'detail_page_path' => 'Umístění:', diff --git a/lang/cs/preferences.php b/lang/cs/preferences.php index ec7afaf27..be0f972f9 100644 --- a/lang/cs/preferences.php +++ b/lang/cs/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Nastavte si e-mailová oznámení, která dostanete při provedení určitých akcí v systému.', 'notifications_opt_own_page_changes' => 'Upozornit na změny stránek u kterých jsem vlastníkem', 'notifications_opt_own_page_comments' => 'Upozornit na komentáře na stránkách, které vlastním', + 'notifications_opt_comment_mentions' => 'Upozornit, když mě někdo zmíní v komentáři', 'notifications_opt_comment_replies' => 'Upozornit na odpovědi na mé komentáře', 'notifications_save' => 'Uložit nastavení', 'notifications_update_success' => 'Nastavení oznámení byla aktualizována!', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index e72d4b403..73ba6bfb0 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -75,7 +75,7 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Žádná omezení nebyla nastavena', // Sorting Settings - 'sorting' => 'Řazení', + 'sorting' => 'Seznamy a řazení', 'sorting_book_default' => 'Výchozí řazení knih', 'sorting_book_default_desc' => 'Vybere výchozí pravidlo řazení pro nové knihy. Řazení neovlivní existující knihy a může být upraveno u konkrétní knihy.', 'sorting_rules' => 'Pravidla řazení', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Datum aktualizace', 'sort_rule_op_chapters_first' => 'Kapitoly jako první', 'sort_rule_op_chapters_last' => 'Kapitoly jako poslední', + 'sorting_page_limits' => 'Počet zobrazených položek na stránce', + 'sorting_page_limits_desc' => 'Nastavte, kolik položek se má zobrazit na stránce v různých seznamech na webu. Obvykle bude nižší počet výkonnější, zatímco vyšší počet eliminuje nutnost proklikávat se několika stránkami. Doporučuje se použít sudý násobek čísla 3 (18, 24, 30 atd.).', // Maintenance settings 'maint' => 'Údržba', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importovat obsah', 'role_editor_change' => 'Změnit editor stránek', 'role_notifications' => 'Přijímat a spravovat oznámení', + 'role_permission_note_users_and_roles' => 'Tato oprávnění zároveň umožní zobrazit a vyhledat uživatele a role na webu.', 'role_asset' => 'Obsahová oprávnění', 'roles_system_warning' => 'Berte na vědomí, že přístup k některému ze tří výše uvedených oprávnění může uživateli umožnit změnit svá vlastní oprávnění nebo oprávnění ostatních uživatelů v systému. Přiřazujte role s těmito oprávněními pouze důvěryhodným uživatelům.', 'role_asset_desc' => 'Tato oprávnění řídí přístup k obsahu napříč systémem. Specifická oprávnění na knihách, kapitolách a stránkách převáží tato nastavení.', 'role_asset_admins' => 'Administrátoři automaticky dostávají přístup k veškerému obsahu, ale tyto volby mohou ukázat nebo skrýt volby v uživatelském rozhraní.', 'role_asset_image_view_note' => 'To se týká viditelnosti ve správci obrázků. Skutečný přístup k nahraným souborům obrázků bude záviset na možnosti uložení systémových obrázků.', + 'role_asset_users_note' => 'Tato oprávnění zároveň umožní zobrazit a vyhledat uživatele v systému.', 'role_all' => 'Vše', 'role_own' => 'Vlastní', 'role_controlled_by_asset' => 'Řídí se obsahem, do kterého jsou nahrávány', diff --git a/lang/cs/validation.php b/lang/cs/validation.php index b05d94625..219f95a87 100644 --- a/lang/cs/validation.php +++ b/lang/cs/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Nahrávání :attribute se nezdařilo.', 'zip_file' => ':attribute musí odkazovat na soubor v archivu ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute musí odkazovat na soubor typu :validTypes, nalezen :foundType.', 'zip_model_expected' => 'Očekáván datový objekt, ale nalezen „:type“.', 'zip_unique' => ':attribute musí být jedinečný pro typ objektu v archivu ZIP.', diff --git a/lang/cy/errors.php b/lang/cy/errors.php index db3a468b6..f6123c928 100644 --- a/lang/cy/errors.php +++ b/lang/cy/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Wedi methu darllen ffeil ZIP.', 'import_zip_cant_decode_data' => 'Wedi methu ffeindio a dadgodio cynnwys ZIP data.json.', 'import_zip_no_data' => 'Nid oes cynnwys llyfr, pennod neu dudalen disgwyliedig yn nata ffeil ZIP.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'ZIP mewnforyn wedi\'i methu dilysu gyda gwallau:', 'import_zip_failed_notification' => 'Wedi methu mewnforio ffeil ZIP.', 'import_perms_books' => 'Dych chi\'n methu\'r caniatâd gofynnol i greu llyfrau.', diff --git a/lang/cy/notifications.php b/lang/cy/notifications.php index 26f65f144..766e666b9 100644 --- a/lang/cy/notifications.php +++ b/lang/cy/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Tudalen wedi\'i diweddaru :pageName', 'updated_page_intro' => 'Mae tudalen newydd wedi cael ei diweddaru yn :appName:', 'updated_page_debounce' => 'Er mwyn atal llu o hysbysiadau, am gyfnod ni fyddwch yn cael hysbysiadau am ragor o olygiadau i\'r dudalen hon gan yr un golygydd.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Enw\'r dudalen:', 'detail_page_path' => 'Llwybr Tudalen:', diff --git a/lang/cy/preferences.php b/lang/cy/preferences.php index 6e00adfe8..4a08ca498 100644 --- a/lang/cy/preferences.php +++ b/lang/cy/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Rheoli’r hysbysiadau e-bost a gewch pan fydd gweithgaredd penodol yn cael ei gyflawni o fewn y system.', 'notifications_opt_own_page_changes' => 'Hysbysu am newidiadau i dudalennau yr wyf yn berchen arnynt', 'notifications_opt_own_page_comments' => 'Hysbysu am sylwadau ar dudalennau yr wyf yn berchen arnynt', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Hysbysu am atebion i\'m sylwadau', 'notifications_save' => 'Dewisiadau Cadw', 'notifications_update_success' => 'Mae’r dewisiadau hysbysu wedi\'u diweddaru!', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index cb08e8c90..29e86e28b 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ni osodwyd cyfyngiad', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Cynnal', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Mewnforio Cynnwys', 'role_editor_change' => 'Newid golygydd tudalen', 'role_notifications' => 'Derbyn a rheoli hysbysiadau', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Caniatâd Asedau', 'roles_system_warning' => 'Byddwch yn ymwybodol y gall mynediad i unrhyw un o\'r tri chaniatâd uchod ganiatáu i ddefnyddiwr newid eu breintiau eu hunain neu freintiau eraill yn y system. Neilltuo rolau gyda\'r caniatâd hyn i ddefnyddwyr dibynadwy yn unig.', 'role_asset_desc' => 'Mae\'r caniatâd hwn yn rheoli mynediad diofyn i\'r asedau o fewn y system. Bydd caniatâd ar Lyfrau, Penodau a Thudalennau yn diystyru\'r caniatâd hwn.', 'role_asset_admins' => 'Mae gweinyddwyr yn cael mynediad awtomatig i\'r holl gynnwys ond gall yr opsiynau hyn ddangos neu guddio opsiynau UI.', 'role_asset_image_view_note' => 'Mae hyn yn ymwneud â gwelededd o fewn y rheolwr delweddau. Bydd mynediad gwirioneddol i ffeiliau delwedd wedi\'u huwchlwytho yn dibynnu ar opsiwn storio delwedd y system.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Popeth', 'role_own' => 'Meddu', 'role_controlled_by_asset' => 'Wedi\'u rheoli gan yr ased y maent yn cael eu huwchlwytho iddo', diff --git a/lang/cy/validation.php b/lang/cy/validation.php index 5259f6472..63a19bf23 100644 --- a/lang/cy/validation.php +++ b/lang/cy/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Nid oedd modd uwchlwytho’r ffeil. Efallai na fydd y gweinydd yn derbyn ffeiliau o\'r maint hwn.', 'zip_file' => 'Mae\'r :attribute angen cyfeirio at ffeil yn y ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Mae\'r :attribute angen cyfeirio at ffeil o fath :valid Types, sydd wedi\'i ffeindio :foundType.', 'zip_model_expected' => 'Dyswgyl am wrthrych data ond wedi ffeindio ":type".', 'zip_unique' => 'Mae rhaid y :attribute fod yn unigol i\'r fath o wrthrych yn y ZIP.', diff --git a/lang/da/auth.php b/lang/da/auth.php index 9f717a3d5..dcc531125 100644 --- a/lang/da/auth.php +++ b/lang/da/auth.php @@ -6,11 +6,11 @@ */ return [ - 'failed' => 'Dee indtastede brugeroplysninger stemmer ikke overens med vores registreringer.', + 'failed' => 'De indtastede brugeroplysninger stemmer ikke overens med vores registreringer.', 'throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds sekunder.', // Login & Register - 'sign_up' => 'Registrér', + 'sign_up' => 'Registrer', 'log_in' => 'Log ind', 'log_in_with' => 'Log ind med :socialDriver', 'sign_up_with' => 'Registrér med :socialDriver', @@ -45,28 +45,28 @@ return [ // Password Reset 'reset_password' => 'Nulstil adgangskode', - 'reset_password_send_instructions' => 'Indtast din E-Mail herunder og du vil blive sendt en E-Mail med et link til at nulstille din adgangskode.', + 'reset_password_send_instructions' => 'Indtast din e-mail herunder og du vil blive sendt en e-mail med et link til at nulstille din adgangskode.', 'reset_password_send_button' => 'Send link til nulstilling', 'reset_password_sent' => 'Et link til nulstilling af adgangskode sendes til :email, hvis den e-mail-adresse findes i systemet.', 'reset_password_success' => 'Din adgangskode er blevet nulstillet.', 'email_reset_subject' => 'Nulstil din :appName adgangskode', - 'email_reset_text' => 'Du modtager denne E-Mail fordi vi har modtaget en anmodning om at nulstille din adgangskode.', + 'email_reset_text' => 'Du modtager denne e-mail fordi vi har modtaget en anmodning om at nulstille din adgangskode.', 'email_reset_not_requested' => 'Hvis du ikke har anmodet om at få din adgangskode nulstillet, behøver du ikke at foretage dig noget.', // Email Confirmation 'email_confirm_subject' => 'Bekræft din E-Mail på :appName', 'email_confirm_greeting' => 'Tak for at oprette dig på :appName!', - 'email_confirm_text' => 'Bekræft venligst din E-Mail adresse ved at klikke på linket nedenfor:', - 'email_confirm_action' => 'Bekræft E-Mail', - 'email_confirm_send_error' => 'E-Mail-bekræftelse kræves, men systemet kunne ikke sende E-Mailen. Kontakt administratoren for at sikre, at E-Mail er konfigureret korrekt.', - 'email_confirm_success' => 'Din email er blevet bekræftet! Du bør nu kune logge ind med denne emailadresse.', + 'email_confirm_text' => 'Bekræft venligst din e-mail adresse ved at klikke på linket nedenfor:', + 'email_confirm_action' => 'Bekræft e-mail', + 'email_confirm_send_error' => 'E-mailbekræftelse kræves, men systemet kunne ikke sende e-mailen. Kontakt administratoren for at sikre, at e-mail er konfigureret korrekt.', + 'email_confirm_success' => 'Din e-mail er blevet bekræftet! Du bør nu kunne logge ind med denne e-mailadresse.', 'email_confirm_resent' => 'Bekræftelsesmail sendt, tjek venligst din indboks.', 'email_confirm_thanks' => 'Tak for bekræftelsen!', 'email_confirm_thanks_desc' => 'Vent venligst et øjeblik, mens din bekræftelse behandles. Hvis du ikke bliver omdirigeret efter 3 sekunder, skal du trykke på linket "Fortsæt" nedenfor for at fortsætte.', - 'email_not_confirmed' => 'E-Mail adresse ikke bekræftet', - 'email_not_confirmed_text' => 'Din E-Mail adresse er endnu ikke blevet bekræftet.', - 'email_not_confirmed_click_link' => 'Klik venligst på linket i E-Mailen der blev sendt kort efter du registrerede dig.', + 'email_not_confirmed' => 'E-mailadresse ikke bekræftet', + 'email_not_confirmed_text' => 'Din e-mailadresse er endnu ikke blevet bekræftet.', + 'email_not_confirmed_click_link' => 'Klik venligst på linket i e-mailen der blev sendt kort efter du registrerede dig.', 'email_not_confirmed_resend' => 'Hvis du ikke kan finde E-Mailen, kan du du få gensendt bekræftelsesemailen ved at trykke herunder.', 'email_not_confirmed_resend_button' => 'Gensend bekræftelsesemail', diff --git a/lang/da/common.php b/lang/da/common.php index 69c80e844..db75772c8 100644 --- a/lang/da/common.php +++ b/lang/da/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'Opret', 'update' => 'Opdater', 'edit' => 'Rediger', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Arkiver', + 'unarchive' => 'Tilbagekald', 'sort' => 'Sorter', 'move' => 'Flyt', 'copy' => 'Kopier', @@ -50,8 +50,8 @@ return [ 'unfavourite' => 'Fjern som foretrukken', 'next' => 'Næste', 'previous' => 'Forrige', - 'filter_active' => 'Aktivt Filter:', - 'filter_clear' => 'Nulstil Filter', + 'filter_active' => 'Aktivt filter:', + 'filter_clear' => 'Nulstil filter', 'download' => 'Hent', 'open_in_tab' => 'Åben i ny fane', 'open' => 'Åbn', diff --git a/lang/da/components.php b/lang/da/components.php index 410ed4fc5..3deeadf48 100644 --- a/lang/da/components.php +++ b/lang/da/components.php @@ -6,8 +6,8 @@ return [ // Image Manager 'image_select' => 'Billedselektion', - 'image_list' => 'Billede Liste', - 'image_details' => 'Billede Detaljer', + 'image_list' => 'Billedliste', + 'image_details' => 'Billeddetaljer', 'image_upload' => 'Upload billede', 'image_intro' => 'Her kan du vælge og administrere billeder, der tidligere er blevet uploadet til systemet.', 'image_intro_upload' => 'Upload et nyt billede ved at trække en billedfil ind i dette vindue, eller ved at bruge knappen "Upload billede" ovenfor.', diff --git a/lang/da/entities.php b/lang/da/entities.php index c91772190..66e3fd206 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'Indsæt tegning', 'pages_md_show_preview' => 'Vis forhåndsvisning', 'pages_md_sync_scroll' => 'Rulning af forhåndsvisning af synkronisering', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Klartekst editor', 'pages_drawing_unsaved' => 'Ikke gemt tegning fundet', 'pages_drawing_unsaved_confirm' => 'Der blev fundet ikke-gemte tegningsdata fra et tidligere mislykket forsøg på at gemme en tegning. Vil du gendanne og fortsætte med at redigere denne ikke-gemte tegning?', 'pages_not_in_chapter' => 'Side er ikke i et kapitel', @@ -400,7 +400,7 @@ return [ 'comment_none' => 'No comments to display', 'comment_placeholder' => 'Skriv en kommentar her', 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', + 'comment_archived_count' => ':count Arkiveret', 'comment_archived_threads' => 'Archived Threads', 'comment_save' => 'Gem kommentar', 'comment_new' => 'Ny kommentar', @@ -412,12 +412,12 @@ return [ 'comment_updated_success' => 'Kommentaren er opdateret', 'comment_archive_success' => 'Comment archived', 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_view' => 'Se kommentar', + 'comment_jump_to_thread' => 'Hop til tråd', 'comment_delete_confirm' => 'Er du sikker på, at du vil slette denne kommentar?', 'comment_in_reply_to' => 'Som svar til :commentId', 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference_outdated' => '(Forældet)', 'comment_editor_explain' => 'Her er de kommentarer, der er blevet efterladt på denne side. Kommentarer kan tilføjes og administreres, når du ser den gemte side.', // Revision diff --git a/lang/da/errors.php b/lang/da/errors.php index 864dec270..6f9d1d53c 100644 --- a/lang/da/errors.php +++ b/lang/da/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Kunne ikke læse ZIP-filen.', 'import_zip_cant_decode_data' => 'Kunne ikke finde og afkode ZIP data.json-indhold.', 'import_zip_no_data' => 'ZIP-filens data har ikke noget forventet bog-, kapitel- eller sideindhold.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP kunne ikke valideres med fejl:', 'import_zip_failed_notification' => 'Kunne ikke importere ZIP-fil.', 'import_perms_books' => 'Du mangler de nødvendige tilladelser til at oprette bøger.', diff --git a/lang/da/notifications.php b/lang/da/notifications.php index 8074c03c4..3aaf2ff18 100644 --- a/lang/da/notifications.php +++ b/lang/da/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Opdateret side: :pageName', 'updated_page_intro' => 'En side er blevet opdateret i :appName:', 'updated_page_debounce' => 'For at forhindre en masse af notifikationer, i et stykke tid vil du ikke blive sendt notifikationer for yderligere redigeringer til denne side af den samme editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Sidens navn:', 'detail_page_path' => 'Sidesti:', diff --git a/lang/da/passwords.php b/lang/da/passwords.php index 343fa2b85..b58a4c94b 100644 --- a/lang/da/passwords.php +++ b/lang/da/passwords.php @@ -7,7 +7,7 @@ return [ 'password' => 'Adgangskoder skal være mindst otte tegn og svare til bekræftelsen.', - 'user' => "Vi kan ikke finde en bruger med den e-mail adresse.", + 'user' => "Vi kan ikke finde en bruger med den e-mailadresse.", 'token' => 'Linket til nulstilling af adgangskode er ugyldigt for denne e-mail-adresse.', 'sent' => 'Vi har sendt dig en e-mail med et link til at nulstille adgangskoden!', 'reset' => 'Dit kodeord er blevet nulstillet!', diff --git a/lang/da/preferences.php b/lang/da/preferences.php index 9b9436dba..5ba4f450c 100644 --- a/lang/da/preferences.php +++ b/lang/da/preferences.php @@ -23,13 +23,14 @@ return [ 'notifications_desc' => 'Administrer de e-mail-notifikationer, du modtager, når visse aktiviteter udføres i systemet.', 'notifications_opt_own_page_changes' => 'Adviser ved ændringer af sider, jeg ejer', 'notifications_opt_own_page_comments' => 'Adviser ved kommentarer på sider, jeg ejer', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Adviser ved svar på mine kommentarer', - 'notifications_save' => 'Gem Indstillinger', + 'notifications_save' => 'Gem indstillinger', 'notifications_update_success' => 'Indstillinger for notifikationer er blevet opdateret!', - 'notifications_watched' => 'Overvågede & Ignorerede', + 'notifications_watched' => 'Overvågede og ignorerede', 'notifications_watched_desc' => 'Nedenfor er de elementer, der har brugerdefinerede overvågning aktivt. For at opdatere dine præferencer for disse, gå til elementet og find derefter overvågning i sidepanelet.', - 'auth' => 'Adgang & Sikkerhed', + 'auth' => 'Adgang og sikkerhed', 'auth_change_password' => 'Skift adgangskode', 'auth_change_password_desc' => 'Skift den adgangskode, du bruger til at logge ind med. Den skal være mindst 8 tegn lang.', 'auth_change_password_success' => 'Adgangskoden er blevet opdateret!', @@ -41,7 +42,7 @@ return [ 'profile_email_desc' => 'Denne e-mail vil blive brugt til notifikationer og, afhængigt af aktiv systemgodkendelse, systemadgang.', 'profile_email_no_permission' => 'Desværre har du ikke tilladelse til at ændre din e-mailadresse. Hvis du ønsker at ændre dette, skal du bede en administrator om at ændre dette for dig.', 'profile_avatar_desc' => 'Vælg et billede som vil blive brugt til at repræsentere dig selv over for andre i systemet. Ideelt set bør dette billede være kvadrat og omkring 256px i bredde og højde.', - 'profile_admin_options' => 'Administrator Indstillinger', + 'profile_admin_options' => 'Administratorindstillinger', 'profile_admin_options_desc' => 'Yderligere indstillinger på administratorniveau, såsom dem der håndterer rolleopgaver, kan findes for din brugerkonto i området "Indstillinger > Brugere".', 'delete_account' => 'Slet konto', diff --git a/lang/da/settings.php b/lang/da/settings.php index a4136f6fd..8d89c30af 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -26,14 +26,14 @@ return [ 'app_secure_images' => 'Højere sikkerhed for billeduploads', 'app_secure_images_toggle' => 'Aktiver højere sikkerhed for billeduploads', 'app_secure_images_desc' => 'Af performanceårsager er alle billeder offentlige. Denne funktion tilføjer en tilfældig, vanskelig at gætte streng foran billed-url\'er. Sørg for, at mappeindeksering ikke er aktiveret for at forhindre nem adgang.', - 'app_default_editor' => 'Standard Side Editor', + 'app_default_editor' => 'Standard sideeditor', 'app_default_editor_desc' => 'Vælg hvilken editor der som standard skal bruges ved redigering af nye sider. Dette kan tilsidesættes på side niveau, hvor tilladelser tillader det.', 'app_custom_html' => 'Tilpasset HTML head indhold', 'app_custom_html_desc' => 'Alt indhold tilføjet her, vil blive indsat i bunden af 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_logo' => 'Applikationslogo', 'app_logo_desc' => 'Det bruges blandt andet i applikationens headerbar. Dette billede skal være 86px i højden. Store billeder vil blive skaleret ned.', - 'app_icon' => 'Program ikon', + 'app_icon' => 'Programikon', 'app_icon_desc' => 'Dette ikon bruges til browserfaner og genvejsikoner. Det skal være et 256px kvadratisk PNG-billede.', '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.', @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ingen restriktion opsat', // Sorting Settings - 'sorting' => 'Sortering', - 'sorting_book_default' => 'Standard bog-sortering', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Vælg den standardsorteringsregel, der skal gælde for nye bøger. Dette påvirker ikke eksisterende bøger og kan tilsidesættes for hver enkelt bog.', 'sorting_rules' => 'Regler for sortering', 'sorting_rules_desc' => 'Det er foruddefinerede sorteringsoperationer, som kan anvendes på indhold i systemet.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Opdateret dato', 'sort_rule_op_chapters_first' => 'Kapitler først', 'sort_rule_op_chapters_last' => 'De sidste kapitler', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Vedligeholdelse', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importer indhold', 'role_editor_change' => 'Skift side editor', 'role_notifications' => 'Modtag og administrer notifikationer', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Tilladelser for medier og "assets"', 'roles_system_warning' => 'Vær opmærksom på, at adgang til alle af de ovennævnte tre tilladelser, kan give en bruger mulighed for at ændre deres egne brugerrettigheder eller brugerrettigheder for andre i systemet. Tildel kun roller med disse tilladelser til betroede brugere.', 'role_asset_desc' => 'Disse tilladelser kontrollerer standardadgang til medier og "assets" i systemet. Tilladelser til bøger, kapitler og sider tilsidesætter disse tilladelser.', 'role_asset_admins' => 'Administratorer får automatisk adgang til alt indhold, men disse indstillinger kan vise eller skjule UI-indstillinger.', 'role_asset_image_view_note' => 'Dette vedrører synlighed i billedhåndteringen. Den faktiske adgang til uploadede billedfiler vil afhænge af systemets billedlagringsindstilling.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alle', 'role_own' => 'Eget', 'role_controlled_by_asset' => 'Styres af det medie/"asset", de uploades til', @@ -245,7 +249,7 @@ return [ 'users_social_accounts_info' => 'Her kan du forbinde dine andre konti for hurtigere og lettere login. Afbrydelse af en konto her tilbagekalder ikke tidligere autoriseret adgang. Tilbagekald adgang fra dine profilindstillinger på den tilsluttede sociale konto.', 'users_social_connect' => 'Forbind konto', 'users_social_disconnect' => 'Frakobl konto', - 'users_social_status_connected' => 'Tilsuttet', + 'users_social_status_connected' => 'Tilsluttet', 'users_social_status_disconnected' => 'Afbrudt', 'users_social_connected' => ':socialAccount kontoen blev knyttet til din profil.', 'users_social_disconnected' => ':socialAccount kontoen blev afbrudt fra din profil.', @@ -289,7 +293,7 @@ return [ 'webhooks_details' => 'Webhook detaljer', 'webhooks_details_desc' => 'Angiv et brugervenligt navn og et POST endpoint som en lokation for webhook data at blive sendt til.', 'webhooks_events' => 'Webhook Begivenheder', - 'webhooks_events_desc' => 'Vælg alle begivenhederd er skal udløse denne webhook til at blive kaldt.', + 'webhooks_events_desc' => 'Vælg alle begivenheder der skal udløse denne webhook til at blive kaldt.', 'webhooks_events_warning' => 'Husk, at disse begivenheder vil blive udløst for alle valgte begivenheder, selv om brugerdefinerede tilladelser bliver anvendt. Sørg for, at brugen af denne webhook ikke vil afsløre fortroligt indhold.', 'webhooks_events_all' => 'Alle systemhændelser', 'webhooks_name' => 'Webhook Navn', diff --git a/lang/da/validation.php b/lang/da/validation.php index 3caf1417a..36b9b49fb 100644 --- a/lang/da/validation.php +++ b/lang/da/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Filen kunne ikke oploades. Serveren accepterer muligvis ikke filer af denne størrelse.', 'zip_file' => 'Attributten skal henvise til en fil i ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Attributten skal henvise til en fil af typen: validTypes, fundet:foundType.', 'zip_model_expected' => 'Data objekt forventet men ":type" fundet.', 'zip_unique' => 'Attributten skal være unik for objekttypen i ZIP.', diff --git a/lang/de/entities.php b/lang/de/entities.php index ac70c2a9f..94c327b7e 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -65,7 +65,7 @@ return [ 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', + 'breadcrumb_siblings_for_book' => 'Navigiere in Büchern', 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', // Permissions and restrictions @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'Zeichnung einfügen', 'pages_md_show_preview' => 'Vorschau anzeigen', 'pages_md_sync_scroll' => 'Vorschau synchronisieren', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Einfacher Editor', 'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden', 'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchten Sie diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?', 'pages_not_in_chapter' => 'Seite ist in keinem Kapitel', diff --git a/lang/de/errors.php b/lang/de/errors.php index c1f65d940..56dc6d59e 100644 --- a/lang/de/errors.php +++ b/lang/de/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', 'import_zip_cant_decode_data' => 'ZIP data.json konnte nicht gefunden und dekodiert werden.', 'import_zip_no_data' => 'ZIP-Datei Daten haben kein erwartetes Buch, Kapitel oder Seiteninhalt.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'ZIP Import konnte mit Fehlern nicht validiert werden:', 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', 'import_perms_books' => 'Ihnen fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', diff --git a/lang/de/notifications.php b/lang/de/notifications.php index f3b86b502..71b71d1b6 100644 --- a/lang/de/notifications.php +++ b/lang/de/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Aktualisierte Seite: :pageName', 'updated_page_intro' => 'Eine Seite wurde in :appName aktualisiert:', 'updated_page_debounce' => 'Um eine Flut von Benachrichtigungen zu vermeiden, werden Sie für eine gewisse Zeit keine Benachrichtigungen für weitere Bearbeitungen dieser Seite durch denselben Bearbeiter erhalten.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Name der Seite:', 'detail_page_path' => 'Seitenpfad:', diff --git a/lang/de/preferences.php b/lang/de/preferences.php index 26f0b05da..1f74f3d3e 100644 --- a/lang/de/preferences.php +++ b/lang/de/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Legen Sie fest, welche E-Mail-Benachrichtigungen Sie erhalten, wenn bestimmte Aktivitäten im System durchgeführt werden.', 'notifications_opt_own_page_changes' => 'Benachrichtigung bei Änderungen an eigenen Seiten', 'notifications_opt_own_page_comments' => 'Benachrichtigung bei Kommentaren an eigenen Seiten', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Bei Antworten auf meine Kommentare benachrichtigen', 'notifications_save' => 'Einstellungen speichern', 'notifications_update_success' => 'Benachrichtigungseinstellungen wurden aktualisiert!', diff --git a/lang/de/settings.php b/lang/de/settings.php index 250668e8a..a87408995 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -76,8 +76,8 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt', // Sorting Settings - 'sorting' => 'Sortierung', - 'sorting_book_default' => 'Standard-Buchsortierung', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Wählen Sie die Standard-Sortierregel aus, die auf neue Bücher angewendet werden soll. Dies wirkt sich nicht auf bestehende Bücher aus und kann pro Buch überschrieben werden.', 'sorting_rules' => 'Sortierregeln', 'sorting_rules_desc' => 'Dies sind vordefinierte Sortieraktionen, die auf Inhalte im System angewendet werden können.', @@ -104,6 +104,8 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung 'sort_rule_op_updated_date' => 'Aktualisierungsdatum', 'sort_rule_op_chapters_first' => 'Kapitel zuerst', 'sort_rule_op_chapters_last' => 'Kapitel zuletzt', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Wartung', @@ -196,11 +198,13 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiten-Editor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Berechtigungen', 'roles_system_warning' => 'Beachten Sie, dass der Zugriff auf eine der oben genannten drei Berechtigungen einem Benutzer erlauben kann, seine eigenen Berechtigungen oder die Rechte anderer im System zu ändern. Weisen Sie nur Rollen, mit diesen Berechtigungen, vertrauenswürdigen Benutzern zu.', 'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungenen.', 'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, aber diese Optionen können Oberflächenoptionen ein- oder ausblenden.', 'role_asset_image_view_note' => 'Das bezieht sich auf die Sichtbarkeit innerhalb des Bildmanagers. Der tatsächliche Zugriff auf hochgeladene Bilddateien hängt von der Speicheroption des Systems für Bilder ab.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alle', 'role_own' => 'Eigene', 'role_controlled_by_asset' => 'Berechtigungen werden vom Uploadziel bestimmt', diff --git a/lang/de/validation.php b/lang/de/validation.php index 2ffad0529..21e850bbf 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', 'zip_file' => ':attribute muss eine Datei innerhalb des ZIP referenzieren.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute muss eine Datei des Typs :validType referenzieren, gefunden :foundType.', 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index 346cd891e..a3e6e5f36 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -65,7 +65,7 @@ return [ 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', + 'breadcrumb_siblings_for_book' => 'Navigiere in Büchern', 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', // Permissions and restrictions @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'Zeichnung einfügen', 'pages_md_show_preview' => 'Vorschau anzeigen', 'pages_md_sync_scroll' => 'Vorschau synchronisieren', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Einfacher Editor', 'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden', 'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchtest du diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?', 'pages_not_in_chapter' => 'Seite ist in keinem Kapitel', diff --git a/lang/de_informal/errors.php b/lang/de_informal/errors.php index 856f02b48..cd14dd929 100644 --- a/lang/de_informal/errors.php +++ b/lang/de_informal/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', 'import_zip_cant_decode_data' => 'Konnte Inhalt der data.json im ZIP nicht finden und dekodieren.', 'import_zip_no_data' => 'ZIP-Datei hat kein erwartetes Buch, Kapitel oder Seiteninhalt.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'ZIP Import konnte aufgrund folgender Fehler nicht validiert werden:', 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', 'import_perms_books' => 'Dir fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', diff --git a/lang/de_informal/notifications.php b/lang/de_informal/notifications.php index 0bf7739f4..99c270ec1 100644 --- a/lang/de_informal/notifications.php +++ b/lang/de_informal/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Aktualisierte Seite: :pageName', 'updated_page_intro' => 'Eine Seite wurde in :appName aktualisiert:', 'updated_page_debounce' => 'Um eine Flut von Benachrichtigungen zu vermeiden, wirst du für eine gewisse Zeit keine Benachrichtigungen für weitere Bearbeitungen dieser Seite durch denselben Bearbeiter erhalten.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Seitenname:', 'detail_page_path' => 'Seitenpfad:', diff --git a/lang/de_informal/preferences.php b/lang/de_informal/preferences.php index 07b83a8da..bfb57b2f4 100644 --- a/lang/de_informal/preferences.php +++ b/lang/de_informal/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Lege fest, welche E-Mail-Benachrichtigungen du erhältst, wenn bestimmte Aktivitäten im System durchgeführt werden.', 'notifications_opt_own_page_changes' => 'Benachrichtigung bei Änderungen an eigenen Seiten', 'notifications_opt_own_page_comments' => 'Benachrichtigung bei Kommentaren an eigenen Seiten', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Bei Antworten auf meine Kommentare benachrichtigen', 'notifications_save' => 'Einstellungen speichern', 'notifications_update_success' => 'Benachrichtigungseinstellungen wurden aktualisiert!', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 50003c58e..ab9a075a0 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -76,8 +76,8 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt', // Sorting Settings - 'sorting' => 'Sortierung', - 'sorting_book_default' => 'Standard-Buchsortierung', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Wähle die Standard-Sortierregel aus, die auf neue Bücher angewendet werden soll. Dies wirkt sich nicht auf bestehende Bücher aus und kann pro Buch überschrieben werden.', 'sorting_rules' => 'Sortierregeln', 'sorting_rules_desc' => 'Dies sind vordefinierte Sortieraktionen, die auf Inhalte im System angewendet werden können.', @@ -104,6 +104,8 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'sort_rule_op_updated_date' => 'Aktualisierungsdatum', 'sort_rule_op_chapters_first' => 'Kapitel zuerst', 'sort_rule_op_chapters_last' => 'Kapitel zuletzt', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Wartung', @@ -196,11 +198,13 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiteneditor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Berechtigungen', 'roles_system_warning' => 'Beachte, dass der Zugriff auf eine der oben genannten drei Berechtigungen einem Benutzer erlauben kann, seine eigenen Berechtigungen oder die Rechte anderer im System zu ändern. Weise nur Rollen mit diesen Berechtigungen vertrauenswürdigen Benutzern zu.', 'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungen.', 'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, aber diese Optionen können Oberflächenoptionen ein- oder ausblenden.', 'role_asset_image_view_note' => 'Das bezieht sich auf die Sichtbarkeit innerhalb des Bildmanagers. Der tatsächliche Zugriff auf hochgeladene Bilddateien hängt von der Speicheroption des Systems für Bilder ab.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alle', 'role_own' => 'Eigene', 'role_controlled_by_asset' => 'Berechtigungen werden vom Uploadziel bestimmt', diff --git a/lang/de_informal/validation.php b/lang/de_informal/validation.php index f7be5a016..d693adecd 100644 --- a/lang/de_informal/validation.php +++ b/lang/de_informal/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', 'zip_file' => ':attribute muss auf eine Datei innerhalb des ZIP verweisen.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute muss eine Datei des Typs :validType referenzieren, gefunden :foundType.', 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', diff --git a/lang/el/errors.php b/lang/el/errors.php index cdfa2155f..fb13ec2fb 100644 --- a/lang/el/errors.php +++ b/lang/el/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/el/notifications.php b/lang/el/notifications.php index 3924cab3b..e9a9fe15d 100644 --- a/lang/el/notifications.php +++ b/lang/el/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Ενημερωμένη σελίδα: :pageName', 'updated_page_intro' => 'Μια σελίδα έχει ενημερωθεί στο :appName:', 'updated_page_debounce' => 'Για να αποτρέψετε μαζικές ειδοποιήσεις, για κάποιο διάστημα δε θα σας αποστέλλονται ειδοποιήσεις για περαιτέρω αλλαγές σε αυτήν τη σελίδα από τον ίδιο συντάκτη.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Όνομα σελίδας:', 'detail_page_path' => 'Διαδρομή σελίδας:', diff --git a/lang/el/preferences.php b/lang/el/preferences.php index 4f7e97d52..2c2ce8fe9 100644 --- a/lang/el/preferences.php +++ b/lang/el/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/el/settings.php b/lang/el/settings.php index 172038fd0..67461604e 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Δε έχουν ρυθμιστεί περιορισμοί ακόμα', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Συντήρηση', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Εισαγωγή περιεχομένου', 'role_editor_change' => 'Αλλαγή προγράμματος επεξεργασίας σελίδας', 'role_notifications' => 'Λήψη & διαχείριση ειδοποιήσεων', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Δικαιώματα Συστήματος', 'roles_system_warning' => 'Λάβετε υπόψη ότι η πρόσβαση σε οποιοδήποτε από τις τρεις παραπάνω άδειες (δικαιώματα) μπορεί να επιτρέψει σε έναν χρήστη να αλλάξει τα δικά του προνόμια ή τα προνόμια άλλων στο σύστημα. Εκχωρήστε ρόλους με αυτά τα δικαιώματα μόνο σε αξιόπιστους χρήστες.', 'role_asset_desc' => 'Αυτά τα δικαιώματα ελέγχουν την προεπιλεγμένη πρόσβαση στα στοιχεία (άδειες) εντός του συστήματος. Τα δικαιώματα σε Βιβλία, Κεφάλαια και Σελίδες θα παρακάμψουν αυτές τις άδειες.', 'role_asset_admins' => 'Οι διαχειριστές έχουν αυτόματα πρόσβαση σε όλο το περιεχόμενο, αλλά αυτές οι επιλογές ενδέχεται να εμφανίζουν ή να αποκρύπτουν τις επιλογές διεπαφής χρήστη.', 'role_asset_image_view_note' => 'Αυτό σχετίζεται με την ορατότητα εντός του διαχειριστή εικόνων. Η πραγματική πρόσβαση των μεταφορτωμένων αρχείων εικόνας θα εξαρτηθεί από την επιλογή αποθήκευσης εικόνας συστήματος.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Ολα', 'role_own' => 'Τα δικά του', 'role_controlled_by_asset' => 'Ελέγχονται από το στοιχείο στο οποίο ανεβαίνουν (Ράφια, Βιβλία)', diff --git a/lang/el/validation.php b/lang/el/validation.php index 12d4919ca..4f384efae 100644 --- a/lang/el/validation.php +++ b/lang/el/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Δεν ήταν δυνατή η αποστολή του αρχείου. Ο διακομιστής ενδέχεται να μην δέχεται αρχεία αυτού του μεγέθους.', 'zip_file' => 'Το :attribute πρέπει να παραπέμπει σε ένα αρχείο εντός του ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Το :attribute πρέπει να αναφέρεται σε αρχείο τύπου :validTypes, βρέθηκε :foundType.', 'zip_model_expected' => 'Αναμενόταν αντικείμενο δεδομένων, αλλά ":type" βρέθηκε.', 'zip_unique' => 'Το :attribute πρέπει να είναι μοναδικό για τον τύπο αντικειμένου εντός του ZIP.', diff --git a/lang/en/errors.php b/lang/en/errors.php index 9d7383796..77d7ee69e 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/en/preferences.php b/lang/en/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/en/settings.php b/lang/en/settings.php index 81c2c0a93..c68605fe1 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/en/validation.php b/lang/en/validation.php index d9b982d1e..ff028525d 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/es/entities.php b/lang/es/entities.php index 23ec5f442..e652d4dc0 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.', 'import_errors' => 'Errores de Importación', 'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Navegar por páginas del mismo nivel', + 'breadcrumb_siblings_for_chapter' => 'Navegar por capítulos del mismo nivel', + 'breadcrumb_siblings_for_book' => 'Navegar por libros del mismo nivel', + 'breadcrumb_siblings_for_bookshelf' => 'Navegar por estantes del mismo nivel', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/es/errors.php b/lang/es/errors.php index 98aa2acd5..53e035c0d 100644 --- a/lang/es/errors.php +++ b/lang/es/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.', 'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.', 'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Error al validar la importación del ZIP con errores:', 'import_zip_failed_notification' => 'Error al importar archivo ZIP.', 'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.', diff --git a/lang/es/notifications.php b/lang/es/notifications.php index 5ebc42129..38c770888 100644 --- a/lang/es/notifications.php +++ b/lang/es/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Página actualizada: :pageName', 'updated_page_intro' => 'Una página ha sido actualizada en :appName:', 'updated_page_debounce' => 'Para prevenir notificaciones en masa, durante un tiempo no se enviarán notificaciones para futuras ediciones de esta página por el mismo editor.', + 'comment_mention_subject' => 'Ha sido mencionado en un comentario en la página: :pageName', + 'comment_mention_intro' => 'Fue mencionado en un comentario en :appName:', 'detail_page_name' => 'Nombre de página:', 'detail_page_path' => 'Ruta de la página:', diff --git a/lang/es/preferences.php b/lang/es/preferences.php index 13af93d29..0328a572a 100644 --- a/lang/es/preferences.php +++ b/lang/es/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Controle las notificaciones por correo electrónico que recibe cuando se realiza cierta actividad dentro del sistema.', 'notifications_opt_own_page_changes' => 'Notificar sobre los cambios en las páginas en las que soy propietario', 'notifications_opt_own_page_comments' => 'Notificar sobre comentarios en las páginas en las que soy propietario', + 'notifications_opt_comment_mentions' => 'Notificarme cuando he sido mencionado en un comentario', 'notifications_opt_comment_replies' => 'Notificar sobre respuestas a mis comentarios', 'notifications_save' => 'Guardar preferencias', 'notifications_update_success' => '¡Se han actualizado las preferencias de notificaciones!', diff --git a/lang/es/settings.php b/lang/es/settings.php index 55eb498d6..1a9927c8e 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ninguna restricción establecida', // Sorting Settings - 'sorting' => 'Ordenación', - 'sorting_book_default' => 'Orden por defecto del libro', + 'sorting' => 'Listas y ordenación', + 'sorting_book_default' => 'Orden de libros por defecto', 'sorting_book_default_desc' => 'Seleccione la regla de ordenación predeterminada para aplicar a nuevos libros. Esto no afectará a los libros existentes, y puede ser anulado por libro.', 'sorting_rules' => 'Reglas de ordenación', 'sorting_rules_desc' => 'Son operaciones de ordenación predefinidas que se pueden aplicar al contenido en el sistema.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Fecha de actualización', 'sort_rule_op_chapters_first' => 'Capítulos al inicio', 'sort_rule_op_chapters_last' => 'Capítulos al final', + 'sorting_page_limits' => 'Límites de visualización por página', + 'sorting_page_limits_desc' => 'Establecer cuántos elementos a mostrar por página en varias listas dentro del sistema. Normalmente una cantidad más baja rendirá mejor, mientras que una cantidad más alta evita la necesidad de hacer clic a través de varias páginas. Se recomienda utilizar un múltiplo par de 3 (18, 24, 30, etc).', // Maintenance settings 'maint' => 'Mantenimiento', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importar contenido', 'role_editor_change' => 'Cambiar editor de página', 'role_notifications' => 'Recibir y gestionar notificaciones', + 'role_permission_note_users_and_roles' => 'Estos permisos proporcionarán también visibilidad y búsqueda de usuarios y roles en el sistema.', 'role_asset' => 'Permisos de contenido', 'roles_system_warning' => 'Tenga en cuenta que el acceso a cualquiera de los tres permisos anteriores puede permitir a un usuario alterar sus propios privilegios o los privilegios de otros en el sistema. Sólo asignar roles con estos permisos a usuarios de confianza.', 'role_asset_desc' => 'Estos permisos controlan el acceso por defecto a los contenidos del sistema. Los permisos de Libros, Capítulos y Páginas sobreescribiran estos permisos.', 'role_asset_admins' => 'A los administradores se les asigna automáticamente permisos para acceder a todo el contenido pero estas opciones podrían mostrar u ocultar opciones de la interfaz.', 'role_asset_image_view_note' => 'Esto se refiere a la visibilidad dentro del gestor de imágenes. El acceso a los archivos de imagen subidos dependerá de la opción de almacenamiento de imágenes del sistema.', + 'role_asset_users_note' => 'Estos permisos proporcionarán también visibilidad y búsqueda de usuarios en el sistema.', 'role_all' => 'Todo', 'role_own' => 'Propio', 'role_controlled_by_asset' => 'Controlado por el contenido al que ha sido subido', diff --git a/lang/es/validation.php b/lang/es/validation.php index d5f4f8495..1a9aebd4c 100644 --- a/lang/es/validation.php +++ b/lang/es/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'El archivo no ha podido subirse. Es posible que el servidor no acepte archivos de este tamaño.', 'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.', 'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".', 'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index 6c54a0004..1ae092de1 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.', 'import_errors' => 'Errores de Importación', 'import_errors_desc' => 'Se produjeron los siguientes errores durante el intento de importación:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Navegar por páginas del mismo nivel', + 'breadcrumb_siblings_for_chapter' => 'Navegar por capítulos del mismo nivel', + 'breadcrumb_siblings_for_book' => 'Navegar por libros del mismo nivel', + 'breadcrumb_siblings_for_bookshelf' => 'Navegar por estantes del mismo nivel', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/es_AR/errors.php b/lang/es_AR/errors.php index 16a5b5467..7bc3a189a 100644 --- a/lang/es_AR/errors.php +++ b/lang/es_AR/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.', 'import_zip_cant_decode_data' => 'No se pudo encontrar ni decodificar el contenido del archivo ZIP data.json.', 'import_zip_no_data' => 'Los datos del archivo ZIP no tienen un libro, un capítulo o contenido de página en su contenido.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Error al validar la importación del ZIP con los errores:', 'import_zip_failed_notification' => 'Error al importar archivo ZIP.', 'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.', diff --git a/lang/es_AR/notifications.php b/lang/es_AR/notifications.php index fc89b2c25..af9f5332b 100644 --- a/lang/es_AR/notifications.php +++ b/lang/es_AR/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Página actualizada: :pageName', 'updated_page_intro' => 'Se actualizó una página en :appName:', 'updated_page_debounce' => 'Para evitar una avalancha de notificaciones, durante un tiempo no se enviarán notificaciones sobre más ediciones de esta página por el mismo editor.', + 'comment_mention_subject' => 'Ha sido mencionado en un comentario en la página: :pageName', + 'comment_mention_intro' => 'Fue mencionado en un comentario en :appName:', 'detail_page_name' => 'Nombre de la página:', 'detail_page_path' => 'Ruta de la página:', diff --git a/lang/es_AR/preferences.php b/lang/es_AR/preferences.php index 11c872021..deca55466 100644 --- a/lang/es_AR/preferences.php +++ b/lang/es_AR/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Controle las notificaciones por correo electrónico que recibe cuando se realiza cierta actividad dentro del sistema.', 'notifications_opt_own_page_changes' => 'Notificar sobre los cambios en las páginas de las que soy propietario', 'notifications_opt_own_page_comments' => 'Notificar sobre comentarios en las páginas de las que soy propietario', + 'notifications_opt_comment_mentions' => 'Notificarme cuando he sido mencionado en un comentario', 'notifications_opt_comment_replies' => 'Notificar sobre respuestas a mis comentarios', 'notifications_save' => 'Guardar preferencias', 'notifications_update_success' => '¡Se actualizaron las preferencias de notificaciones!', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 5cbcff86e..3eb41d2cc 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ninguna restricción establecida', // Sorting Settings - 'sorting' => 'Ordenando', - 'sorting_book_default' => 'Orden predeterminado del libro', + 'sorting' => 'Listas y ordenación', + 'sorting_book_default' => 'Orden de libros por defecto', 'sorting_book_default_desc' => 'Seleccione la regla de ordenación predeterminada para aplicar a nuevos libros. Esto no afectará a los libros existentes, y puede ser anulado por libro.', 'sorting_rules' => 'Reglas de Ordenación', 'sorting_rules_desc' => 'Son operaciones de ordenación predefinidas que se pueden aplicar al contenido en el sistema.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Fecha de actualización', 'sort_rule_op_chapters_first' => 'Capítulos al inicio', 'sort_rule_op_chapters_last' => 'Capítulos al final', + 'sorting_page_limits' => 'Límites de visualización por página', + 'sorting_page_limits_desc' => 'Establecer cuántos elementos a mostrar por página en varias listas dentro del sistema. Normalmente una cantidad más baja rendirá mejor, mientras que una cantidad más alta evita la necesidad de hacer clic a través de varias páginas. Se recomienda utilizar un múltiplo par de 3 (18, 24, 30, etc).', // Maintenance settings 'maint' => 'Mantenimiento', @@ -196,11 +198,13 @@ return [ 'role_import_content' => 'Importar contenido', 'role_editor_change' => 'Cambiar editor de página', 'role_notifications' => 'Recibir y gestionar notificaciones', + 'role_permission_note_users_and_roles' => 'Estos permisos proporcionarán también visibilidad y búsqueda de usuarios y roles en el sistema.', 'role_asset' => 'Permisos de activos', 'roles_system_warning' => 'Tenga en cuenta que el acceso a cualquiera de los tres permisos anteriores puede permitir a un usuario modificar sus propios privilegios o los privilegios de otros usuarios en el sistema. Asignar roles con estos permisos sólo a usuarios de comfianza.', 'role_asset_desc' => 'Estos permisos controlan el acceso por defecto a los activos del sistema. Permisos definidos en Libros, Capítulos y Páginas ignorarán estos permisos.', 'role_asset_admins' => 'Los administradores reciben automáticamente acceso a todo el contenido pero estas opciones pueden mostrar u ocultar opciones de UI.', 'role_asset_image_view_note' => 'Esto se refiere a la visibilidad dentro del gestor de imágenes. El acceso real a los archivos de imágenes subidos, dependerá de la opción de almacenamiento de imágenes del sistema.', + 'role_asset_users_note' => 'Estos permisos proporcionarán también visibilidad y búsqueda de usuarios en el sistema.', 'role_all' => 'Todo', 'role_own' => 'Propio', 'role_controlled_by_asset' => 'Controlado por el activo al que ha sido subido', diff --git a/lang/es_AR/validation.php b/lang/es_AR/validation.php index db16845e0..1073933fe 100644 --- a/lang/es_AR/validation.php +++ b/lang/es_AR/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'El archivo no se pudo subir. Puede ser que el servidor no acepte archivos de este tamaño.', 'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.', 'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".', 'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.', diff --git a/lang/et/entities.php b/lang/et/entities.php index 72f09eca0..323985b2d 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'See kustutab üleslaaditud ZIP-faili, ja seda ei saa tagasi võtta.', 'import_errors' => 'Importimise vead', 'import_errors_desc' => 'Importimisel esinesid järgmised vead:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Sirvi teisi lehti', + 'breadcrumb_siblings_for_chapter' => 'Sirvi teisi peatükke', + 'breadcrumb_siblings_for_book' => 'Sirvi teisi raamatuid', + 'breadcrumb_siblings_for_bookshelf' => 'Sirvi teisi riiuleid', // Permissions and restrictions 'permissions' => 'Õigused', diff --git a/lang/et/errors.php b/lang/et/errors.php index 49e755fcc..e1c951993 100644 --- a/lang/et/errors.php +++ b/lang/et/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'ZIP-faili lugemine ebaõnnestus.', 'import_zip_cant_decode_data' => 'ZIP-failist ei leitud data.json sisu.', 'import_zip_no_data' => 'ZIP-failist ei leitud raamatute, peatükkide või lehtede sisu.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Imporditud ZIP-faili valideerimine ebaõnnestus vigadega:', 'import_zip_failed_notification' => 'ZIP-faili importimine ebaõnnestus.', 'import_perms_books' => 'Sul puuduvad õigused raamatute lisamiseks.', diff --git a/lang/et/notifications.php b/lang/et/notifications.php index 0b5fc814c..2843d5799 100644 --- a/lang/et/notifications.php +++ b/lang/et/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Muudetud leht: :pageName', 'updated_page_intro' => 'Rakenduses :appName muudeti lehte:', 'updated_page_debounce' => 'Et vältida liigseid teavitusi, ei saadeta sulle mõnda aega teavitusi selle lehe muutmiste kohta sama kasutaja poolt.', + 'comment_mention_subject' => 'Sind mainiti kommentaaris lehel: :pageName', + 'comment_mention_intro' => 'Sind mainiti kommentaaris rakenduses :appName:', 'detail_page_name' => 'Lehe nimetus:', 'detail_page_path' => 'Lehe asukoht:', diff --git a/lang/et/preferences.php b/lang/et/preferences.php index f4ba5e6ae..a4003463c 100644 --- a/lang/et/preferences.php +++ b/lang/et/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Halda e-posti teavitusi, mis saadetakse teatud tegevuste puhul.', 'notifications_opt_own_page_changes' => 'Teavita muudatustest minu lehtedel', 'notifications_opt_own_page_comments' => 'Teavita kommentaaridest minu lehtedel', + 'notifications_opt_comment_mentions' => 'Teavita mind, kui mind kommentaaris mainitakse', 'notifications_opt_comment_replies' => 'Teavita vastustest minu kommentaaridele', 'notifications_save' => 'Salvesta eelistused', 'notifications_update_success' => 'Teavituste eelistused on salvestatud!', diff --git a/lang/et/settings.php b/lang/et/settings.php index 003238cfe..fbd9d9c7e 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Piirangut ei ole', // Sorting Settings - 'sorting' => 'Sorteerimine', - 'sorting_book_default' => 'Vaikimisi raamatu sorteerimine', + 'sorting' => 'Loendid ja järjestamine', + 'sorting_book_default' => 'Vaikimisi raamatute sorteerimise reegel', 'sorting_book_default_desc' => 'Vali vaikimisi uutele raamatutele rakenduv sorteerimisreegel. See ei mõjuta olemasolevaid raamatuid ning seda saab raamatupõhiselt muuta.', 'sorting_rules' => 'Sorteerimisreeglid', 'sorting_rules_desc' => 'Need on eeldefineeritud sorteerimistoimingud, mida saab süsteemis olevale sisule rakendada.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Muutmise aeg', 'sort_rule_op_chapters_first' => 'Peatükid eespool', 'sort_rule_op_chapters_last' => 'Peatükid tagapool', + 'sorting_page_limits' => 'Leheküljepõhised kuvalimiidid', + 'sorting_page_limits_desc' => 'Seadista, mitut objekti erinevates loendites ühel leheküljel kuvada. Väiksem väärtus tähendab reeglina paremat jõudlust, samas kui suurem väärtus vähendab vajadust mitut lehekülge läbi klikkida. Soovituslik on kasutada 3-ga jaguvat väärtust (18, 24, 30 jne).', // Maintenance settings 'maint' => 'Hooldus', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Imporditud sisu', 'role_editor_change' => 'Lehe redaktori muutmine', 'role_notifications' => 'Võta vastu ja halda teavitusi', + 'role_permission_note_users_and_roles' => 'Need õigused lubavad ka süsteemis olevaid kasutajaid ja rolle vaadata ja otsida.', 'role_asset' => 'Sisu õigused', 'roles_system_warning' => 'Pane tähele, et ülalolevad kolm õigust võimaldavad kasutajal enda või teiste kasutajate õiguseid muuta. Määra nende õigustega roll ainult usaldusväärsetele kasutajatele.', 'role_asset_desc' => 'Need load kontrollivad vaikimisi ligipääsu süsteemis olevale sisule. Raamatute, peatükkide ja lehtede õigused rakenduvad esmajärjekorras.', 'role_asset_admins' => 'Administraatoritel on automaatselt ligipääs kogu sisule, aga need valikud võivad peida või näidata kasutajaliidese elemente.', 'role_asset_image_view_note' => 'See käib nähtavuse kohta pildifailide halduris. Tegelik ligipääs üleslaaditud pildifailidele sõltub süsteemsest piltide salvestamise valikust.', + 'role_asset_users_note' => 'Need õigused lubavad ka süsteemis olevaid kasutajaid vaadata ja otsida.', 'role_all' => 'Kõik', 'role_own' => 'Enda omad', 'role_controlled_by_asset' => 'Õigused määratud seotud objekti kaudu', diff --git a/lang/et/validation.php b/lang/et/validation.php index 1354d1ed4..947007d26 100644 --- a/lang/et/validation.php +++ b/lang/et/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Faili üleslaadimine ebaõnnestus. Server ei pruugi sellise suurusega faile vastu võtta.', 'zip_file' => ':attribute peab viitama failile ZIP-arhiivi sees.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute peab viitama :validTypes tüüpi failile, leiti :foundType.', 'zip_model_expected' => 'Oodatud andmete asemel leiti ":type".', 'zip_unique' => ':attribute peab olema ZIP-arhiivi piires objekti tüübile unikaalne.', diff --git a/lang/eu/errors.php b/lang/eu/errors.php index 2a747e5f8..822c64829 100644 --- a/lang/eu/errors.php +++ b/lang/eu/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/eu/notifications.php b/lang/eu/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/eu/notifications.php +++ b/lang/eu/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/eu/preferences.php b/lang/eu/preferences.php index da7638593..3818932d4 100644 --- a/lang/eu/preferences.php +++ b/lang/eu/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index e93f3c964..9a9227b83 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Mugarik gabe', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Mantentze-lanak', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Fitxategi baimenak', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Guztiak', 'role_own' => 'Norberarenak', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/eu/validation.php b/lang/eu/validation.php index f79dc852f..6dddd5297 100644 --- a/lang/eu/validation.php +++ b/lang/eu/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/fa/editor.php b/lang/fa/editor.php index a8b3d211a..8a05571b8 100644 --- a/lang/fa/editor.php +++ b/lang/fa/editor.php @@ -42,13 +42,13 @@ return [ 'callout_warning' => 'هشدار', 'callout_danger' => 'خطر', 'bold' => 'توپر', - 'italic' => 'حروف کج(ایتالیک)', + 'italic' => 'ایتالیک', 'underline' => 'زیرخط', 'strikethrough' => 'خط خورده', 'superscript' => 'بالانویسی', 'subscript' => 'پایین نویسی', 'text_color' => 'رنگ متن', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'رنگ هایلایت', 'custom_color' => 'رنگ دلخواه', 'remove_color' => 'حذف رنگ', 'background_color' => 'رنگ زمینه', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index 34a12689a..d7c0cc1c0 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -6,10 +6,10 @@ return [ // Shared - 'recently_created' => 'اخیرا ایجاد شده', - 'recently_created_pages' => 'صفحات اخیرا ایجاد شده', - 'recently_updated_pages' => 'صفحاتی که اخیرا روزآمد شده‌اند', - 'recently_created_chapters' => 'فصل های اخیرا ایجاد شده', + 'recently_created' => 'تازه ایجاد شده', + 'recently_created_pages' => 'صفحه‌های تازه ایجاد شده', + 'recently_updated_pages' => 'صفحه‌های تازه به‌روزرسانی‌شده', + 'recently_created_chapters' => 'فصل‌های تازه ایجاد شده', 'recently_created_books' => 'کتاب های اخیرا ایجاد شده', 'recently_created_shelves' => 'قفسه کتاب های اخیرا ایجاد شده', 'recently_update' => 'اخیرا به روز شده', @@ -39,13 +39,13 @@ return [ 'export_pdf' => 'فایل PDF', 'export_text' => 'پرونده متنی ساده', 'export_md' => 'راهنما مارک‌دون', - 'export_zip' => 'فایل فشرده‌ی قابل‌حمل (ZIP)', + 'export_zip' => 'فایل فشرده‌ی زیپ', 'default_template' => 'قالب پیش‌فرض صفحه', 'default_template_explain' => 'قالبی برای صفحه تعیین کنید که به‌عنوان محتوای پیش‌فرض در تمام صفحاتی که در این مورد ایجاد می‌شوند، به‌کار رود. توجه داشته باشید این قالب تنها در صورتی اعمال می‌شود که سازندهٔ صفحه به صفحهٔ قالب انتخاب‌شده دسترسی نمایشی داشته باشد.', 'default_template_select' => 'انتخاب صفحهٔ قالب', 'import' => 'وارد کردن', 'import_validate' => 'اعتبارسنجی آیتم‌های واردشده', - 'import_desc' => 'می‌توانید کتاب‌ها، فصل‌ها و صفحات را با استفاده از یک فایل فشرده (ZIP) که از همین سامانه یا نمونه‌ای دیگر استخراج شده، وارد کنید. برای ادامه، یک فایل ZIP انتخاب نمایید. پس از بارگذاری و اعتبارسنجی فایل، در مرحله بعد می‌توانید تنظیمات انتقال را انجام داده و انتقال را تأیید کنید.', + 'import_desc' => 'می‌توانید کتاب‌ها، فصل‌ها و صفحه‌ها را با استفاده از یک فایل فشردهٔ ZIP وارد کنید که از همین سامانه یا نمونه‌ای دیگر استخراج شده است. برای ادامه، فایل ZIP را انتخاب و بارگذاری کنید. پس از بارگذاری و اعتبارسنجی، در مرحلهٔ بعد می‌توانید تنظیمات ورود را انجام داده و آن را تأیید کنید.', 'import_zip_select' => 'انتخاب فایل ZIP برای بارگذاری', 'import_zip_validation_errors' => 'هنگام اعتبارسنجی فایل ZIP ارائه‌شده، خطاهایی شناسایی شد:', 'import_pending' => 'ورودی‌های در انتظار انتقال', @@ -54,25 +54,25 @@ return [ 'import_continue_desc' => 'محتوای فایل ZIP بارگذاری‌شده را که قرار است وارد سامانه شود، مرور کنید. پس از اطمینان از صحت آن، انتقال را آغاز نمایید تا محتوا به این سامانه افزوده شود. توجه داشته باشید که پس از انتقال موفق، فایل ZIP بارگذاری‌شده به‌صورت خودکار حذف خواهد شد.', 'import_details' => 'جزئیات انتقال ورودی', 'import_run' => 'شروع فرایند انتقال ورودی', - 'import_size' => 'حجم فایل ZIP واردشده: :size', - 'import_uploaded_at' => 'زمان بارگذاری: :relativeTime', - 'import_uploaded_by' => 'بارگذاری شده توسط:', + 'import_size' => 'حجم فایل ZIP واردشده:size', + 'import_uploaded_at' => 'زمان بارگذاری:relativeTime', + 'import_uploaded_by' => 'بارگذاری شده توسط', 'import_location' => 'مکان انتقال', 'import_location_desc' => 'برای محتوای واردشده، مقصدی انتخاب کنید. برای ایجاد محتوا در آن مقصد، داشتن مجوزهای لازم ضروری است.', 'import_delete_confirm' => 'مطمئن هستید که می‌خواهید آیتم واردشده را حدف کنید؟', 'import_delete_desc' => 'با انجام این کار، فایل ZIP واردشده حذف می‌شود و این عمل بازگشت‌ناپذیر است.', 'import_errors' => 'خطای انتقال ورودی', 'import_errors_desc' => 'در جریان تلاش برای انتقال ورودی، خطاهای زیر رخ داد:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'پیمایش صفحات هم‌سطح', + 'breadcrumb_siblings_for_chapter' => 'پیمایش فصل‌های هم‌سطح', + 'breadcrumb_siblings_for_book' => 'پیمایش کتاب‌های هم‌سطح', + 'breadcrumb_siblings_for_bookshelf' => 'پیمایش قفسه‌های هم‌سطح', // Permissions and restrictions 'permissions' => 'مجوزها', 'permissions_desc' => 'مجوزها را در اینجا تنظیم کنید تا مجوزهای پیش فرض تنظیم شده برای نقش های کاربر را لغو کنید.', - 'permissions_book_cascade' => 'مجوزهای تنظیم‌شده روی کتاب‌ها به‌طور خودکار به فصل‌ها و صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها (فصل‌ها و صفحات) تعریف شده باشد.', - 'permissions_chapter_cascade' => 'مجوزهای تنظیم‌شده روی فصل‌ها به‌طور خودکار به صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها (صفحات) تعریف شده باشد.', + 'permissions_book_cascade' => 'مجوزهای تنظیم‌شده روی کتاب‌ها به‌طور خودکار به فصل‌ها و صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها تعریف شده باشد.', + 'permissions_chapter_cascade' => 'مجوزهای تنظیم‌شده روی فصل‌ها به‌طور خودکار به صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها تعریف شده باشد.', 'permissions_save' => 'ذخيره مجوزها', 'permissions_owner' => 'مالک', 'permissions_role_everyone_else' => 'سایر کاربران', @@ -82,7 +82,7 @@ return [ // Search 'search_results' => 'نتایج جستجو', - 'search_total_results_found' => 'نتیجه یافت شد :count | نتایج یافت شده :count', + 'search_total_results_found' => 'نتیجه یافت شد:count | نتایج یافت شده:count', 'search_clear' => 'پاک کردن جستجو', 'search_no_pages' => 'هیچ صفحه ای با این جستجو مطابقت ندارد', 'search_for_term' => 'جستجو برای :term', @@ -155,7 +155,7 @@ return [ 'books_delete' => 'حذف کتاب', 'books_delete_named' => 'حذف کتاب:bookName', 'books_delete_explain' => 'با این کار کتابی با نام \':bookName\' حذف می شود. تمام صفحات و فصل ها حذف خواهند شد.', - 'books_delete_confirmation' => 'آیا مطمئن هستید که می خواهید این کتاب را حذف کنید؟', + 'books_delete_confirmation' => 'آیا مطمئن هستید که می خواهید این کتاب را حذف کنید?', 'books_edit' => 'ویرایش کتاب', 'books_edit_named' => 'ویرایش کتاب:bookName', 'books_form_book_name' => 'نام کتاب', @@ -169,14 +169,14 @@ return [ 'books_permissions_active' => 'مجوزهای کتاب فعال است', 'books_search_this' => 'این کتاب را جستجو کنید', 'books_navigation' => 'ناوبری کتاب', - 'books_sort' => 'مرتب سازی مطالب کتاب', + 'books_sort' => 'مرتب‌سازی مطالب کتاب', 'books_sort_desc' => 'برای سامان‌دهی محتوای یک کتاب، می‌توانید فصل‌ها و صفحات آن را جابه‌جا کنید. همچنین می‌توانید کتاب‌های دیگری بیفزایید تا جابه‌جایی فصل‌ها و صفحات میان کتاب‌ها آسان شود. در صورت تمایل، می‌توانید قاعده‌ای برای مرتب‌سازی خودکار تعیین کنید تا محتوای کتاب در صورت ایجاد تغییرات، به طور خودکار مرتب شود.', 'books_sort_auto_sort' => 'گزینه مرتب‌سازی خودکار', 'books_sort_auto_sort_active' => 'مرتب‌سازی خودکار با قاعده: :sortName فعال است', 'books_sort_named' => 'مرتب‌سازی کتاب:bookName', - 'books_sort_name' => 'مرتب سازی بر اساس نام', - 'books_sort_created' => 'مرتب سازی بر اساس تاریخ ایجاد', - 'books_sort_updated' => 'مرتب سازی بر اساس تاریخ به روز رسانی', + 'books_sort_name' => 'مرتب‌سازی بر اساس نام', + 'books_sort_created' => 'مرتب‌سازی بر اساس تاریخ ایجاد', + 'books_sort_updated' => 'مرتب‌سازی بر اساس تاریخ به روز رسانی', 'books_sort_chapters_first' => 'فصل اول', 'books_sort_chapters_last' => 'فصل آخر', 'books_sort_show_other' => 'نمایش کتاب‌های دیگر', @@ -234,9 +234,7 @@ return [ 'pages_delete_draft' => 'حذف صفحه پیش نویس', 'pages_delete_success' => 'صفحه حذف شد', 'pages_delete_draft_success' => 'صفحه پیش نویس حذف شد', - 'pages_delete_warning_template' => 'این صفحه هم‌اکنون به‌عنوان قالب پیش‌فرض صفحه برای یک کتاب یا فصل در حال استفاده است. پس از حذف این صفحه، کتاب‌ها یا فصل‌های مربوطه دیگر قالب پیش‌فرض صفحه نخواهند داشت. - -', + 'pages_delete_warning_template' => 'این صفحه هم‌اکنون به‌عنوان قالب پیش‌فرض صفحه برای یک کتاب یا فصل در حال استفاده است. پس از حذف این صفحه، کتاب‌ها یا فصل‌های مربوط دیگر قالب پیش‌فرض صفحه نخواهند داشت.', 'pages_delete_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه را حذف کنید؟', 'pages_delete_draft_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه پیش نویس را حذف کنید؟', 'pages_editing_named' => 'ویرایش صفحه :pageName', @@ -245,7 +243,7 @@ return [ 'pages_edit_draft' => 'ویرایش پیش نویس صفحه', 'pages_editing_draft' => 'در حال ویرایش پیش نویس', 'pages_editing_page' => 'در حال ویرایش صفحه', - 'pages_edit_draft_save_at' => 'پیش نویس ذخیره شده در', + 'pages_edit_draft_save_at' => 'پیش نویس ذخیره شده در ', 'pages_edit_delete_draft' => 'حذف پیش نویس', 'pages_edit_delete_draft_confirm' => 'آیا از حذف تغییرات صفحه پیش‌نویس اطمینان دارید؟ تمامی تغییرات‌تان، از آخرین ذخیره‌سازی کامل، از بین خواهد رفت و ویرایش‌گر به آخرین وضعیت پیش‌نویس ذخیره شده بازگردانی خواهد شد.', 'pages_edit_discard_draft' => 'دور انداختن پیش نویس', @@ -253,8 +251,7 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(مطالب تمیز)', 'pages_edit_switch_to_markdown_stable' => '(محتوای پایدار)', 'pages_edit_switch_to_wysiwyg' => 'به ویرایشگر WYSIWYG بروید', - 'pages_edit_switch_to_new_wysiwyg' => 'تغییر به ویرایشگر جدید WYSIWYG -(ویرایشگر WYSIWYG: «آنچه می‌بینید همان است که به‌دست می‌آورید»)', + 'pages_edit_switch_to_new_wysiwyg' => 'تغییر به ویرایشگر جدید WYSIWYG', 'pages_edit_switch_to_new_wysiwyg_desc' => '(در مرحله آزمایش بتا)', 'pages_edit_set_changelog' => 'تنظیم تغییرات', 'pages_edit_enter_changelog_desc' => 'توضیح مختصری از تغییراتی که ایجاد کرده اید وارد کنید', @@ -275,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'درج طرح', 'pages_md_show_preview' => 'دیدن پیش نمایش', 'pages_md_sync_scroll' => 'هماهنگ سازی اسکرول پیش نمایش', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'ویرایشگر متن ساده', 'pages_drawing_unsaved' => 'نقاشی ذخیره نشده پیدا شد', 'pages_drawing_unsaved_confirm' => 'نسخه‌ای ذخیره‌نشده از طراحی‌های قبلی پیدا شد. آیا می‌خواهید این طراحی ذخیره‌نشده را بازیابی کنید و به ویرایش آن ادامه دهید؟', 'pages_not_in_chapter' => 'صفحه در یک فصل نیست', diff --git a/lang/fa/errors.php b/lang/fa/errors.php index 9d5257fcf..d35745615 100644 --- a/lang/fa/errors.php +++ b/lang/fa/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'امکان ایجاد کاربر وجود ندارد؛ زیرا ارسال ایمیل دعوت با خطا مواجه شد.', 'import_zip_cant_decode_data' => 'محتوای data.json در فایل ZIP پیدا یا رمزگشایی نشد.', 'import_zip_no_data' => 'داده‌های فایل ZIP فاقد محتوای کتاب، فصل یا صفحه مورد انتظار است.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'اعتبارسنجی فایل ZIP واردشده با خطا مواجه شد:', 'import_zip_failed_notification' => ' فایل ZIP وارد نشد.', 'import_perms_books' => 'شما مجوز لازم برای ایجاد کتاب را ندارید.', diff --git a/lang/fa/notifications.php b/lang/fa/notifications.php index 4595fd825..d216b04fe 100644 --- a/lang/fa/notifications.php +++ b/lang/fa/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'صفحه جدید: :pageName', 'updated_page_intro' => 'یک صفحه جدید ایجاد شده است در :appName:', 'updated_page_debounce' => 'برای جلوگیری از انبوه اعلان‌ها، برای مدتی اعلان‌ ویرایش‌هایی که توسط همان ویرایشگر در این صفحه انجام می‌شود، ارسال نخواهد شد.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'نام صفحه:', 'detail_page_path' => 'نام میسر صفحه:', diff --git a/lang/fa/preferences.php b/lang/fa/preferences.php index ceecedc80..00d277bdf 100644 --- a/lang/fa/preferences.php +++ b/lang/fa/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'تنظیمات اطلاعیه‌های ایمیلی هنگام انجام فعالیت‌های خاص در سیستم.', 'notifications_opt_own_page_changes' => 'در صورت تغییرات در صفحاتی که متعلق به من است، اطلاع بده', 'notifications_opt_own_page_comments' => 'در صورت ثبت نظر در صفحاتی که متعلق به من است، اطلاع بده', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'پس از درج پاسخ به روی نظراتی که من ثبت کرده‌ام، اطلاع بده', 'notifications_save' => 'ذخیره تنظیمات', 'notifications_update_success' => 'تنظیمات اعلان‌ها به روز شده است!', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index e4cb9018e..abbfce470 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'بدون محدودیت', // Sorting Settings - 'sorting' => 'مرتب‌سازی', - 'sorting_book_default' => 'مرتب‌سازی پیش‌فرض کتاب', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'قانون پیش‌فرض مرتب‌سازی را برای کتاب‌های جدید انتخاب کنید. تغییر قانون بر ترتیب کتاب‌های موجود تأثیری ندارد و می‌تواند برای هر کتاب به‌صورت جداگانه تغییر یابد.', 'sorting_rules' => 'قوانین مرتب‌سازی', 'sorting_rules_desc' => 'این‌ها عملیات مرتب‌سازی از پیش تعریف‌شده‌ای هستند که می‌توانید آن‌ها را بر محتوای سیستم اعمال کنید.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'تاریخ به‌روزرسانی', 'sort_rule_op_chapters_first' => 'ابتدا فصل‌ها', 'sort_rule_op_chapters_last' => 'فصل‌ها در آخر', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'نگهداری', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'وارد کردن محتوا', 'role_editor_change' => 'تغییر ویرایشگر صفحه', 'role_notifications' => 'دریافت و مدیریت اعلان‌ها', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'مجوزهای دارایی', 'roles_system_warning' => 'توجه داشته باشید که دسترسی به هر یک از سه مجوز فوق می‌تواند به کاربر اجازه دهد تا امتیازات خود یا امتیازات دیگران را در سیستم تغییر دهد. فقط نقش هایی را با این مجوزها به کاربران مورد اعتماد اختصاص دهید.', 'role_asset_desc' => 'این مجوزها دسترسی پیش‌فرض به دارایی‌های درون سیستم را کنترل می‌کنند. مجوزهای مربوط به کتاب‌ها، فصل‌ها و صفحات این مجوزها را لغو می‌کنند.', 'role_asset_admins' => 'به ادمین‌ها به‌طور خودکار به همه محتوا دسترسی داده می‌شود، اما این گزینه‌ها ممکن است گزینه‌های UI را نشان داده یا پنهان کنند.', 'role_asset_image_view_note' => 'این مربوط به مرئی بودن در بخش مدیر تصاویر است. دسترسی عملی به تصاویر آپلود شده بستگی به گزینه ذخیره‌سازی تصویر سیستم دارد.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'همه', 'role_own' => 'صاحب', 'role_controlled_by_asset' => 'توسط دارایی که در آن آپلود می شود کنترل می شود', diff --git a/lang/fa/validation.php b/lang/fa/validation.php index e1c69ece9..93c7dcb66 100644 --- a/lang/fa/validation.php +++ b/lang/fa/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'بارگذاری فایل :attribute موفقیت آمیز نبود.', 'zip_file' => 'ویژگی :attribute باید به یک فایل درون پرونده فشرده شده اشاره کند.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'ویژگی :attribute باید به فایلی با نوع :validTypes اشاره کند، اما نوع یافت‌شده :foundType است.', 'zip_model_expected' => 'سیستم در این بخش انتظار دریافت یک شیء داده‌ای را داشت، اما «:type» دریافت گردید', 'zip_unique' => 'برای هر نوع شیء در فایل ZIP، مقدار ویژگی :attribute باید یکتا و بدون تکرار باشد.', diff --git a/lang/fi/errors.php b/lang/fi/errors.php index 9af7490d5..f470ce614 100644 --- a/lang/fi/errors.php +++ b/lang/fi/errors.php @@ -110,6 +110,7 @@ Sovellus ei tunnista ulkoisen todennuspalvelun pyyntöä. Ongelman voi aiheuttaa 'import_zip_cant_read' => 'ZIP-tiedostoa ei voitu lukea.', 'import_zip_cant_decode_data' => 'ZIP-tiedoston data.json sisältöä ei löydy eikä sitä voitu purkaa.', 'import_zip_no_data' => 'ZIP-tiedostoilla ei ole odotettua kirjaa, lukua tai sivun sisältöä.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Tuonti ZIP epäonnistui virheiden kanssa:', 'import_zip_failed_notification' => 'ZIP-tiedoston tuominen epäonnistui.', 'import_perms_books' => 'Sinulla ei ole tarvittavia oikeuksia luoda kirjoja.', diff --git a/lang/fi/notifications.php b/lang/fi/notifications.php index a737ad7d9..647378ded 100644 --- a/lang/fi/notifications.php +++ b/lang/fi/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Päivitetty sivu: :pageName', 'updated_page_intro' => 'Sivu on päivitetty sivustolla :appName:', 'updated_page_debounce' => 'Useiden ilmoitusten välttämiseksi sinulle ei toistaiseksi lähetetä ilmoituksia saman toimittajan tekemistä uusista muokkauksista tälle sivulle.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Sivun nimi:', 'detail_page_path' => 'Sivun polku:', diff --git a/lang/fi/preferences.php b/lang/fi/preferences.php index 25cd76bf7..05afc111f 100644 --- a/lang/fi/preferences.php +++ b/lang/fi/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Hallitse järjestelmän toimintoihin liittyviä sähköposti-ilmoituksia.', 'notifications_opt_own_page_changes' => 'Ilmoita omistamilleni sivuille tehdyistä muutoksista', 'notifications_opt_own_page_comments' => 'Ilmoita omistamilleni sivuille tehdyistä kommenteista', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Ilmoita vastauksista kommentteihini', 'notifications_save' => 'Tallenna asetukset', 'notifications_update_success' => 'Ilmoitusasetukset on päivitetty!', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index d9e91d4fa..499122fe3 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ei rajoituksia', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Huolto', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Vaihda sivun editoria', 'role_notifications' => 'Vastaanota ja hallinnoi ilmoituksia', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Sisältöjen oikeudet', 'roles_system_warning' => 'Huomaa, että minkä tahansa edellä mainituista kolmesta käyttöoikeudesta voi antaa käyttäjälle mahdollisuuden muuttaa omia tai muiden järjestelmän käyttäjien oikeuksia. Anna näitä oikeuksia sisältävät roolit vain luotetuille käyttäjille.', 'role_asset_desc' => 'Näillä asetuksilla hallitaan oletuksena annettavia käyttöoikeuksia järjestelmässä oleviin sisältöihin. Yksittäisten kirjojen, lukujen ja sivujen käyttöoikeudet kumoavat nämä käyttöoikeudet.', 'role_asset_admins' => 'Ylläpitäjät saavat automaattisesti pääsyn kaikkeen sisältöön, mutta nämä vaihtoehdot voivat näyttää tai piilottaa käyttöliittymävalintoja.', 'role_asset_image_view_note' => 'Tämä tarkoittaa näkyvyyttä kuvien hallinnassa. Pääsy ladattuihin kuvatiedostoihin riippuu asetetusta kuvien tallennusvaihtoehdosta.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Kaikki', 'role_own' => 'Omat', 'role_controlled_by_asset' => 'Määräytyy sen sisällön mukaan, johon ne on ladattu', diff --git a/lang/fi/validation.php b/lang/fi/validation.php index 8adc934a1..aef10b4d3 100644 --- a/lang/fi/validation.php +++ b/lang/fi/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Tiedostoa ei voitu ladata. Palvelin ei ehkä hyväksy tämän kokoisia tiedostoja.', 'zip_file' => 'Attribuutin :attribute on viitattava tiedostoon ZIP-tiedoston sisällä.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/fr/activities.php b/lang/fr/activities.php index b65134a24..0e70917da 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -59,7 +59,7 @@ return [ 'favourite_remove_notification' => '":name" a été supprimé de vos favoris', // Watching - 'watch_update_level_notification' => 'Suivre les préférences mises à jour avec succès', + 'watch_update_level_notification' => 'Préférences de surveillance mises à jour avec succès', // Auth 'auth_login' => 'connecté', diff --git a/lang/fr/editor.php b/lang/fr/editor.php index 73bc6ad21..4d4d8c0e8 100644 --- a/lang/fr/editor.php +++ b/lang/fr/editor.php @@ -35,7 +35,7 @@ return [ 'header_tiny' => 'En-tête minuscule', 'paragraph' => 'Paragraphe', 'blockquote' => 'Bloc de citation', - 'inline_code' => 'Ligne de Code', + 'inline_code' => 'Ligne de code', 'callouts' => 'Légendes', 'callout_information' => 'Information', 'callout_success' => 'Succès', @@ -47,7 +47,7 @@ return [ 'strikethrough' => 'Barré', 'superscript' => 'Exposant', 'subscript' => 'Indice', - 'text_color' => 'Couleur Texte', + 'text_color' => 'Couleur de texte', 'highlight_color' => 'Couleur de surlignage', 'custom_color' => 'Couleur personnalisée', 'remove_color' => 'Supprimer la couleur', @@ -75,7 +75,7 @@ return [ 'insert_media_title' => 'Insérer/Modifier un média', 'clear_formatting' => 'Effacer le formatage', 'source_code' => 'Code source', - 'source_code_title' => 'Code Source', + 'source_code_title' => 'Code source', 'fullscreen' => 'Plein écran', 'image_options' => 'Options d\'image', @@ -130,7 +130,7 @@ return [ 'caption' => 'Légende', 'show_caption' => 'Afficher la légende', 'constrain' => 'Conserver les proportions', - 'cell_border_solid' => 'En continue', + 'cell_border_solid' => 'Continu', 'cell_border_dotted' => 'En pointillé', 'cell_border_dashed' => 'En tirets', 'cell_border_double' => 'En double trait', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index 827e88a1b..ecf26442b 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Ceci supprimera le fichier ZIP importé et ne pourra pas être annulé.', 'import_errors' => 'Erreurs d\'importation', 'import_errors_desc' => 'Les erreurs suivantes se sont produites lors de la tentative d\'importation :', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Naviguer entre les pages voisines', + 'breadcrumb_siblings_for_chapter' => 'Naviguer entre les chapitres voisins', + 'breadcrumb_siblings_for_book' => 'Naviguer entre les livres voisins', + 'breadcrumb_siblings_for_bookshelf' => 'Naviguer entre les étagères voisines', // Permissions and restrictions 'permissions' => 'Autorisations', @@ -243,7 +243,7 @@ return [ 'pages_edit_draft' => 'Modifier le brouillon', 'pages_editing_draft' => 'Modification du brouillon', 'pages_editing_page' => 'Modification de la page', - 'pages_edit_draft_save_at' => 'Brouillon enregistré le ', + 'pages_edit_draft_save_at' => 'Brouillon enregistré à ', 'pages_edit_delete_draft' => 'Supprimer le brouillon', 'pages_edit_delete_draft_confirm' => 'Êtes-vous sûr de vouloir supprimer vos modifications de page brouillon ? Toutes vos modifications, depuis la dernière sauvegarde complète, seront perdues et l\'éditeur sera mis à jour avec l\'état de sauvegarde de la dernière page non-brouillon.', 'pages_edit_discard_draft' => 'Jeter le brouillon', @@ -253,9 +253,9 @@ return [ 'pages_edit_switch_to_wysiwyg' => 'Basculer vers l\'éditeur WYSIWYG', 'pages_edit_switch_to_new_wysiwyg' => 'Basculer vers le nouveau WYSIWYG', 'pages_edit_switch_to_new_wysiwyg_desc' => '(En bêta-test)', - 'pages_edit_set_changelog' => 'Remplir le journal des changements', + 'pages_edit_set_changelog' => 'Journal des changements', 'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués', - 'pages_edit_enter_changelog' => 'Ouvrir le journal des changements', + 'pages_edit_enter_changelog' => 'Saisir les changements', 'pages_editor_switch_title' => 'Changer d\'éditeur', 'pages_editor_switch_are_you_sure' => 'Êtes-vous sûr de vouloir modifier l\'éditeur de cette page ?', 'pages_editor_switch_consider_following' => 'Considérez ce qui suit lors du changement d\'éditeur :', @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'Insérer un dessin', 'pages_md_show_preview' => 'Prévisualisation', 'pages_md_sync_scroll' => 'Défilement prévisualisation', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Éditeur texte brut', 'pages_drawing_unsaved' => 'Dessin non enregistré trouvé', 'pages_drawing_unsaved_confirm' => 'Des données de dessin non enregistrées ont été trouvées à partir d\'une tentative de sauvegarde de dessin échouée. Voulez-vous restaurer et continuer à modifier ce dessin non sauvegardé ?', 'pages_not_in_chapter' => 'La page n\'est pas dans un chapitre', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index 94d21e1dd..a4fb9b565 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Impossible de lire le fichier ZIP.', 'import_zip_cant_decode_data' => 'Impossible de trouver et de décoder le contenu ZIP data.json.', 'import_zip_no_data' => 'Les données du fichier ZIP n\'ont pas de livre, de chapitre ou de page attendus.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'L\'importation du ZIP n\'a pas été validée avec les erreurs :', 'import_zip_failed_notification' => 'Impossible d\'importer le fichier ZIP.', 'import_perms_books' => 'Vous n\'avez pas les permissions requises pour créer des livres.', diff --git a/lang/fr/notifications.php b/lang/fr/notifications.php index 5d7ae88f7..b82f82bd0 100644 --- a/lang/fr/notifications.php +++ b/lang/fr/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Page mise à jour: :pageName', 'updated_page_intro' => 'Une page a été mise à jour dans :appName:', 'updated_page_debounce' => 'Pour éviter de nombreuses notifications, pendant un certain temps, vous ne recevrez pas de notifications pour d\'autres modifications de cette page par le même éditeur.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nom de la page :', 'detail_page_path' => 'Chemin de la page :', diff --git a/lang/fr/preferences.php b/lang/fr/preferences.php index 2180ed70c..c595222f1 100644 --- a/lang/fr/preferences.php +++ b/lang/fr/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Contrôlez les notifications par e-mail que vous recevez lorsque certaines activités sont effectuées dans le système.', 'notifications_opt_own_page_changes' => 'Notifier lors des modifications des pages que je possède', 'notifications_opt_own_page_comments' => 'Notifier lorsque les pages que je possède sont commentées', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notifier les réponses à mes commentaires', 'notifications_save' => 'Enregistrer les préférences', 'notifications_update_success' => 'Les préférences de notification ont été mises à jour !', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index e73d8a4b1..7ce2312f3 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Aucune restriction en place', // Sorting Settings - 'sorting' => 'Tri', - 'sorting_book_default' => 'Tri des livres par défaut', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Sélectionnez le tri par défaut à mettre en place sur les nouveaux livres. Cela n’affectera pas les livres existants, et peut être redéfini dans les livres.', 'sorting_rules' => 'Règles de tri', 'sorting_rules_desc' => 'Ce sont les opérations de tri qui peuvent être appliquées au contenu du système.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Date de mise à jour', 'sort_rule_op_chapters_first' => 'Chapitres en premier', 'sort_rule_op_chapters_last' => 'Chapitres en dernier', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importer le contenu', 'role_editor_change' => 'Changer l\'éditeur de page', 'role_notifications' => 'Recevoir et gérer les notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Permissions des ressources', 'roles_system_warning' => 'Sachez que l\'accès à l\'une des trois permissions ci-dessus peut permettre à un utilisateur de modifier ses propres privilèges ou les privilèges des autres utilisateurs du système. N\'attribuez uniquement des rôles avec ces permissions qu\'à des utilisateurs de confiance.', 'role_asset_desc' => 'Ces permissions contrôlent l\'accès par défaut des ressources dans le système. Les permissions dans les livres, les chapitres et les pages ignoreront ces permissions', 'role_asset_admins' => 'Les administrateurs ont automatiquement accès à tous les contenus mais les options suivantes peuvent afficher ou masquer certaines options de l\'interface.', 'role_asset_image_view_note' => 'Cela concerne la visibilité dans le gestionnaire d\'images. L\'accès réel des fichiers d\'image téléchargés dépendra de l\'option de stockage d\'images du système.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Tous', 'role_own' => 'Propres', 'role_controlled_by_asset' => 'Contrôlé par les ressources les ayant envoyés', diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 7db0493a1..74ecca12e 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.', 'zip_file' => 'L\'attribut :attribute doit référencer un fichier dans le ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute doit référencer un fichier de type :validTypes, trouvé :foundType.', 'zip_model_expected' => 'Objet de données attendu, mais ":type" trouvé.', 'zip_unique' => 'L\'attribut :attribute doit être unique pour le type d\'objet dans le ZIP.', diff --git a/lang/he/activities.php b/lang/he/activities.php index 8dd244b72..e94d53e0d 100644 --- a/lang/he/activities.php +++ b/lang/he/activities.php @@ -128,12 +128,12 @@ return [ 'comment_delete' => 'תגובה נמחקה', // Sort Rules - 'sort_rule_create' => 'created sort rule', - 'sort_rule_create_notification' => 'Sort rule successfully created', - 'sort_rule_update' => 'updated sort rule', - 'sort_rule_update_notification' => 'Sort rule successfully updated', - 'sort_rule_delete' => 'deleted sort rule', - 'sort_rule_delete_notification' => 'Sort rule successfully deleted', + 'sort_rule_create' => 'נוצר חוק מיון', + 'sort_rule_create_notification' => 'חוק מיון נוצר בהצלחה', + 'sort_rule_update' => 'חוק מיון עודכן', + 'sort_rule_update_notification' => 'חוק מיון עודכן בהצלחה', + 'sort_rule_delete' => 'חוק מיון נמחק', + 'sort_rule_delete_notification' => 'חוק מיון נמחק בהצלחה', // Other 'permissions_update' => 'הרשאות עודכנו', diff --git a/lang/he/auth.php b/lang/he/auth.php index 2948d5b20..6f4f77223 100644 --- a/lang/he/auth.php +++ b/lang/he/auth.php @@ -106,12 +106,13 @@ return [ 'mfa_verify_access' => 'אשר גישה', 'mfa_verify_access_desc' => 'חשבון המשתמש שלך דורש ממך לאת את הזהות שלך בשכבת הגנה נוספת על מנת לאפשר לך גישה. יש לאשר גישה דרך אחד האמצעים הקיימים על מנת להמשיך.', 'mfa_verify_no_methods' => 'אין אפשרויות אימות דו-שלבי מוגדרות', - 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', + 'mfa_verify_no_methods_desc' => 'לא נמצאו אפשרויות ווידוא זהות עבור המשתמש שלך. +נדרש לקנפג לפחות אחד על מנת לקבל גישה.', 'mfa_verify_use_totp' => 'אמת באמצעות אפליקציה', 'mfa_verify_use_backup_codes' => 'אמת באמצעות קוד גיבוי', 'mfa_verify_backup_code' => 'קוד גיבוי', 'mfa_verify_backup_code_desc' => 'הזן מטה אחד מקודי הגיבוי הנותרים לך:', 'mfa_verify_backup_code_enter_here' => 'הזן קוד גיבוי כאן', 'mfa_verify_totp_desc' => 'הזן את הקוד, שהונפק דרך האפליקציה שלך, מטה:', - 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', + 'mfa_setup_login_notification' => 'אמצעי זיהוי זהות הוגדרו, אנא התחבר מחדש.', ]; diff --git a/lang/he/common.php b/lang/he/common.php index 2e387f67d..c89ee7782 100644 --- a/lang/he/common.php +++ b/lang/he/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'צור', 'update' => 'עדכן', 'edit' => 'ערוך', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'הכנס לארכיון', + 'unarchive' => 'הוצא מארכיון', 'sort' => 'מיין', 'move' => 'הזז', 'copy' => 'העתק', diff --git a/lang/he/entities.php b/lang/he/entities.php index 0cf2bb7fd..d8b264d8b 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -22,8 +22,8 @@ return [ 'meta_created_name' => 'נוצר :timeLength על ידי :user', 'meta_updated' => 'עודכן :timeLength', 'meta_updated_name' => 'עודכן :timeLength על ידי :user', - 'meta_owned_name' => 'Owned by :user', - 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'meta_owned_name' => 'בבעלות של :user', + 'meta_reference_count' => '', 'entity_select' => 'בחר יישות', 'entity_select_lack_permission' => 'אין לך אישורים דרושים לבחירת פריט זה', 'images' => 'תמונות', diff --git a/lang/he/errors.php b/lang/he/errors.php index 75f7949ea..a2bc86ae4 100644 --- a/lang/he/errors.php +++ b/lang/he/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/he/notifications.php b/lang/he/notifications.php index 8385c0a6d..cfab57117 100644 --- a/lang/he/notifications.php +++ b/lang/he/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'עמוד עודכן :pageName', 'updated_page_intro' => 'דף עודכן ב:appName:', 'updated_page_debounce' => 'על מנת לעצור הצפת התראות, לזמן מסוים אתה לא תקבל התראות על שינויים עתידיים בדף זה על ידי אותו עורך.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'שם עמוד:', 'detail_page_path' => 'נתיב לעמוד:', diff --git a/lang/he/preferences.php b/lang/he/preferences.php index 9e05eefb3..fa0c89ea5 100644 --- a/lang/he/preferences.php +++ b/lang/he/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'העדפות קבלת מייל והתראות כאשר מבוצעת פעולה מסויימת במערכת.', 'notifications_opt_own_page_changes' => 'עדכן אותי כאשר מתבצעים שינויים לדפים שבבעלותי', 'notifications_opt_own_page_comments' => 'עדכן אותי כאשר נוספות הערות לדפים שבבעלותי', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'עדכן אותי כאשר מתקבלות תגובות להערות שלי', 'notifications_save' => 'שמור העדפות', 'notifications_update_success' => 'הגדרת התראות עודכנו!', diff --git a/lang/he/settings.php b/lang/he/settings.php index 5b405072f..0b5034475 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'אין הגבלה לדומיין', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'תחזוקה', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'שנה עורך עמודים', 'role_notifications' => 'ניהול התראות', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'הרשאות משאבים', 'roles_system_warning' => 'שימו לב לכך שגישה לכל אחת משלושת ההרשאות הנ"ל יכולה לאפשר למשתמש לשנות את הפריווילגיות שלהם או של אחרים במערכת. הגדירו תפקידים להרשאות אלה למשתמשים בהם אתם בוטחים בלבד.', 'role_asset_desc' => 'הרשאות אלו שולטות בגישת ברירת המחדל למשאבים בתוך המערכת. הרשאות של ספרים, פרקים ודפים יגברו על הרשאות אלו.', 'role_asset_admins' => 'מנהלים מקבלים הרשאה מלאה לכל התוכן אך אפשרויות אלו עלולות להציג או להסתיר אפשרויות בממשק', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'הכל', 'role_own' => 'שלי', 'role_controlled_by_asset' => 'נשלטים על ידי המשאב אליו הועלו', diff --git a/lang/he/validation.php b/lang/he/validation.php index 2d4f8b305..db2080a2b 100644 --- a/lang/he/validation.php +++ b/lang/he/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'שדה :attribute ארעה שגיאה בעת ההעלאה.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/hr/errors.php b/lang/hr/errors.php index 92b9de7d2..ad1b2668f 100644 --- a/lang/hr/errors.php +++ b/lang/hr/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/hr/notifications.php b/lang/hr/notifications.php index c3a38cdfa..c9428e2dc 100644 --- a/lang/hr/notifications.php +++ b/lang/hr/notifications.php @@ -13,6 +13,8 @@ return [ Ažurirana stranica: :pageName', 'updated_page_intro' => 'Stranica je ažurirana u :appName:', 'updated_page_debounce' => 'Kako biste spriječili velik broj obavijesti, nećete primati obavijesti o daljnjim izmjenama ove stranice od istog urednika neko vrijeme.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Naziv Stranice:', 'detail_page_path' => 'Page Path:', diff --git a/lang/hr/preferences.php b/lang/hr/preferences.php index 2f409f8d3..5da5a72fb 100644 --- a/lang/hr/preferences.php +++ b/lang/hr/preferences.php @@ -25,6 +25,7 @@ return [ 'notifications_opt_own_page_comments' => 'ChatGPT Obavijesti o komentarima na stranicama koje posjedujem', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Obavijesti o odgovorima na moje komentare', 'notifications_save' => 'Spremi Postavke', 'notifications_update_success' => 'Postavke obavijesti su ažurirane!', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index 5f0007426..6465d0ea7 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Bez ograničenja', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Održavanje', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Promijeni uređivač stranica', 'role_notifications' => 'Primanje i upravljanje obavijestima', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Upravljanje vlasništvom', 'roles_system_warning' => 'Uzmite u obzir da pristup bilo kojem od ovih dopuštenja dozvoljavate korisniku upravljanje dopuštenjima ostalih u sustavu. Ova dopuštenja dodijelite pouzdanim korisnicima.', 'role_asset_desc' => 'Ova dopuštenja kontroliraju zadane pristupe. Dopuštenja za knjige, poglavlja i stranice ih poništavaju.', 'role_asset_admins' => 'Administratori automatski imaju pristup svim sadržajima, ali ove opcije mogu prikazati ili sakriti korisnička sučelja.', 'role_asset_image_view_note' => 'Ovo se odnosi na vidljivost unutar upravitelja slika. Stvarni pristup uploadiranim slikovnim datotekama ovisit će o odabranim opcijama pohrane slika u sustavu.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Sve', 'role_own' => 'Vlastito', 'role_controlled_by_asset' => 'Kontrolirano od strane vlasnika', diff --git a/lang/hr/validation.php b/lang/hr/validation.php index 32b11a9bd..22dae5c56 100644 --- a/lang/hr/validation.php +++ b/lang/hr/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Datoteka se ne može prenijeti. Server možda ne prihvaća datoteke te veličine.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/hu/errors.php b/lang/hu/errors.php index 8ee055e29..10e3adbf9 100644 --- a/lang/hu/errors.php +++ b/lang/hu/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/hu/notifications.php b/lang/hu/notifications.php index c8cae9143..d8a29688a 100644 --- a/lang/hu/notifications.php +++ b/lang/hu/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Frissített oldal: :pageName', 'updated_page_intro' => 'Az oldal frissítése sikeres volt itt: :appName:', 'updated_page_debounce' => 'Az értesítések tömegének elkerülése érdekében egy ideig nem kap értesítést az oldal további szerkesztéseiről ugyanaz a szerkesztő.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Oldal neve:', 'detail_page_path' => 'Oldal helye:', diff --git a/lang/hu/preferences.php b/lang/hu/preferences.php index 4251a28fb..fb3c335e8 100644 --- a/lang/hu/preferences.php +++ b/lang/hu/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Állítsd be az e-mail értesítéseket, amelyeket akkor kapsz, ha bizonyos tevékenység történik a rendszeren belül.', 'notifications_opt_own_page_changes' => 'Értesítsen változásokról az általam tulajdonolt oldalakon', 'notifications_opt_own_page_comments' => 'Értesítés a hozzászólásokról az általam tulajdonolt oldalakon', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Értesítsen válaszokról a hozzászólásaimra', 'notifications_save' => 'Beállítások mentése', 'notifications_update_success' => 'Az értesítési beállítások frissítve lettek!', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index 813fee7b0..c6810ed68 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nincs beállítva korlátozás', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Karbantartás', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Oldalszerkesztő módosítása', 'role_notifications' => 'Értesítések fogadása és kezelése', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Eszköz jogosultságok', 'roles_system_warning' => 'Ne feledje, hogy a fenti három engedély bármelyikéhez való hozzáférés lehetővé teszi a felhasználó számára, hogy módosítsa saját vagy a rendszerben mások jogosultságait. Csak megbízható felhasználókhoz rendeljen szerepeket ezekkel az engedélyekkel.', 'role_asset_desc' => 'Ezek a jogosultságok vezérlik az alapértelmezés szerinti hozzáférést a rendszerben található eszközökhöz. A könyvek, fejezetek és oldalak jogosultságai felülírják ezeket a jogosultságokat.', 'role_asset_admins' => 'Az adminisztrátorok automatikusan hozzáférést kapnak minden tartalomhoz, de ezek a beállítások megjeleníthetnek vagy elrejthetnek felhasználói felület beállításokat.', 'role_asset_image_view_note' => 'Ez a képkezelőn belüli láthatóságra vonatkozik. A feltöltött képfájlok tényleges elérése a rendszerkép tárolási beállításától függ.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Összes', 'role_own' => 'Saját', 'role_controlled_by_asset' => 'Az általuk feltöltött eszköz által ellenőrzött', diff --git a/lang/hu/validation.php b/lang/hu/validation.php index a215416ca..b740ca0c6 100644 --- a/lang/hu/validation.php +++ b/lang/hu/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'A fájlt nem lehet feltölteni. A kiszolgáló nem fogad el ilyen méretű fájlokat.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/id/components.php b/lang/id/components.php index 45aa72fba..6f1f905a4 100644 --- a/lang/id/components.php +++ b/lang/id/components.php @@ -13,7 +13,7 @@ return [ 'image_intro_upload' => 'Unggah gambar baru dengan menyeret berkas gambar ke jendela ini, atau dengan menggunakan tombol "Unggah Gambar" di atas.', 'image_all' => 'Semua', 'image_all_title' => 'Lihat semua gambar', - 'image_book_title' => 'Lihat gambar yang diunggah ke buku ini', + 'image_book_title' => 'Lihat gambar untuk diunggah ke buku ini', 'image_page_title' => 'Lihat gambar yang diunggah ke halaman ini', 'image_search_hint' => 'Cari berdasarkan nama gambar', 'image_uploaded' => 'Diunggah :uploadedDate', @@ -33,7 +33,7 @@ return [ 'image_update_success' => 'Detail gambar berhasil diperbarui', 'image_delete_success' => 'Gambar berhasil dihapus', 'image_replace' => 'Ganti Gambar', - 'image_replace_success' => 'Berkas gambar berhasil diperbarui', + 'image_replace_success' => 'Detail gambar berhasil diperbarui', 'image_rebuild_thumbs' => 'Buat Ulang Variasi Ukuran', 'image_rebuild_thumbs_success' => 'Variasi ukuran gambar berhasil dibuat ulang!', diff --git a/lang/id/errors.php b/lang/id/errors.php index 6f7661738..772547831 100644 --- a/lang/id/errors.php +++ b/lang/id/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Tidak dapat membaca berkas ZIP.', 'import_zip_cant_decode_data' => 'Tidak dapat menemukan dan mendekode konten ZIP data.json.', 'import_zip_no_data' => 'Data berkas ZIP tidak berisi konten buku, bab, atau halaman yang diharapkan.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Impor ZIP gagal divalidasi dengan kesalahan:', 'import_zip_failed_notification' => 'Gagal mengimpor berkas ZIP.', 'import_perms_books' => 'Anda tidak memiliki izin yang diperlukan untuk membuat buku.', diff --git a/lang/id/notifications.php b/lang/id/notifications.php index b22af1f73..7b9d2181a 100644 --- a/lang/id/notifications.php +++ b/lang/id/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Halaman yang diperbarui: :pageName', 'updated_page_intro' => 'Halaman telah diperbarui di :appName:', 'updated_page_debounce' => 'Untuk mencegah banyaknya pemberitahuan, untuk sementara Anda tidak akan dikirimi pemberitahuan untuk pengeditan lebih lanjut pada halaman ini oleh editor yang sama.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nama Halaman:', 'detail_page_path' => 'Jalur Halaman:', diff --git a/lang/id/passwords.php b/lang/id/passwords.php index 3ee2e4d57..2559b2651 100644 --- a/lang/id/passwords.php +++ b/lang/id/passwords.php @@ -6,7 +6,7 @@ */ return [ - 'password' => 'Kata sandi harus setidaknya delapan karakter dan sesuai dengan konfirmasi.', + 'password' => 'Passwords must be at least eight characters and match the confirmation.', 'user' => "Kami tidak dapat menemukan pengguna dengan alamat email tersebut.", 'token' => 'Token setel ulang sandi tidak valid untuk alamat email ini.', 'sent' => 'Kami telah mengirimkan email tautan pengaturan ulang kata sandi Anda!', diff --git a/lang/id/preferences.php b/lang/id/preferences.php index 2872f5f3c..1c7d4248d 100644 --- a/lang/id/preferences.php +++ b/lang/id/preferences.php @@ -5,16 +5,16 @@ */ return [ - 'my_account' => 'My Account', + 'my_account' => 'Akun Saya', - 'shortcuts' => 'Shortcuts', + 'shortcuts' => 'Pintasan', 'shortcuts_interface' => 'UI Shortcut Preferences', 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', - 'shortcuts_section_navigation' => 'Navigation', + 'shortcuts_section_navigation' => 'Navigasi', 'shortcuts_section_actions' => 'Common Actions', - 'shortcuts_save' => 'Save Shortcuts', + 'shortcuts_save' => 'Simpan Pintasan', 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', 'shortcuts_update_success' => 'Shortcut preferences have been updated!', 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', @@ -30,7 +31,7 @@ return [ 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', 'auth' => 'Access & Security', - 'auth_change_password' => 'Change Password', + 'auth_change_password' => 'Ubah Kata Sandi', 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', 'auth_change_password_success' => 'Password has been updated!', diff --git a/lang/id/settings.php b/lang/id/settings.php index 51c0613cb..cc9426683 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Tidak ada batasan yang ditetapkan', // Sorting Settings - 'sorting' => 'Menyortir', - 'sorting_book_default' => 'Penyortiran Buku Default', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Pilih aturan sortir default yang akan diterapkan pada buku baru. Aturan ini tidak akan memengaruhi buku yang sudah ada, dan dapat diganti per buku.', 'sorting_rules' => 'Aturan Penyortiran', 'sorting_rules_desc' => 'Ini adalah operasi penyortiran yang telah ditetapkan sebelumnya yang dapat diterapkan pada konten dalam sistem.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Tanggal Pembaruan', 'sort_rule_op_chapters_first' => 'Bab di Urutan Pertama', 'sort_rule_op_chapters_last' => 'Bab di Urutan Terakhir', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Pemeliharaan', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Impor Konten', 'role_editor_change' => 'Ubah editor halaman', 'role_notifications' => 'Terima dan kelola notifikasi', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Izin Aset', 'roles_system_warning' => 'Ketahuilah bahwa akses ke salah satu dari tiga izin di atas dapat memungkinkan pengguna untuk mengubah hak mereka sendiri atau orang lain dalam sistem. Hanya tetapkan peran dengan izin ini untuk pengguna tepercaya.', 'role_asset_desc' => 'Izin ini mengontrol akses default ke aset dalam sistem. Izin pada Buku, Bab, dan Halaman akan menggantikan izin ini.', 'role_asset_admins' => 'Admin secara otomatis diberi akses ke semua konten tetapi opsi ini dapat menampilkan atau menyembunyikan opsi UI.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Semua', 'role_own' => 'Sendiri', 'role_controlled_by_asset' => 'Dikendalikan oleh aset tempat mereka diunggah', diff --git a/lang/id/validation.php b/lang/id/validation.php index d5ccb2709..b649ef6fe 100644 --- a/lang/id/validation.php +++ b/lang/id/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Berkas tidak dapat diunggah. Server mungkin tidak menerima berkas dengan ukuran ini.', 'zip_file' => ':attribute perlu merujuk ke sebuah file yang terdapat di dalam arsip ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute seharusnya berupa file dengan tipe :validTypes, tapi yang Anda unggah bertipe :foundType.', 'zip_model_expected' => 'Diharapkan sebuah objek data, namun yang ditemukan adalah \':type\'.', 'zip_unique' => ':attribute harus bersifat unik untuk setiap jenis objek dalam file ZIP.', diff --git a/lang/is/errors.php b/lang/is/errors.php index 50e30a8c5..a29c8475a 100644 --- a/lang/is/errors.php +++ b/lang/is/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Gat ekki lesið ZIP skrá.', 'import_zip_cant_decode_data' => 'Fann ekki ZIP data.json innihald.', 'import_zip_no_data' => 'ZIP skráin inniheldur ekkert efni.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'ZIP skráin stóðst ekki staðfestingu og skilaði villu:', 'import_zip_failed_notification' => 'Gat ekki lesið inn ZIP skrá.', 'import_perms_books' => 'Þú hefur ekki heimild til að búa til bækur.', diff --git a/lang/is/notifications.php b/lang/is/notifications.php index b4fe01ad7..956fc7432 100644 --- a/lang/is/notifications.php +++ b/lang/is/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Uppfærð síða á: :pageName', 'updated_page_intro' => 'Síða hefur verið uppfærð á :appName:', 'updated_page_debounce' => 'Til að fyrirbyggja fjöldatilkynningar verður þér ekki sendar tilkynningar í smá stund um uppfærslu á þessari síðu frá sama höfundi.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Síðunafn:', 'detail_page_path' => 'Síðuslóð:', diff --git a/lang/is/preferences.php b/lang/is/preferences.php index b7ebc2df0..b6f261deb 100644 --- a/lang/is/preferences.php +++ b/lang/is/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Stýrðu þeim tölvupóst tilkynningum sem þú færð þegar ákveðnar aðgerðir eru gerðar af kerfinu.', 'notifications_opt_own_page_changes' => 'Láta vita þegar gerðar eru breytingar á síðum sem ég á', 'notifications_opt_own_page_comments' => 'Láta vita þegar gerðar eru athugasmedir við síður sem ég á', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Láta vita þegar athugasemdum mínum er svarað', 'notifications_save' => 'Vista stillingar', 'notifications_update_success' => 'Stillingar á tilkynningum hafa verið uppfærðar!', diff --git a/lang/is/settings.php b/lang/is/settings.php index 3765db061..5699c88d6 100644 --- a/lang/is/settings.php +++ b/lang/is/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Engin skilyrði sett', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Viðhald', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Flytja inn efni', 'role_editor_change' => 'Skipta um ritil síðu', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Allt', 'role_own' => 'Eigin', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/is/validation.php b/lang/is/validation.php index 9183d27cf..7f2af0f08 100644 --- a/lang/is/validation.php +++ b/lang/is/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/it/errors.php b/lang/it/errors.php index b3763cce2..62b698acc 100644 --- a/lang/it/errors.php +++ b/lang/it/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Impossibile leggere il file ZIP.', 'import_zip_cant_decode_data' => 'Impossibile trovare e decodificare il contenuto ZIP data.json.', 'import_zip_no_data' => 'I dati del file ZIP non hanno il contenuto previsto di libri, capitoli o pagine.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'L\'importazione ZIP non è stata convalidata con errori:', 'import_zip_failed_notification' => 'Impossibile importare il file ZIP.', 'import_perms_books' => 'Non hai i permessi necessari per creare libri.', diff --git a/lang/it/notifications.php b/lang/it/notifications.php index 6b8932ebe..a4e57abdf 100644 --- a/lang/it/notifications.php +++ b/lang/it/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Pagina aggiornata: :pageName', 'updated_page_intro' => 'Una pagina è stata aggiornata in :appName:', 'updated_page_debounce' => 'Per evitare una massa di notifiche, per un po\' non ti verranno inviate notifiche per ulteriori modifiche a questa pagina dallo stesso editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nome della pagina:', 'detail_page_path' => 'Percorso della pagina:', diff --git a/lang/it/preferences.php b/lang/it/preferences.php index f25138401..db6b90754 100644 --- a/lang/it/preferences.php +++ b/lang/it/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Controlla le notifiche email che ricevi quando viene eseguita una determinata attività all\'interno del sistema.', 'notifications_opt_own_page_changes' => 'Notifica in caso di modifiche alle pagine che possiedo', 'notifications_opt_own_page_comments' => 'Notifica i commenti sulle pagine che possiedo', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notificare le risposte ai miei commenti', 'notifications_save' => 'Salva preferenze', 'notifications_update_success' => 'Le preferenze di notifica sono state aggiornate!', diff --git a/lang/it/settings.php b/lang/it/settings.php index 8bda7f8ff..9f2d882af 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nessuna restrizione impostata', // Sorting Settings - 'sorting' => 'Ordinamento', - 'sorting_book_default' => 'Ordinamento libri predefinito', + 'sorting' => 'Elenchi E Ordinamento', + 'sorting_book_default' => 'Regola Di Ordinamento Libro Predefinita', 'sorting_book_default_desc' => 'Selezionare la regola di ordinamento predefinita da applicare ai nuovi libri. Questa regola non influisce sui libri esistenti e può essere modificata per ogni libro.', 'sorting_rules' => 'Regole di ordinamento', 'sorting_rules_desc' => 'Si tratta di operazioni di ordinamento predefinite applicabili ai contenuti del sistema.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Data di aggiornamento', 'sort_rule_op_chapters_first' => 'Capitoli Prima', 'sort_rule_op_chapters_last' => 'Capitoli dopo', + 'sorting_page_limits' => 'Limiti Visualizzazione Per Pagina', + 'sorting_page_limits_desc' => 'Imposta il numero di elementi da visualizzare per pagina nei vari elenchi all\'interno del sistema. In genere, un numero inferiore garantisce prestazioni migliori, mentre un numero più elevato evita la necessità di cliccare su più pagine. Si consiglia di utilizzare un multiplo pari di 3 (18, 24, 30, ecc...).', // Maintenance settings 'maint' => 'Manutenzione', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importa contenuto', 'role_editor_change' => 'Cambiare editor di pagina', 'role_notifications' => 'Ricevere e gestire le notifiche', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Permessi entità', 'roles_system_warning' => 'Siate consapevoli che l\'accesso a uno dei tre permessi qui sopra può consentire a un utente di modificare i propri privilegi o i privilegi di altri nel sistema. Assegna ruoli con questi permessi solo ad utenti fidati.', 'role_asset_desc' => 'Questi permessi controllano l\'accesso predefinito alle entità. I permessi in libri, capitoli e pagine sovrascriveranno questi.', 'role_asset_admins' => 'Gli amministratori hanno automaticamente accesso a tutti i contenuti ma queste opzioni possono mostrare o nascondere le opzioni della UI.', 'role_asset_image_view_note' => 'Questo si riferisce alla visibilità all\'interno del gestore delle immagini. L\'accesso effettivo ai file di immagine caricati dipenderà dall\'opzione di archiviazione delle immagini di sistema.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Tutti', 'role_own' => 'Propri', 'role_controlled_by_asset' => 'Controllato dall\'entità in cui sono caricati', diff --git a/lang/it/validation.php b/lang/it/validation.php index c945ff7d4..54ef4dba6 100644 --- a/lang/it/validation.php +++ b/lang/it/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.', 'zip_file' => 'L\'attributo :attribute deve fare riferimento a un file all\'interno dello ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Il campo :attribute deve fare riferimento a un file di tipo :validTypes, trovato :foundType.', 'zip_model_expected' => 'Oggetto dati atteso ma ":type" trovato.', 'zip_unique' => 'L\'attributo :attribute deve essere univoco per il tipo di oggetto all\'interno dello ZIP.', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index eed0c92ca..551a2e496 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'アップロードされたインポートZIPファイルは削除され、元に戻すことはできません。', 'import_errors' => 'インポートエラー', 'import_errors_desc' => 'インポート中に次のエラーが発生しました:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => '階層内のページ', + 'breadcrumb_siblings_for_chapter' => '階層内のチャプタ', + 'breadcrumb_siblings_for_book' => '階層内のブック', + 'breadcrumb_siblings_for_bookshelf' => '階層内の棚', // Permissions and restrictions 'permissions' => '権限', diff --git a/lang/ja/errors.php b/lang/ja/errors.php index 8161c08b8..443a810f9 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'ZIPファイルを読み込めません。', 'import_zip_cant_decode_data' => 'ZIPファイル内に data.json が見つからないかデコードできませんでした。', 'import_zip_no_data' => 'ZIPファイルのデータにブック、チャプター、またはページコンテンツがありません。', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'エラーによりインポートZIPの検証に失敗しました:', 'import_zip_failed_notification' => 'ZIP ファイルのインポートに失敗しました。', 'import_perms_books' => 'ブックを作成するために必要な権限がありません。', diff --git a/lang/ja/notifications.php b/lang/ja/notifications.php index 6fc0321d3..ea6286b51 100644 --- a/lang/ja/notifications.php +++ b/lang/ja/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'ページの更新: :pageName', 'updated_page_intro' => ':appName でページが更新されました', 'updated_page_debounce' => '大量の通知を防ぐために、しばらくの間は同じユーザがこのページをさらに編集しても通知は送信されません。', + 'comment_mention_subject' => 'ページのコメントであなたにメンションされています: :pageName', + 'comment_mention_intro' => ':appName: のコメントであなたにメンションされました', 'detail_page_name' => 'ページ名:', 'detail_page_path' => 'ページパス:', diff --git a/lang/ja/preferences.php b/lang/ja/preferences.php index 0207596a9..c74518393 100644 --- a/lang/ja/preferences.php +++ b/lang/ja/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'システム内で特定のアクティビティが実行されたときに受信する電子メール通知を制御します。', 'notifications_opt_own_page_changes' => '自分が所有するページの変更を通知する', 'notifications_opt_own_page_comments' => '自分が所有するページへのコメントを通知する', + 'notifications_opt_comment_mentions' => 'コメントでメンションされたときに通知する', 'notifications_opt_comment_replies' => '自分のコメントへの返信を通知する', 'notifications_save' => '設定を保存', 'notifications_update_success' => '通知設定を更新しました。', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index db14f3ccc..53b14233e 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => '制限しない', // Sorting Settings - 'sorting' => 'ソート', - 'sorting_book_default' => 'ブックのデフォルトソート', + 'sorting' => '一覧とソート', + 'sorting_book_default' => 'ブックのデフォルトソートルール', 'sorting_book_default_desc' => '新しいブックに適用するデフォルトのソートルールを選択します。これは既存のブックには影響しません。ルールはブックごとに上書きすることができます。', 'sorting_rules' => 'ソートルール', 'sorting_rules_desc' => 'これらはシステム内のコンテンツに適用できる事前定義のソート操作です。', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => '更新日時', 'sort_rule_op_chapters_first' => 'チャプタを最初に', 'sort_rule_op_chapters_last' => 'チャプタを最後に', + 'sorting_page_limits' => 'ページング表示制限', + 'sorting_page_limits_desc' => 'システム内の各種リストで1ページに表示するアイテム数を設定します。 通常、少ない数に設定するとパフォーマンスが向上し、多い数に設定するとページの移動操作が少なくなります。 3の倍数(18、24、30など)を使用することをお勧めします。', // Maintenance settings 'maint' => 'メンテナンス', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'コンテンツのインポート', 'role_editor_change' => 'ページエディタの変更', 'role_notifications' => '通知の受信と管理', + 'role_permission_note_users_and_roles' => '技術的には、これらの権限によりシステムのユーザーおよび役割の可視性と検索も提供されます。', 'role_asset' => 'アセット権限', 'roles_system_warning' => '上記の3つの権限のいずれかを付与することは、ユーザーが自分の特権またはシステム内の他のユーザーの特権を変更できる可能性があることに注意してください。これらの権限は信頼できるユーザーにのみ割り当ててください。', 'role_asset_desc' => '各アセットに対するデフォルトの権限を設定します。ここで設定した権限が優先されます。', 'role_asset_admins' => '管理者にはすべてのコンテンツへのアクセス権が自動的に付与されますが、これらのオプションはUIオプションを表示または非表示にする場合があります。', 'role_asset_image_view_note' => 'これは画像マネージャー内の可視性に関連しています。アップロードされた画像ファイルへの実際のアクセスは、システムの画像保存オプションに依存します。', + 'role_asset_users_note' => '技術的には、これらの権限によりシステム内のユーザーの可視性と検索も提供されます。', 'role_all' => '全て', 'role_own' => '自身', 'role_controlled_by_asset' => 'このアセットに対し、右記の操作を許可:', diff --git a/lang/ja/validation.php b/lang/ja/validation.php index 7d18c85be..0efbc7d68 100644 --- a/lang/ja/validation.php +++ b/lang/ja/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。', 'zip_file' => ':attribute はZIP 内のファイルを参照する必要があります。', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute は種別 :validType のファイルを参照する必要がありますが、種別 :foundType となっています。', 'zip_model_expected' => 'データオブジェクトが期待されますが、":type" が見つかりました。', 'zip_unique' => 'ZIP内のオブジェクトタイプに :attribute が一意である必要があります。', diff --git a/lang/ka/errors.php b/lang/ka/errors.php index 9d7383796..77d7ee69e 100644 --- a/lang/ka/errors.php +++ b/lang/ka/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/ka/notifications.php b/lang/ka/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/ka/notifications.php +++ b/lang/ka/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/ka/preferences.php b/lang/ka/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/ka/preferences.php +++ b/lang/ka/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index 81c2c0a93..c68605fe1 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/ka/validation.php b/lang/ka/validation.php index d9b982d1e..ff028525d 100644 --- a/lang/ka/validation.php +++ b/lang/ka/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/ko/errors.php b/lang/ko/errors.php index 9639a5036..12ee2697a 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'ZIP 파일을 읽을 수 없습니다.', 'import_zip_cant_decode_data' => 'ZIP data.json 콘텐츠를 찾아서 디코딩할 수 없습니다.', 'import_zip_no_data' => '컨텐츠 ZIP 파일 데이터에 데이터가 비어있습니다.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => '컨텐츠 ZIP 파일을 가져오려다 실패했습니다. 이유:', 'import_zip_failed_notification' => '컨텐츠 ZIP 파일을 가져오지 못했습니다.', 'import_perms_books' => '책을 만드는 데 필요한 권한이 없습니다.', diff --git a/lang/ko/notifications.php b/lang/ko/notifications.php index b337fa86d..a5abcf0f9 100644 --- a/lang/ko/notifications.php +++ b/lang/ko/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => '페이지 업데이트됨: :pageName', 'updated_page_intro' => ':appName: 에서 페이지가 업데이트되었습니다:', 'updated_page_debounce' => '알림이 한꺼번에 몰리는 것을 방지하기 위해 당분간 동일한 편집자가 이 페이지를 추가로 편집할 경우 알림이 전송되지 않습니다.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => '페이지 이름:', 'detail_page_path' => '페이지 경로:', diff --git a/lang/ko/preferences.php b/lang/ko/preferences.php index 79692f564..1979a23e4 100644 --- a/lang/ko/preferences.php +++ b/lang/ko/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => '시스템 내에서 특정 활동이 수행될 때 수신하는 이메일 알림을 제어합니다.', 'notifications_opt_own_page_changes' => '내가 소유한 페이지가 변경되면 알림 받기', 'notifications_opt_own_page_comments' => '내가 소유한 페이지에 댓글이 달렸을 때 알림 받기', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => '내 댓글에 대한 답글 알림 받기', 'notifications_save' => '환경설정 저장', 'notifications_update_success' => '알림 환경설정이 업데이트되었습니다!', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 797c12043..1138b0ece 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => '차단한 도메인 없음', // Sorting Settings - 'sorting' => '정렬', - 'sorting_book_default' => '기본 책 정렬', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => '새로운 책에 적용할 기본 정렬 규칙을 선택하세요. 이 선택은 기존 책에는 영향을 주지 않고, 기존 책의 설정은 책마다 변경할 수 있습니다.', 'sorting_rules' => '정렬 규칙', 'sorting_rules_desc' => '현재 시스템에 미리 정의된 정렬 규칙의 목록입니다.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => '수정일', 'sort_rule_op_chapters_first' => '챕터 우선 정렬', 'sort_rule_op_chapters_last' => '챕터 나중 정렬', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => '유지관리', @@ -195,11 +197,13 @@ return [ 'role_import_content' => '내용 가져오기', 'role_editor_change' => '페이지 편집기 변경', 'role_notifications' => '알림 수신 및 관리', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => '권한 항목', 'roles_system_warning' => '위 세 권한은 자신의 권한이나 다른 유저의 권한을 바꿀 수 있습니다.', 'role_asset_desc' => '책, 챕터, 문서별 권한은 이 설정에 우선합니다.', 'role_asset_admins' => '관리자 권한은 어디든 접근할 수 있지만 이 설정은 사용자 인터페이스에서 해당 활동을 표시할지 결정합니다.', 'role_asset_image_view_note' => '이는 이미지 관리자 내 가시성과 관련이 있습니다. 업로드된 이미지 파일의 실제 접근은 시스템의 이미지 저장 설정에 따라 달라집니다.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => '모든 항목', 'role_own' => '직접 만든 항목', 'role_controlled_by_asset' => '저마다 다름', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index ef7361ff9..a60ac2f21 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => '파일 크기가 서버에서 허용하는 수치를 넘습니다.', 'zip_file' => ':attribute은(는) 컨텐츠 ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute은(는) :validTypes, found :foundType 유형의 파일을 참조해야 합니다.', 'zip_model_expected' => '데이터 객체가 필요하지만 ":type" 타입이 발견되었습니다.', 'zip_unique' => ':attribute은(는) 컨텐츠 ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', diff --git a/lang/ku/errors.php b/lang/ku/errors.php index 9d7383796..77d7ee69e 100644 --- a/lang/ku/errors.php +++ b/lang/ku/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/ku/notifications.php b/lang/ku/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/ku/notifications.php +++ b/lang/ku/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/ku/preferences.php b/lang/ku/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/ku/preferences.php +++ b/lang/ku/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/ku/settings.php b/lang/ku/settings.php index 81c2c0a93..c68605fe1 100644 --- a/lang/ku/settings.php +++ b/lang/ku/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/ku/validation.php b/lang/ku/validation.php index d9b982d1e..ff028525d 100644 --- a/lang/ku/validation.php +++ b/lang/ku/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/lt/editor.php b/lang/lt/editor.php index 0d250e9a7..054190819 100644 --- a/lang/lt/editor.php +++ b/lang/lt/editor.php @@ -37,7 +37,7 @@ return [ 'blockquote' => 'Blockquote', 'inline_code' => 'Inline code', 'callouts' => 'Callouts', - 'callout_information' => 'Information', + 'callout_information' => 'Informacija', 'callout_success' => 'Success', 'callout_warning' => 'Warning', 'callout_danger' => 'Danger', @@ -61,7 +61,7 @@ return [ 'list_task' => 'Task list', 'indent_increase' => 'Increase indent', 'indent_decrease' => 'Decrease indent', - 'table' => 'Table', + 'table' => 'Lentelė', 'insert_image' => 'Insert image', 'insert_image_title' => 'Insert/Edit Image', 'insert_link' => 'Insert/edit link', @@ -150,11 +150,11 @@ return [ 'text_to_display' => 'Text to display', 'title' => 'Title', 'browse_links' => 'Browse links', - 'open_link' => 'Open link', - 'open_link_in' => 'Open link in...', + 'open_link' => 'Atverti nuorodą', + 'open_link_in' => 'Atverti nuorodą...', 'open_link_current' => 'Current window', - 'open_link_new' => 'New window', - 'remove_link' => 'Remove link', + 'open_link_new' => 'Naujame lange', + 'remove_link' => 'Pašalinti nuorodą', 'insert_collapsible' => 'Insert collapsible block', 'collapsible_unwrap' => 'Unwrap', 'edit_label' => 'Edit label', @@ -163,14 +163,14 @@ return [ 'toggle_label' => 'Toggle label', // About view - 'about' => 'About the editor', - 'about_title' => 'About the WYSIWYG Editor', + 'about' => 'Apie redaktorių', + 'about_title' => 'Apie WYSIWYG redaktorių', 'editor_license' => 'Editor License & Copyright', 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', - 'save_continue' => 'Save Page & Continue', + 'save_continue' => 'Išsaugoti puslapį ir tęsti', 'callouts_cycle' => '(Keep pressing to toggle through types)', 'link_selector' => 'Link to content', 'shortcuts' => 'Shortcuts', diff --git a/lang/lt/entities.php b/lang/lt/entities.php index 562456781..6c4472d85 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -416,7 +416,7 @@ return [ 'comment_jump_to_thread' => 'Jump to thread', 'comment_delete_confirm' => 'Esate tikri, kad norite ištrinti šį komentarą?', 'comment_in_reply_to' => 'Atsakydamas į :commentId', - 'comment_reference' => 'Reference', + 'comment_reference' => 'Nuoroda', 'comment_reference_outdated' => '(Outdated)', 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', @@ -446,7 +446,7 @@ return [ 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', // References - 'references' => 'References', + 'references' => 'Nuorodos', 'references_none' => 'There are no tracked references to this item.', 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', diff --git a/lang/lt/errors.php b/lang/lt/errors.php index 392e99a51..f2917058e 100644 --- a/lang/lt/errors.php +++ b/lang/lt/errors.php @@ -23,7 +23,7 @@ return [ 'saml_no_email_address' => 'Nerandamas šio naudotojo elektroninio pašto adresas išorinės autentifikavimo sistemos pateiktuose duomenyse', 'saml_invalid_response_id' => 'Prašymas iš išorinės autentifikavimo sistemos nėra atpažintas proceso, kurį pradėjo ši programa. Naršymas po prisijungimo gali sukelti šią problemą.', 'saml_fail_authed' => 'Prisijungimas, naudojant :system nepavyko, sistema nepateikė sėkmingo leidimo.', - 'oidc_already_logged_in' => 'Already logged in', + 'oidc_already_logged_in' => 'Jau prisijungta', 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', 'social_no_action_defined' => 'Neapibrėžtas joks veiksmas', @@ -97,7 +97,7 @@ return [ '404_page_not_found' => 'Puslapis nerastas', 'sorry_page_not_found' => 'Atleiskite, puslapis, kurio ieškote, nerastas.', 'sorry_page_not_found_permission_warning' => 'Jei tikėjotės, kad šis puslapis egzistuoja, galbūt neturite leidimo jo peržiūrėti.', - 'image_not_found' => 'Image Not Found', + 'image_not_found' => 'Paveikslėlis nerastas', 'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.', 'image_not_found_details' => 'If you expected this image to exist it might have been deleted.', 'return_home' => 'Grįžti į namus', @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/lt/notifications.php b/lang/lt/notifications.php index 1afd23f1d..5f938c397 100644 --- a/lang/lt/notifications.php +++ b/lang/lt/notifications.php @@ -6,21 +6,23 @@ return [ 'new_comment_subject' => 'New comment on page: :pageName', 'new_comment_intro' => 'A user has commented on a page in :appName:', - 'new_page_subject' => 'New page: :pageName', + 'new_page_subject' => 'Naujas puslapis: :pageName', 'new_page_intro' => 'A new page has been created in :appName:', 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', - 'detail_page_name' => 'Page Name:', + 'detail_page_name' => 'Puslapio pavadinimas:', 'detail_page_path' => 'Page Path:', 'detail_commenter' => 'Commenter:', - 'detail_comment' => 'Comment:', - 'detail_created_by' => 'Created By:', - 'detail_updated_by' => 'Updated By:', + 'detail_comment' => 'Komentaras:', + 'detail_created_by' => 'Sukurta:', + 'detail_updated_by' => 'Atnaujinta:', 'action_view_comment' => 'View Comment', - 'action_view_page' => 'View Page', + 'action_view_page' => 'Peržiūrėti puslapį', 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', 'footer_reason_link' => 'your notification preferences', diff --git a/lang/lt/passwords.php b/lang/lt/passwords.php index 672620d35..e661c020f 100644 --- a/lang/lt/passwords.php +++ b/lang/lt/passwords.php @@ -7,9 +7,9 @@ return [ 'password' => 'Slaptažodis privalo būti mažiausiai aštuonių simbolių ir atitikti patvirtinimą.', - 'user' => "We can't find a user with that e-mail address.", + 'user' => "Nerastas vartotojas pagal šį el. pašto adresą.", 'token' => 'Slaptažodžio nustatymo raktas yra neteisingas šiam elektroninio pašto adresui.', - 'sent' => 'Elektroniu paštu jums atsiuntėme slaptažodžio atkūrimo nuorodą!', + 'sent' => 'Elektroniniu paštu Jums išsiųsta slaptažodžio atkūrimo nuoroda!', 'reset' => 'Jūsų slaptažodis buvo atkurtas!', ]; diff --git a/lang/lt/preferences.php b/lang/lt/preferences.php index 5c38d191c..7f591cdab 100644 --- a/lang/lt/preferences.php +++ b/lang/lt/preferences.php @@ -23,8 +23,9 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', - 'notifications_save' => 'Save Preferences', + 'notifications_save' => 'Išsaugoti nuostatas', 'notifications_update_success' => 'Notification preferences have been updated!', 'notifications_watched' => 'Watched & Ignored Items', 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index 563038bf2..bcc7c82bd 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -9,8 +9,8 @@ return [ // Common Messages 'settings' => 'Nustatymai', 'settings_save' => 'Išsaugoti nustatymus', - 'system_version' => 'System Version', - 'categories' => 'Categories', + 'system_version' => 'Sistemos versija', + 'categories' => 'Kategorijos', // App Settings 'app_customization' => 'Tinkinimas', @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nėra jokių apribojimų', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Priežiūra', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Nuosavybės leidimai', 'roles_system_warning' => 'Būkite sąmoningi, kad prieiga prie bet kurio iš trijų leidimų viršuje gali leisti naudotojui pakeisti jų pačių privilegijas arba kitų privilegijas sistemoje. Paskirkite vaidmenis su šiais leidimais tik patikimiems naudotojams.', 'role_asset_desc' => 'Šie leidimai kontroliuoja numatytą prieigą į nuosavybę, esančią sistemoje. Knygų, skyrių ir puslapių leidimai nepaisys šių leidimų.', 'role_asset_admins' => 'Administratoriams automatiškai yra suteikiama prieiga prie viso turinio, tačiau šie pasirinkimai gali rodyti arba slėpti vartotojo sąsajos parinktis.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Visi', 'role_own' => 'Nuosavi', 'role_controlled_by_asset' => 'Kontroliuojami nuosavybės, į kurią yra įkelti', diff --git a/lang/lt/validation.php b/lang/lt/validation.php index 92de23004..65e5936c2 100644 --- a/lang/lt/validation.php +++ b/lang/lt/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Šis failas negali būti įkeltas. Serveris gali nepriimti tokio dydžio failų.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/lv/errors.php b/lang/lv/errors.php index 28cc0d892..b4e9ec61a 100644 --- a/lang/lv/errors.php +++ b/lang/lv/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Nevarēja nolasīt ZIP failu.', 'import_zip_cant_decode_data' => 'Nevarēja atrast un nolasīt data.json saturu ZIP failā.', 'import_zip_no_data' => 'ZIP faila datos nav atrasts grāmatu, nodaļu vai lapu saturs.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'ZIP faila imports ir neveiksmīgs ar šādām kļūdām:', 'import_zip_failed_notification' => 'ZIP faila imports ir neveiksmīgs.', 'import_perms_books' => 'Jums nav nepieciešamo tiesību izveidot grāmatas.', diff --git a/lang/lv/notifications.php b/lang/lv/notifications.php index 9fd60222a..7233822a4 100644 --- a/lang/lv/notifications.php +++ b/lang/lv/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Atjaunināta lapa: :pageName', 'updated_page_intro' => 'Lapa atjaunināta :appName:', 'updated_page_debounce' => 'Lai novērstu pārliecīgu paziņojumu sūtīšanu, uz laiku jums tiks pārtraukti paziņojumi par turpmākiem šī lietotāja labojumiem šai lapai.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Lapas nosaukums:', 'detail_page_path' => 'Ceļš uz lapu:', diff --git a/lang/lv/preferences.php b/lang/lv/preferences.php index b91cc2e08..3c107d5fa 100644 --- a/lang/lv/preferences.php +++ b/lang/lv/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Pārvaldiet epasta paziņojumus, ko saņemsiet, kad sistēmā tiek veiktas noteiktas darbības.', 'notifications_opt_own_page_changes' => 'Paziņot par izmaiņām manās lapās', 'notifications_opt_own_page_comments' => 'Paziņot par komentāriem manās lapās', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Paziņot par atbildēm uz maniem komentāriem', 'notifications_save' => 'Saglabāt iestatījumus', 'notifications_update_success' => 'Paziņojumu iestatījumi ir atjaunoti!', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index 9901f7811..fb5cf13dd 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nav ierobežojumu', // Sorting Settings - 'sorting' => 'Kārtošana', - 'sorting_book_default' => 'Noklusētā grāmatu kārtošana', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Izvēlieties noklusēto kārtošanas nosacījumu, ko pielietot jaunām grāmatām. Šis neskars jau esošas grāmatas, un to var izmainīt grāmatas iestatījumos.', 'sorting_rules' => 'Kārtošanas noteikumi', 'sorting_rules_desc' => 'Šīs ir iepriekš noteiktas kārtošanas darbības, ko var pielietot saturam šajā sistēmā.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Atjaunināšanas datums', 'sort_rule_op_chapters_first' => 'Nodaļas pirmās', 'sort_rule_op_chapters_last' => 'Nodaļas pēdējās', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Apkope', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importēt saturu', 'role_editor_change' => 'Mainīt lapu redaktoru', 'role_notifications' => 'Saņemt un pārvaldīt paziņojumus', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Resursa piekļuves tiesības', 'roles_system_warning' => 'Jebkuras no trīs augstāk redzamajām atļaujām dod iespēju lietotājam mainīt savas un citu lietotāju sistēmas atļaujas. Pievieno šīs grupu atļaujas tikai tiem lietotājiem, kuriem uzticies.', 'role_asset_desc' => 'Šīs piekļuves tiesības kontrolē noklusēto piekļuvi sistēmas resursiem. Grāmatām, nodaļām un lapām norādītās tiesības būs pārākas par šīm.', 'role_asset_admins' => 'Administratoriem automātiski ir piekļuve visam saturam, bet šie uzstādījumi var noslēpt vai parādīt lietotāja saskarnes iespējas.', 'role_asset_image_view_note' => 'Šis ir saistīts ar redzamību attēlu pārvaldniekā. Faktiskā piekļuve augšupielādēto attēlu failiem būs atkarīga no sistēmas attēlu glabātuves uzstādījuma.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Visi', 'role_own' => 'Savi', 'role_controlled_by_asset' => 'Kontrolē resurss, uz ko tie ir augšupielādēti', diff --git a/lang/lv/validation.php b/lang/lv/validation.php index dd318119a..befad5eeb 100644 --- a/lang/lv/validation.php +++ b/lang/lv/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.', 'zip_file' => ':attribute ir jāatsaucas uz failu ZIP arhīvā.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute ir jāatsaucas uz failu ar tipu :validTypes, bet atrasts :foundType.', 'zip_model_expected' => 'Sagaidīts datu objekts, bet atrasts ":type".', 'zip_unique' => ':attribute jābūt unikālam šim objekta tipam ZIP arhīvā.', diff --git a/lang/nb/editor.php b/lang/nb/editor.php index 77b01f17b..23a788d71 100644 --- a/lang/nb/editor.php +++ b/lang/nb/editor.php @@ -48,7 +48,7 @@ return [ 'superscript' => 'Hevet skrift', 'subscript' => 'Senket skrift', 'text_color' => 'Tekstfarge', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Uthevingsfarge', 'custom_color' => 'Egenvalgt farge', 'remove_color' => 'Fjern farge', 'background_color' => 'Bakgrunnsfarge', diff --git a/lang/nb/errors.php b/lang/nb/errors.php index 400681b10..ef68da4fc 100644 --- a/lang/nb/errors.php +++ b/lang/nb/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Kunne ikke lese ZIP-filen.', 'import_zip_cant_decode_data' => 'Kunne ikke finne og dekode ZIP data.json innhold.', 'import_zip_no_data' => 'ZIP-fildata har ingen forventet bok, kapittel eller sideinnhold.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import av ZIP feilet i å validere med feil:', 'import_zip_failed_notification' => 'Kunne ikke importere ZIP-fil.', 'import_perms_books' => 'Du mangler nødvendige tillatelser for å lage bøker.', diff --git a/lang/nb/notifications.php b/lang/nb/notifications.php index bb46e09db..1e16229f3 100644 --- a/lang/nb/notifications.php +++ b/lang/nb/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Oppdatert side: :pageName', 'updated_page_intro' => 'En side er oppdatert i :appName:', 'updated_page_debounce' => 'For å forhindre mange varslinger, vil du ikke få nye varslinger for endringer på denne siden fra samme forfatter.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Sidenavn:', 'detail_page_path' => 'Side bane:', diff --git a/lang/nb/preferences.php b/lang/nb/preferences.php index 245c9c954..6a6850b83 100644 --- a/lang/nb/preferences.php +++ b/lang/nb/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Kontroller e-postvarslene du mottar når en bestemt aktivitet utføres i systemet.', 'notifications_opt_own_page_changes' => 'Varsle ved endringer til sider jeg eier', 'notifications_opt_own_page_comments' => 'Varsle om kommentarer på sider jeg eier', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Varsle ved svar på mine kommentarer', 'notifications_save' => 'Lagre innstillinger', 'notifications_update_success' => 'Varslingsinnstillingene er oppdatert!', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 68bab6f30..d1f40814e 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ingen begrensninger er satt', // Sorting Settings - 'sorting' => 'Sortering', - 'sorting_book_default' => 'Standard boksortering', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Velg standard sorteringsregelen som skal brukes for nye bøker. Dette vil ikke påvirke eksisterende bøker, og kan overstyres per bok.', 'sorting_rules' => 'Sorteringsregler', 'sorting_rules_desc' => 'Dette er forhåndsdefinerte sorteringsoperasjoner som kan brukes på innhold i systemet.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Kapitler først', 'sort_rule_op_chapters_last' => 'Kapitler sist', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Vedlikehold', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import innhold', 'role_editor_change' => 'Endre sideredigering', 'role_notifications' => 'Motta og administrere varslinger', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Eiendomstillatelser', 'roles_system_warning' => 'Vær oppmerksom på at tilgang til noen av de ovennevnte tre tillatelsene kan tillate en bruker å endre sine egne rettigheter eller rettighetene til andre i systemet. Bare tildel roller med disse tillatelsene til pålitelige brukere.', 'role_asset_desc' => 'Disse tillatelsene kontrollerer standard tilgang til eiendelene i systemet. Tillatelser til bøker, kapitler og sider overstyrer disse tillatelsene.', 'role_asset_admins' => 'Administratorer får automatisk tilgang til alt innhold, men disse alternativene kan vise eller skjule UI-alternativer.', 'role_asset_image_view_note' => 'Dette gjelder synlighet innenfor bilde-administrasjonen. Faktisk tilgang på opplastede bildefiler vil være avhengig av valget for systemlagring av bildet.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alle', 'role_own' => 'Egne', 'role_controlled_by_asset' => 'Kontrollert av eiendelen de er lastet opp til', diff --git a/lang/nb/validation.php b/lang/nb/validation.php index a156da8f7..27922e402 100644 --- a/lang/nb/validation.php +++ b/lang/nb/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.', 'zip_file' => 'Attributtet :attribute må henvises til en fil i ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Attributtet :attribute må referere en fil av typen :validTypes, som ble funnet :foundType.', 'zip_model_expected' => 'Data objekt forventet, men ":type" funnet.', 'zip_unique' => 'Attributtet :attribute må være unikt for objekttypen i ZIP.', diff --git a/lang/ne/errors.php b/lang/ne/errors.php index 79ee27c78..bbcaec9e1 100644 --- a/lang/ne/errors.php +++ b/lang/ne/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'ZIP फाइल पढ्न सकिएन।', 'import_zip_cant_decode_data' => 'ZIP डाटा.json सामग्री पत्ता लाग्न र डिकोड गर्न सकिएन।', 'import_zip_no_data' => 'ZIP फाइल डाटामा अपेक्षित पुस्तक, अध्याय वा पाना सामग्री छैन।', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'आयात ZIP प्रमाणीकरण असफल भयो। त्रुटिहरू छन्:', 'import_zip_failed_notification' => 'ZIP फाइल आयात गर्न असफल भयो।', 'import_perms_books' => 'तपाईंलाई पुस्तकहरू सिर्जना गर्न आवश्यक अनुमति छैन।', diff --git a/lang/ne/notifications.php b/lang/ne/notifications.php index 1e9c57221..a4eb69832 100644 --- a/lang/ne/notifications.php +++ b/lang/ne/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'पाना अपडेट भयो: :pageName', 'updated_page_intro' => ':appName मा पाना अपडेट गरिएको छ', 'updated_page_debounce' => 'धेरै सूचना नपरोस् भनेर, केही समयको लागि एउटै सम्पादकबाट हुने थप सम्पादनहरूका सूचना तपाईंलाई पठाइने छैन।', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'पानाको नाम:', 'detail_page_path' => 'पानाको स्थान:', diff --git a/lang/ne/preferences.php b/lang/ne/preferences.php index 70388e8ba..2efdafc9f 100644 --- a/lang/ne/preferences.php +++ b/lang/ne/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'प्रणालीमा केही क्रियाकलापहरू गर्दा तपाईंलाई प्राप्त हुने इमेल सूचनाहरू नियन्त्रण गर्नुहोस्।', 'notifications_opt_own_page_changes' => 'मैले स्वामित्व राख्ने पृष्ठहरूमा परिवर्तन हुँदा सूचित गर्नुहोस्', 'notifications_opt_own_page_comments' => 'मैले स्वामित्व राख्ने पृष्ठहरूमा टिप्पणी हुँदा सूचित गर्नुहोस्', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'मेरो टिप्पणीहरूमा उत्तर आएको बेला सूचित गर्नुहोस्', 'notifications_save' => 'प्राथमिकताहरू बचत गर्नुहोस्', 'notifications_update_success' => 'सूचना प्राथमिकताहरू अपडेट गरिएका छन्!', diff --git a/lang/ne/settings.php b/lang/ne/settings.php index eb99f26bd..37e59978e 100644 --- a/lang/ne/settings.php +++ b/lang/ne/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'कुनै प्रतिबन्ध छैन', // Sorting Settings - 'sorting' => 'क्रमबद्धता', - 'sorting_book_default' => 'डिफल्ट पुस्तक क्रम', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'नयाँ पुस्तकहरूमा लागु गर्न डिफल्ट क्रम नियम चयन गर्नुहोस्। यो अस्तित्वमा रहेका पुस्तकहरूमा असर पार्दैन र पुस्तक अनुसार ओभरराइड गर्न सकिन्छ।', 'sorting_rules' => 'क्रम नियमहरू', 'sorting_rules_desc' => 'यी पूर्वनिर्धारित क्रम सञ्चालनहरू हुन् जुन प्रणालीमा सामग्रीमा लागू गर्न सकिन्छ।', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'अपडेट मिति', 'sort_rule_op_chapters_first' => 'पहिले अध्यायहरू', 'sort_rule_op_chapters_last' => 'अन्त्यमा अध्यायहरू', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'सम्भार', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'सामग्री आयात गर्नुहोस्', 'role_editor_change' => 'पृष्ठ सम्पादक परिवर्तन गर्नुहोस्', 'role_notifications' => 'सूचनाहरू प्राप्त र व्यवस्थापन गर्नुहोस्', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'संपत्ति अनुमति', 'roles_system_warning' => 'माथिका कुनै पनि तीन अनुमति प्रयोगकर्ताले आफैं वा अरूका अधिकार परिवर्तन गर्न सक्छन्। यी अनुमति भएको भूमिका मात्र भरपर्दो प्रयोगकर्तालाई दिनुहोस्।', 'role_asset_desc' => 'यी अनुमतिले प्रणालीभित्र सम्पत्तिमा डिफल्ट पहुँच नियन्त्रण गर्छ। पुस्तक, अध्याय र पृष्ठमा अनुमति यी भन्दा प्राथमिक हुन्छ।', 'role_asset_admins' => 'प्रशासनकर्ताहरूलाई सबै सामग्रीमा स्वतः पहुँच दिइन्छ, यी विकल्पहरूले UI मा देखिने वा लुकेका विकल्पहरू मात्र प्रभाव पार्न सक्छ।', 'role_asset_image_view_note' => 'यो छवि व्यवस्थापक भित्रको दृश्यता सम्बन्धि हो। अपलोड गरिएको छविमा वास्तविक पहुँच प्रणालीको छवि भण्डारण विकल्प अनुसार हुन्छ।', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'सबै', 'role_own' => 'आफ्नो', 'role_controlled_by_asset' => 'अपलोड गरिएको सम्पत्तिले नियन्त्रण गरेको', diff --git a/lang/ne/validation.php b/lang/ne/validation.php index f00c0f731..ed61d3d6f 100644 --- a/lang/ne/validation.php +++ b/lang/ne/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'फाइल अपलोड हुन सकेन। सर्भरले यस्तो साइज स्वीकार नगर्न सक्छ।', 'zip_file' => ':attribute ले ZIP फाइलभित्रको फाइल देखाउनु पर्छ।', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute मा :validTypes प्रकारको फाइल हुनुपर्छ, तर :foundType भेटियो।', 'zip_model_expected' => 'डेटा वस्तु चाहिएको थियो तर ":type" भेटियो।', 'zip_unique' => ':attribute ZIP भित्रको वस्तु प्रकारको लागि अद्वितीय हुनुपर्छ।', diff --git a/lang/nl/activities.php b/lang/nl/activities.php index 3d548e2a7..230c7a58b 100644 --- a/lang/nl/activities.php +++ b/lang/nl/activities.php @@ -122,7 +122,7 @@ return [ 'recycle_bin_destroy' => 'verwijderde van prullenbak', // Comments - 'commented_on' => 'reageerde op', + 'commented_on' => 'plaatste opmerking in', 'comment_create' => 'voegde opmerking toe', 'comment_update' => 'paste opmerking aan', 'comment_delete' => 'verwijderde opmerking', diff --git a/lang/nl/common.php b/lang/nl/common.php index 47d8703ca..bcd7fbe08 100644 --- a/lang/nl/common.php +++ b/lang/nl/common.php @@ -40,7 +40,7 @@ return [ 'delete_confirm' => 'Verwijdering bevestigen', 'search' => 'Zoek', 'search_clear' => 'Zoekopdracht wissen', - 'reset' => 'Reset', + 'reset' => 'Wissen', 'remove' => 'Verwijder', 'add' => 'Voeg toe', 'configure' => 'Configureer', diff --git a/lang/nl/editor.php b/lang/nl/editor.php index 0963df71f..cc3ad108c 100644 --- a/lang/nl/editor.php +++ b/lang/nl/editor.php @@ -48,7 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Tekstkleur', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Accentkleur', 'custom_color' => 'Aangepaste kleur', 'remove_color' => 'Verwijder kleur', 'background_color' => 'Tekstmarkeringskleur', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index 967b7ecfb..b9be1f42b 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Dit zal het Zip-bestand van de import permanent verwijderen.', 'import_errors' => 'Importeerfouten', 'import_errors_desc' => 'De volgende fouten deden zich voor tijdens het importeren:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Navigeer pagina\'s op hetzelfde niveau', + 'breadcrumb_siblings_for_chapter' => 'Navigeer hoofdstukken op hetzelfde niveau', + 'breadcrumb_siblings_for_book' => 'Navigeer boeken op hetzelfde niveau', + 'breadcrumb_siblings_for_bookshelf' => 'Navigeer boekenplanken op hetzelfde niveau', // Permissions and restrictions 'permissions' => 'Machtigingen', @@ -272,11 +272,11 @@ return [ 'pages_md_insert_drawing' => 'Tekening invoegen', 'pages_md_show_preview' => 'Toon voorbeeld', 'pages_md_sync_scroll' => 'Synchroniseer scrollen van voorbeeld', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Normale tekst bewerker', 'pages_drawing_unsaved' => 'Niet-opgeslagen Tekening Gevonden', 'pages_drawing_unsaved_confirm' => 'Er zijn niet-opgeslagen tekeninggegevens gevonden van een eerdere mislukte poging om de tekening op te slaan. Wil je deze niet-opgeslagen tekening herstellen en verder bewerken?', 'pages_not_in_chapter' => 'Pagina is niet in een hoofdstuk', - 'pages_move' => 'Pagina verplaatsten', + 'pages_move' => 'Pagina Verplaatsen', 'pages_copy' => 'Pagina kopiëren', 'pages_copy_desination' => 'Kopieër bestemming', 'pages_copy_success' => 'Pagina succesvol gekopieerd', @@ -394,27 +394,27 @@ return [ 'profile_not_created_shelves' => ':userName heeft geen boekenplanken gemaakt', // Comments - 'comment' => 'Reactie', - 'comments' => 'Reacties', - 'comment_add' => 'Reactie toevoegen', + 'comment' => 'Opmerking', + 'comments' => 'Opmerkingen', + 'comment_add' => 'Opmerking toevoegen', 'comment_none' => 'Geen opmerkingen om weer te geven', - 'comment_placeholder' => 'Laat hier een reactie achter', - 'comment_thread_count' => ':count Reactie Thread|:count Reactie Threads', + 'comment_placeholder' => 'Laat hier een opmerking achter', + 'comment_thread_count' => ':count Opmerking Thread|:count Opmerking Threads', 'comment_archived_count' => ':count Gearchiveerd', 'comment_archived_threads' => 'Gearchiveerde Threads', - 'comment_save' => 'Sla reactie op', - 'comment_new' => 'Nieuwe reactie', - 'comment_created' => 'reactie gegeven :createDiff', + 'comment_save' => 'Sla opmerking op', + 'comment_new' => 'Nieuwe opmerking', + 'comment_created' => 'opmerking gegeven :createDiff', 'comment_updated' => 'Updatet :updateDiff door :username', 'comment_updated_indicator' => 'Bijgewerkt', - 'comment_deleted_success' => 'Reactie verwijderd', - 'comment_created_success' => 'Reactie toegevoegd', - 'comment_updated_success' => 'Reactie bijgewerkt', + 'comment_deleted_success' => 'Opmerking verwijderd', + 'comment_created_success' => 'Opmerking toegevoegd', + 'comment_updated_success' => 'Opmerking bijgewerkt', 'comment_archive_success' => 'Opmerking gearchiveerd', 'comment_unarchive_success' => 'Opmerking teruggehaald', 'comment_view' => 'Opmerking weergeven', 'comment_jump_to_thread' => 'Ga naar thread', - 'comment_delete_confirm' => 'Weet je zeker dat je deze reactie wilt verwijderen?', + 'comment_delete_confirm' => 'Weet je zeker dat je deze opmerking wilt verwijderen?', 'comment_in_reply_to' => 'Als antwoord op :commentId', 'comment_reference' => 'Verwijzing', 'comment_reference_outdated' => '(Verouderd)', @@ -466,11 +466,11 @@ return [ 'watch_desc_comments_page' => 'Geef een melding van pagina wijzigingen en nieuwe opmerkingen.', 'watch_change_default' => 'Standaardvoorkeuren voor meldingen wijzigen', 'watch_detail_ignore' => 'Meldingen negeren', - 'watch_detail_new' => 'Op de uitkijk voor nieuwe pagina\'s', - 'watch_detail_updates' => 'Op de uitkijk voor nieuwe pagina\'s en aanpassingen', - 'watch_detail_comments' => 'Op de uitkijk voor nieuwe pagina\'s, aanpassingen en opmerkingen', - 'watch_detail_parent_book' => 'Op de uitkijk via hogerliggend boek', - 'watch_detail_parent_book_ignore' => 'Aan het negeren via hogerliggend boek', - 'watch_detail_parent_chapter' => 'Op de uitkijk via hogerliggend hoofdstuk', - 'watch_detail_parent_chapter_ignore' => 'Aan het negeren via hogerliggend hoofdstuk', + 'watch_detail_new' => 'Nieuwe pagina\'s aan het volgen', + 'watch_detail_updates' => 'Nieuwe pagina\'s en aanpassingen aan het volgen', + 'watch_detail_comments' => 'Nieuwe pagina\'s, aanpassingen en opmerkingen aan het volgen', + 'watch_detail_parent_book' => 'Aan het volgen via bovenliggend boek', + 'watch_detail_parent_book_ignore' => 'Aan het negeren via bovenliggend boek', + 'watch_detail_parent_chapter' => 'Aan het volgen via bovenliggend hoofdstuk', + 'watch_detail_parent_chapter_ignore' => 'Aan het negeren via bovenliggend hoofdstuk', ]; diff --git a/lang/nl/errors.php b/lang/nl/errors.php index d8d5af723..c2a666546 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -87,11 +87,11 @@ return [ 'role_cannot_remove_only_admin' => 'Deze gebruiker is de enige gebruiker die is toegewezen aan de beheerdersrol. Wijs de beheerdersrol toe aan een andere gebruiker voordat u probeert deze hier te verwijderen.', // Comments - 'comment_list' => 'Er is een fout opgetreden tijdens het ophalen van de reacties.', - 'cannot_add_comment_to_draft' => 'Je kunt geen reacties toevoegen aan een concept.', - 'comment_add' => 'Er is een fout opgetreden tijdens het aanpassen / toevoegen van de reactie.', - 'comment_delete' => 'Er is een fout opgetreden tijdens het verwijderen van de reactie.', - 'empty_comment' => 'Kan geen lege reactie toevoegen.', + 'comment_list' => 'Er is een fout opgetreden tijdens het ophalen van de opmerkingen.', + 'cannot_add_comment_to_draft' => 'Je kunt geen opmerkingen toevoegen aan een concept.', + 'comment_add' => 'Er is een fout opgetreden tijdens het aanpassen / toevoegen van de opmerking.', + 'comment_delete' => 'Er is een fout opgetreden tijdens het verwijderen van de opmerking.', + 'empty_comment' => 'Kan geen lege opmerking toevoegen.', // Error pages '404_page_not_found' => 'Pagina Niet Gevonden', @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Kon het Zip-bestand niet lezen.', 'import_zip_cant_decode_data' => 'Kon de data.json Zip-inhoud niet vinden of decoderen.', 'import_zip_no_data' => 'Zip-bestand bevat niet de verwachte boek, hoofdstuk of pagina-inhoud.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'De validatie van het Zip-bestand is mislukt met de volgende fouten:', 'import_zip_failed_notification' => 'Importeren van het Zip-bestand is mislukt.', 'import_perms_books' => 'Je mist de vereiste machtigingen om boeken te maken.', diff --git a/lang/nl/notifications.php b/lang/nl/notifications.php index bc5f380c3..ce2a3ebff 100644 --- a/lang/nl/notifications.php +++ b/lang/nl/notifications.php @@ -5,16 +5,18 @@ return [ 'new_comment_subject' => 'Nieuwe opmerking op pagina: :pageName', - 'new_comment_intro' => 'Een gebruiker heeft gereageerd op een pagina in :appName:', + 'new_comment_intro' => 'Een gebruiker heeft een opmerking geplaatst op een pagina in :appName:', 'new_page_subject' => 'Nieuwe pagina: :pageName', 'new_page_intro' => 'Een nieuwe pagina is gemaakt in :appName:', 'updated_page_subject' => 'Aangepaste pagina: :pageName', 'updated_page_intro' => 'Een pagina werd aangepast in :appName:', 'updated_page_debounce' => 'Om een stortvloed aan meldingen te voorkomen, zul je een tijdje geen meldingen ontvangen voor verdere bewerkingen van deze pagina door dezelfde redacteur.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Pagina Naam:', 'detail_page_path' => 'Paginapad:', - 'detail_commenter' => 'Reageerder:', + 'detail_commenter' => 'Commentator:', 'detail_comment' => 'Opmerking:', 'detail_created_by' => 'Gemaakt Door:', 'detail_updated_by' => 'Aangepast Door:', diff --git a/lang/nl/preferences.php b/lang/nl/preferences.php index 5e385276c..1fb56af07 100644 --- a/lang/nl/preferences.php +++ b/lang/nl/preferences.php @@ -23,11 +23,12 @@ return [ 'notifications_desc' => 'Bepaal welke e-mailmeldingen je ontvangt wanneer bepaalde activiteiten in het systeem worden uitgevoerd.', 'notifications_opt_own_page_changes' => 'Geef melding bij wijzigingen aan pagina\'s waarvan ik de eigenaar ben', 'notifications_opt_own_page_comments' => 'Geef melding van opmerkingen op pagina\'s waarvan ik de eigenaar ben', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Geef melding van reacties op mijn opmerkingen', 'notifications_save' => 'Voorkeuren opslaan', 'notifications_update_success' => 'Voorkeuren voor meldingen zijn bijgewerkt!', 'notifications_watched' => 'Gevolgde & Genegeerde Items', - 'notifications_watched_desc' => 'Hieronder staan de items waarvoor aangepaste \'Volg\'-voorkeuren zijn toegepast. Om je voorkeuren voor deze items bij te werken, bekijk je het item en zoek je naar de \'Volg\' opties in de zijbalk.', + 'notifications_watched_desc' => 'Hieronder staan de items waarvoor aangepaste volgvoorkeuren zijn toegepast. Om je voorkeuren voor deze items bij te werken, bekijk je het item en zoek je naar de volgopties in de zijbalk.', 'auth' => 'Toegang & Beveiliging', 'auth_change_password' => 'Wachtwoord Wijzigen', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 1aeabed10..2041669eb 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -43,9 +43,9 @@ return [ 'app_footer_links_label' => 'Link label', 'app_footer_links_url' => 'Link URL', 'app_footer_links_add' => 'Voettekst link toevoegen', - 'app_disable_comments' => 'Reacties uitschakelen', - 'app_disable_comments_toggle' => 'Reacties uitschakelen', - 'app_disable_comments_desc' => 'Schakel reacties uit op alle pagina\'s in de applicatie.
Bestaande reacties worden niet getoond.', + 'app_disable_comments' => 'Opmerkingen uitschakelen', + 'app_disable_comments_toggle' => 'Opmerkingen uitschakelen', + 'app_disable_comments_desc' => 'Schakel opmerkingen uit op alle pagina\'s in de applicatie.
Bestaande opmerkingen worden niet getoond.', // Color settings 'color_scheme' => 'Kleurenschema van applicatie', @@ -75,7 +75,7 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Geen beperkingen ingesteld', // Sorting Settings - 'sorting' => 'Sorteren', + 'sorting' => 'Lijsten & Sorteren', 'sorting_book_default' => 'Standaard Sorteerregel Boek', 'sorting_book_default_desc' => 'Selecteer de standaard sorteerregel om toe te passen op nieuwe boeken. Dit heeft geen invloed op bestaande boeken, en kan per boek worden overschreven.', 'sorting_rules' => 'Sorteerregels', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Bijwerkdatum', 'sort_rule_op_chapters_first' => 'Hoofdstukken Eerst', 'sort_rule_op_chapters_last' => 'Hoofdstukken Laatst', + 'sorting_page_limits' => 'Weergavelimiet Per Pagina', + 'sorting_page_limits_desc' => 'Stel in hoeveel items er op een pagina worden laten zien in de verschillende lijstweergaves. Een lager aantal verbeterd de snelheid, een hoger aantal verminderd het doorklikken door pagina\'s. Een even veelvoud van 3 (18, 24, 30, etc...) wordt aanbevolen.', // Maintenance settings 'maint' => 'Onderhoud', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importeer inhoud', 'role_editor_change' => 'Wijzig pagina bewerker', 'role_notifications' => 'Meldingen ontvangen & beheren', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Machtigingen', 'roles_system_warning' => 'Wees ervan bewust dat toegang tot een van de bovengenoemde drie machtigingen een gebruiker in staat kan stellen zijn eigen machtigingen of de machtigingen van anderen in het systeem kan wijzigen. Wijs alleen rollen toe met deze machtigingen aan vertrouwde gebruikers.', 'role_asset_desc' => 'Deze machtigingen bepalen de standaard toegang tot de assets binnen het systeem. Machtigingen op boeken, hoofdstukken en pagina\'s overschrijven deze instelling.', 'role_asset_admins' => 'Beheerders krijgen automatisch toegang tot alle inhoud, maar deze opties kunnen gebruikersinterface opties tonen of verbergen.', 'role_asset_image_view_note' => 'Dit heeft betrekking op de zichtbaarheid binnen de afbeeldingsbeheerder. De werkelijke toegang tot geüploade afbeeldingsbestanden hangt af van de gekozen opslagmethode.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alles', 'role_own' => 'Eigen', 'role_controlled_by_asset' => 'Gecontroleerd door de asset waar deze is geüpload', diff --git a/lang/nl/validation.php b/lang/nl/validation.php index 7c7e20c8b..e2f48e31a 100644 --- a/lang/nl/validation.php +++ b/lang/nl/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Het bestand kon niet worden geüpload. De server accepteert mogelijk geen bestanden van deze grootte.', 'zip_file' => 'Het \':attribute\' veld moet verwijzen naar een bestand in de ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Het \':attribute\' veld moet verwijzen naar een bestand met het type :validTypes, vond :foundType.', 'zip_model_expected' => 'Dataobject verwacht maar vond ":type".', 'zip_unique' => ':attribute moet uniek zijn voor het objecttype binnen de ZIP.', diff --git a/lang/nn/errors.php b/lang/nn/errors.php index c56b7d078..01d83e0ac 100644 --- a/lang/nn/errors.php +++ b/lang/nn/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/nn/notifications.php b/lang/nn/notifications.php index 247d8d105..25f0f30c8 100644 --- a/lang/nn/notifications.php +++ b/lang/nn/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Oppdatert side: :pageName', 'updated_page_intro' => 'Ei side vart oppdatert i :appName:', 'updated_page_debounce' => 'For å forhindre mange varslingar, vil du ikkje få nye varslinger for endringar på denne siden frå same forfattar.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Sidenamn:', 'detail_page_path' => 'Sidenamn:', diff --git a/lang/nn/preferences.php b/lang/nn/preferences.php index ac6dc1b77..17d1fca42 100644 --- a/lang/nn/preferences.php +++ b/lang/nn/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Kontroller e-postvarslene du mottar når en bestemt aktivitet utføres i systemet.', 'notifications_opt_own_page_changes' => 'Varsle ved endringer til sider jeg eier', 'notifications_opt_own_page_comments' => 'Varsle om kommentarer på sider jeg eier', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Varsle ved svar på mine kommentarer', 'notifications_save' => 'Lagre innstillinger', 'notifications_update_success' => 'Varslingsinnstillingene er oppdatert!', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index c098ac753..6d2259bd8 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ingen begrensninger er satt', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Vedlikehold', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Endre sideredigering', 'role_notifications' => 'Motta og administrere varslinger', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Eiendomstillatelser', 'roles_system_warning' => 'Vær oppmerksom på at tilgang til noen av de ovennevnte tre tillatelsene kan tillate en bruker å endre sine egne rettigheter eller rettighetene til andre i systemet. Bare tildel roller med disse tillatelsene til pålitelige brukere.', 'role_asset_desc' => 'Disse tillatelsene kontrollerer standard tilgang til eiendelene i systemet. Tillatelser til bøker, kapitler og sider overstyrer disse tillatelsene.', 'role_asset_admins' => 'Administratorer får automatisk tilgang til alt innhold, men disse alternativene kan vise eller skjule UI-alternativer.', 'role_asset_image_view_note' => 'Dette gjelder synlighet innenfor bilde-administrasjonen. Faktisk tilgang på opplastede bildefiler vil være avhengig av valget for systemlagring av bildet.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alle', 'role_own' => 'Egne', 'role_controlled_by_asset' => 'Kontrollert av eiendelen de er lastet opp til', diff --git a/lang/nn/validation.php b/lang/nn/validation.php index a24ebd171..ff7a026a2 100644 --- a/lang/nn/validation.php +++ b/lang/nn/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/pl/errors.php b/lang/pl/errors.php index e6ad2093f..04146bbb7 100644 --- a/lang/pl/errors.php +++ b/lang/pl/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/pl/notifications.php b/lang/pl/notifications.php index 661149994..a2c9ff1c0 100644 --- a/lang/pl/notifications.php +++ b/lang/pl/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Zaktualizowano stronę: :pageName', 'updated_page_intro' => 'Strona została zaktualizowana w :appName:', 'updated_page_debounce' => 'Aby zapobiec nadmiarowi powiadomień, przez jakiś czas nie będziesz otrzymywać powiadomień o dalszych edycjach tej strony przez tego samego edytora.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nazwa strony:', 'detail_page_path' => 'Ścieżka strony:', diff --git a/lang/pl/preferences.php b/lang/pl/preferences.php index dd3482102..372b8eda6 100644 --- a/lang/pl/preferences.php +++ b/lang/pl/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Kontroluj otrzymywane powiadomienia e-mail, gdy określona aktywność jest wykonywana w systemie.', 'notifications_opt_own_page_changes' => 'Powiadom o zmianach na stronach, których jestem właścicielem', 'notifications_opt_own_page_comments' => 'Powiadom o komentarzach na stronach, których jestem właścicielem', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Powiadom o odpowiedziach na moje komentarze', 'notifications_save' => 'Zapisz preferencje', 'notifications_update_success' => 'Preferencje powiadomień zostały zaktualizowane!', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 7c84ce34b..c32797f2f 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Brak restrykcji', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Konserwacja', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Zmień edytor strony', 'role_notifications' => 'Odbieranie i zarządzanie powiadomieniami', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Zarządzanie zasobami', 'roles_system_warning' => 'Pamiętaj, że dostęp do trzech powyższych uprawnień może pozwolić użytkownikowi na zmianę własnych uprawnień lub uprawnień innych osób w systemie. Przypisz tylko role z tymi uprawnieniami do zaufanych użytkowników.', 'role_asset_desc' => 'Te ustawienia kontrolują zarządzanie zasobami systemu. Uprawnienia książek, rozdziałów i stron nadpisują te ustawienia.', 'role_asset_admins' => 'Administratorzy mają automatycznie dostęp do wszystkich treści, ale te opcję mogą być pokazywać lub ukrywać opcje interfejsu użytkownika.', 'role_asset_image_view_note' => 'To odnosi się do widoczności w ramach menedżera obrazów. Rzeczywista możliwość dostępu do przesłanych plików obrazów będzie zależeć od systemowej opcji przechowywania obrazów.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Wszyscy', 'role_own' => 'Własne', 'role_controlled_by_asset' => 'Kontrolowane przez zasób, do którego zostały udostępnione', diff --git a/lang/pl/validation.php b/lang/pl/validation.php index f20a66dfb..d1e8fada1 100644 --- a/lang/pl/validation.php +++ b/lang/pl/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Plik nie może zostać wysłany. Serwer nie akceptuje plików o takim rozmiarze.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/pt/errors.php b/lang/pt/errors.php index b337005e1..973bf61e2 100644 --- a/lang/pt/errors.php +++ b/lang/pt/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/pt/notifications.php b/lang/pt/notifications.php index 1243c6680..cbe3a511c 100644 --- a/lang/pt/notifications.php +++ b/lang/pt/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Página atualizada: :pageName', 'updated_page_intro' => 'Uma página foi atualizada em :appName:', 'updated_page_debounce' => 'Para evitar um grande volume de notificações, durante algum tempo não serão enviadas notificações de edições futuras para esta página através do mesmo editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nome da Página:', 'detail_page_path' => 'Page Path:', diff --git a/lang/pt/preferences.php b/lang/pt/preferences.php index 860eec645..b7308aaf9 100644 --- a/lang/pt/preferences.php +++ b/lang/pt/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Controlar as notificações via correio eletrónico quando certas atividades são executadas pelo sistema.', 'notifications_opt_own_page_changes' => 'Notificar quando páginas que possuo sofrem alterações', 'notifications_opt_own_page_comments' => 'Notificar quando comentam páginas que possuo', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notificar respostas aos meus comentários', 'notifications_save' => 'Guardar preferências', 'notifications_update_success' => 'Preferências de notificação foram atualizadas!', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index 0c765fb98..c2179e640 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nenhuma restrição definida', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Manutenção', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Alterar editor de página', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Permissões de Ativos', 'roles_system_warning' => 'Esteja ciente de que o acesso a qualquer uma das três permissões acima pode permitir que um utilizador altere os seus próprios privilégios ou privilégios de outros no sistema. Apenas atribua cargos com essas permissões a utilizadores de confiança.', 'role_asset_desc' => 'Estas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por estas permissões.', 'role_asset_admins' => 'Os administradores recebem automaticamente acesso a todo o conteúdo, mas estas opções podem mostrar ou ocultar as opções da Interface de Usuário.', 'role_asset_image_view_note' => 'Isto está relacionado com a visibilidade do gerenciador de imagens. O acesso real dos arquivos de imagem enviados dependerá da opção de armazenamento de imagens do sistema.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Todos', 'role_own' => 'Próprio', 'role_controlled_by_asset' => 'Controlado pelo ativo para o qual eles são enviados', diff --git a/lang/pt/validation.php b/lang/pt/validation.php index df414c992..17d891cdb 100644 --- a/lang/pt/validation.php +++ b/lang/pt/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index fecd47e82..5a948c8dc 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Isto irá excluir o arquivo ZIP de importação carregado e não poderá ser desfeito.', 'import_errors' => 'Erros de importação', 'import_errors_desc' => 'Os seguintes erros ocorreram durante a tentativa de importação:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Navegue pelas páginas relacionadas', + 'breadcrumb_siblings_for_chapter' => 'Navegue pelos capítulos relacionados', + 'breadcrumb_siblings_for_book' => 'Navegue pelos livros relacionados', + 'breadcrumb_siblings_for_bookshelf' => 'Navegue pelas estantes relacionadas', // Permissions and restrictions 'permissions' => 'Permissões', diff --git a/lang/pt_BR/errors.php b/lang/pt_BR/errors.php index 8dab893c7..37cbd7ff2 100644 --- a/lang/pt_BR/errors.php +++ b/lang/pt_BR/errors.php @@ -110,6 +110,7 @@ return [ 'import_zip_cant_read' => 'Não foi possível ler o arquivo ZIP.', 'import_zip_cant_decode_data' => 'Não foi possível encontrar e decodificar o conteúdo ZIP data.json.', 'import_zip_no_data' => 'Os dados do arquivo ZIP não têm o conteúdo esperado livro, capítulo ou página.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Falhou na validação da importação do ZIP com erros:', 'import_zip_failed_notification' => 'Falhou ao importar arquivo ZIP.', 'import_perms_books' => 'Você não tem as permissões necessárias para criar livros.', diff --git a/lang/pt_BR/notifications.php b/lang/pt_BR/notifications.php index 6397b20e5..8c98467c8 100644 --- a/lang/pt_BR/notifications.php +++ b/lang/pt_BR/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Página atualizada: :pageName', 'updated_page_intro' => 'Uma página foi atualizada em :appName:', 'updated_page_debounce' => 'Para prevenir notificações em massa, por enquanto notificações não serão enviadas para você para próximas edições nessa página pelo mesmo editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nome da Página:', 'detail_page_path' => 'Caminho da Página:', diff --git a/lang/pt_BR/preferences.php b/lang/pt_BR/preferences.php index e0b79ff06..d2b7fc540 100644 --- a/lang/pt_BR/preferences.php +++ b/lang/pt_BR/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Controle as notificações por e-mail que você recebe quando uma determinada atividade é executada no sistema.', 'notifications_opt_own_page_changes' => 'Notificar quando houver alterações em páginas que eu possuo', 'notifications_opt_own_page_comments' => 'Notificar comentários nas páginas que eu possuo', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notificar ao responder aos meus comentários', 'notifications_save' => 'Salvar Preferências', 'notifications_update_success' => 'Preferências de notificação foram atualizadas!', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index b940e57d4..53947a36d 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -16,7 +16,7 @@ return [ 'app_customization' => 'Customização', 'app_features_security' => 'Recursos & Segurança', 'app_name' => 'Nome da Aplicação', - 'app_name_desc' => 'Esse nome será mostrado no cabeçalho e em e-mails.', + 'app_name_desc' => 'Esse nome será mostrado no cabeçalho e nos e-mails.', 'app_name_header' => 'Mostrar o nome no cabeçalho', 'app_public_access' => 'Acesso Público', 'app_public_access_desc' => 'Habilitar esta opção irá permitir que visitantes, que não estão logados, acessem o conteúdo em sua instância do BookStack.', @@ -29,14 +29,14 @@ return [ 'app_default_editor' => 'Editor de Página Padrão', 'app_default_editor_desc' => 'Selecione qual editor será usado por padrão ao editar novas páginas. Isso pode ser substituído em um nível de página onde é permitido.', 'app_custom_html' => 'Conteúdo customizado para HTML', - 'app_custom_html_desc' => 'Quaisquer conteúdos aqui adicionados serão inseridos no final da seção de cada página. Essa é uma maneira útil de sobrescrever estilos e adicionar códigos de análise de site.', - 'app_custom_html_disabled_notice' => 'O conteúdo customizado do HTML está desabilitado nesta página de configurações, para garantir que quaisquer alterações danosas possam ser revertidas.', + 'app_custom_html_desc' => 'Qualquer conteúdo adicionado aqui será inserido ao final do HTML de todas as páginas. Isso é útil para sobrescrever estilos e adicionar códigos de análise e estatística do site.', + 'app_custom_html_disabled_notice' => 'O conteúdo customizado do HTML está desabilitado nesta página de configurações para garantir que quaisquer alterações danosas possam ser revertidas.', 'app_logo' => 'Logo da Aplicação', 'app_logo_desc' => 'Isto é usado na barra de cabeçalho do aplicativo, entre outras áreas. Esta imagem deve ter 86px de altura. Imagens grandes serão reduzidas.', 'app_icon' => 'Ícone do Aplicativo', 'app_icon_desc' => 'Este ícone é usado para guias e ícones de atalhos do navegador. Deve ser uma imagem PNG quadrada de 256px.', 'app_homepage' => 'Página Inicial', - 'app_homepage_desc' => 'Selecione uma opção para ser exibida como página inicial em vez da padrão. Permissões de página serão ignoradas para as páginas selecionadas.', + 'app_homepage_desc' => 'Selecione uma opção para ser exibida como página inicial no lugar da página padrão. Permissões de página serão ignoradas para as páginas selecionadas.', 'app_homepage_select' => 'Selecione uma página', 'app_footer_links' => 'Links do Rodapé', 'app_footer_links_desc' => 'Adicionar links para mostrar dentro do rodapé do site. Estes serão exibidos na parte inferior da maioria das páginas, incluindo aqueles que não necessitam de login. Você pode usar uma etiqueta de "trans::" para usar traduções definidas pelo sistema. Por exemplo: Usando "trans::common.privacy_policy" fornecerá o texto traduzido "Política de Privacidade" e "trans::common.terms_of_service" fornecerá o texto traduzido "Termos de Serviço".', @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nenhuma restrição definida', // Sorting Settings - 'sorting' => 'Ordenação', - 'sorting_book_default' => 'Ordenação padrão de livros', + 'sorting' => 'Listas e classificações', + 'sorting_book_default' => 'Regra padrão de classificação de livros', 'sorting_book_default_desc' => 'Selecione a regra de ordenação padrão a ser aplicada a novos livros. Isso não afetará os livros existentes e pode ser substituído para cada livro individualmente.', 'sorting_rules' => 'Regras de ordenação', 'sorting_rules_desc' => 'Estas são operações de ordenação pré-definidas que podem ser aplicadas a conteúdos no sistema.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Data de Atualização', 'sort_rule_op_chapters_first' => 'Capítulos Primeiro', 'sort_rule_op_chapters_last' => 'Capítulos por Último', + 'sorting_page_limits' => 'Limites de exibição por página', + 'sorting_page_limits_desc' => 'Defina quantos itens serão exibidos por página em diferentes listas do sistema. Normalmente, um número menor proporciona melhor desempenho, enquanto um número maior evita a necessidade de clicar em várias páginas. É recomendado o uso de um múltiplo par de 3 (18, 24, 30, etc.).', // Maintenance settings 'maint' => 'Manutenção', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Importar conteúdo', 'role_editor_change' => 'Alterar página de edição', 'role_notifications' => 'Receber e gerenciar notificações', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Permissões de Ativos', 'roles_system_warning' => 'Esteja ciente de que o acesso a qualquer uma das três permissões acima pode permitir que um usuário altere seus próprios privilégios ou privilégios de outros usuários no sistema. Apenas atribua perfis com essas permissões para usuários confiáveis.', 'role_asset_desc' => 'Essas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por essas permissões.', 'role_asset_admins' => 'Administradores recebem automaticamente acesso a todo o conteúdo, mas essas opções podem mostrar ou ocultar as opções da Interface de Usuário.', 'role_asset_image_view_note' => 'Isso está relacionado à visibilidade no gerenciador de imagens. O acesso real dos arquivos de imagem carregados dependerá da opção de armazenamento de imagem do sistema.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Todos', 'role_own' => 'Próprio', 'role_controlled_by_asset' => 'Controlado pelos ativos nos quais o upload foi realizado', diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php index 9ddfdf2a5..e30ebc377 100644 --- a/lang/pt_BR/validation.php +++ b/lang/pt_BR/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', 'zip_file' => 'O :attribute precisa fazer referência a um arquivo do ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'O :attribute precisa fazer referência a um arquivo do tipo :validTypes, encontrado :foundType.', 'zip_model_expected' => 'Objeto de dados esperado, mas ":type" encontrado.', 'zip_unique' => 'O :attribute deve ser único para o tipo de objeto dentro do ZIP.', diff --git a/lang/ro/errors.php b/lang/ro/errors.php index 07c295b07..dec7be134 100644 --- a/lang/ro/errors.php +++ b/lang/ro/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/ro/notifications.php b/lang/ro/notifications.php index 676eeb814..da7f590fd 100644 --- a/lang/ro/notifications.php +++ b/lang/ro/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Pagina actualizată: :pageName', 'updated_page_intro' => 'O nouă pagină a fost creată în :appName:', 'updated_page_debounce' => 'Pentru a preveni notificări în masă, pentru un timp nu veți primi notificări suplimentare la această pagină de către același editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Nume pagină:', 'detail_page_path' => 'Page Path:', diff --git a/lang/ro/preferences.php b/lang/ro/preferences.php index f7529305c..93db2f66a 100644 --- a/lang/ro/preferences.php +++ b/lang/ro/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Controlați notificările prin e-mail pe care le primiți atunci când o anumită activitate este efectuată în sistem.', 'notifications_opt_own_page_changes' => 'Notifică la comentarii pe paginile pe care le dețin', 'notifications_opt_own_page_comments' => 'Notifică la comentarii pe paginile pe care le dețin', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notifică la răspunsurile la comentariile mele', 'notifications_save' => 'Salvează Preferințe', 'notifications_update_success' => 'Preferințele de notificare au fost actualizate!', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index a28dca22c..02052ef3c 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nicio restricție setată', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Mentenanţă', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Schimbă editorul de pagină', 'role_notifications' => 'Primire și gestionare notificări', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Permisiuni active', 'roles_system_warning' => 'Fi conștient de faptul că accesul la oricare dintre cele trei permisiuni de mai sus poate permite unui utilizator să își modifice propriile privilegii sau privilegiile altor persoane din sistem. Atribuie doar roluri cu aceste permisiuni utilizatorilor de încredere.', 'role_asset_desc' => 'Aceste permisiuni controlează accesul implicit la activele din sistem. Permisiunile pe Cărți, Capitole și Pagini vor suprascrie aceste permisiuni.', 'role_asset_admins' => 'Administratorilor li se acordă automat acces la tot conținutul, dar aceste opțiuni pot afișa sau ascunde opțiunile UI.', 'role_asset_image_view_note' => 'Acest lucru se referă la vizibilitatea în managerul de imagini. Accesul efectiv al fișierelor de imagine încărcate va depinde de opțiunea de stocare a imaginilor din sistem.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Tot', 'role_own' => 'Propriu', 'role_controlled_by_asset' => 'Controlat de activele pe care sunt încărcate', diff --git a/lang/ro/validation.php b/lang/ro/validation.php index 56a3e2e05..44fdd7ad8 100644 --- a/lang/ro/validation.php +++ b/lang/ro/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Fişierul nu a putut fi încărcat. Serverul nu poate accepta fişiere de această dimensiune.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/ru/editor.php b/lang/ru/editor.php index bd0c45f31..d26f7edfe 100644 --- a/lang/ru/editor.php +++ b/lang/ru/editor.php @@ -29,10 +29,10 @@ return [ // Toolbar 'formats' => 'Форматы', - 'header_large' => 'Большой', - 'header_medium' => 'Средний', - 'header_small' => 'Маленький', - 'header_tiny' => 'Крошечный', + 'header_large' => 'Крупный заголовок', + 'header_medium' => 'Средний заголовок', + 'header_small' => 'Небольшой заголовок', + 'header_tiny' => 'Маленький заголовок', 'paragraph' => 'Обычный текст', 'blockquote' => 'Цитата', 'inline_code' => 'Встроенный код', diff --git a/lang/ru/errors.php b/lang/ru/errors.php index e87c90936..82aecb657 100644 --- a/lang/ru/errors.php +++ b/lang/ru/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'У вас недостаточно прав для создания книг.', diff --git a/lang/ru/notifications.php b/lang/ru/notifications.php index c5e98da80..289de42b6 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Обновлена страница: :pageName', 'updated_page_intro' => 'Страница была обновлена в :appName:', 'updated_page_debounce' => 'Чтобы предотвратить массовые уведомления, в течение некоторого времени вы не будете получать уведомления о дальнейших правках этой страницы этим же редактором.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Имя страницы:', 'detail_page_path' => 'Путь страницы:', diff --git a/lang/ru/preferences.php b/lang/ru/preferences.php index 27217815d..b61b252c8 100644 --- a/lang/ru/preferences.php +++ b/lang/ru/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Управляйте полученными по электронной почте уведомлениями при выполнении определенных действий в системе.', 'notifications_opt_own_page_changes' => 'Уведомлять об изменениях в собственных страницах', 'notifications_opt_own_page_comments' => 'Уведомлять о комментариях на собственных страницах', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Уведомлять об ответах на мои комментарии', 'notifications_save' => 'Сохранить настройки', 'notifications_update_success' => 'Настройки уведомлений были обновлены!', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 69c13e46d..47839d520 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Без ограничений', // Sorting Settings - 'sorting' => 'Сортировка', - 'sorting_book_default' => 'Сортировка книг по умолчанию', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Выберите правило сортировки по умолчанию для новых книг. Это не повлияет на существующие книги, и может быть изменено для каждой книги отдельно.', 'sorting_rules' => 'Правила сортировки', 'sorting_rules_desc' => 'Выберите правило сортировки по умолчанию для новых книг. Это не повлияет на существующие книги и может быть изменено для каждой книги отдельно.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Главы в конце', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Обслуживание', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Изменение редактора страниц', 'role_notifications' => 'Получение и управление уведомлениями', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Права доступа к материалам', 'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.', 'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.', 'role_asset_admins' => 'Администраторы автоматически получают доступ ко всему контенту, но эти опции могут отображать или скрывать параметры пользовательского интерфейса.', 'role_asset_image_view_note' => 'Это относится к видимости в менеджере изображений. Фактический доступ к загруженным файлам изображений будет зависеть от опции хранения системных изображений.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Все', 'role_own' => 'Владелец', 'role_controlled_by_asset' => 'Контролируется активом, в который они загружены', diff --git a/lang/ru/validation.php b/lang/ru/validation.php index 156a05fe6..ce94faa3b 100644 --- a/lang/ru/validation.php +++ b/lang/ru/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/sk/errors.php b/lang/sk/errors.php index 143259f3a..30609b201 100644 --- a/lang/sk/errors.php +++ b/lang/sk/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/sk/notifications.php b/lang/sk/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/sk/notifications.php +++ b/lang/sk/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/sk/preferences.php b/lang/sk/preferences.php index 85da4f331..54fbdb677 100644 --- a/lang/sk/preferences.php +++ b/lang/sk/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index 2731fd01a..04855a7f9 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Nie sú nastavené žiadne obmedzenia', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Údržba', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Zmeniť editor stránky', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Oprávnenia majetku', 'roles_system_warning' => 'Uvedomte si, že prístup ku ktorémukoľvek z vyššie uvedených troch povolení môže používateľovi umožniť zmeniť svoje vlastné privilégiá alebo privilégiá ostatných v systéme. Roly s týmito povoleniami priraďujte iba dôveryhodným používateľom.', 'role_asset_desc' => 'Tieto oprávnenia regulujú prednastavený prístup k zdroju v systéme. Oprávnenia pre knihy, kapitoly a stránky majú vyššiu prioritu.', 'role_asset_admins' => 'Správcovia majú automaticky prístup ku všetkému obsahu, ale tieto možnosti môžu zobraziť alebo skryť možnosti používateľského rozhrania.', 'role_asset_image_view_note' => 'Toto sa týka viditeľnosti v rámci správcu obrázkov. Skutočný prístup k nahratým súborom obrázkov bude závisieť od možnosti ukladania obrázkov systému.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Všetko', 'role_own' => 'Vlastné', 'role_controlled_by_asset' => 'Regulované zdrojom, do ktorého sú nahrané', diff --git a/lang/sk/validation.php b/lang/sk/validation.php index 415f7cf05..60063752b 100644 --- a/lang/sk/validation.php +++ b/lang/sk/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Súbor sa nepodarilo nahrať. Server nemusí akceptovať súbory tejto veľkosti.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/sl/errors.php b/lang/sl/errors.php index da80fd391..cb3db2747 100644 --- a/lang/sl/errors.php +++ b/lang/sl/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/sl/notifications.php b/lang/sl/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/sl/notifications.php +++ b/lang/sl/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/sl/preferences.php b/lang/sl/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/sl/preferences.php +++ b/lang/sl/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 904978f47..6eaed0a17 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Brez omejitev', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Vzdrževanje', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Sistemska dovoljenja', 'roles_system_warning' => 'Zavedajte se, da lahko dostop do kateregakoli od zgornjih treh dovoljenj uporabniku omogoči, da spremeni lastne privilegije ali privilegije drugih v sistemu. Vloge s temi dovoljenji dodelite samo zaupanja vrednim uporabnikom.', 'role_asset_desc' => 'Ta dovoljenja nadzorujejo privzeti dostop do sredstev v sistemu. Dovoljenja za knjige, poglavja in strani bodo razveljavila ta dovoljenja.', 'role_asset_admins' => 'Skrbniki samodejno pridobijo dostop do vseh vsebin, vendar lahko te možnosti prikažejo ali pa skrijejo možnosti uporabniškega vmesnika.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Vse', 'role_own' => 'Lasten', 'role_controlled_by_asset' => 'Nadzira ga sredstvo, v katerega so naloženi', diff --git a/lang/sl/validation.php b/lang/sl/validation.php index 0d9b56c10..84ad6ca45 100644 --- a/lang/sl/validation.php +++ b/lang/sl/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Datoteke ni bilo mogoče naložiti. Strežnik morda ne sprejema datotek te velikosti.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/sq/common.php b/lang/sq/common.php index 06a9e855c..aa2988124 100644 --- a/lang/sq/common.php +++ b/lang/sq/common.php @@ -89,8 +89,8 @@ return [ 'homepage' => 'Homepage', 'header_menu_expand' => 'Expand Header Menu', 'profile_menu' => 'Profile Menu', - 'view_profile' => 'View Profile', - 'edit_profile' => 'Edit Profile', + 'view_profile' => 'Shiko profilin', + 'edit_profile' => 'Ndrysho profilin', 'dark_mode' => 'Dark Mode', 'light_mode' => 'Light Mode', 'global_search' => 'Global Search', diff --git a/lang/sq/errors.php b/lang/sq/errors.php index 9d7383796..77d7ee69e 100644 --- a/lang/sq/errors.php +++ b/lang/sq/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/sq/notifications.php b/lang/sq/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/sq/notifications.php +++ b/lang/sq/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/sq/preferences.php b/lang/sq/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/sq/preferences.php +++ b/lang/sq/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index 81c2c0a93..c68605fe1 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/sq/validation.php b/lang/sq/validation.php index d9b982d1e..ff028525d 100644 --- a/lang/sq/validation.php +++ b/lang/sq/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/sr/activities.php b/lang/sr/activities.php index ad594a220..b4aa5e2a7 100644 --- a/lang/sr/activities.php +++ b/lang/sr/activities.php @@ -85,9 +85,9 @@ return [ 'webhook_delete_notification' => 'Вебхоок је успешно обрисан', // Imports - 'import_create' => 'created import', + 'import_create' => 'креиран увоз', 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', + 'import_run' => 'ажуриран увоз', 'import_run_notification' => 'Content successfully imported', 'import_delete' => 'deleted import', 'import_delete_notification' => 'Import successfully deleted', diff --git a/lang/sr/common.php b/lang/sr/common.php index e185c99f5..c5c62db65 100644 --- a/lang/sr/common.php +++ b/lang/sr/common.php @@ -30,7 +30,7 @@ return [ 'create' => 'Креирај', 'update' => 'Ажурирање', 'edit' => 'Уреди', - 'archive' => 'Archive', + 'archive' => 'Архивирај', 'unarchive' => 'Un-Archive', 'sort' => 'Разврстај', 'move' => 'Премести', diff --git a/lang/sr/editor.php b/lang/sr/editor.php index e5052595c..2756775a9 100644 --- a/lang/sr/editor.php +++ b/lang/sr/editor.php @@ -13,7 +13,7 @@ return [ 'cancel' => 'Поништи', 'save' => 'Сачувај', 'close' => 'Затвори', - 'apply' => 'Apply', + 'apply' => 'Примени', 'undo' => 'Опозови', 'redo' => 'Понови', 'left' => 'Лево', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index 456a76ceb..151edee40 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -50,7 +50,7 @@ return [ 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', 'import_pending' => 'Pending Imports', 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', + 'import_continue' => 'Настави увоз', 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', 'import_details' => 'Import Details', 'import_run' => 'Run Import', @@ -109,7 +109,7 @@ return [ // Shelves 'shelf' => 'Shelf', - 'shelves' => 'Shelves', + 'shelves' => 'Полице', 'x_shelves' => ':count Shelf|:count Shelves', 'shelves_empty' => 'No shelves have been created', 'shelves_create' => 'Create New Shelf', diff --git a/lang/sr/errors.php b/lang/sr/errors.php index ee8443461..f28fc01a9 100644 --- a/lang/sr/errors.php +++ b/lang/sr/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/sr/notifications.php b/lang/sr/notifications.php index 6aa3f2abb..4cc499fdd 100644 --- a/lang/sr/notifications.php +++ b/lang/sr/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Ажурирана страница: :pageName', 'updated_page_intro' => 'Страница је ажурирана у :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Назив странице:', 'detail_page_path' => 'Путања странице:', diff --git a/lang/sr/preferences.php b/lang/sr/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/sr/preferences.php +++ b/lang/sr/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/sr/settings.php b/lang/sr/settings.php index b7213e905..d34ff3f3b 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Нема постављених ограничења', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Одржавање', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/sr/validation.php b/lang/sr/validation.php index d9b982d1e..6770c5a80 100644 --- a/lang/sr/validation.php +++ b/lang/sr/validation.php @@ -14,7 +14,7 @@ return [ 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', - 'array' => 'The :attribute must be an array.', + 'array' => ':attribute мора бити низ.', 'backup_codes' => 'The provided code is not valid or has already been used.', 'before' => 'The :attribute must be a date before :date.', 'between' => [ @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/sv/common.php b/lang/sv/common.php index 23a98d9e3..2eab9bd7b 100644 --- a/lang/sv/common.php +++ b/lang/sv/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'Skapa', 'update' => 'Uppdatera', 'edit' => 'Redigera', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Arkivera', + 'unarchive' => 'Avarkivera', 'sort' => 'Sortera', 'move' => 'Flytta', 'copy' => 'Kopiera', diff --git a/lang/sv/editor.php b/lang/sv/editor.php index c2485817d..fbcdf8c33 100644 --- a/lang/sv/editor.php +++ b/lang/sv/editor.php @@ -48,7 +48,7 @@ return [ 'superscript' => 'Upphöjd', 'subscript' => 'Nedsänkt', 'text_color' => 'Textfärg', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Markera färg', 'custom_color' => 'Anpassad färg', 'remove_color' => 'Ta bort färg', 'background_color' => 'Bakgrundsfärg', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index a1e826b30..08c44ff1f 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -17,7 +17,7 @@ return [ 'recent_activity' => 'Aktivitet', 'create_now' => 'Skapa en nu', 'revisions' => 'Revisioner', - 'meta_revision' => 'Revisions #:revisionCount', + 'meta_revision' => 'Revision #:revisionCount', 'meta_created' => 'Skapad :timeLength', 'meta_created_name' => 'Skapad :timeLength av :user', 'meta_updated' => 'Uppdaterad :timeLength', @@ -410,14 +410,14 @@ return [ 'comment_deleted_success' => 'Kommentar borttagen', 'comment_created_success' => 'Kommentaren har sparats', 'comment_updated_success' => 'Kommentaren har uppdaterats', - 'comment_archive_success' => 'Comment archived', + 'comment_archive_success' => 'Arkivera kommentar', 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_view' => 'Visa kommentar', + 'comment_jump_to_thread' => 'Hoppa till tråd', 'comment_delete_confirm' => 'Är du säker på att du vill ta bort den här kommentaren?', 'comment_in_reply_to' => 'Som svar på :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => 'Referens', + 'comment_reference_outdated' => '(Utdaterad)', 'comment_editor_explain' => 'Här är kommentarer som lämnats på denna sida. Kommentarer kan läggas till och hanteras när den sparade sidan visas.', // Revision diff --git a/lang/sv/errors.php b/lang/sv/errors.php index d65b7d0a0..3a5478977 100644 --- a/lang/sv/errors.php +++ b/lang/sv/errors.php @@ -37,20 +37,20 @@ return [ 'social_driver_not_found' => 'Drivrutinen för den här tjänsten hittades inte', 'social_driver_not_configured' => 'Dina inställningar för :socialAccount är inte korrekta.', 'invite_token_expired' => 'Denna inbjudningslänk har löpt ut. Du kan istället försöka återställa ditt kontos lösenord.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'En användare för denna åtgärd kunde inte hittas.', // System 'path_not_writable' => 'Kunde inte ladda upp till sökvägen :filePath. Kontrollera att webbservern har skrivåtkomst.', 'cannot_get_image_from_url' => 'Kan inte hämta bild från :url', 'cannot_create_thumbs' => 'Servern kan inte skapa miniatyrer. Kontrollera att du har PHPs GD-tillägg aktiverat.', 'server_upload_limit' => 'Servern tillåter inte så här stora filer. Prova en mindre fil.', - 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'server_post_limit' => 'Servern kan inte ta emot den angivna mängden data. Försök igen med mindre data eller en mindre fil.', 'uploaded' => 'Servern tillåter inte så här stora filer. Prova en mindre fil.', // Drawing & Images 'image_upload_error' => 'Ett fel inträffade vid uppladdningen', 'image_upload_type_error' => 'Filtypen du försöker ladda upp är ogiltig', - 'image_upload_replace_type' => 'Image file replacements must be of the same type', + 'image_upload_replace_type' => 'Bilder som skall ersättas måste vara av samma filtyp', 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', 'image_gallery_thumbnail_memory_limit' => 'Misslyckades att skapa galleriminiatyrer på grund av otillräckliga systemresurser.', @@ -107,8 +107,9 @@ return [ // Import 'import_zip_cant_read' => 'Kunde inte läsa ZIP-filen.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_cant_decode_data' => 'Kunde inte hitta och avkoda ZIP data.json innehåll.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'ZIP-filen kunde inte valideras med fel:', 'import_zip_failed_notification' => 'Det gick inte att importera ZIP-fil.', 'import_perms_books' => 'Du saknar behörighet att skapa böcker.', diff --git a/lang/sv/notifications.php b/lang/sv/notifications.php index 58418ddb0..19933c049 100644 --- a/lang/sv/notifications.php +++ b/lang/sv/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Uppdaterad sida: :pageName', 'updated_page_intro' => 'En sida har blivit uppdaterad i :appName:', 'updated_page_debounce' => 'För att förhindra en massa notiser, så kommer det inte skickas nya notiser på ett tag för ytterligare ändringar till denna sida av samma skribent.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Sidonamn:', 'detail_page_path' => 'Sidosökväg:', diff --git a/lang/sv/preferences.php b/lang/sv/preferences.php index 945099151..492081e59 100644 --- a/lang/sv/preferences.php +++ b/lang/sv/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/sv/settings.php b/lang/sv/settings.php index fdf8ebd06..2e86241da 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Ingen begränsning inställd', // Sorting Settings - 'sorting' => 'Sorterar', - 'sorting_book_default' => 'Standard boksortering', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Välj standard sorteringsregel som skall tillämpas på nya böcker. Detta påverkar inte befintliga böcker och kan åsidosättas per bok.', 'sorting_rules' => 'Sorteringsregler', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -91,18 +91,20 @@ return [ 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', 'sort_rule_operations' => 'Sort Operations', 'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.', - 'sort_rule_available_operations' => 'Available Operations', + 'sort_rule_available_operations' => 'Tillgängliga åtgärder', 'sort_rule_available_operations_empty' => 'No operations remaining', 'sort_rule_configured_operations' => 'Configured Operations', 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', 'sort_rule_op_asc' => '(Asc)', 'sort_rule_op_desc' => '(Desc)', - 'sort_rule_op_name' => 'Name - Alphabetical', - 'sort_rule_op_name_numeric' => 'Name - Numeric', - 'sort_rule_op_created_date' => 'Created Date', - 'sort_rule_op_updated_date' => 'Updated Date', + 'sort_rule_op_name' => 'Namn - Alfabetisk ordning', + 'sort_rule_op_name_numeric' => 'Namn - Numerisk ordning', + 'sort_rule_op_created_date' => 'Datum skapat', + 'sort_rule_op_updated_date' => 'Datum uppdaterat', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Underhåll', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Ändra sidredigerare', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Tillgång till innehåll', 'roles_system_warning' => 'Var medveten om att åtkomst till någon av ovanstående tre behörigheter kan tillåta en användare att ändra sina egna rättigheter eller andras rättigheter i systemet. Tilldela endast roller med dessa behörigheter till betrodda användare.', 'role_asset_desc' => 'Det här är standardinställningarna för allt innehåll i systemet. Eventuella anpassade rättigheter på böcker, kapitel och sidor skriver över dessa inställningar.', 'role_asset_admins' => 'Administratörer har automatisk tillgång till allt innehåll men dessa alternativ kan visa och dölja vissa gränssnittselement', 'role_asset_image_view_note' => 'Detta avser synlighet inom bildhanteraren. Faktisk åtkomst för uppladdade bildfiler kommer att bero på alternativ för bildlagring.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Alla', 'role_own' => 'Egna', 'role_controlled_by_asset' => 'Kontrolleras av den sida de laddas upp till', diff --git a/lang/sv/validation.php b/lang/sv/validation.php index 4cc98c575..b0c1f7a1b 100644 --- a/lang/sv/validation.php +++ b/lang/sv/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Filen kunde inte laddas upp. Servern kanske inte tillåter filer med denna storlek.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/tk/errors.php b/lang/tk/errors.php index 9d7383796..77d7ee69e 100644 --- a/lang/tk/errors.php +++ b/lang/tk/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/tk/notifications.php b/lang/tk/notifications.php index 1afd23f1d..563ac24e8 100644 --- a/lang/tk/notifications.php +++ b/lang/tk/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/tk/preferences.php b/lang/tk/preferences.php index 2872f5f3c..f4459d738 100644 --- a/lang/tk/preferences.php +++ b/lang/tk/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index 81c2c0a93..c68605fe1 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Maintenance', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', diff --git a/lang/tk/validation.php b/lang/tk/validation.php index d9b982d1e..ff028525d 100644 --- a/lang/tk/validation.php +++ b/lang/tk/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/tr/errors.php b/lang/tr/errors.php index fd7426871..259cc4b1c 100644 --- a/lang/tr/errors.php +++ b/lang/tr/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/tr/notifications.php b/lang/tr/notifications.php index c26a90b32..5dfb71978 100644 --- a/lang/tr/notifications.php +++ b/lang/tr/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Updated page: :pageName', 'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Page Name:', 'detail_page_path' => 'Page Path:', diff --git a/lang/tr/preferences.php b/lang/tr/preferences.php index 1ce7b01fd..f4ec771b2 100644 --- a/lang/tr/preferences.php +++ b/lang/tr/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 1eb15d9d4..71d56000f 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Hiçbir kısıtlama tanımlanmamış', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Bakım', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Yazı editörünü değiştir', 'role_notifications' => 'Receive & manage notifications', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Varlık Yetkileri', 'roles_system_warning' => 'Yukarıdaki üç izinden herhangi birine erişimin, kullanıcının kendi ayrıcalıklarını veya sistemdeki diğerlerinin ayrıcalıklarını değiştirmesine izin verebileceğini unutmayın. Yalnızca bu izinlere sahip rolleri güvenilir kullanıcılara atayın.', 'role_asset_desc' => 'Bu izinler, sistem içindeki varlıklara varsayılan erişim izinlerini ayarlar. Kitaplar, bölümler ve sayfalar üzerindeki izinler, buradaki izinleri geçersiz kılar.', 'role_asset_admins' => 'Yöneticilere otomatik olarak bütün içeriğe erişim yetkisi verilir ancak bu seçenekler, kullanıcı arayüzündeki bazı seçeneklerin gösterilmesine veya gizlenmesine neden olabilir.', 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Hepsi', 'role_own' => 'Kendine Ait', 'role_controlled_by_asset' => 'Yüklendikleri varlık tarafından kontrol ediliyor', diff --git a/lang/tr/validation.php b/lang/tr/validation.php index 9dbfadd6b..5916f4543 100644 --- a/lang/tr/validation.php +++ b/lang/tr/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Dosya yüklemesi başarısız oldu. Sunucu, bu boyuttaki dosyaları kabul etmiyor olabilir.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/uk/common.php b/lang/uk/common.php index 8539a93cf..d71f57162 100644 --- a/lang/uk/common.php +++ b/lang/uk/common.php @@ -30,8 +30,8 @@ return [ 'create' => 'Створити', 'update' => 'Оновити', 'edit' => 'Редагувати', - 'archive' => 'Archive', - 'unarchive' => 'Un-Archive', + 'archive' => 'Архів', + 'unarchive' => 'Розархівувати', 'sort' => 'Сортувати', 'move' => 'Перемістити', 'copy' => 'Копіювати', diff --git a/lang/uk/editor.php b/lang/uk/editor.php index 6dbeb509b..b9024e052 100644 --- a/lang/uk/editor.php +++ b/lang/uk/editor.php @@ -48,7 +48,7 @@ return [ 'superscript' => 'Верхній індекс', 'subscript' => 'Нижній індекс', 'text_color' => 'Колір тексту', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Колір підсвічування', 'custom_color' => 'Власний колір', 'remove_color' => 'Видалити колір', 'background_color' => 'Колір фону', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index 72853697b..cf79b530c 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => 'Це видалить завантажений імпорт файлу ZIP, і його не можна буде скасувати.', 'import_errors' => 'Помилки імпорту', 'import_errors_desc' => 'Під час спроби імпорту відбулися наступні помилки:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Переглянути інші сторінки', + 'breadcrumb_siblings_for_chapter' => 'Переглянути інші розділи', + 'breadcrumb_siblings_for_book' => 'Переглянути інші книги', + 'breadcrumb_siblings_for_bookshelf' => 'Переглянути інші полиці', // Permissions and restrictions 'permissions' => 'Дозволи', @@ -252,7 +252,7 @@ return [ 'pages_edit_switch_to_markdown_stable' => '(Стабілізувати вміст)', 'pages_edit_switch_to_wysiwyg' => 'Змінити редактор на WYSIWYG', 'pages_edit_switch_to_new_wysiwyg' => 'Перейти на новий WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(На бета-тестування)', 'pages_edit_set_changelog' => 'Встановити журнал змін', 'pages_edit_enter_changelog_desc' => 'Введіть короткий опис внесених вами змін', 'pages_edit_enter_changelog' => 'Введіть список змін', @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => 'Вставити малюнок', 'pages_md_show_preview' => 'Показати попередній перегляд', 'pages_md_sync_scroll' => 'Синхронізація прокручування попереднього перегляду', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => 'Текстовий редактор', 'pages_drawing_unsaved' => 'Знайдено незбережену чернетку', 'pages_drawing_unsaved_confirm' => 'Незбережені чернетки були знайдені з попередньої спроби зберегти звіт. Хочете відновити і продовжити редагування цієї чернетки?', 'pages_not_in_chapter' => 'Сторінка не знаходиться в розділі', @@ -397,11 +397,11 @@ return [ 'comment' => 'Коментар', 'comments' => 'Коментарі', 'comment_add' => 'Додати коментар', - 'comment_none' => 'No comments to display', + 'comment_none' => 'Немає коментарів для відображення', 'comment_placeholder' => 'Залиште коментар тут', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_thread_count' => ':count тема коментарів|:count теми коментарів', + 'comment_archived_count' => 'Архівовано :count', + 'comment_archived_threads' => 'Архівовані теми', 'comment_save' => 'Зберегти коментар', 'comment_new' => 'Новий коментар', 'comment_created' => 'прокоментував :createDiff', @@ -410,14 +410,14 @@ return [ 'comment_deleted_success' => 'Коментар видалено', 'comment_created_success' => 'Коментар додано', 'comment_updated_success' => 'Коментар оновлено', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_archive_success' => 'Коментар архівовано', + 'comment_unarchive_success' => 'Коментар розархівовано', + 'comment_view' => 'Переглянути коментар', + 'comment_jump_to_thread' => 'Перейти до теми', 'comment_delete_confirm' => 'Ви впевнені, що хочете видалити цей коментар?', 'comment_in_reply_to' => 'У відповідь на :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => 'По посиланню', + 'comment_reference_outdated' => '(Застарілий)', 'comment_editor_explain' => 'Ось коментарі, які залишилися на цій сторінці. Коментарі можна додати та керовані при перегляді збереженої сторінки.', // Revision diff --git a/lang/uk/errors.php b/lang/uk/errors.php index eed61e8a0..40c0d9014 100644 --- a/lang/uk/errors.php +++ b/lang/uk/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Не вдалося прочитати ZIP-файл.', 'import_zip_cant_decode_data' => 'Не вдалося знайти і розшифрувати контент ZIP data.json.', 'import_zip_no_data' => 'ZIP-файл не містить очікуваної книги, глави або вмісту сторінки.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Не вдалося виконати перевірку ZIP-адреси із помилками:', 'import_zip_failed_notification' => 'Не вдалося імпортувати ZIP-файл.', 'import_perms_books' => 'У Вас не вистачає необхідних прав для створення книг.', diff --git a/lang/uk/notifications.php b/lang/uk/notifications.php index a08b9a100..d40457f98 100644 --- a/lang/uk/notifications.php +++ b/lang/uk/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Оновлено сторінку: :pageName', 'updated_page_intro' => 'Оновлено сторінку у :appName:', 'updated_page_debounce' => 'Для запобігання кількості сповіщень, деякий час ви не будете відправлені повідомлення для подальших змін на цій сторінці тим самим редактором.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Назва сторінки:', 'detail_page_path' => 'Шлях до сторінки:', diff --git a/lang/uk/preferences.php b/lang/uk/preferences.php index 14989d2a7..8af3a8d9e 100644 --- a/lang/uk/preferences.php +++ b/lang/uk/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Контролюйте сповіщення по електронній пошті, які ви отримуєте, коли виконується певна активність у системі.', 'notifications_opt_own_page_changes' => 'Повідомляти при змінах сторінок якими я володію', 'notifications_opt_own_page_comments' => 'Повідомляти при коментарях на моїх сторінках', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Повідомляти про відповіді на мої коментарі', 'notifications_save' => 'Зберегти налаштування', 'notifications_update_success' => 'Налаштування сповіщень було оновлено!', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index 3798f1b60..633582ca8 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Не встановлено обмежень', // Sorting Settings - 'sorting' => 'Сортування', - 'sorting_book_default' => 'Типовий порядок сортування', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Виберіть правило сортування за замовчуванням для застосування нових книг. Це не вплине на існуючі книги, і може бути перевизначено для кожної книги.', 'sorting_rules' => 'Сортувати правила', 'sorting_rules_desc' => 'Це попередньо визначені операції сортування, які можуть бути застосовані до вмісту в системі.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Дата оновлення', 'sort_rule_op_chapters_first' => 'Спочатку розділи', 'sort_rule_op_chapters_last' => 'Розділи останні', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Обслуговування', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Імпортувати вміст', 'role_editor_change' => 'Змінити редактор сторінок', 'role_notifications' => 'Отримувати та керувати повідомленнями', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Дозволи', 'roles_system_warning' => 'Майте на увазі, що доступ до будь-якого з вищезазначених трьох дозволів може дозволити користувачеві змінювати власні привілеї або привілеї інших в системі. Ролі з цими дозволами призначайте лише довіреним користувачам.', 'role_asset_desc' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.', 'role_asset_admins' => 'Адміністратори автоматично отримують доступ до всього вмісту, але ці параметри можуть відображати або приховувати параметри інтерфейсу користувача.', 'role_asset_image_view_note' => 'Це стосується видимості в менеджері зображень. Фактичний доступ завантажуваних зображень буде залежний від опції зберігання системних зображень.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Все', 'role_own' => 'Власне', 'role_controlled_by_asset' => 'Контролюється за об\'єктом, до якого вони завантажуються', diff --git a/lang/uk/validation.php b/lang/uk/validation.php index 6161aba67..79aa50702 100644 --- a/lang/uk/validation.php +++ b/lang/uk/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Не вдалося завантажити файл. Сервер може не приймати файли такого розміру.', 'zip_file' => 'Поле :attribute повинне вказувати файл в ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'Поле :attribute повинне посилатись на файл типу :validtypes, знайдений :foundType.', 'zip_model_expected' => 'Очікувався об’єкт даних, але знайдено ":type".', 'zip_unique' => 'Поле :attribute має бути унікальним для типу об\'єкта в ZIP.', diff --git a/lang/uz/errors.php b/lang/uz/errors.php index a0d86c441..052b29adf 100644 --- a/lang/uz/errors.php +++ b/lang/uz/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', diff --git a/lang/uz/notifications.php b/lang/uz/notifications.php index bec9b3925..ece09441e 100644 --- a/lang/uz/notifications.php +++ b/lang/uz/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => ':pageName sahifasi yangilandi', 'updated_page_intro' => ':appName ichida sahifa yangilandi:', 'updated_page_debounce' => 'Xabarnomalar koʻp boʻlishining oldini olish uchun bir muncha vaqt oʻsha muharrir tomonidan ushbu sahifaga keyingi tahrirlar haqida bildirishnomalar yuborilmaydi.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Sahifa nomi:', 'detail_page_path' => 'Page Path:', diff --git a/lang/uz/preferences.php b/lang/uz/preferences.php index de36b953e..996f9d3c4 100644 --- a/lang/uz/preferences.php +++ b/lang/uz/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Tizimda muayyan harakatlar amalga oshirilganda qabul qilinadigan elektron pochta xabarnomalarini boshqaring.', 'notifications_opt_own_page_changes' => 'Menga tegishli boʻlgan sahifalarimdagi oʻzgarishlar haqida xabar bering', 'notifications_opt_own_page_comments' => 'Menga tegishli sahifalardagi sharhlar haqida xabar bering', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Fikrlarimga javoblarim haqida xabar bering', 'notifications_save' => 'Afzalliklarni saqlash', 'notifications_update_success' => 'Bildirishnoma sozlamalari yangilandi!', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index 83fedc569..ad191143f 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Cheklov oʻrnatilmagan', // Sorting Settings - 'sorting' => 'Sorting', - 'sorting_book_default' => 'Default Book Sort', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_rules' => 'Sort Rules', 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Updated Date', 'sort_rule_op_chapters_first' => 'Chapters First', 'sort_rule_op_chapters_last' => 'Chapters Last', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Xizmat', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Import content', 'role_editor_change' => 'Sahifa muharririni o\'zgartirish', 'role_notifications' => 'Bildirishnomalarni qabul qilish va boshqarish', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Obyektga ruxsatlar', 'roles_system_warning' => 'Shuni yodda tutingki, yuqoridagi uchta ruxsatdan birortasiga kirish foydalanuvchiga o\'z imtiyozlarini yoki tizimdagi boshqalarning imtiyozlarini o\'zgartirishi mumkin. Ishonchli foydalanuvchilarga faqat ushbu ruxsatlarga ega rollarni tayinlang.', 'role_asset_desc' => 'Bu ruxsatlar tizim ichidagi aktivlarga standart kirishni nazorat qiladi. Kitoblar, boblar va sahifalardagi ruxsatlar bu ruxsatlarni bekor qiladi.', 'role_asset_admins' => 'Administratorlarga avtomatik ravishda barcha kontentga kirish huquqi beriladi, lekin bu parametrlar UI parametrlarini koʻrsatishi yoki yashirishi mumkin.', 'role_asset_image_view_note' => 'Bu tasvir menejeridagi ko\'rinishga tegishli. Yuklangan rasm fayllariga haqiqiy kirish tizim tasvirini saqlash opsiyasiga bog\'liq bo\'ladi.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Hammasi', 'role_own' => 'Shaxsiy', 'role_controlled_by_asset' => 'Ular yuklangan obyekt tomonidan nazorat qilinadi', diff --git a/lang/uz/validation.php b/lang/uz/validation.php index d9d0fa0b2..600195587 100644 --- a/lang/uz/validation.php +++ b/lang/uz/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Faylni yuklashda xatolik. Server bunday hajmdagi faylllarni yuklamasligi mumkin.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/lang/vi/editor.php b/lang/vi/editor.php index d76a70815..fd3dbdfb1 100644 --- a/lang/vi/editor.php +++ b/lang/vi/editor.php @@ -48,7 +48,7 @@ return [ 'superscript' => 'Chỉ số trên', 'subscript' => 'Chỉ số dưới', 'text_color' => 'Màu chữ', - 'highlight_color' => 'Highlight color', + 'highlight_color' => 'Màu đánh dấu', 'custom_color' => 'Màu tùy chỉnh', 'remove_color' => 'Xóa màu', 'background_color' => 'Màu nền', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index 1914d32c8..f538f993c 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -60,13 +60,13 @@ return [ 'import_location' => 'Vị trí nhập', 'import_location_desc' => 'Chọn vị trí đích cho nội dung đã nhập của bạn. Bạn sẽ cần các quyền liên quan để tạo trong vị trí bạn chọn.', 'import_delete_confirm' => 'Bạn có chắc chắn muốn xóa lượt nhập này không?', - 'import_delete_desc' => 'Thao tác này sẽ xóa tệp ZIP nhập đã tải lên và không thể hoàn tác.', - 'import_errors' => 'Lỗi nhập', + 'import_delete_desc' => 'Thao tác này sẽ xóa tệp ZIP đã tải lên, và không thể hoàn tác.', + 'import_errors' => 'Lỗi khi nhập dữ liệu', 'import_errors_desc' => 'Các lỗi sau đã xảy ra trong quá trình nhập:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => 'Điều hướng anh chị em cho trang', + 'breadcrumb_siblings_for_chapter' => 'Điều hướng anh chị em cho chương', + 'breadcrumb_siblings_for_book' => 'Điều hướng anh chị em cho sách', + 'breadcrumb_siblings_for_bookshelf' => 'Điều hướng anh chị em cho kệ sách', // Permissions and restrictions 'permissions' => 'Quyền', diff --git a/lang/vi/errors.php b/lang/vi/errors.php index bae87dc38..d422b716a 100644 --- a/lang/vi/errors.php +++ b/lang/vi/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => 'Không thể đọc tệp ZIP.', 'import_zip_cant_decode_data' => 'Không thể tìm và giải mã nội dung ZIP data.json.', 'import_zip_no_data' => 'Dữ liệu tệp ZIP không có nội dung sách, chương hoặc trang mong đợi.', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => 'Nhập tệp ZIP không hợp lệ với các lỗi:', 'import_zip_failed_notification' => 'Không thể nhập tệp ZIP.', 'import_perms_books' => 'Bạn không có quyền cần thiết để tạo sách.', diff --git a/lang/vi/notifications.php b/lang/vi/notifications.php index a18695b23..45fb4434f 100644 --- a/lang/vi/notifications.php +++ b/lang/vi/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => 'Trang đã cập nhật: :pageName', 'updated_page_intro' => 'Một trang mới đã được cập nhật trong :appName:', 'updated_page_debounce' => 'Để tránh việc nhận quá nhiều thông báo, trong một thời gian, bạn sẽ không nhận được thông báo về những chỉnh sửa tiếp theo cho trang này từ cùng một biên tập viên.', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => 'Tên Trang:', 'detail_page_path' => 'Đường dẫn trang:', diff --git a/lang/vi/preferences.php b/lang/vi/preferences.php index 76bc0be16..3e06ccd30 100644 --- a/lang/vi/preferences.php +++ b/lang/vi/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', 'notifications_save' => 'Save Preferences', 'notifications_update_success' => 'Notification preferences have been updated!', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index 1ff252702..69eadddd3 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => 'Không có giới hạn nào được thiết lập', // Sorting Settings - 'sorting' => 'Sắp xếp', - 'sorting_book_default' => 'Sắp xếp sách mặc định', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => 'Chọn quy tắc sắp xếp mặc định để áp dụng cho sách mới. Điều này sẽ không ảnh hưởng đến các sách hiện có và có thể được ghi đè cho từng sách.', 'sorting_rules' => 'Quy tắc sắp xếp', 'sorting_rules_desc' => 'Đây là các thao tác sắp xếp được xác định trước có thể được áp dụng cho nội dung trong hệ thống.', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => 'Ngày cập nhật', 'sort_rule_op_chapters_first' => 'Chương trước', 'sort_rule_op_chapters_last' => 'Chương sau', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => 'Bảo trì', @@ -195,11 +197,13 @@ return [ 'role_import_content' => 'Nhập nội dung', 'role_editor_change' => 'Thay đổi trình soạn thảo trang', 'role_notifications' => 'Nhận & quản lý thông báo', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => 'Quyền tài sản (asset)', 'roles_system_warning' => 'Cần lưu ý rằng việc truy cập vào bất kỳ ba quyền trên có thể cho phép người dùng thay đổi đặc quyền của chính họ hoặc đặc quyền của những người khác trong hệ thống. Chỉ gán các vai trò có các quyền này cho những người dùng đáng tin cậy.', 'role_asset_desc' => 'Các quyền này điều khiển truy cập mặc định tới tài sản (asset) nằm trong hệ thống. Quyền tại Sách, Chương và Trang sẽ ghi đè các quyền này.', 'role_asset_admins' => 'Quản trị viên được tự động cấp quyền truy cập đến toàn bộ nội dung, tuy nhiên các tùy chọn đó có thể hiện hoặc ẩn tùy chọn giao diện.', 'role_asset_image_view_note' => 'Điều này liên quan đến khả năng hiển thị trong trình quản lý hình ảnh. Quyền truy cập thực tế vào các tệp hình ảnh đã tải lên sẽ phụ thuộc vào tùy chọn lưu trữ hình ảnh của hệ thống.', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'Tất cả', 'role_own' => 'Sở hữu', 'role_controlled_by_asset' => 'Kiểm soát các tài sản (asset) người dùng tải lên', diff --git a/lang/vi/validation.php b/lang/vi/validation.php index bc6fc0e39..d7d9a9de7 100644 --- a/lang/vi/validation.php +++ b/lang/vi/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => 'Tệp tin đã không được tải lên. Máy chủ không chấp nhận các tệp tin với dung lượng lớn như tệp tin trên.', 'zip_file' => ':attribute cần tham chiếu đến một tệp trong ZIP.', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute cần tham chiếu đến một tệp có kiểu: :validTypes, tìm thấy :foundType.', 'zip_model_expected' => 'Đối tượng dữ liệu được mong đợi nhưng tìm thấy ":type".', 'zip_unique' => ':attribute phải là duy nhất cho kiểu đối tượng trong ZIP.', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 89c23c281..03f6393ca 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -46,7 +46,7 @@ return [ 'import' => '导入', 'import_validate' => '验证导入', 'import_desc' => '使用便携式 zip 导出从相同或不同的实例导入书籍、章节和页面。选择一个 ZIP 文件以继续。文件上传并验证后,您就可以在下一个视图中配置和确认导入。', - 'import_zip_select' => '选择要上床的 ZIP 文件', + 'import_zip_select' => '选择要上传的 ZIP 文件', 'import_zip_validation_errors' => '验证提供的 ZIP 文件时检测到错误:', 'import_pending' => '等待导入', 'import_pending_none' => '尚未开始导入。', diff --git a/lang/zh_CN/errors.php b/lang/zh_CN/errors.php index 37ef86af5..828e7c102 100644 --- a/lang/zh_CN/errors.php +++ b/lang/zh_CN/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => '无法读取 ZIP 文件。', 'import_zip_cant_decode_data' => '无法找到并解码 ZIP data.json 内容。', 'import_zip_no_data' => 'ZIP 文件数据没有预期的书籍、章节或页面内容。', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => '导入 ZIP 验证失败,出现错误:', 'import_zip_failed_notification' => 'ZIP 文件导入失败。', 'import_perms_books' => '您缺少创建书籍所需的权限。', diff --git a/lang/zh_CN/notifications.php b/lang/zh_CN/notifications.php index 52c9822bc..e4eebf5cc 100644 --- a/lang/zh_CN/notifications.php +++ b/lang/zh_CN/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => '页面更新::pageName', 'updated_page_intro' => ':appName: 中的一个页面已被更新', 'updated_page_debounce' => '为了防止出现大量通知,一段时间内您不会收到同一编辑者再次编辑本页面的通知。', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => '页面名称:', 'detail_page_path' => '页面路径:', diff --git a/lang/zh_CN/preferences.php b/lang/zh_CN/preferences.php index f1ef3957d..f89448dd3 100644 --- a/lang/zh_CN/preferences.php +++ b/lang/zh_CN/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => '控制在系统内发生某些活动时您会收到的电子邮件通知。', 'notifications_opt_own_page_changes' => '在我拥有的页面被修改时通知我', 'notifications_opt_own_page_comments' => '在我拥有的页面上有新评论时通知我', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => '在有人回复我的频率时通知我', 'notifications_save' => '保存偏好设置', 'notifications_update_success' => '通知偏好设置已更新!', diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php index 2114c3648..93f3076c5 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => '尚未设置限制', // Sorting Settings - 'sorting' => '排序', - 'sorting_book_default' => '默认书卷排序', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => '选择要应用于新书的默认排序规则。这不会影响现有书,并且可以每本书覆盖。', 'sorting_rules' => '排序规则', 'sorting_rules_desc' => '这些是预定义的排序操作,可应用于系统中的内容。', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => '更新时间', 'sort_rule_op_chapters_first' => '章节正序', 'sort_rule_op_chapters_last' => '章节倒序', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => '维护', @@ -195,11 +197,13 @@ return [ 'role_import_content' => '导入内容', 'role_editor_change' => '更改页面编辑器', 'role_notifications' => '管理和接收通知', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => '资源许可', 'roles_system_warning' => '请注意,拥有以上三个权限中的任何一个都会允许用户更改自己的权限或系统中其他人的权限。 请只将拥有这些权限的角色分配给你信任的用户。', 'role_asset_desc' => '对系统内资源的默认访问许可将由这些权限控制。单独设置在书籍、章节和页面上的权限将覆盖这里的权限设定。', 'role_asset_admins' => '管理员可自动获得对所有内容的访问权限,但这些选项可能会显示或隐藏UI选项。', 'role_asset_image_view_note' => '这与图像管理器中的可见性有关。已经上传的图片的实际访问取决于系统图像存储选项。', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => '全部的', 'role_own' => '拥有的', 'role_controlled_by_asset' => '由其所在的资源来控制', diff --git a/lang/zh_CN/validation.php b/lang/zh_CN/validation.php index 1a576e6bc..748c8f567 100644 --- a/lang/zh_CN/validation.php +++ b/lang/zh_CN/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => '无法上传文件。 服务器可能不接受此大小的文件。', 'zip_file' => ':attribute 需要引用 ZIP 内的文件。', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute 需要引用类型为 :validTypes 的文件,找到 :foundType 。', 'zip_model_expected' => '预期的数据对象,但找到了 ":type" 。', 'zip_unique' => '对于 ZIP 中的对象类型来说,:attribute 必须是唯一的。', diff --git a/lang/zh_TW/common.php b/lang/zh_TW/common.php index 0c64ac835..46e749fd6 100644 --- a/lang/zh_TW/common.php +++ b/lang/zh_TW/common.php @@ -31,7 +31,7 @@ return [ 'update' => '更新', 'edit' => '編輯', 'archive' => '歸檔', - 'unarchive' => 'Un-Archive', + 'unarchive' => '取消封存', 'sort' => '排序', 'move' => '移動', 'copy' => '複製', diff --git a/lang/zh_TW/editor.php b/lang/zh_TW/editor.php index 93c8d571d..c56f33efc 100644 --- a/lang/zh_TW/editor.php +++ b/lang/zh_TW/editor.php @@ -48,7 +48,7 @@ return [ 'superscript' => '上標', 'subscript' => '下標', 'text_color' => '文本顏色', - 'highlight_color' => 'Highlight color', + 'highlight_color' => '突顯顏色', 'custom_color' => '自訂顏色', 'remove_color' => '移除颜色', 'background_color' => '背景顏色', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index 06db353b2..46bbe62f9 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -63,10 +63,10 @@ return [ 'import_delete_desc' => '這將會刪除已上傳的匯入 ZIP 檔案,且無法還原。', 'import_errors' => '匯入錯誤', 'import_errors_desc' => '嘗試匯入時發生以下錯誤:', - 'breadcrumb_siblings_for_page' => 'Navigate siblings for page', - 'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter', - 'breadcrumb_siblings_for_book' => 'Navigate siblings for book', - 'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf', + 'breadcrumb_siblings_for_page' => '瀏覽同層級頁面', + 'breadcrumb_siblings_for_chapter' => '瀏覽同章節頁面', + 'breadcrumb_siblings_for_book' => '瀏覽同書籍頁面', + 'breadcrumb_siblings_for_bookshelf' => '瀏覽同書架頁面', // Permissions and restrictions 'permissions' => '權限', @@ -252,7 +252,7 @@ return [ 'pages_edit_switch_to_markdown_stable' => '(保留內容)', 'pages_edit_switch_to_wysiwyg' => '切換到所見即所得編輯器', 'pages_edit_switch_to_new_wysiwyg' => '切換為新的所見即所得編輯器', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(仍在測試中)', 'pages_edit_set_changelog' => '設定變更日誌', 'pages_edit_enter_changelog_desc' => '輸入對您所做變動的簡易描述', 'pages_edit_enter_changelog' => '輸入變更日誌', @@ -272,7 +272,7 @@ return [ 'pages_md_insert_drawing' => '插入繪圖', 'pages_md_show_preview' => '顯示預覽', 'pages_md_sync_scroll' => '預覽頁面同步捲動', - 'pages_md_plain_editor' => 'Plaintext editor', + 'pages_md_plain_editor' => '純文字編輯器', 'pages_drawing_unsaved' => '偵測到未儲存的繪圖', 'pages_drawing_unsaved_confirm' => '從之前保存失敗的繪圖中發現了可恢復的數據。您想恢復並繼續編輯這個未保存的繪圖嗎?', 'pages_not_in_chapter' => '頁面不在章節中', @@ -397,11 +397,11 @@ return [ 'comment' => '評論', 'comments' => '評論', 'comment_add' => '新增評論', - 'comment_none' => 'No comments to display', + 'comment_none' => '無可顯示的留言', 'comment_placeholder' => '在這裡評論', - 'comment_thread_count' => ':count Comment Thread|:count Comment Threads', - 'comment_archived_count' => ':count Archived', - 'comment_archived_threads' => 'Archived Threads', + 'comment_thread_count' => ':count 個留言討論串|:count 個留言討論串', + 'comment_archived_count' => ':count 個已封存', + 'comment_archived_threads' => '已封存的討論串', 'comment_save' => '儲存評論', 'comment_new' => '新評論', 'comment_created' => '評論於 :createDiff', @@ -410,14 +410,14 @@ return [ 'comment_deleted_success' => '評論已刪除', 'comment_created_success' => '評論已加入', 'comment_updated_success' => '評論已更新', - 'comment_archive_success' => 'Comment archived', - 'comment_unarchive_success' => 'Comment un-archived', - 'comment_view' => 'View comment', - 'comment_jump_to_thread' => 'Jump to thread', + 'comment_archive_success' => '留言已封存', + 'comment_unarchive_success' => '留言已解除封存', + 'comment_view' => '檢視留言', + 'comment_jump_to_thread' => '前往討論串', 'comment_delete_confirm' => '您確定要刪除這則評論?', 'comment_in_reply_to' => '回覆 :commentId', - 'comment_reference' => 'Reference', - 'comment_reference_outdated' => '(Outdated)', + 'comment_reference' => '參考資料', + 'comment_reference_outdated' => '(過期)', 'comment_editor_explain' => '此為頁面上的評論。在查看檢視與儲存頁面的同時,可以新增和管理評論。', // Revision diff --git a/lang/zh_TW/errors.php b/lang/zh_TW/errors.php index d4239ed6c..e6ef7e020 100644 --- a/lang/zh_TW/errors.php +++ b/lang/zh_TW/errors.php @@ -109,6 +109,7 @@ return [ 'import_zip_cant_read' => '無法讀取 ZIP 檔案。', 'import_zip_cant_decode_data' => '無法尋找並解碼 ZIP data.json 內容。', 'import_zip_no_data' => 'ZIP 檔案資料沒有預期的書本、章節或頁面內容。', + 'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.', 'import_validation_failed' => '匯入 ZIP 驗證失敗,發生錯誤:', 'import_zip_failed_notification' => '匯入 ZIP 檔案失敗。', 'import_perms_books' => '您缺乏建立書本所需的權限。', diff --git a/lang/zh_TW/notifications.php b/lang/zh_TW/notifications.php index 7fcfa6400..f5deae328 100644 --- a/lang/zh_TW/notifications.php +++ b/lang/zh_TW/notifications.php @@ -11,6 +11,8 @@ return [ 'updated_page_subject' => '頁面更新::pageName', 'updated_page_intro' => ':appName: 中的一個頁面已被更新', 'updated_page_debounce' => '為了防止出現大量通知,一段時間內您不會收到同一編輯者再次編輯本頁面的通知。', + 'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName', + 'comment_mention_intro' => 'You were mentioned in a comment on :appName:', 'detail_page_name' => '頁面名稱:', 'detail_page_path' => '頁面路徑:', diff --git a/lang/zh_TW/preferences.php b/lang/zh_TW/preferences.php index 4900ca152..0bfce9d83 100644 --- a/lang/zh_TW/preferences.php +++ b/lang/zh_TW/preferences.php @@ -23,6 +23,7 @@ return [ 'notifications_desc' => '控制在系統有特定活動時,是否要接收電子郵件通知', 'notifications_opt_own_page_changes' => '當我的頁面有異動時發送通知', 'notifications_opt_own_page_comments' => '當我的頁面有評論時發送通知', + 'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment', 'notifications_opt_comment_replies' => '當我的評論有新的回覆時發送通知', 'notifications_save' => '儲存偏好設定', 'notifications_update_success' => '通知設定已更新', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 752d3f2a7..9b5efa09e 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -75,8 +75,8 @@ return [ 'reg_confirm_restrict_domain_placeholder' => '尚未設定限制', // Sorting Settings - 'sorting' => '排序', - 'sorting_book_default' => '預設書籍排序', + 'sorting' => 'Lists & Sorting', + 'sorting_book_default' => 'Default Book Sort Rule', 'sorting_book_default_desc' => '選取要套用至新書籍的預設排序規則。這不會影響現有書籍,並可按書籍覆寫。', 'sorting_rules' => '排序規則', 'sorting_rules_desc' => '這些是預先定義的排序作業,可套用於系統中的內容。', @@ -103,6 +103,8 @@ return [ 'sort_rule_op_updated_date' => '更新日期', 'sort_rule_op_chapters_first' => '第一章', 'sort_rule_op_chapters_last' => '最後一章', + 'sorting_page_limits' => 'Per-Page Display Limits', + 'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.', // Maintenance settings 'maint' => '維護', @@ -196,11 +198,13 @@ return [ 'role_import_content' => '匯入內容', 'role_editor_change' => '重設頁面編輯器', 'role_notifications' => '管理和接收通知', + 'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.', 'role_asset' => '資源權限', 'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。', 'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。', 'role_asset_admins' => '管理員會自動取得對所有內容的存取權,但這些選項可能會顯示或隱藏使用者介面的選項。', 'role_asset_image_view_note' => '這與圖像管理器中的可見性有關。已經上傳的圖片的實際訪問取決於系統圖像存儲選項。', + 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => '全部', 'role_own' => '擁有', 'role_controlled_by_asset' => '依據隸屬的資源來決定', diff --git a/lang/zh_TW/validation.php b/lang/zh_TW/validation.php index 300314707..e6004c59d 100644 --- a/lang/zh_TW/validation.php +++ b/lang/zh_TW/validation.php @@ -106,6 +106,7 @@ return [ 'uploaded' => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。', 'zip_file' => ':attribute 需要參照 ZIP 中的檔案。', + 'zip_file_size' => 'The file :attribute must not exceed :size MB.', 'zip_file_mime' => ':attribute 需要參照類型為 :validTypes 的檔案,找到 :foundType。', 'zip_model_expected' => '預期為資料物件,但找到「:type」。', 'zip_unique' => '對於 ZIP 中的物件類型,:attribute 必須是唯一的。', diff --git a/package-lock.json b/package-lock.json index 86bdc05e8..a620e1bf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,48 +5,47 @@ "packages": { "": { "dependencies": { - "@codemirror/commands": "^6.8.0", + "@codemirror/commands": "^6.10.0", "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-javascript": "^6.2.3", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.3.2", - "@codemirror/lang-php": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/language": "^6.10.8", - "@codemirror/legacy-modes": "^6.4.3", + "@codemirror/language": "^6.11.3", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/state": "^6.5.2", - "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.36.3", - "@lezer/highlight": "^1.2.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.8", + "@lezer/highlight": "^1.2.3", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^29.5.14", - "codemirror": "^6.0.1", + "@types/jest": "^30.0.0", + "codemirror": "^6.0.2", "eventsource-client": "^1.1.4", - "idb-keyval": "^6.2.1", + "idb-keyval": "^6.2.2", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.6.2", + "snabbdom": "^3.6.3", "sortablejs": "^1.15.6" }, "devDependencies": { - "@eslint/js": "^9.21.0", - "@lezer/generator": "^1.7.2", + "@eslint/js": "^9.39.1", + "@lezer/generator": "^1.8.0", "@types/markdown-it": "^14.1.2", - "@types/sortablejs": "^1.15.8", + "@types/sortablejs": "^1.15.9", "chokidar-cli": "^3.0", - "esbuild": "^0.25.0", - "eslint": "^9.21.0", - "eslint-plugin-import": "^2.31.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "livereload": "^0.9.3", + "esbuild": "^0.27.0", + "eslint": "^9.39.1", + "eslint-plugin-import": "^2.32.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", "npm-run-all": "^4.1.5", - "sass": "^1.85.0", - "ts-jest": "^29.2.6", + "sass": "^1.94.2", + "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "5.7.*" + "typescript": "5.9.*" } }, "node_modules/@ampproject/remapping": { @@ -63,6 +62,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -537,9 +557,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", - "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -570,9 +590,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", - "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz", + "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -595,9 +615,9 @@ } }, "node_modules/@codemirror/lang-html": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", - "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -608,7 +628,7 @@ "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", - "@lezer/html": "^1.3.0" + "@lezer/html": "^1.3.12" } }, "node_modules/@codemirror/lang-javascript": { @@ -637,9 +657,9 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.3.tgz", - "integrity": "sha512-1fn1hQAPWlSSMCvnF810AkhWpNLkJpl66CRfIy3vVl20Sl4NwChkorCHqpMtNbXr1EuMJsrDnhEpjZxKZ2UX3A==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", @@ -679,9 +699,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz", - "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==", + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -693,9 +713,9 @@ } }, "node_modules/@codemirror/legacy-modes": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.1.tgz", - "integrity": "sha512-DJYQQ00N1/KdESpZV7jg9hafof/iBNp9h7TYo1SLMk86TWl9uDsVdho2dzd81K+v4retmK6mdC7WpuOQDytQqw==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" @@ -745,9 +765,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.38.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", - "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "version": "6.38.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", + "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -780,10 +800,159 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", "cpu": [ "ppc64" ], @@ -798,9 +967,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", "cpu": [ "arm" ], @@ -815,9 +984,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", "cpu": [ "arm64" ], @@ -832,9 +1001,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", "cpu": [ "x64" ], @@ -849,9 +1018,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", "cpu": [ "arm64" ], @@ -866,9 +1035,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", "cpu": [ "x64" ], @@ -883,9 +1052,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", "cpu": [ "arm64" ], @@ -900,9 +1069,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", "cpu": [ "x64" ], @@ -917,9 +1086,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", "cpu": [ "arm" ], @@ -934,9 +1103,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", "cpu": [ "arm64" ], @@ -951,9 +1120,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", "cpu": [ "ia32" ], @@ -968,9 +1137,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", "cpu": [ "loong64" ], @@ -985,9 +1154,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", "cpu": [ "mips64el" ], @@ -1002,9 +1171,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", "cpu": [ "ppc64" ], @@ -1019,9 +1188,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", "cpu": [ "riscv64" ], @@ -1036,9 +1205,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", "cpu": [ "s390x" ], @@ -1053,9 +1222,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", "cpu": [ "x64" ], @@ -1070,9 +1239,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", "cpu": [ "arm64" ], @@ -1087,9 +1256,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", "cpu": [ "x64" ], @@ -1104,9 +1273,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", "cpu": [ "arm64" ], @@ -1121,9 +1290,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", "cpu": [ "x64" ], @@ -1138,9 +1307,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", "cpu": [ "arm64" ], @@ -1155,9 +1324,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", "cpu": [ "x64" ], @@ -1172,9 +1341,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", "cpu": [ "arm64" ], @@ -1189,9 +1358,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", "cpu": [ "ia32" ], @@ -1206,9 +1375,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", "cpu": [ "x64" ], @@ -1223,9 +1392,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1265,13 +1434,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1280,19 +1449,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1327,9 +1499,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", - "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, "license": "MIT", "engines": { @@ -1340,9 +1512,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1350,13 +1522,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1429,6 +1601,80 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1471,9 +1717,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1547,61 +1793,61 @@ } }, "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1612,116 +1858,174 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "^29.7.0" + "jest-mock": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", + "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "expect": "30.2.0", + "jest-snapshot": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@jest/get-type": "30.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", + "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", + "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1733,106 +2037,123 @@ } }, "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "write-file-atomic": "^5.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1875,9 +2196,9 @@ } }, "node_modules/@lezer/common": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", - "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", + "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", "license": "MIT" }, "node_modules/@lezer/css": { @@ -1906,18 +2227,18 @@ } }, "node_modules/@lezer/highlight": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.3.0" } }, "node_modules/@lezer/html": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", - "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz", + "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -1994,6 +2315,19 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -2304,6 +2638,30 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2312,9 +2670,9 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "license": "MIT" }, "node_modules/@sinonjs/commons": { @@ -2328,13 +2686,13 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@ssddanbrown/codemirror-lang-smarty": { @@ -2354,16 +2712,6 @@ "@lezer/lr": "^1.0.0" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2392,6 +2740,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2428,13 +2787,13 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/estree": { @@ -2444,16 +2803,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2479,19 +2828,19 @@ } }, "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", "license": "MIT", "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" + "expect": "^30.0.0", + "pretty-format": "^30.0.0" } }, "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", "dependencies": { @@ -2549,9 +2898,9 @@ } }, "node_modules/@types/sortablejs": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", - "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", "dev": true, "license": "MIT" }, @@ -2583,13 +2932,281 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "BSD-3-Clause" + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/acorn": { "version": "8.15.0", @@ -2604,17 +3221,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -2639,16 +3245,13 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -2685,13 +3288,16 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { @@ -2858,13 +3464,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2875,13 +3474,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2899,81 +3491,64 @@ } }, "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.8.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", - "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -2994,24 +3569,24 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { @@ -3282,9 +3857,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "funding": [ { "type": "github", @@ -3297,9 +3872,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", + "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", "dev": true, "license": "MIT" }, @@ -3365,9 +3940,9 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, @@ -3389,19 +3964,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3416,28 +3978,6 @@ "dev": true, "license": "MIT" }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3466,46 +4006,32 @@ "node": ">= 8" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { @@ -3598,9 +4124,9 @@ "license": "MIT" }, "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3665,16 +4191,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -3709,15 +4225,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3731,20 +4238,6 @@ "node": ">=0.10.0" } }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3760,21 +4253,12 @@ "node": ">= 0.4" } }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.190", @@ -3975,9 +4459,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3988,32 +4472,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" } }, "node_modules/escalade": { @@ -4039,48 +4523,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", - "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.31.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -4338,9 +4799,9 @@ } }, "node_modules/eventsource-client": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/eventsource-client/-/eventsource-client-1.1.4.tgz", - "integrity": "sha512-CKnqZTwXCnHN2EqrEB9eLSjMMRqHum09VOsikkgSPoa2Jr2XgQnX7P1Fxhnnj/UHxi3GQ2xVsXDKIktEes07bg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/eventsource-client/-/eventsource-client-1.2.0.tgz", + "integrity": "sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==", "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.0" @@ -4350,12 +4811,12 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz", - "integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/execa": { @@ -4382,29 +4843,38 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/fast-deep-equal": { @@ -4451,39 +4921,6 @@ "node": ">=16.0.0" } }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4550,21 +4987,21 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">= 6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/fs.realpath": { @@ -4731,22 +5168,21 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4765,6 +5201,32 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4814,6 +5276,28 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4915,16 +5399,16 @@ "license": "ISC" }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-escaper": { @@ -4935,32 +5419,31 @@ "license": "MIT" }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-signals": { @@ -5610,24 +6093,24 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5638,42 +6121,39 @@ "node": ">=8" } }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, - "license": "Apache-2.0", + "license": "BlueOak-1.0.0", "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" + "@isaacs/cliui": "^8.0.2" }, - "bin": { - "jake": "bin/cli.js" + "funding": { + "url": "https://github.com/sponsors/isaacs" }, - "engines": { - "node": ">=10" + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -5685,76 +6165,75 @@ } }, "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", + "execa": "^5.1.1", + "jest-util": "30.2.0", "p-limit": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -5765,6 +6244,16 @@ } } }, + "node_modules/jest-cli/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-cli/node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5812,6 +6301,19 @@ "node": ">=8" } }, + "node_modules/jest-cli/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-cli/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5860,117 +6362,120 @@ } }, "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", + "pretty-format": "30.2.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "@types/node": "*", + "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "esbuild-register": { + "optional": true + }, "ts-node": { "optional": true } } }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", + "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", + "@jest/environment": "30.2.0", + "@jest/environment-jsdom-abstract": "30.2.0", + "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "jsdom": "^26.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -5979,120 +6484,110 @@ } }, "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", + "@jest/types": "30.2.0", "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", "walker": "^1.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "optionalDependencies": { - "fsevents": "^2.3.2" + "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "^29.7.0" + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-pnp-resolver": { @@ -6114,153 +6609,153 @@ } }, "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -6271,38 +6766,50 @@ } }, "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-validate/node_modules/camelcase": { @@ -6319,39 +6826,40 @@ } }, "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "jest-util": "30.2.0", + "string-length": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-util": "^29.7.0", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "supports-color": "^8.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker/node_modules/supports-color": { @@ -6377,9 +6885,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6390,44 +6898,38 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -6506,16 +7008,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6556,54 +7048,6 @@ "uc.micro": "^2.0.0" } }, - "node_modules/livereload": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", - "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.0", - "livereload-js": "^3.3.1", - "opts": ">= 1.2.0", - "ws": "^7.4.3" - }, - "bin": { - "livereload": "bin/livereload.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/livereload-js": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", - "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", - "dev": true, - "license": "MIT" - }, - "node_modules/livereload/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -6715,9 +7159,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -6812,29 +7256,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -6868,6 +7289,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6875,6 +7306,22 @@ "dev": true, "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6882,6 +7329,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -7135,9 +7589,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", - "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "dev": true, "license": "MIT" }, @@ -7282,13 +7736,6 @@ "node": ">= 0.8.0" } }, - "node_modules/opts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", - "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -7349,6 +7796,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7444,6 +7898,30 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -7598,17 +8076,17 @@ } }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -7623,33 +8101,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7670,9 +8121,9 @@ } }, "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -7686,13 +8137,6 @@ ], "license": "MIT" }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7788,13 +8232,6 @@ "dev": true, "license": "ISC" }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -7849,15 +8286,12 @@ "node": ">=4" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } + "license": "MIT" }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -7922,9 +8356,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", - "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", + "version": "1.94.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", + "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", "dev": true, "license": "MIT", "dependencies": { @@ -8164,18 +8598,17 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/slash": { "version": "3.0.0", @@ -8187,9 +8620,9 @@ } }, "node_modules/snabbdom": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.2.tgz", - "integrity": "sha512-ig5qOnCDbugFntKi6c7Xlib8bA6xiJVk8O+WdFrV3wxbMqeHO0hXFQC4nAhPVWfZfi8255lcZkNhtIBINCc4+Q==", + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz", + "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==", "license": "MIT", "engines": { "node": ">=12.17.0" @@ -8324,6 +8757,29 @@ "node": ">=10" } }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -8339,6 +8795,62 @@ "node": ">=6" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", @@ -8441,6 +8953,23 @@ } }, "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -8453,6 +8982,16 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -8524,6 +9063,22 @@ "dev": true, "license": "MIT" }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8539,6 +9094,48 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -8559,48 +9156,45 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/ts-jest": { - "version": "29.4.0", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", - "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -8641,9 +9235,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -8746,6 +9340,14 @@ "node": ">=4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8861,9 +9463,9 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8880,6 +9482,20 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -8905,14 +9521,39 @@ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "node_modules/update-browserslist-db": { @@ -8956,17 +9597,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -9007,16 +9637,16 @@ "license": "MIT" }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/walker": { @@ -9040,40 +9670,40 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { @@ -9198,6 +9828,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", @@ -9213,6 +9850,80 @@ "node": ">=6" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", @@ -9274,17 +9985,17 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "signal-exit": "^4.0.1" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/ws": { @@ -9310,13 +10021,13 @@ } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { diff --git a/package.json b/package.json index 637457a93..cd5bc8d2b 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,13 @@ "build:css:dev": "sass ./resources/sass:./public/dist --embed-sources", "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources", "build:css:production": "sass ./resources/sass:./public/dist -s compressed", - "build:js:dev": "node dev/build/esbuild.js", - "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" \"./resources/**/*.ts\" -c \"npm run build:js:dev\"", - "build:js:production": "node dev/build/esbuild.js production", + "build:js:dev": "node dev/build/esbuild.mjs", + "build:js:watch": "node dev/build/esbuild.mjs watch", + "build:js:production": "node dev/build/esbuild.mjs production", "build": "npm-run-all --parallel build:*:dev", "production": "npm-run-all --parallel build:*:production", - "dev": "npm-run-all --parallel watch livereload", + "dev": "npm-run-all --parallel build:*:watch", "watch": "npm-run-all --parallel build:*:watch", - "livereload": "livereload ./public/dist/", "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads", "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"", @@ -19,47 +18,46 @@ "test": "jest" }, "devDependencies": { - "@eslint/js": "^9.21.0", - "@lezer/generator": "^1.7.2", + "@eslint/js": "^9.39.1", + "@lezer/generator": "^1.8.0", "@types/markdown-it": "^14.1.2", - "@types/sortablejs": "^1.15.8", + "@types/sortablejs": "^1.15.9", "chokidar-cli": "^3.0", - "esbuild": "^0.25.0", - "eslint": "^9.21.0", - "eslint-plugin-import": "^2.31.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "livereload": "^0.9.3", + "esbuild": "^0.27.0", + "eslint": "^9.39.1", + "eslint-plugin-import": "^2.32.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", "npm-run-all": "^4.1.5", - "sass": "^1.85.0", - "ts-jest": "^29.2.6", + "sass": "^1.94.2", + "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "5.7.*" + "typescript": "5.9.*" }, "dependencies": { - "@codemirror/commands": "^6.8.0", + "@codemirror/commands": "^6.10.0", "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-javascript": "^6.2.3", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.3.2", - "@codemirror/lang-php": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/language": "^6.10.8", - "@codemirror/legacy-modes": "^6.4.3", + "@codemirror/language": "^6.11.3", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/state": "^6.5.2", - "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.36.3", - "@lezer/highlight": "^1.2.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.8", + "@lezer/highlight": "^1.2.3", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^29.5.14", - "codemirror": "^6.0.1", + "@types/jest": "^30.0.0", + "codemirror": "^6.0.2", "eventsource-client": "^1.1.4", - "idb-keyval": "^6.2.1", + "idb-keyval": "^6.2.2", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.6.2", + "snabbdom": "^3.6.3", "sortablejs": "^1.15.6" } } diff --git a/phpcs.xml b/phpcs.xml index 8d4c6b702..8337d5aac 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -14,7 +14,7 @@ - + ./tests/* diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0f2021383..72189222f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,7 @@ parameters: - app # The level 8 is the highest level - level: 1 + level: 3 phpVersion: min: 80200 diff --git a/phpunit.xml b/phpunit.xml index 243c3d3bc..8a7ab9cb7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,6 @@ @@ -16,6 +16,8 @@ + + diff --git a/public/web.config b/public/web.config index 474eb6898..b08c89c9a 100644 --- a/public/web.config +++ b/public/web.config @@ -1,6 +1,6 @@ diff --git a/readme.md b/readme.md index 50b908107..3ee5f242a 100644 --- a/readme.md +++ b/readme.md @@ -51,48 +51,53 @@ Big thanks to these companies for supporting the project. Federated.computer - + + + Diagrams.net + + + #### Bronze Sponsors - - + - - + + - - + + + - +
- Diagrams.net - Cloudabove
Practicali
Stellar Hosted
NETWAYS Web Services
Schroeck IT Consulting
Practinet
Route4Me - Route Optimizer and Route Planner Software + phamos +
- phamos - SiteSpeakAI + Admin Intelligence +
diff --git a/resources/js/code/languages.js b/resources/js/code/languages.js index da9631a13..52fc55dbf 100644 --- a/resources/js/code/languages.js +++ b/resources/js/code/languages.js @@ -33,6 +33,7 @@ const modeMap = { 'f#': () => legacyLoad('fSharp'), fsharp: () => legacyLoad('fSharp'), go: () => legacyLoad('go'), + groovy: () => legacyLoad('groovy'), haskell: () => legacyLoad('haskell'), hs: () => legacyLoad('haskell'), html: async () => html({selfClosingTags: true}), diff --git a/resources/js/code/legacy-modes.mjs b/resources/js/code/legacy-modes.mjs index 7b70bec10..a5878ccab 100644 --- a/resources/js/code/legacy-modes.mjs +++ b/resources/js/code/legacy-modes.mjs @@ -5,6 +5,8 @@ export {clojure} from '@codemirror/legacy-modes/mode/clojure'; export {diff} from '@codemirror/legacy-modes/mode/diff'; export {fortran} from '@codemirror/legacy-modes/mode/fortran'; export {go} from '@codemirror/legacy-modes/mode/go'; +export {groovy} from '@codemirror/legacy-modes/mode/groovy'; +export {haxe} from '@codemirror/legacy-modes/mode/haxe'; export {haskell} from '@codemirror/legacy-modes/mode/haskell'; export {julia} from '@codemirror/legacy-modes/mode/julia'; export {lua} from '@codemirror/legacy-modes/mode/lua'; diff --git a/resources/js/components/api-nav.ts b/resources/js/components/api-nav.ts new file mode 100644 index 000000000..a84c1cb33 --- /dev/null +++ b/resources/js/components/api-nav.ts @@ -0,0 +1,32 @@ +import {Component} from "./component"; + +export class ApiNav extends Component { + private select!: HTMLSelectElement; + private sidebar!: HTMLElement; + private body!: HTMLElement; + + setup() { + this.select = this.$refs.select as HTMLSelectElement; + this.sidebar = this.$refs.sidebar; + this.body = this.$el.ownerDocument.documentElement; + this.select.addEventListener('change', () => { + const section = this.select.value; + const sidebarTarget = document.getElementById(`sidebar-header-${section}`); + const contentTarget = document.getElementById(`section-${section}`); + if (sidebarTarget && contentTarget) { + + const sidebarPos = sidebarTarget.getBoundingClientRect().top - this.sidebar.getBoundingClientRect().top + this.sidebar.scrollTop; + this.sidebar.scrollTo({ + top: sidebarPos - 120, + behavior: 'smooth', + }); + + const bodyPos = contentTarget.getBoundingClientRect().top + this.body.scrollTop; + this.body.scrollTo({ + top: bodyPos - 20, + behavior: 'smooth', + }); + } + }); + } +} \ No newline at end of file diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index dcb28abd7..11a012e85 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -1,6 +1,7 @@ export {AddRemoveRows} from './add-remove-rows'; export {AjaxDeleteRow} from './ajax-delete-row'; export {AjaxForm} from './ajax-form'; +export {ApiNav} from './api-nav'; export {Attachments} from './attachments'; export {AttachmentsList} from './attachments-list'; export {AutoSuggest} from './auto-suggest'; diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index 8334ebb8a..68cd46f04 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -2,7 +2,7 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; import {PageCommentReference} from "./page-comment-reference"; import {HttpError} from "../services/http"; -import {SimpleWysiwygEditorInterface} from "../wysiwyg"; +import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg"; import {el} from "../wysiwyg/utils/dom"; export interface PageCommentReplyEventData { @@ -104,7 +104,7 @@ export class PageComment extends Component { this.input.parentElement?.appendChild(container); this.input.hidden = true; - this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, { + this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, editorContent, { darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.$opts.textDirection, translations: (window as unknown as Record).editor_translations, diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index a1eeda1f9..707ca3f69 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -5,7 +5,7 @@ import {PageCommentReference} from "./page-comment-reference"; import {scrollAndHighlightElement} from "../services/util"; import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment"; import {el} from "../wysiwyg/utils/dom"; -import {SimpleWysiwygEditorInterface} from "../wysiwyg"; +import {createCommentEditorInstance, SimpleWysiwygEditorInterface} from "../wysiwyg"; export class PageComments extends Component { @@ -200,7 +200,7 @@ export class PageComments extends Component { this.formInput.parentElement?.appendChild(container); this.formInput.hidden = true; - this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '

', { + this.wysiwygEditor = wysiwygModule.createCommentEditorInstance(container as HTMLElement, '

', { darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.wysiwygTextDirection, translations: (window as unknown as Record).editor_translations, diff --git a/resources/js/wysiwyg/api/__tests__/api-test-utils.ts b/resources/js/wysiwyg/api/__tests__/api-test-utils.ts new file mode 100644 index 000000000..dacec3392 --- /dev/null +++ b/resources/js/wysiwyg/api/__tests__/api-test-utils.ts @@ -0,0 +1,14 @@ +import {createTestContext} from "lexical/__tests__/utils"; +import {EditorApi} from "../api"; +import {EditorUiContext} from "../../ui/framework/core"; +import {LexicalEditor} from "lexical"; + + +/** + * Create an instance of the EditorApi and EditorUiContext. + */ +export function createEditorApiInstance(): { api: EditorApi; context: EditorUiContext, editor: LexicalEditor} { + const context = createTestContext(); + const api = new EditorApi(context); + return {api, context, editor: context.editor}; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/api/__tests__/content.test.ts b/resources/js/wysiwyg/api/__tests__/content.test.ts new file mode 100644 index 000000000..0915b6685 --- /dev/null +++ b/resources/js/wysiwyg/api/__tests__/content.test.ts @@ -0,0 +1,93 @@ +import {createEditorApiInstance} from "./api-test-utils"; +import {$createParagraphNode, $createTextNode, $getRoot, IS_BOLD, LexicalEditor} from "lexical"; +import {expectNodeShapeToMatch} from "lexical/__tests__/utils"; + + +describe('Editor API: Content Module', () => { + + describe('insertHtml()', () => { + it('should insert html at selection by default', () => { + const {api, editor} = createEditorApiInstance(); + insertAndSelectSampleBlock(editor); + + api.content.insertHtml('pp'); + editor.commitUpdates(); + + expectNodeShapeToMatch(editor, [ + {type: 'paragraph', children: [ + {text: 'He'}, + {text: 'pp', format: IS_BOLD}, + {text: 'o World'} + ]} + ]); + }); + + it('should handle a mix of inline and block elements', () => { + const {api, editor} = createEditorApiInstance(); + insertAndSelectSampleBlock(editor); + + api.content.insertHtml('

cat

pp

dog

'); + editor.commitUpdates(); + + expectNodeShapeToMatch(editor, [ + {type: 'paragraph', children: [{text: 'cat'}]}, + {type: 'paragraph', children: [ + {text: 'He'}, + {text: 'pp', format: IS_BOLD}, + {text: 'o World'} + ]}, + {type: 'paragraph', children: [{text: 'dog'}]}, + ]); + }); + + it('should throw and error if an invalid position is provided', () => { + const {api, editor} = createEditorApiInstance(); + insertAndSelectSampleBlock(editor); + + + expect(() => { + api.content.insertHtml('happy

cat

', 'near-the-end'); + }).toThrow('Invalid position: near-the-end. Valid positions are: start, end, selection'); + }); + + it('should append html if end provided as a position', () => { + const {api, editor} = createEditorApiInstance(); + insertAndSelectSampleBlock(editor); + + api.content.insertHtml('happy

cat

', 'end'); + editor.commitUpdates(); + + expectNodeShapeToMatch(editor, [ + {type: 'paragraph', children: [{text: 'Hello World'}]}, + {type: 'paragraph', children: [{text: 'happy'}]}, + {type: 'paragraph', children: [{text: 'cat'}]}, + ]); + }); + + it('should prepend html if start provided as a position', () => { + const {api, editor} = createEditorApiInstance(); + insertAndSelectSampleBlock(editor); + + api.content.insertHtml('happy

cat

', 'start'); + editor.commitUpdates(); + + expectNodeShapeToMatch(editor, [ + {type: 'paragraph', children: [{text: 'happy'}]}, + {type: 'paragraph', children: [{text: 'cat'}]}, + {type: 'paragraph', children: [{text: 'Hello World'}]}, + ]); + }); + }); + + function insertAndSelectSampleBlock(editor: LexicalEditor) { + editor.updateAndCommit(() => { + const p = $createParagraphNode(); + const text = $createTextNode('Hello World'); + p.append(text); + $getRoot().append(p); + + text.select(2, 4); + }); + } + +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/api/__tests__/ui.test.ts b/resources/js/wysiwyg/api/__tests__/ui.test.ts new file mode 100644 index 000000000..80045bb48 --- /dev/null +++ b/resources/js/wysiwyg/api/__tests__/ui.test.ts @@ -0,0 +1,123 @@ +import {createEditorApiInstance} from "./api-test-utils"; +import {EditorApiButton, EditorApiToolbar, EditorApiToolbarSection} from "../ui"; +import {getMainEditorFullToolbar} from "../../ui/defaults/toolbars"; +import {EditorContainerUiElement} from "../../ui/framework/core"; +import {EditorOverflowContainer} from "../../ui/framework/blocks/overflow-container"; + + +describe('Editor API: UI Module', () => { + + describe('createButton()', () => { + it('should return a button', () => { + const {api} = createEditorApiInstance(); + const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => ''}); + expect(button).toBeInstanceOf(EditorApiButton); + }); + + it('should only need action to be required', () => { + const {api} = createEditorApiInstance(); + const button = api.ui.createButton({action: () => ''}); + expect(button).toBeInstanceOf(EditorApiButton); + }); + + it('should pass the label and icon to the button', () => { + const {api} = createEditorApiInstance(); + const button = api.ui.createButton({label: 'TestLabel', icon: 'cat', action: () => ''}); + const html = button._getOriginalModel().getDOMElement().outerHTML; + expect(html).toContain('TestLabel'); + expect(html).toContain('cat'); + }) + }); + + describe('EditorApiButton', () => { + + describe('setActive()', () => { + it('should update the active state of the button', () => { + const {api} = createEditorApiInstance(); + const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => ''}); + + button.setActive(true); + expect(button._getOriginalModel().isActive()).toBe(true); + + button.setActive(false); + expect(button._getOriginalModel().isActive()).toBe(false); + }) + }); + + it('should call the provided action on click', () => { + const {api} = createEditorApiInstance(); + let count = 0; + const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => { + count++; + }}); + + const dom = button._getOriginalModel().getDOMElement(); + dom.click(); + dom.click(); + expect(count).toBe(2); + }); + + }); + + describe('getMainToolbar()', () => { + it('should return the main editor toolbar', () => { + const {api, context} = createEditorApiInstance(); + context.manager.setToolbar(getMainEditorFullToolbar(context)); + + const toolbar = api.ui.getMainToolbar(); + + expect(toolbar).toBeInstanceOf(EditorApiToolbar); + }); + }); + + describe('EditorApiToolbar', () => { + describe('getSections()', () => { + it('should return the sections of the toolbar', () => { + const {api, context} = createEditorApiInstance(); + context.manager.setToolbar(testToolbar()); + const toolbar = api.ui.getMainToolbar(); + + const sections = toolbar?.getSections() || []; + + expect(sections.length).toBe(2); + expect(sections[0]).toBeInstanceOf(EditorApiToolbarSection); + }) + }) + }) + + describe('EditorApiToolbarSection', () => { + + describe('getLabel()', () => { + it('should return the label of the section', () => { + const {api, context} = createEditorApiInstance(); + context.manager.setToolbar(testToolbar()); + const section = api.ui.getMainToolbar()?.getSections()[0] as EditorApiToolbarSection; + expect(section.getLabel()).toBe('section-a'); + }) + }); + + describe('addButton()', () => { + it('should add a button to the section', () => { + const {api, context} = createEditorApiInstance(); + const toolbar = testToolbar(); + context.manager.setToolbar(toolbar); + const section = api.ui.getMainToolbar()?.getSections()[0] as EditorApiToolbarSection; + + const button = api.ui.createButton({label: 'TestButtonText!', action: () => ''}); + section.addButton(button); + + const toolbarRendered = toolbar.getDOMElement().innerHTML; + expect(toolbarRendered).toContain('TestButtonText!'); + }); + }); + + }); + + function testToolbar(): EditorContainerUiElement { + return new EditorContainerUiElement([ + new EditorOverflowContainer('section-a', 1, []), + new EditorOverflowContainer('section-b', 1, []), + ]); + } + +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/api/api.ts b/resources/js/wysiwyg/api/api.ts new file mode 100644 index 000000000..f732170b3 --- /dev/null +++ b/resources/js/wysiwyg/api/api.ts @@ -0,0 +1,14 @@ +import {EditorApiUiModule} from "./ui"; +import {EditorUiContext} from "../ui/framework/core"; +import {EditorApiContentModule} from "./content"; + +export class EditorApi { + + public ui: EditorApiUiModule; + public content: EditorApiContentModule; + + constructor(context: EditorUiContext) { + this.ui = new EditorApiUiModule(context); + this.content = new EditorApiContentModule(context); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/api/content.ts b/resources/js/wysiwyg/api/content.ts new file mode 100644 index 000000000..167ec7905 --- /dev/null +++ b/resources/js/wysiwyg/api/content.ts @@ -0,0 +1,26 @@ +import {EditorUiContext} from "../ui/framework/core"; +import {appendHtmlToEditor, insertHtmlIntoEditor, prependHtmlToEditor} from "../utils/actions"; + + +export class EditorApiContentModule { + readonly #context: EditorUiContext; + + constructor(context: EditorUiContext) { + this.#context = context; + } + + insertHtml(html: string, position: string = 'selection'): void { + const validPositions = ['start', 'end', 'selection']; + if (!validPositions.includes(position)) { + throw new Error(`Invalid position: ${position}. Valid positions are: ${validPositions.join(', ')}`); + } + + if (position === 'start') { + prependHtmlToEditor(this.#context.editor, html); + } else if (position === 'end') { + appendHtmlToEditor(this.#context.editor, html); + } else { + insertHtmlIntoEditor(this.#context.editor, html); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/api/ui.ts b/resources/js/wysiwyg/api/ui.ts new file mode 100644 index 000000000..c5b822a46 --- /dev/null +++ b/resources/js/wysiwyg/api/ui.ts @@ -0,0 +1,89 @@ +import {EditorButton} from "../ui/framework/buttons"; +import {EditorContainerUiElement, EditorUiContext} from "../ui/framework/core"; +import {EditorOverflowContainer} from "../ui/framework/blocks/overflow-container"; + +type EditorApiButtonOptions = { + label?: string; + icon?: string; + action: () => void; +}; + +export class EditorApiButton { + readonly #button: EditorButton; + #isActive: boolean = false; + + constructor(options: EditorApiButtonOptions, context: EditorUiContext) { + this.#button = new EditorButton({ + label: options.label || '', + icon: options.icon || '', + action: () => { + options.action(); + }, + isActive: () => this.#isActive, + }); + this.#button.setContext(context); + } + + setActive(active: boolean = true): void { + this.#isActive = active; + this.#button.setActiveState(active); + } + + _getOriginalModel() { + return this.#button; + } +} + +export class EditorApiToolbar { + readonly #toolbar: EditorContainerUiElement; + + constructor(toolbar: EditorContainerUiElement) { + this.#toolbar = toolbar; + } + + getSections(): EditorApiToolbarSection[] { + const sections = this.#toolbar.getChildren(); + return sections.filter(section => { + return section instanceof EditorOverflowContainer; + }).map(section => new EditorApiToolbarSection(section)); + } +} + +export class EditorApiToolbarSection { + readonly #section: EditorOverflowContainer; + + constructor(section: EditorOverflowContainer) { + this.#section = section; + } + + getLabel(): string { + return this.#section.getLabel(); + } + + addButton(button: EditorApiButton, targetIndex: number = -1): void { + this.#section.addChild(button._getOriginalModel(), targetIndex); + this.#section.rebuildDOM(); + } +} + + +export class EditorApiUiModule { + readonly #context: EditorUiContext; + + constructor(context: EditorUiContext) { + this.#context = context; + } + + createButton(options: EditorApiButtonOptions): EditorApiButton { + return new EditorApiButton(options, this.#context); + } + + getMainToolbar(): EditorApiToolbar|null { + const toolbar = this.#context.manager.getToolbar(); + if (!toolbar) { + return null; + } + + return new EditorApiToolbar(toolbar); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index e01b4e8f4..01964b066 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,8 +1,13 @@ -import {createEditor, LexicalEditor} from 'lexical'; +import {createEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; -import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; +import { + getNodesForBasicEditor, + getNodesForCommentEditor, + getNodesForPageEditor, + registerCommonNodeMutationListeners +} from './nodes'; import {buildEditorUI} from "./ui"; import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; @@ -17,9 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling"; import {registerAutoLinks} from "./services/auto-links"; import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars"; import {modals} from "./ui/defaults/modals"; -import {CodeBlockDecorator} from "./ui/decorators/code-block"; -import {DiagramDecorator} from "./ui/decorators/diagram"; +import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator"; +import {DiagramDecorator} from "./ui/decorators/DiagramDecorator"; import {registerMouseHandling} from "./services/mouse-handling"; +import {registerSelectionHandling} from "./services/selection-handling"; +import {EditorApi} from "./api/api"; +import {registerMentions} from "./services/mentions"; +import {MentionDecorator} from "./ui/decorators/MentionDecorator"; const theme = { text: { @@ -53,6 +62,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerShortcuts(context), registerKeyboardHandling(context), registerMouseHandling(context), + registerSelectionHandling(context), registerTableResizer(editor, context.scrollDOM), registerTableSelectionHandler(editor), registerTaskListHandler(editor, context.editorDOM), @@ -92,6 +102,11 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerCommonNodeMutationListeners(context); + window.$events.emitPublic(container, 'editor-wysiwyg::post-init', { + usage: 'page-editor', + api: new EditorApi(context), + }); + return new SimpleWysiwygEditorInterface(context); } @@ -120,6 +135,47 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s setEditorContentFromHtml(editor, htmlContent); + window.$events.emitPublic(container, 'editor-wysiwyg::post-init', { + usage: 'description-editor', + api: new EditorApi(context), + }); + + return new SimpleWysiwygEditorInterface(context); +} + +export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { + const editor = createEditor({ + namespace: 'BookStackCommentEditor', + nodes: getNodesForCommentEditor(), + onError: console.error, + theme: theme, + }); + + const context: EditorUiContext = buildEditorUI(container, editor, options); + editor.setRootElement(context.editorDOM); + + const editorTeardown = mergeRegister( + registerRichText(editor), + registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(context), + registerAutoLinks(editor), + registerMentions(context), + ); + + // Register toolbars, modals & decorators + context.manager.setToolbar(getBasicEditorToolbar(context)); + context.manager.registerContextToolbar('link', contextToolbars.link); + context.manager.registerModal('link', modals.link); + context.manager.onTeardown(editorTeardown); + context.manager.registerDecoratorType('mention', MentionDecorator); + + setEditorContentFromHtml(editor, htmlContent); + + window.$events.emitPublic(container, 'editor-wysiwyg::post-init', { + usage: 'comment-editor', + api: new EditorApi(context), + }); + return new SimpleWysiwygEditorInterface(context); } diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index f995237a0..1b378b4a0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -78,6 +78,8 @@ export const KEY_ESCAPE_COMMAND: LexicalCommand = createCommand('KEY_ESCAPE_COMMAND'); export const KEY_DELETE_COMMAND: LexicalCommand = createCommand('KEY_DELETE_COMMAND'); +export const KEY_AT_COMMAND: LexicalCommand = + createCommand('KEY_AT_COMMAND'); export const KEY_TAB_COMMAND: LexicalCommand = createCommand('KEY_TAB_COMMAND'); export const INSERT_TAB_COMMAND: LexicalCommand = diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts index 26cf25a80..2d197ccc2 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -67,7 +67,7 @@ import { SELECTION_CHANGE_COMMAND, UNDO_COMMAND, } from '.'; -import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; +import {KEY_AT_COMMAND, KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; import { COMPOSITION_START_CHAR, DOM_ELEMENT_TYPE, @@ -97,7 +97,7 @@ import { getEditorPropertyFromDOMNode, getEditorsToPropagate, getNearestEditorFromDOMNode, - getWindow, + getWindow, isAt, isBackspace, isBold, isCopy, @@ -1062,6 +1062,8 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { } else if (isDeleteLineForward(key, metaKey)) { event.preventDefault(); dispatchCommand(editor, DELETE_LINE_COMMAND, false); + } else if (isAt(key)) { + dispatchCommand(editor, KEY_AT_COMMAND, event); } else if (isBold(key, altKey, metaKey, ctrlKey)) { event.preventDefault(); dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index 6d79c01cc..95a723469 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -383,6 +383,14 @@ export class LexicalNode { return isSelected; } + /** + * Indicate if this node should be selected directly instead of the default + * where the selection would descend to the nearest initial child element. + */ + shouldSelectDirectly(): boolean { + return false; + } + /** * Returns this nodes key. */ diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts index 297286a4b..36e2db547 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -476,12 +476,12 @@ export class RangeSelection implements BaseSelection { const startOffset = firstPoint.offset; const endOffset = lastPoint.offset; - if ($isElementNode(firstNode)) { + if ($isElementNode(firstNode) && !firstNode.shouldSelectDirectly()) { const firstNodeDescendant = firstNode.getDescendantByIndex(startOffset); firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode; } - if ($isElementNode(lastNode)) { + if ($isElementNode(lastNode) && !lastNode.shouldSelectDirectly()) { let lastNodeDescendant = lastNode.getDescendantByIndex(endOffset); // We don't want to over-select, as node selection infers the child before @@ -499,7 +499,7 @@ export class RangeSelection implements BaseSelection { let nodes: Array; if (firstNode.is(lastNode)) { - if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) { + if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0 && !firstNode.shouldSelectDirectly()) { nodes = []; } else { nodes = [firstNode]; @@ -2708,6 +2708,8 @@ export function updateDOMSelection( const range = document.createRange(); range.selectNode(selectionTarget); selectionRect = range.getBoundingClientRect(); + } else if (selectionTarget instanceof Range) { + selectionRect = selectionTarget.getBoundingClientRect(); } else { selectionRect = selectionTarget.getBoundingClientRect(); } diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts index 71096b19d..b0bf2f180 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -1056,6 +1056,10 @@ export function isDelete(key: string): boolean { return key === 'Delete'; } +export function isAt(key: string): boolean { + return key === '@'; +} + export function isSelectAll( key: string, metaKey: boolean, diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts index a54d33ca4..f195974d0 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -2510,8 +2510,8 @@ describe('LexicalEditor tests', () => { ); }); - expect(onError).toBeCalledWith(updateError); - expect(textListener).toBeCalledWith('Hello\n\nworld'); + expect(onError).toHaveBeenCalledWith(updateError); + expect(textListener).toHaveBeenCalledWith('Hello\n\nworld'); expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState); }); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts index fcf666213..33c59bf5f 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts @@ -138,7 +138,7 @@ describe('LexicalNode tests', () => { const validNode = new TextNode(textNode.__text, textNode.__key); expect(textNode.getLatest()).toBe(textNode); expect(validNode.getLatest()).toBe(textNode); - expect(() => new TestNode(textNode.__key)).toThrowError( + expect(() => new TestNode(textNode.__key)).toThrow( /TestNode.*re-use key.*TextNode/, ); }); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index fd87877ee..ab54bdb31 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -504,7 +504,7 @@ export function createTestContext(): EditorUiContext { options: {}, scrollDOM: scrollWrap, translate(text: string): string { - return ""; + return text; } }; @@ -769,6 +769,7 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { type nodeTextShape = { text: string; + format?: number; }; type nodeShape = { @@ -786,7 +787,13 @@ export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextSha if (shape.type === 'text') { // @ts-ignore - return {text: node.text} + const shape: nodeTextShape = {text: node.text} + // @ts-ignore + if (node && node.format) { + // @ts-ignore + shape.format = node.format; + } + return shape; } if (children.length > 0) { @@ -864,4 +871,26 @@ export function dispatchEditorMouseClick(editor: LexicalEditor, clientX: number, }); dom?.dispatchEvent(event); editor.commitUpdates(); +} + +export function patchRange() { + const RangePrototype = Object.getPrototypeOf(document.createRange()); + RangePrototype.getBoundingClientRect = function (): DOMRect { + const rect = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + return { + ...rect, + toJSON() { + return rect; + }, + }; + }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index a27603773..0329aed6f 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -150,6 +150,20 @@ export class ElementNode extends LexicalNode { } return node; } + getFirstSelectableDescendant(): null | T { + if (this.shouldSelectDirectly()) { + return null; + } + let node = this.getFirstChild(); + while ($isElementNode(node) && !node.shouldSelectDirectly()) { + const child = node.getFirstChild(); + if (child === null) { + break; + } + node = child; + } + return node; + } getLastDescendant(): null | T { let node = this.getLastChild(); while ($isElementNode(node)) { @@ -161,6 +175,20 @@ export class ElementNode extends LexicalNode { } return node; } + getLastSelectableDescendant(): null | T { + if (this.shouldSelectDirectly()) { + return null; + } + let node = this.getLastChild(); + while ($isElementNode(node) && !node.shouldSelectDirectly()) { + const child = node.getLastChild(); + if (child === null) { + break; + } + node = child; + } + return node; + } getDescendantByIndex(index: number): null | T { const children = this.getChildren(); const childrenLength = children.length; @@ -279,7 +307,7 @@ export class ElementNode extends LexicalNode { let anchorOffset = _anchorOffset; let focusOffset = _focusOffset; const childrenCount = this.getChildrenSize(); - if (!this.canBeEmpty()) { + if (!this.canBeEmpty() && !this.shouldSelectDirectly()) { if (_anchorOffset === 0 && _focusOffset === 0) { const firstChild = this.getFirstChild(); if ($isTextNode(firstChild) || $isElementNode(firstChild)) { @@ -319,11 +347,11 @@ export class ElementNode extends LexicalNode { return selection; } selectStart(): RangeSelection { - const firstNode = this.getFirstDescendant(); + const firstNode = this.getFirstSelectableDescendant(); return firstNode ? firstNode.selectStart() : this.select(); } selectEnd(): RangeSelection { - const lastNode = this.getLastDescendant(); + const lastNode = this.getLastSelectableDescendant(); return lastNode ? lastNode.selectEnd() : this.select(); } clear(): this { diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts index 6e3a3861a..0bc7646c9 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts @@ -18,10 +18,12 @@ import { } from 'lexical'; import { - $createTestElementNode, - createTestEditor, + $createTestElementNode, + createTestEditor, patchRange, } from '../../../__tests__/utils'; +patchRange(); + describe('LexicalElementNode tests', () => { let container: HTMLElement; @@ -54,6 +56,7 @@ describe('LexicalElementNode tests', () => { editor = createTestEditor(); editor.setRootElement(root); + root.focus(); // Insert initial block await update(() => { @@ -63,11 +66,11 @@ describe('LexicalElementNode tests', () => { // Prevent text nodes from combining. text2.setMode('segmented'); const text3 = $createTextNode('Baz'); - // Some operations require a selection to exist, hence - // we make a selection in the setup code. - text.select(0, 0); block.append(text, text2, text3); $getRoot().append(block); + // Some operations require a selection to exist, hence + // we make a selection in the setup code. + text.select(0, 0); }); } diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts index c03f1bdb2..cee414919 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -146,12 +146,12 @@ describe('LexicalHeadlessEditor', () => { editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'foo'); }); - expect(onUpdate).toBeCalled(); - expect(onCommand).toBeCalledWith('foo', expect.anything()); - expect(onTransform).toBeCalledWith( + expect(onUpdate).toHaveBeenCalled(); + expect(onCommand).toHaveBeenCalledWith('foo', expect.anything()); + expect(onTransform).toHaveBeenCalledWith( expect.objectContaining({__type: 'paragraph'}), ); - expect(onTextContent).toBeCalledWith('Helloworld'); + expect(onTextContent).toHaveBeenCalledWith('Helloworld'); }); it('can preserve selection for pending editor state (within update loop)', async () => { diff --git a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts new file mode 100644 index 000000000..9010b3c78 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts @@ -0,0 +1,125 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, DOMConversionOutput, + type EditorConfig, + LexicalEditor, LexicalNode, + SerializedLexicalNode, + Spread +} from "lexical"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; + +export type SerializedMentionNode = Spread<{ + user_id: number; + user_name: string; + user_slug: string; +}, SerializedLexicalNode> + +export class MentionNode extends DecoratorNode { + __user_id: number = 0; + __user_name: string = ''; + __user_slug: string = ''; + + static getType(): string { + return 'mention'; + } + static clone(node: MentionNode): MentionNode { + const newNode = new MentionNode(node.__key); + newNode.__user_id = node.__user_id; + newNode.__user_name = node.__user_name; + newNode.__user_slug = node.__user_slug; + return newNode; + } + + setUserDetails(userId: number, userName: string, userSlug: string): void { + const self = this.getWritable(); + self.__user_id = userId; + self.__user_name = userName; + self.__user_slug = userSlug; + } + + hasUserSet(): boolean { + return this.__user_id > 0; + } + + isInline(): boolean { + return true; + } + + isParentRequired(): boolean { + return true; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'mention', + getNode: () => this, + }; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const element = document.createElement('a'); + element.setAttribute('target', '_blank'); + element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug)); + element.setAttribute('data-mention-user-id', String(this.__user_id)); + element.setAttribute('title', '@' + this.__user_name); + element.textContent = '@' + this.__user_name; + return element; + } + + updateDOM(prevNode: MentionNode): boolean { + return prevNode.__user_id !== this.__user_id; + } + + static importDOM(): DOMConversionMap|null { + return { + a(node: HTMLElement): DOMConversion|null { + if (node.hasAttribute('data-mention-user-id')) { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = new MentionNode(); + node.setUserDetails( + Number(element.getAttribute('data-mention-user-id') || '0'), + element.innerText.replace(/^@/, ''), + element.getAttribute('href')?.split('/user/')[1] || '' + ); + + return { + node, + after(childNodes): LexicalNode[] { + return []; + } + }; + }, + priority: 4, + }; + } + return null; + }, + }; + } + + exportJSON(): SerializedMentionNode { + return { + type: 'mention', + version: 1, + user_id: this.__user_id, + user_name: this.__user_name, + user_slug: this.__user_slug, + }; + } + + static importJSON(serializedNode: SerializedMentionNode): MentionNode { + return $createMentionNode(serializedNode.user_id, serializedNode.user_name, serializedNode.user_slug); + } +} + +export function $createMentionNode(userId: number, userName: string, userSlug: string) { + const node = new MentionNode(); + node.setUserDetails(userId, userName, userSlug); + return node; +} + +export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode { + return node instanceof MentionNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 3c845359a..cdf32fdcb 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -75,6 +75,9 @@ export class DetailsNode extends ElementNode { if (this.__open) { el.setAttribute('open', 'true'); + el.removeAttribute('contenteditable'); + } else { + el.setAttribute('contenteditable', 'false'); } const summary = document.createElement('summary'); @@ -84,7 +87,7 @@ export class DetailsNode extends ElementNode { event.preventDefault(); _editor.update(() => { this.select(); - }) + }); }); el.append(summary); @@ -96,6 +99,11 @@ export class DetailsNode extends ElementNode { if (prevNode.__open !== this.__open) { dom.toggleAttribute('open', this.__open); + if (this.__open) { + dom.removeAttribute('contenteditable'); + } else { + dom.setAttribute('contenteditable', 'false'); + } } return prevNode.__id !== this.__id @@ -144,6 +152,7 @@ export class DetailsNode extends ElementNode { } element.removeAttribute('open'); + element.removeAttribute('contenteditable'); return {element}; } @@ -165,6 +174,14 @@ export class DetailsNode extends ElementNode { return node; } + shouldSelectDirectly(): boolean { + return true; + } + + canBeEmpty(): boolean { + return false; + } + } export function $createDetailsNode() { diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts index faa31d887..828aad839 100644 --- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts @@ -1,6 +1,5 @@ -import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils"; -import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; -import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical"; +import {createTestContext} from "lexical/__tests__/utils"; +import {$createDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; const editorConfig = Object.freeze({ namespace: '', @@ -9,32 +8,28 @@ const editorConfig = Object.freeze({ }); describe('LexicalDetailsNode tests', () => { - initializeUnitTest((testEnv) => { + test('createDOM()', () => { + const {editor} = createTestContext(); + let html!: string; - test('createDOM()', () => { - const {editor} = testEnv; - let html!: string; - - editor.updateAndCommit(() => { - const details = $createDetailsNode(); - html = details.createDOM(editorConfig, editor).outerHTML; - }); - - expect(html).toBe(`
`); + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = details.createDOM(editorConfig, editor).outerHTML; }); - test('exportDOM()', () => { - const {editor} = testEnv; - let html!: string; + expect(html).toBe(`
`); + }); - editor.updateAndCommit(() => { - const details = $createDetailsNode(); - html = (details.exportDOM(editor).element as HTMLElement).outerHTML; - }); + test('exportDOM()', () => { + const {editor} = createTestContext(); + let html!: string; - expect(html).toBe(`
`); + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + details.setSummary('Hello there<>!') + html = (details.exportDOM(editor).element as HTMLElement).outerHTML; }); - + expect(html).toBe(`
Hello there<>!
`); }); }) \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts index cc09d1735..14c8b5121 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -37,12 +37,12 @@ import { TextNode, } from 'lexical'; import { - $assertRangeSelection, - $createTestDecoratorNode, - $createTestElementNode, - createTestEditor, - initializeClipboard, - invariant, + $assertRangeSelection, + $createTestDecoratorNode, + $createTestElementNode, + createTestEditor, + initializeClipboard, + invariant, patchRange, } from 'lexical/__tests__/utils'; import { @@ -91,24 +91,7 @@ jest.mock('lexical/shared/environment', () => { return {...originalModule, IS_FIREFOX: true}; }); -Range.prototype.getBoundingClientRect = function (): DOMRect { - const rect = { - bottom: 0, - height: 0, - left: 0, - right: 0, - top: 0, - width: 0, - x: 0, - y: 0, - }; - return { - ...rect, - toJSON() { - return rect; - }, - }; -}; +patchRange(); describe('LexicalSelection tests', () => { let container: HTMLElement; diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts index 84c82edec..39d4a04bd 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts @@ -422,9 +422,6 @@ export function setNativeSelection( range.setEnd(focusNode, focusOffset); domSelection.removeAllRanges(); domSelection.addRange(range); - Promise.resolve().then(() => { - document.dispatchEvent(new Event('selectionchange')); - }); } export function setNativeSelectionWithPaths( @@ -647,6 +644,8 @@ export async function applySelectionInputs( editor: LexicalEditor, ) { const rootElement = editor.getRootElement()!; + // Set initial focus as if we're in the editor + rootElement.focus(); for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts index 2879decda..560d316fe 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -114,7 +114,7 @@ describe('LexicalTableNode tests', () => { }); // Make sure paragraph is inserted inside empty cells expect(testEnv.innerHTML).toBe( - `

Hello there

General Kenobi!

Lexical is nice


`, + `

Hello there

General Kenobi!

Lexical is nice


`, ); }); diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index 413e2c4cd..7c1a71579 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -19,6 +19,7 @@ import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; +import {MentionNode} from "@lexical/link/LexicalMentionNode"; export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ @@ -51,6 +52,13 @@ export function getNodesForBasicEditor(): (KlassConstructor ]; } +export function getNodesForCommentEditor(): (KlassConstructor | LexicalNodeReplacement)[] { + return [ + ...getNodesForBasicEditor(), + MentionNode, + ]; +} + export function registerCommonNodeMutationListeners(context: EditorUiContext): void { const decorated = [ImageNode, CodeBlockNode, DiagramNode]; diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index 2ffa722e4..f7fc81cb3 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,4 +1,4 @@ -import {$getSelection, LexicalEditor} from "lexical"; +import {LexicalEditor} from "lexical"; import { appendHtmlToEditor, focusEditor, diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index b4f546117..aba0b1aa8 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -18,6 +18,7 @@ import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isDiagramNode} from "../utils/diagrams"; +import {$unwrapDetailsNode} from "../utils/details"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -172,6 +173,35 @@ function getDetailsScenario(editor: LexicalEditor): { } } +function unwrapDetailsNode(context: EditorUiContext, event: KeyboardEvent): boolean { + const selection = $getSelection(); + const nodes = selection?.getNodes() || []; + + if (nodes.length !== 1) { + return false; + } + + const selectedNearestBlock = $getNearestNodeBlockParent(nodes[0]); + if (!selectedNearestBlock) { + return false; + } + + const selectedParentBlock = selectedNearestBlock.getParent(); + const selectRange = selection?.getStartEndPoints(); + + if (selectRange && $isDetailsNode(selectedParentBlock) && selectRange[0].offset === 0 && selectedNearestBlock.getIndexWithinParent() === 0) { + event.preventDefault(); + context.editor.update(() => { + $unwrapDetailsNode(selectedParentBlock); + selectedNearestBlock.selectStart(); + context.manager.triggerLayoutUpdate(); + }); + return true; + } + + return false; +} + function $isSingleListItem(nodes: LexicalNode[]): boolean { if (nodes.length !== 1) { return false; @@ -201,9 +231,9 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo } export function registerKeyboardHandling(context: EditorUiContext): () => void { - const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => { + const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event): boolean => { deleteSingleSelectedNode(context.editor); - return false; + return unwrapDetailsNode(context, event); }, COMMAND_PRIORITY_LOW); const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => { diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts new file mode 100644 index 000000000..ba6739f32 --- /dev/null +++ b/resources/js/wysiwyg/services/mentions.ts @@ -0,0 +1,82 @@ +import { + $getSelection, $isRangeSelection, + COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, RangeSelection, TextNode +} from "lexical"; +import {KEY_AT_COMMAND} from "lexical/LexicalCommands"; +import {$createMentionNode, $isMentionNode, MentionNode} from "@lexical/link/LexicalMentionNode"; +import {EditorUiContext} from "../ui/framework/core"; +import {MentionDecorator} from "../ui/decorators/MentionDecorator"; +import {$selectSingleNode} from "../utils/selection"; + + +function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { + const textNode = selection.getNodes()[0] as TextNode; + const selectionPos = selection.getStartEndPoints(); + if (!selectionPos) { + return; + } + + const offset = selectionPos[0].offset; + + // Ignore if the @ sign is not after a space or the start of the line + const atStart = offset === 0; + const afterSpace = textNode.getTextContent().charAt(offset - 1) === ' '; + if (!atStart && !afterSpace) { + return; + } + + const split = textNode.splitText(offset); + const priorTextNode = split[0]; + const afterTextNode = split[atStart ? 0 : 1]; + + const mention = $createMentionNode(0, '', ''); + priorTextNode.insertAfter(mention); + afterTextNode.spliceText(0, 1, '', false); + $selectSingleNode(mention); + + requestAnimationFrame(() => { + const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey()); + if (mentionDecorator instanceof MentionDecorator) { + mentionDecorator.showSelection() + } + }); +} + +function selectMention(context: EditorUiContext, event: KeyboardEvent): boolean { + const selected = $getSelection()?.getNodes() || []; + if (selected.length === 1 && $isMentionNode(selected[0])) { + const mention = selected[0] as MentionNode; + const decorator = context.manager.getDecoratorByNodeKey(mention.getKey()) as MentionDecorator; + decorator.showSelection(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + return false; +} + +export function registerMentions(context: EditorUiContext): () => void { + const editor = context.editor; + + const unregisterCommand = editor.registerCommand(KEY_AT_COMMAND, function (event: KeyboardEvent): boolean { + const selection = $getSelection(); + if ($isRangeSelection(selection) && selection.isCollapsed()) { + window.setTimeout(() => { + editor.update(() => { + enterUserSelectMode(context, selection); + }); + }, 1); + } + return false; + }, COMMAND_PRIORITY_NORMAL); + + const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, function (event: KeyboardEvent): boolean { + return selectMention(context, event); + }, COMMAND_PRIORITY_NORMAL); + + return (): void => { + unregisterCommand(); + unregisterEnter(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/mouse-handling.ts b/resources/js/wysiwyg/services/mouse-handling.ts index 058efc8d2..100dd44ff 100644 --- a/resources/js/wysiwyg/services/mouse-handling.ts +++ b/resources/js/wysiwyg/services/mouse-handling.ts @@ -1,31 +1,41 @@ import {EditorUiContext} from "../ui/framework/core"; import { - $createParagraphNode, $getRoot, - $getSelection, + $createParagraphNode, $getNearestNodeFromDOMNode, $getRoot, $isDecoratorNode, CLICK_COMMAND, - COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, - KEY_BACKSPACE_COMMAND, - KEY_DELETE_COMMAND, - KEY_ENTER_COMMAND, KEY_TAB_COMMAND, - LexicalEditor, + COMMAND_PRIORITY_LOW, ElementNode, LexicalNode } from "lexical"; import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; -import {getLastSelection} from "../utils/selection"; -import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes"; -import {$setInsetForSelection} from "../utils/lists"; -import {$isListItemNode} from "@lexical/list"; -import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isDiagramNode} from "../utils/diagrams"; import {$isTableNode} from "@lexical/table"; +import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; function isHardToEscapeNode(node: LexicalNode): boolean { - return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node); + return $isDecoratorNode(node) + || $isImageNode(node) + || $isMediaNode(node) + || $isDiagramNode(node) + || $isTableNode(node) + || $isDetailsNode(node); +} + +function $getContextNode(event: MouseEvent): ElementNode { + if (event.target instanceof HTMLElement) { + const nearestDetails = event.target.closest('details'); + if (nearestDetails) { + const detailsNode = $getNearestNodeFromDOMNode(nearestDetails); + if ($isDetailsNode(detailsNode)) { + return detailsNode; + } + } + } + return $getRoot(); } function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean { - const lastNode = $getRoot().getLastChild(); + const contextNode = $getContextNode(event); + const lastNode = contextNode.getLastChild(); if (!lastNode || !isHardToEscapeNode(lastNode)) { return false; } @@ -40,7 +50,7 @@ function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boole if (isClickBelow) { context.editor.update(() => { const newNode = $createParagraphNode(); - $getRoot().append(newNode); + contextNode.append(newNode); newNode.select(); }); return true; @@ -49,7 +59,6 @@ function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boole return false; } - export function registerMouseHandling(context: EditorUiContext): () => void { const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => { insertBelowLastNode(context, event); diff --git a/resources/js/wysiwyg/services/selection-handling.ts b/resources/js/wysiwyg/services/selection-handling.ts new file mode 100644 index 000000000..e3ea965f2 --- /dev/null +++ b/resources/js/wysiwyg/services/selection-handling.ts @@ -0,0 +1,49 @@ +import {EditorUiContext} from "../ui/framework/core"; +import { + $getSelection, + COMMAND_PRIORITY_LOW, + SELECTION_CHANGE_COMMAND +} from "lexical"; +import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; + + +const trackedDomNodes = new Set(); + +/** + * Set a selection indicator on nodes which require it. + * @param context + */ +function setSelectionIndicator(context: EditorUiContext): boolean { + + for (const domNode of trackedDomNodes) { + domNode.classList.remove('selected'); + trackedDomNodes.delete(domNode); + } + + const selection = $getSelection(); + const nodes = selection?.getNodes() || []; + + if (nodes.length === 1) { + if ($isDetailsNode(nodes[0])) { + const domEl = context.editor.getElementByKey(nodes[0].getKey()); + if (domEl) { + domEl.classList.add('selected'); + trackedDomNodes.add(domEl); + } + } + } + + return false; +} + +export function registerSelectionHandling(context: EditorUiContext): () => void { + const unregisterSelectionChange = context.editor.registerCommand(SELECTION_CHANGE_COMMAND, (): boolean => { + setSelectionIndicator(context); + return false; + }, COMMAND_PRIORITY_LOW); + + + return () => { + unregisterSelectionChange(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index ead4c38d4..c4be0f3cf 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -71,13 +71,17 @@ const actionsByKeys: Record = { return true; }, 'meta+shift+k': (editor, context) => { - showLinkSelector(entity => { - insertOrUpdateLink(editor, { - text: entity.name, - title: entity.link, - target: '', - url: entity.link, - }); + editor.getEditorState().read(() => { + const selection = $getSelection(); + const selectionText = selection?.getTextContent() || ''; + showLinkSelector(entity => { + insertOrUpdateLink(editor, { + text: entity.name, + title: entity.link, + target: '', + url: entity.link, + }); + }, selectionText); }); return true; }, diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts similarity index 84% rename from resources/js/wysiwyg/ui/decorators/code-block.ts rename to resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts index daae32e19..d95185e0b 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts @@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator { // @ts-ignore protected editor: any = null; - setup(context: EditorUiContext, element: HTMLElement) { + setup(element: HTMLElement) { const codeNode = this.getNode() as CodeBlockNode; const preEl = element.querySelector('pre'); if (!preEl) { @@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator { element.addEventListener('click', event => { requestAnimationFrame(() => { - context.editor.update(() => { + this.context.editor.update(() => { $selectSingleNode(this.getNode()); }); }); }); element.addEventListener('dblclick', event => { - context.editor.getEditorState().read(() => { - $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); + this.context.editor.getEditorState().read(() => { + $openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode)); }); }); const selectionChange = (selection: BaseSelection|null): void => { element.classList.toggle('selected', $selectionContainsNode(selection, codeNode)); }; - context.manager.onSelectionChange(selectionChange); + this.context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { - context.manager.offSelectionChange(selectionChange); + this.context.manager.offSelectionChange(selectionChange); }); // @ts-ignore @@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator { } } - render(context: EditorUiContext, element: HTMLElement): void { + render(element: HTMLElement): void { if (this.completedSetup) { this.update(); } else { - this.setup(context, element); + this.setup(element); } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts similarity index 70% rename from resources/js/wysiwyg/ui/decorators/diagram.ts rename to resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts index 52a73ad72..e46dcc312 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts @@ -9,33 +9,33 @@ import {$openDrawingEditorForNode} from "../../utils/diagrams"; export class DiagramDecorator extends EditorDecorator { protected completedSetup: boolean = false; - setup(context: EditorUiContext, element: HTMLElement) { + setup(element: HTMLElement) { const diagramNode = this.getNode(); element.classList.add('editor-diagram'); - context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { + this.context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { if (!element.contains(event.target as HTMLElement)) { return false; } - context.editor.update(() => { + this.context.editor.update(() => { $selectSingleNode(this.getNode()); }); return true; }, COMMAND_PRIORITY_NORMAL); element.addEventListener('dblclick', event => { - context.editor.getEditorState().read(() => { - $openDrawingEditorForNode(context, (this.getNode() as DiagramNode)); + this.context.editor.getEditorState().read(() => { + $openDrawingEditorForNode(this.context, (this.getNode() as DiagramNode)); }); }); const selectionChange = (selection: BaseSelection|null): void => { element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode)); }; - context.manager.onSelectionChange(selectionChange); + this.context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { - context.manager.offSelectionChange(selectionChange); + this.context.manager.offSelectionChange(selectionChange); }); this.completedSetup = true; @@ -45,11 +45,11 @@ export class DiagramDecorator extends EditorDecorator { // } - render(context: EditorUiContext, element: HTMLElement): void { + render(element: HTMLElement): void { if (this.completedSetup) { this.update(); } else { - this.setup(context, element); + this.setup(element); } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts new file mode 100644 index 000000000..d6cafba43 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts @@ -0,0 +1,197 @@ +import {EditorDecorator} from "../framework/decorator"; +import {EditorUiContext} from "../framework/core"; +import {el, htmlToDom} from "../../utils/dom"; +import {showLoading} from "../../../services/dom"; +import {MentionNode} from "@lexical/link/LexicalMentionNode"; +import {debounce} from "../../../services/util"; +import {$createTextNode} from "lexical"; +import {KeyboardNavigationHandler} from "../../../services/keyboard-navigation"; + +import searchIcon from "@icons/search.svg"; + +function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void { + return (event: PointerEvent) => { + const userItem = (event.target as HTMLElement).closest('a[data-id]') as HTMLAnchorElement | null; + if (!userItem) { + return; + } + + const id = Number(userItem.dataset.id || '0'); + const name = userItem.dataset.name || ''; + const slug = userItem.dataset.slug || ''; + + onSelect(id, name, slug); + event.preventDefault(); + }; +} + +function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, controller: AbortController, onCancel: () => void): void { + selectList.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + onCancel(); + } + }, {signal: controller.signal}); + + const input = selectList.querySelector('input') as HTMLInputElement; + input.addEventListener('keydown', (event) => { + if (event.key === 'Backspace' && input.value === '') { + onCancel(); + event.preventDefault(); + event.stopPropagation(); + } + }, {signal: controller.signal}); + + context.editorDOM.addEventListener('click', (event) => { + onCancel() + }, {signal: controller.signal}); + context.editorDOM.addEventListener('keydown', (event) => { + onCancel(); + }, {signal: controller.signal}); +} + +function handleUserListLoading(selectList: HTMLElement) { + const cache = new Map(); + + const updateUserList = async (searchTerm: string) => { + // Empty list + for (const child of [...selectList.children]) { + child.remove(); + } + + // Fetch new content + let responseHtml = ''; + if (cache.has(searchTerm)) { + responseHtml = cache.get(searchTerm) || ''; + } else { + const loadingWrap = el('div', {class: 'flex-container-row items-center dropdown-search-item'}); + showLoading(loadingWrap); + selectList.appendChild(loadingWrap); + + const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`); + responseHtml = resp.data as string; + cache.set(searchTerm, responseHtml); + loadingWrap.remove(); + } + + const doc = htmlToDom(responseHtml); + const toInsert = [...doc.body.children]; + for (const listEl of toInsert) { + const adopted = window.document.adoptNode(listEl) as HTMLElement; + selectList.appendChild(adopted); + } + }; + + // Initial load + updateUserList(''); + + const input = selectList.parentElement?.querySelector('input') as HTMLInputElement; + const updateUserListDebounced = debounce(updateUserList, 200, false); + input.addEventListener('input', () => { + const searchTerm = input.value; + updateUserListDebounced(searchTerm); + }); +} + +function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement { + const searchInput = el('input', {type: 'text'}); + const list = el('div', {class: 'dropdown-search-list'}); + const iconWrap = el('div'); + iconWrap.innerHTML = searchIcon; + const icon = iconWrap.children[0] as HTMLElement; + icon.classList.add('svg-icon'); + const userSelect = el('div', {class: 'dropdown-search-dropdown compact card'}, [ + el('div', {class: 'dropdown-search-search'}, [icon, searchInput]), + list, + ]); + + context.containerDOM.appendChild(userSelect); + + userSelect.style.display = 'block'; + userSelect.style.top = '0'; + userSelect.style.left = '0'; + const mentionPos = mentionDOM.getBoundingClientRect(); + const userSelectPos = userSelect.getBoundingClientRect(); + userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`; + userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`; + + searchInput.focus(); + + return userSelect; +} + +export class MentionDecorator extends EditorDecorator { + protected abortController: AbortController | null = null; + protected dropdownContainer: HTMLElement | null = null; + protected mentionElement: HTMLElement | null = null; + + setup(element: HTMLElement) { + this.mentionElement = element; + + element.addEventListener('click', (event: PointerEvent) => { + this.showSelection(); + event.preventDefault(); + event.stopPropagation(); + }); + } + + showSelection() { + if (!this.mentionElement || this.dropdownContainer) { + return; + } + + this.hideSelection(); + this.abortController = new AbortController(); + + this.dropdownContainer = buildAndShowUserSelectorAtElement(this.context, this.mentionElement); + handleUserListLoading(this.dropdownContainer.querySelector('.dropdown-search-list') as HTMLElement); + + this.dropdownContainer.addEventListener('click', userClickHandler((id, name, slug) => { + this.context.editor.update(() => { + const mentionNode = this.getNode() as MentionNode; + this.hideSelection(); + mentionNode.setUserDetails(id, name, slug); + mentionNode.selectNext(); + }); + }), {signal: this.abortController.signal}); + + handleUserSelectCancel(this.context, this.dropdownContainer, this.abortController, () => { + if ((this.getNode() as MentionNode).hasUserSet()) { + this.hideSelection() + } else { + this.revertMention(); + } + }); + + new KeyboardNavigationHandler(this.dropdownContainer); + } + + hideSelection() { + this.abortController?.abort(); + this.dropdownContainer?.remove(); + this.abortController = null; + this.dropdownContainer = null; + this.context.manager.focus(); + } + + revertMention() { + this.hideSelection(); + this.context.editor.update(() => { + const text = $createTextNode('@'); + const before = this.getNode().getPreviousSibling(); + this.getNode().replace(text); + requestAnimationFrame(() => { + this.context.editor.update(() => { + if (text.isAttached()) { + text.selectEnd(); + } else if (before?.isAttached()) { + before?.selectEnd(); + } + }); + }); + }); + } + + render(element: HTMLElement): void { + this.setup(element); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 41f6061b6..00dc9500e 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -34,6 +34,7 @@ import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; +import {$unwrapDetailsNode} from "../../../utils/details"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', @@ -193,6 +194,8 @@ export const details: EditorButtonDefinition = { .filter(n => n !== null) as ElementNode[]; const uniqueTopLevels = [...new Set(topLevels)]; + detailsNode.setOpen(true); + if (uniqueTopLevels.length > 0) { uniqueTopLevels[0].insertAfter(detailsNode); } else { @@ -249,11 +252,7 @@ export const detailsUnwrap: EditorButtonDefinition = { context.editor.update(() => { const details = $getNodeFromSelection($getSelection(), $isDetailsNode); if ($isDetailsNode(details)) { - const children = details.getChildren(); - for (const child of children) { - details.insertBefore(child); - } - details.remove(); + $unwrapDetailsNode(details); context.manager.triggerLayoutUpdate(); } }) diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index 33468e0a2..d6af99638 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -88,7 +88,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai return new EditorSimpleClassContainer('editor-toolbar-main', [ // History state - new EditorOverflowContainer(2, [ + new EditorOverflowContainer('history', 2, [ new EditorButton(undo), new EditorButton(redo), ]), @@ -110,7 +110,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ]), // Inline formats - new EditorOverflowContainer(6, [ + new EditorOverflowContainer('inline_formats', 6, [ new EditorButton(bold), new EditorButton(italic), new EditorButton(underline), @@ -128,7 +128,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ]), // Alignment - new EditorOverflowContainer(6, [ + new EditorOverflowContainer('alignment', 6, [ new EditorButton(alignLeft), new EditorButton(alignCenter), new EditorButton(alignRight), @@ -138,7 +138,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ].filter(x => x !== null)), // Lists - new EditorOverflowContainer(3, [ + new EditorOverflowContainer('lists', 3, [ new EditorButton(bulletList), new EditorButton(numberList), new EditorButton(taskList), @@ -147,7 +147,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ]), // Insert types - new EditorOverflowContainer(4, [ + new EditorOverflowContainer('inserts', 4, [ new EditorButton(link), new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [ @@ -200,7 +200,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ]), // Meta elements - new EditorOverflowContainer(3, [ + new EditorOverflowContainer('meta', 3, [ new EditorButton(source), new EditorButton(about), new EditorButton(fullscreen), @@ -223,11 +223,13 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ - new EditorButton(bold), - new EditorButton(italic), - new EditorButton(link), - new EditorButton(bulletList), - new EditorButton(numberList), + new EditorOverflowContainer('formats', 7, [ + new EditorButton(bold), + new EditorButton(italic), + new EditorButton(link), + new EditorButton(bulletList), + new EditorButton(numberList), + ]) ]); } @@ -241,7 +243,7 @@ export const contextToolbars: Record = { content: () => [new EditorButton(media)], }, link: { - selector: 'a', + selector: 'a:not([data-mention-user-id])', content() { return [ new EditorButton(link), @@ -261,16 +263,16 @@ export const contextToolbars: Record = { selector: 'td,th', content() { return [ - new EditorOverflowContainer(2, [ + new EditorOverflowContainer('table', 2, [ new EditorButton(tableProperties), new EditorButton(deleteTable), ]), - new EditorOverflowContainer(3, [ + new EditorOverflowContainer('table_row',3, [ new EditorButton(insertRowAbove), new EditorButton(insertRowBelow), new EditorButton(deleteRow), ]), - new EditorOverflowContainer(3, [ + new EditorOverflowContainer('table_column', 3, [ new EditorButton(insertColumnBefore), new EditorButton(insertColumnAfter), new EditorButton(deleteColumn), diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts index 1c9664505..3b582eee6 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -9,9 +9,11 @@ export class EditorOverflowContainer extends EditorContainerUiElement { protected size: number; protected overflowButton: EditorDropdownButton; protected content: EditorUiElement[]; + protected label: string; - constructor(size: number, children: EditorUiElement[]) { + constructor(label: string, size: number, children: EditorUiElement[]) { super(children); + this.label = label; this.size = size; this.content = children; this.overflowButton = new EditorDropdownButton({ @@ -24,6 +26,11 @@ export class EditorOverflowContainer extends EditorContainerUiElement { this.addChildren(this.overflowButton); } + addChild(child: EditorUiElement, targetIndex: number = -1): void { + this.content.splice(targetIndex, 0, child); + this.addChildren(child); + } + protected buildDOM(): HTMLElement { const slicePosition = this.content.length > this.size ? this.size - 1 : this.size; const visibleChildren = this.content.slice(0, slicePosition); @@ -41,5 +48,8 @@ export class EditorOverflowContainer extends EditorContainerUiElement { }, visibleElements); } + getLabel(): string { + return this.label; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index 6ea0b8b39..2f46a19ef 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -42,7 +42,7 @@ export abstract class EditorDecorator { * If an element is returned, this will be appended to the element * that is being decorated. */ - abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; + abstract render(decorated: HTMLElement): HTMLElement|void; /** * Destroy this decorator. Used for tear-down operations upon destruction diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index b12d9f692..3d8c53d46 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -98,6 +98,13 @@ export class EditorForm extends EditorContainerUiElement { this.definition = definition; } + focusOnFirst() { + const focusable = this.getDOMElement().querySelector('input,select,textarea'); + if (focusable) { + (focusable as HTMLElement).focus(); + } + } + setValues(values: Record) { for (const name of Object.keys(values)) { const field = this.getFieldByName(name); diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 3f46455da..78d0cc9a2 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -90,7 +90,7 @@ export class EditorUIManager { } // @ts-ignore - const decorator = new decoratorClass(nodeKey); + const decorator = new decoratorClass(this.getContext()); this.decoratorInstancesByNodeKey[nodeKey] = decorator; return decorator; } @@ -109,6 +109,10 @@ export class EditorUIManager { this.getContext().containerDOM.prepend(toolbar.getDOMElement()); } + getToolbar(): EditorContainerUiElement|null { + return this.toolbar; + } + registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) { this.contextToolbarDefinitionsByKey[key] = definition; } @@ -202,6 +206,14 @@ export class EditorUIManager { } } + /** + * Set the UI focus to the editor. + */ + focus(): void { + this.getContext().editorDOM.focus(); + this.getContext().editor.focus(); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; @@ -258,7 +270,7 @@ export class EditorUIManager { const adapter = decorators[key]; const decorator = this.getDecorator(adapter.type, key); decorator.setNode(adapter.getNode()); - const decoratorEl = decorator.render(this.getContext(), decoratedEl); + const decoratorEl = decorator.render(decoratedEl); if (decoratorEl) { decoratedEl.append(decoratorEl); } diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index 4dbe9d962..3f5a5881f 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -14,6 +14,7 @@ export interface EditorFormModalDefinition extends EditorModalDefinition { export class EditorFormModal extends EditorContainerUiElement { protected definition: EditorFormModalDefinition; protected key: string; + protected originalFocus: Element|null = null; constructor(definition: EditorFormModalDefinition, key: string) { super([new EditorForm(definition.form)]); @@ -22,6 +23,7 @@ export class EditorFormModal extends EditorContainerUiElement { } show(defaultValues: Record) { + this.originalFocus = document.activeElement as Element; const dom = this.getDOMElement(); document.body.append(dom); @@ -31,11 +33,15 @@ export class EditorFormModal extends EditorContainerUiElement { form.setOnSuccessfulSubmit(this.hide.bind(this)); this.getContext().manager.setModalActive(this.key, this); + form.focusOnFirst(); } hide() { this.getContext().manager.setModalInactive(this.key); this.teardown(); + if (this.originalFocus instanceof HTMLElement && this.originalFocus.isConnected) { + this.originalFocus.focus(); + } } getForm(): EditorForm { @@ -69,6 +75,12 @@ export class EditorFormModal extends EditorContainerUiElement { } }); + wrapper.addEventListener('keydown', event => { + if (event.key === 'Escape') { + this.hide(); + } + }); + return wrapper; } } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index b7ce65eeb..e18ac515f 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -1,6 +1,6 @@ -import {$getRoot, $getSelection, LexicalEditor} from "lexical"; +import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical"; import {$generateHtmlFromNodes} from "@lexical/html"; -import {$htmlToBlockNodes} from "./nodes"; +import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes"; export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { editor.update(() => { @@ -42,14 +42,34 @@ export function prependHtmlToEditor(editor: LexicalEditor, html: string) { export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) { editor.update(() => { const selection = $getSelection(); - const nodes = $htmlToBlockNodes(editor, html); + const nodes = $htmlToNodes(editor, html); - const reference = selection?.getNodes()[0]; - const referencesParents = reference?.getParents() || []; - const topLevel = referencesParents[referencesParents.length - 1]; - if (topLevel && reference) { - for (let i = nodes.length - 1; i >= 0; i--) { - reference.insertAfter(nodes[i]); + let reference = selection?.getNodes()[0]; + let replacedReference = false; + let parentBlock = reference ? $getNearestNodeBlockParent(reference) : null; + + for (let i = nodes.length - 1; i >= 0; i--) { + const toInsert = nodes[i]; + if ($isBlockElementNode(toInsert) && parentBlock) { + // Insert at a block level, before or after the referenced block + // depending on if the reference has been replaced. + if (replacedReference) { + parentBlock.insertBefore(toInsert); + } else { + parentBlock.insertAfter(toInsert); + } + } else if ($isBlockElementNode(toInsert)) { + // Otherwise append blocks to the root + $getRoot().append(toInsert); + } else if (!replacedReference) { + // First inline node, replacing existing selection + $insertNodes([toInsert]); + reference = toInsert; + parentBlock = $getNearestNodeBlockParent(reference); + replacedReference = true; + } else { + // For other inline nodes, insert before the reference node + reference?.insertBefore(toInsert) } } }); diff --git a/resources/js/wysiwyg/utils/details.ts b/resources/js/wysiwyg/utils/details.ts new file mode 100644 index 000000000..79e8600d0 --- /dev/null +++ b/resources/js/wysiwyg/utils/details.ts @@ -0,0 +1,9 @@ +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; + +export function $unwrapDetailsNode(node: DetailsNode) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/links.ts b/resources/js/wysiwyg/utils/links.ts index 03c4a5ef0..a7d999d0c 100644 --- a/resources/js/wysiwyg/utils/links.ts +++ b/resources/js/wysiwyg/utils/links.ts @@ -8,7 +8,7 @@ type EditorEntityData = { export function showLinkSelector(callback: (entity: EditorEntityData) => any, selectionText?: string) { const selector: EntitySelectorPopup = window.$components.first('entity-selector-popup') as EntitySelectorPopup; selector.show((entity: EditorEntityData) => callback(entity), { - initialValue: selectionText, + initialValue: selectionText || '', searchEndpoint: '/search/entity-selector', entityTypes: 'page,book,chapter,bookshelf', entityPermission: 'view', diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 116a3f4e5..ed70bf699 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -25,10 +25,13 @@ function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { }); } -export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] { +export function $htmlToNodes(editor: LexicalEditor, html: string): LexicalNode[] { const dom = htmlToDom(html); - const nodes = $generateNodesFromDOM(editor, dom); - return wrapTextNodes(nodes); + return $generateNodesFromDOM(editor, dom); +} + +export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] { + return wrapTextNodes($htmlToNodes(editor, html)); } export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null { diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 8c248caee..4857c2cee 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -274,7 +274,19 @@ .sticky-sidebar { position: sticky; - top: vars.$m; - max-height: calc(100vh - #{vars.$m}); + top: 0; + padding-left: 2px; + max-height: calc(100vh); overflow-y: auto; + .sticky-sidebar-header { + position: sticky; + top: 0; + background: #F2F2F2; + background: linear-gradient(180deg,rgba(242, 242, 242, 1) 66%, rgba(242, 242, 242, 0) 100%); + z-index: 4; + } +} +.dark-mode .sticky-sidebar-header { + background: #111; + background: linear-gradient(180deg,rgba(17, 17, 17, 1) 66%, rgba(17, 17, 17, 0) 100%); } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 8ea15de80..8608427d8 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -746,7 +746,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { @include mixins.lightDark(border-color, #DDD, #444); margin-inline-start: vars.$xs; width: vars.$l; - height: calc(100% - vars.$m); + height: calc(100% - #{vars.$m}); } .comment-reference-indicator-wrap a { @@ -982,8 +982,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .dropdown-search-item { padding: vars.$s vars.$m; + font-size: 0.8rem; &:hover,&:focus { background-color: #F2F2F2; + @include mixins.lightDark(background-color, #F2F2F2, #444); text-decoration: none; } } @@ -996,6 +998,21 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { input:focus { outline: 0; } + .svg-icon { + font-size: vars.$fs-m; + } + &.compact { + .dropdown-search-list { + max-height: 320px; + } + .dropdown-search-item { + padding: vars.$xs vars.$s; + } + .avatar { + width: 22px; + height: 22px; + } + } } @include mixins.smaller-than(vars.$bp-l) { diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index aba1556a9..e77ea6330 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -198,4 +198,41 @@ body .page-content img, color: inherit; text-decoration: underline; } +} + +/** + * Mention Links + */ + +a[data-mention-user-id] { + display: inline-block; + position: relative; + color: var(--color-link); + padding: 0.1em 0.4em; + display: -webkit-inline-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.92em; + margin-inline: 0.2em; + vertical-align: middle; + border-radius: 3px; + border: 1px solid transparent; + &:hover { + text-decoration: none; + border-color: currentColor; + } + &:after { + content: ''; + background-color: currentColor; + opacity: 0.2; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + display: block; + } } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index a7f5ab387..cd4bb5d2e 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -437,6 +437,20 @@ body.editor-is-fullscreen { .editor-node-resizer.active .editor-node-resizer-ghost { display: block; } +.editor-content-area details[contenteditable="false"], +.editor-content-area summary[contenteditable="false"] { + user-select: none; +} +.editor-content-area details[contenteditable="false"] > details * { + pointer-events: none; +} +.editor-content-area details summary { + caret-color: transparent; +} +.editor-content-area details.selected { + outline: 1px dashed var(--editor-color-primary); + outline-offset: 1px; +} .editor-table-marker { position: fixed; diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index ff17cf527..ea453b96e 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -348,6 +348,10 @@ input[type=color] { } } +.small-inputs input { + width: 150px; +} + .simple-code-input { background-color: #F8F8F8; font-family: monospace; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 1e503dd0f..d9a1195aa 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -366,9 +366,10 @@ details.sort-box[open] summary .caret-container svg { ul.pagination { display: inline-flex; + flex-wrap: wrap; list-style: none; margin: vars.$m 0; - padding-inline-start: 1px; + padding-inline: 1px; li:first-child { a, span { border-radius: 3px 0 0 3px; @@ -384,6 +385,7 @@ ul.pagination { padding: vars.$xxs vars.$s; border: 1px solid #CCC; margin-inline-start: -1px; + margin-block-end: -1px; user-select: none; @include mixins.lightDark(color, #555, #eee); @include mixins.lightDark(border-color, #ccc, #666); diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php index 9345a7bce..c331f0707 100644 --- a/resources/views/api-docs/index.blade.php +++ b/resources/views/api-docs/index.blade.php @@ -2,50 +2,69 @@ @section('body') -
+
- - @if(!userCan('users-manage')) + @if(!userCan(\BookStack\Permissions\Permission::UsersManage))

{{ trans('preferences.profile_email_no_permission') }}

@endif
@@ -75,7 +75,7 @@ - @if(userCan('users-manage')) + @if(userCan(\BookStack\Permissions\Permission::UsersManage))
diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php index aa3e49ded..3a1ff49d3 100644 --- a/resources/views/users/api-tokens/edit.blade.php +++ b/resources/views/users/api-tokens/edit.blade.php @@ -42,12 +42,12 @@
- - {{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }} + + {{ trans('settings.user_api_token_created', ['timeAgo' => $dates->relative($token->created_at)]) }}
- - {{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }} + + {{ trans('settings.user_api_token_updated', ['timeAgo' => $dates->relative($token->created_at)]) }}
diff --git a/resources/views/users/api-tokens/parts/list.blade.php b/resources/views/users/api-tokens/parts/list.blade.php index 70aaa58f3..bd8dd53cc 100644 --- a/resources/views/users/api-tokens/parts/list.blade.php +++ b/resources/views/users/api-tokens/parts/list.blade.php @@ -2,7 +2,7 @@

{{ trans('settings.users_api_tokens') }}

- @if(userCan('access-api')) + @if(userCan(\BookStack\Permissions\Permission::AccessApi)) {{ trans('settings.users_api_tokens_docs') }} {{ trans('settings.users_api_tokens_create') }} @endif diff --git a/resources/views/users/create.blade.php b/resources/views/users/create.blade.php index dafc623e1..daffc91a3 100644 --- a/resources/views/users/create.blade.php +++ b/resources/views/users/create.blade.php @@ -18,7 +18,7 @@
diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index bf1eb08a7..86287646f 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -17,9 +17,9 @@ @include('form.text', ['name' => 'name'])
- @if($authMethod !== 'ldap' || userCan('users-manage')) + @if($authMethod !== 'ldap' || userCan(\BookStack\Permissions\Permission::UsersManage)) - @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')]) + @include('form.text', ['name' => 'email', 'disabled' => !userCan(\BookStack\Permissions\Permission::UsersManage)]) @endif
diff --git a/resources/views/users/parts/users-list-item.blade.php b/resources/views/users/parts/users-list-item.blade.php index dc7c9f272..9c7ecd147 100644 --- a/resources/views/users/parts/users-list-item.blade.php +++ b/resources/views/users/parts/users-list-item.blade.php @@ -20,7 +20,7 @@ @if($user->last_activity_at) {{ trans('settings.users_latest_activity') }}
- {{ $user->last_activity_at->diffForHumans() }} + {{ $dates->relative($user->last_activity_at) }} @endif
diff --git a/resources/views/users/profile.blade.php b/resources/views/users/profile.blade.php index a8be8a4c1..9879091a5 100644 --- a/resources/views/users/profile.blade.php +++ b/resources/views/users/profile.blade.php @@ -23,7 +23,7 @@

{{ $user->name }}

- {{ trans('entities.profile_user_for_x', ['time' => $user->created_at->diffForHumans(null, true)]) }} + {{ trans('entities.profile_user_for_x', ['time' => $dates->relative($user->created_at, false)]) }}

diff --git a/routes/api.php b/routes/api.php index 99df24aed..308a95d8c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,11 +2,11 @@ /** * Routes for the BookStack API. - * Routes have a uri prefix of /api/. + * Routes have a URI prefix of /api/. * Controllers all end with "ApiController" */ -use BookStack\Activity\Controllers\AuditLogApiController; +use BookStack\Activity\Controllers as ActivityControllers; use BookStack\Api\ApiDocsController; use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; @@ -19,25 +19,18 @@ use BookStack\Users\Controllers\RoleApiController; use BookStack\Users\Controllers\UserApiController; use Illuminate\Support\Facades\Route; -Route::get('docs.json', [ApiDocsController::class, 'json']); +// Main Entity Routes -Route::get('attachments', [AttachmentApiController::class, 'list']); -Route::post('attachments', [AttachmentApiController::class, 'create']); -Route::get('attachments/{id}', [AttachmentApiController::class, 'read']); -Route::put('attachments/{id}', [AttachmentApiController::class, 'update']); -Route::delete('attachments/{id}', [AttachmentApiController::class, 'delete']); - -Route::get('books', [EntityControllers\BookApiController::class, 'list']); -Route::post('books', [EntityControllers\BookApiController::class, 'create']); -Route::get('books/{id}', [EntityControllers\BookApiController::class, 'read']); -Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']); -Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']); - -Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']); -Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']); -Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']); -Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']); -Route::get('books/{id}/export/zip', [ExportControllers\BookExportApiController::class, 'exportZip']); +Route::get('pages', [EntityControllers\PageApiController::class, 'list']); +Route::post('pages', [EntityControllers\PageApiController::class, 'create']); +Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']); +Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']); +Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']); +Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']); +Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']); +Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']); +Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']); +Route::get('pages/{id}/export/zip', [ExportControllers\PageExportApiController::class, 'exportZip']); Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); @@ -50,25 +43,16 @@ Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApi Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']); Route::get('chapters/{id}/export/zip', [ExportControllers\ChapterExportApiController::class, 'exportZip']); -Route::get('pages', [EntityControllers\PageApiController::class, 'list']); -Route::post('pages', [EntityControllers\PageApiController::class, 'create']); -Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']); -Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']); -Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']); - -Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']); -Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']); -Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']); -Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']); -Route::get('pages/{id}/export/zip', [ExportControllers\PageExportApiController::class, 'exportZip']); - -Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); -Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); -Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']); -Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']); -Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']); - -Route::get('search', [SearchApiController::class, 'all']); +Route::get('books', [EntityControllers\BookApiController::class, 'list']); +Route::post('books', [EntityControllers\BookApiController::class, 'create']); +Route::get('books/{id}', [EntityControllers\BookApiController::class, 'read']); +Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']); +Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']); +Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']); +Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']); +Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']); +Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']); +Route::get('books/{id}/export/zip', [ExportControllers\BookExportApiController::class, 'exportZip']); Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']); Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']); @@ -76,17 +60,34 @@ Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 're Route::put('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'update']); Route::delete('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'delete']); -Route::get('users', [UserApiController::class, 'list']); -Route::post('users', [UserApiController::class, 'create']); -Route::get('users/{id}', [UserApiController::class, 'read']); -Route::put('users/{id}', [UserApiController::class, 'update']); -Route::delete('users/{id}', [UserApiController::class, 'delete']); +// Additional Model Routes, in alphabetical order -Route::get('roles', [RoleApiController::class, 'list']); -Route::post('roles', [RoleApiController::class, 'create']); -Route::get('roles/{id}', [RoleApiController::class, 'read']); -Route::put('roles/{id}', [RoleApiController::class, 'update']); -Route::delete('roles/{id}', [RoleApiController::class, 'delete']); +Route::get('attachments', [AttachmentApiController::class, 'list']); +Route::post('attachments', [AttachmentApiController::class, 'create']); +Route::get('attachments/{id}', [AttachmentApiController::class, 'read']); +Route::put('attachments/{id}', [AttachmentApiController::class, 'update']); +Route::delete('attachments/{id}', [AttachmentApiController::class, 'delete']); + +Route::get('audit-log', [ActivityControllers\AuditLogApiController::class, 'list']); + +Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']); +Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']); +Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']); +Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']); +Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']); + +Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); +Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); + +Route::get('docs.json', [ApiDocsController::class, 'json']); + +Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); +Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); +Route::get('image-gallery/url/data', [ImageGalleryApiController::class, 'readDataForUrl']); +Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']); +Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']); +Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']); +Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']); Route::get('imports', [ExportControllers\ImportApiController::class, 'list']); Route::post('imports', [ExportControllers\ImportApiController::class, 'create']); @@ -98,9 +99,18 @@ Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'li Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']); -Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); -Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); +Route::get('roles', [RoleApiController::class, 'list']); +Route::post('roles', [RoleApiController::class, 'create']); +Route::get('roles/{id}', [RoleApiController::class, 'read']); +Route::put('roles/{id}', [RoleApiController::class, 'update']); +Route::delete('roles/{id}', [RoleApiController::class, 'delete']); -Route::get('audit-log', [AuditLogApiController::class, 'list']); +Route::get('search', [SearchApiController::class, 'all']); Route::get('system', [SystemApiController::class, 'read']); + +Route::get('users', [UserApiController::class, 'list']); +Route::post('users', [UserApiController::class, 'create']); +Route::get('users/{id}', [UserApiController::class, 'read']); +Route::put('users/{id}', [UserApiController::class, 'update']); +Route::delete('users/{id}', [UserApiController::class, 'delete']); diff --git a/routes/web.php b/routes/web.php index d27855100..ea89fc76e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -204,6 +204,7 @@ Route::middleware('auth')->group(function () { // User Search Route::get('/search/users/select', [UserControllers\UserSearchController::class, 'forSelect']); + Route::get('/search/users/mention', [UserControllers\UserSearchController::class, 'forMentions']); // Template System Route::get('/templates', [EntityControllers\PageTemplateController::class, 'list']); diff --git a/tests/Activity/AuditLogTest.php b/tests/Activity/AuditLogTest.php index 6b435544d..a6ba6be9f 100644 --- a/tests/Activity/AuditLogTest.php +++ b/tests/Activity/AuditLogTest.php @@ -83,6 +83,22 @@ class AuditLogTest extends TestCase $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); } + public function test_deleted_user_shows_if_user_created_date_is_later_than_activity() + { + $viewer = $this->users->viewer(); + $this->actingAs($viewer); + $page = $this->entities->page(); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); + $viewer->created_at = Carbon::now()->addDay(); + $viewer->save(); + + $this->actingAs($this->users->admin()); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); + $resp->assertDontSee($viewer->name); + } + public function test_filters_by_key() { $this->actingAs($this->users->admin()); diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Activity/CommentDisplayTest.php similarity index 94% rename from tests/Entity/CommentDisplayTest.php rename to tests/Activity/CommentDisplayTest.php index bffe29fa9..798ea16ee 100644 --- a/tests/Entity/CommentDisplayTest.php +++ b/tests/Activity/CommentDisplayTest.php @@ -1,10 +1,8 @@ create([ 'created_by' => $editor->id, - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, ]); $resp = $this->actingAs($editor)->get($page->getUrl()); @@ -84,7 +82,7 @@ class CommentDisplayTest extends TestCase public function test_comment_displays_relative_times() { $page = $this->entities->page(); - $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]); + $comment = Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => $page->getMorphClass()]); $comment->created_at = now()->subWeek(); $comment->updated_at = now()->subDay(); $comment->save(); diff --git a/tests/Activity/CommentMentionTest.php b/tests/Activity/CommentMentionTest.php new file mode 100644 index 000000000..7f26f8689 --- /dev/null +++ b/tests/Activity/CommentMentionTest.php @@ -0,0 +1,129 @@ +users->viewer(); + $this->permissions->grantUserRolePermissions($userToMention, [Permission::ReceiveNotifications]); + $editor = $this->users->editor(); + $page = $this->entities->pageWithinChapter(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

Hello @user

' + ])->assertOk(); + + $notifications->assertSentTo($userToMention, function (CommentMentionNotification $notification) use ($userToMention, $editor, $page) { + $mail = $notification->toMail($userToMention); + $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES); + $subjectPrefix = 'You have been mentioned in a comment on page: ' . mb_substr($page->name, 0, 20); + return str_starts_with($mail->subject, $subjectPrefix) + && str_contains($mailContent, 'View Comment') + && str_contains($mailContent, 'Page Name: ' . $page->name) + && str_contains($mailContent, 'Page Path: ' . $page->book->getShortName(24) . ' > ' . $page->chapter->getShortName(24)) + && str_contains($mailContent, 'Commenter: ' . $editor->name) + && str_contains($mailContent, 'Comment: Hello @user'); + }); + } + + public function test_mentions_are_not_notified_if_mentioned_by_same_user() + { + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, [Permission::ReceiveNotifications]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

Hello

' + ])->assertOk(); + + $notifications->assertNothingSent(); + } + + public function test_mentions_are_logged_to_the_database_even_if_not_notified() + { + $editor = $this->users->editor(); + $otherUser = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($editor, [Permission::ReceiveNotifications]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

Hello and

' + ])->assertOk(); + + $notifications->assertNothingSent(); + + $comment = $page->comments()->latest()->first(); + $this->assertDatabaseHas('mention_history', [ + 'mentionable_id' => $comment->id, + 'mentionable_type' => 'comment', + 'from_user_id' => $editor->id, + 'to_user_id' => $otherUser->id, + ]); + $this->assertDatabaseHas('mention_history', [ + 'mentionable_id' => $comment->id, + 'mentionable_type' => 'comment', + 'from_user_id' => $editor->id, + 'to_user_id' => $editor->id, + ]); + } + + public function test_comment_updates_will_send_notifications_only_if_mention_is_new() + { + $userToMention = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($userToMention, [Permission::ReceiveNotifications]); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, [Permission::CommentUpdateOwn]); + $page = $this->entities->page(); + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$page->id}", [ + 'html' => '

Hello there

' + ])->assertOk(); + $comment = $page->comments()->latest()->first(); + + $notifications->assertNothingSent(); + + $this->put("/comment/{$comment->id}", [ + 'html' => '

Hello

' + ])->assertOk(); + + $notifications->assertSentTo($userToMention, CommentMentionNotification::class); + $notifications->assertCount(1); + + $this->put("/comment/{$comment->id}", [ + 'html' => '

Hello again

' + ])->assertOk(); + + $notifications->assertCount(1); + } + + public function test_notification_limited_to_those_with_view_permissions() + { + $userA = $this->users->newUser(); + $userB = $this->users->newUser(); + $this->permissions->grantUserRolePermissions($userA, [Permission::ReceiveNotifications]); + $this->permissions->grantUserRolePermissions($userB, [Permission::ReceiveNotifications]); + $notifications = Notification::fake(); + $page = $this->entities->page(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, ['view'], $userA->roles()->first()); + + $this->asAdmin()->post("/comment/{$page->id}", [ + 'html' => '

Hello and

' + ])->assertOk(); + + $notifications->assertCount(1); + $notifications->assertSentTo($userA, CommentMentionNotification::class); + } +} diff --git a/tests/Entity/CommentSettingTest.php b/tests/Activity/CommentSettingTest.php similarity index 96% rename from tests/Entity/CommentSettingTest.php rename to tests/Activity/CommentSettingTest.php index 7de457441..f8210114c 100644 --- a/tests/Entity/CommentSettingTest.php +++ b/tests/Activity/CommentSettingTest.php @@ -1,6 +1,6 @@ asAdmin(); $page = $this->entities->page(); + Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]); $comment = Comment::factory()->make(['parent_id' => 2]); $resp = $this->postJson("/comment/$page->id", $comment->getAttributes()); @@ -24,10 +24,9 @@ class CommentStoreTest extends TestCase $pageResp->assertSee($comment->html, false); $this->assertDatabaseHas('comments', [ - 'local_id' => 1, - 'entity_id' => $page->id, - 'entity_type' => Page::newModelInstance()->getMorphClass(), - 'text' => null, + 'local_id' => 3, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', 'parent_id' => 2, ]); @@ -53,9 +52,9 @@ class CommentStoreTest extends TestCase ]); if ($valid) { - $this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + $this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); } else { - $this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]); + $this->assertDatabaseMissing('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]); } } } @@ -80,7 +79,7 @@ class CommentStoreTest extends TestCase $this->assertDatabaseHas('comments', [ 'html' => $newHtml, - 'entity_id' => $page->id, + 'commentable_id' => $page->id, ]); $this->assertActivityExists(ActivityType::COMMENT_UPDATE); @@ -219,7 +218,7 @@ class CommentStoreTest extends TestCase $page = $this->entities->page(); Comment::factory()->create([ 'html' => '

scriptincommentest

', - 'entity_type' => 'page', 'entity_id' => $page + 'commentable_type' => 'page', 'commentable_id' => $page ]); $resp = $this->asAdmin()->get($page->getUrl()); @@ -237,8 +236,8 @@ class CommentStoreTest extends TestCase $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); @@ -260,8 +259,8 @@ class CommentStoreTest extends TestCase $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php new file mode 100644 index 000000000..51009da39 --- /dev/null +++ b/tests/Activity/CommentsApiTest.php @@ -0,0 +1,250 @@ +users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]); + + $page = $this->entities->page(); + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->actingAsForApi($user); + + $actions = [ + ['GET', '/api/comments'], + ['GET', "/api/comments/{$comment->id}"], + ['POST', "/api/comments"], + ['PUT', "/api/comments/{$comment->id}"], + ['DELETE', "/api/comments/{$comment->id}"], + ]; + + foreach ($actions as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertNotPermissionError($resp); + } + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->getJson("/api/comments")->assertSee(['id' => $comment->id]); + + $this->permissions->removeUserRolePermissions($user, [ + Permission::CommentDeleteAll, Permission::CommentDeleteOwn, + Permission::CommentUpdateAll, Permission::CommentUpdateOwn, + Permission::CommentCreateAll + ]); + + $this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('post', "/api/comments")); + $this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}")); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->json('get', "/api/comments/{$comment->id}")->assertStatus(404); + $this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]); + } + + public function test_index() + { + $page = $this->entities->page(); + Comment::query()->delete(); + + $comments = Comment::factory()->count(10)->make(); + $page->comments()->saveMany($comments); + + $firstComment = $comments->first(); + $resp = $this->actingAsApiEditor()->getJson('/api/comments'); + $resp->assertJson([ + 'data' => [ + [ + 'id' => $firstComment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'parent_id' => null, + 'local_id' => $firstComment->local_id, + ], + ], + ]); + $resp->assertJsonCount(10, 'data'); + $resp->assertJson(['total' => 10]); + + $filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}"); + $filtered->assertJsonCount(1, 'data'); + $filtered->assertJson(['total' => 1]); + } + + public function test_create() + { + $page = $this->entities->page(); + + $resp = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful comment

', + 'content_ref' => 'test-content-ref', + ]); + $resp->assertOk(); + $id = $resp->json('id'); + + $this->assertDatabaseHas('comments', [ + 'id' => $id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful comment

', + ]); + + $comment = Comment::query()->findOrFail($id); + $this->assertIsInt($comment->local_id); + + $reply = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful reply

', + 'content_ref' => 'test-content-ref', + 'reply_to' => $comment->local_id, + ]); + $reply->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $reply->json('id'), + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful reply

', + 'parent_id' => $comment->local_id, + ]); + } + + public function test_read() + { + $page = $this->entities->page(); + $user = $this->users->viewer(); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $user->id, + 'updated_by' => $user->id, + ]); + $page->comments()->save($comment); + $comment->refresh(); + $reply = Comment::factory()->make([ + 'parent_id' => $comment->local_id, + 'html' => '

A lovelyreply

', + ]); + $page->comments()->save($reply); + + $resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}"); + $resp->assertJson([ + 'id' => $comment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

A lovely comment

', + 'archived' => false, + 'created_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'updated_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'replies' => [ + [ + 'id' => $reply->id, + 'html' => '

A lovelyreply

' + ] + ] + ]); + } + + public function test_update() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => null, + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'html' => '

A lovely updated comment

', + ])->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 0, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 1, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => false, + 'html' => '

A lovely updated again comment

', + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated again comment

', + 'archived' => 0, + ]); + } + + public function test_update_cannot_archive_replies() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => 90, + ]); + $page->comments()->save($comment); + + $resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json()); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => 0, + ]); + } + + public function test_destroy() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204); + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + } +} diff --git a/tests/Activity/MentionParserTest.php b/tests/Activity/MentionParserTest.php new file mode 100644 index 000000000..08bfc10d2 --- /dev/null +++ b/tests/Activity/MentionParserTest.php @@ -0,0 +1,43 @@ +Hello @User

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([5], $result); + + // Test multiple mentions + $html = '

@Alice and @Bob

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([1, 2], $result); + + // Test filtering out invalid IDs (zero and negative) + $html = '

@Invalid @Negative @Valid

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([3], $result); + + // Test non-mention links are ignored + $html = '

Normal Link @User

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([7], $result); + + // Test empty HTML + $result = $parser->parseUserIdsFromHtml(''); + $this->assertEquals([], $result); + + // Test duplicate user IDs + $html = '

@User mentioned @User again

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([4], $result); + } +} diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index c405b07ae..8be09f890 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -327,6 +327,24 @@ class WatchTest extends TestCase }); } + public function test_notify_watch_page_ignore_when_no_page_owner() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $entities['page']->owned_by = null; + $entities['page']->save(); + + $watches = new UserEntityWatchOptions($editor, $entities['page']); + $watches->updateLevelByValue(WatchLevels::IGNORE); + + $notifications = Notification::fake(); + $this->asAdmin(); + + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + + $notifications->assertNothingSent(); + } + public function test_notifications_sent_in_right_language() { $editor = $this->users->editor(); @@ -340,8 +358,8 @@ class WatchTest extends TestCase ActivityType::PAGE_CREATE => $entities['page'], ActivityType::PAGE_UPDATE => $entities['page'], ActivityType::COMMENT_CREATE => Comment::factory()->make([ - 'entity_id' => $entities['page']->id, - 'entity_type' => $entities['page']->getMorphClass(), + 'commentable_id' => $entities['page']->id, + 'commentable_type' => $entities['page']->getMorphClass(), ]), ]; diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index 93e4b02e4..4e446bf5d 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase { use TestsApi; - protected $endpoint = '/api/books'; + protected string $endpoint = '/api/books'; public function test_requests_succeed_with_default_auth() { diff --git a/tests/Api/ApiDocsTest.php b/tests/Api/ApiDocsTest.php index a1603e0ef..bdf753e87 100644 --- a/tests/Api/ApiDocsTest.php +++ b/tests/Api/ApiDocsTest.php @@ -22,7 +22,7 @@ class ApiDocsTest extends TestCase $resp->assertStatus(200); $resp->assertSee(url('/api/docs.json')); $resp->assertSee('Show a JSON view of the API docs data.'); - $resp->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + $resp->assertHeader('Content-Type', 'text/html; charset=utf-8'); } public function test_docs_json_endpoint_returns_json() diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 22ccfb482..86e10f58a 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -5,7 +5,6 @@ namespace Tests\Api; use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; -use Illuminate\Support\Facades\DB; use Tests\TestCase; class BooksApiTest extends TestCase @@ -47,8 +46,8 @@ class BooksApiTest extends TestCase [ 'id' => $book->id, 'cover' => [ - 'id' => $book->cover->id, - 'url' => $book->cover->url, + 'id' => $book->coverInfo()->getImage()->id, + 'url' => $book->coverInfo()->getImage()->url, ], ], ]]); @@ -94,7 +93,7 @@ class BooksApiTest extends TestCase ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('books', $expectedDetails); + $this->assertDatabaseHasEntityData('book', $expectedDetails); } public function test_book_name_needed_to_create() @@ -153,23 +152,23 @@ class BooksApiTest extends TestCase $directChildCount = $book->directPages()->count() + $book->chapters()->count(); $resp->assertStatus(200); $resp->assertJsonCount($directChildCount, 'contents'); - $resp->assertJson([ - 'contents' => [ - [ - 'type' => 'chapter', - 'id' => $chapter->id, - 'name' => $chapter->name, - 'slug' => $chapter->slug, - 'pages' => [ - [ - 'id' => $chapterPage->id, - 'name' => $chapterPage->name, - 'slug' => $chapterPage->slug, - ] - ] - ] - ] - ]); + + $contents = $resp->json('contents'); + $respChapter = array_values(array_filter($contents, fn ($item) => ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0]; + $this->assertArrayMapIncludes([ + 'id' => $chapter->id, + 'type' => 'chapter', + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], $respChapter); + + $respPage = array_values(array_filter($respChapter['pages'], fn ($item) => ($item['id'] === $chapterPage->id)))[0]; + + $this->assertArrayMapIncludes([ + 'id' => $chapterPage->id, + 'name' => $chapterPage->name, + 'slug' => $chapterPage->slug, + ], $respPage); } public function test_read_endpoint_contents_nested_pages_has_permissions_applied() @@ -224,14 +223,14 @@ class BooksApiTest extends TestCase $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); + $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $book = $this->entities->book(); - DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); + Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -247,7 +246,7 @@ class BooksApiTest extends TestCase $this->actingAsApiEditor(); /** @var Book $book */ $book = $this->entities->book(); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -257,7 +256,7 @@ class BooksApiTest extends TestCase $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -266,7 +265,7 @@ class BooksApiTest extends TestCase $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -275,7 +274,7 @@ class BooksApiTest extends TestCase $book->refresh(); $resp->assertStatus(200); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 5d7b05308..194140a56 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -91,7 +91,7 @@ class ChaptersApiTest extends TestCase 'description' => 'A chapter created via the API', ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('chapters', $expectedDetails); + $this->assertDatabaseHasEntityData('chapter', $expectedDetails); } public function test_chapter_name_needed_to_create() @@ -155,7 +155,7 @@ class ChaptersApiTest extends TestCase 'owned_by' => $page->owned_by, 'created_by' => $page->created_by, 'updated_by' => $page->updated_by, - 'book_id' => $page->id, + 'book_id' => $page->book->id, 'chapter_id' => $chapter->id, 'priority' => $page->priority, 'book_slug' => $chapter->book->slug, @@ -213,7 +213,7 @@ class ChaptersApiTest extends TestCase $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('chapters', array_merge($details, [ + $this->assertDatabaseHasEntityData('chapter', array_merge($details, [ 'id' => $chapter->id, 'description' => 'A chapter updated via the API' ])); } @@ -222,7 +222,7 @@ class ChaptersApiTest extends TestCase { $this->actingAsApiEditor(); $chapter = $this->entities->chapter(); - DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -244,8 +244,8 @@ class ChaptersApiTest extends TestCase $resp->assertOk(); $chapter->refresh(); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); } public function test_update_with_new_book_id_requires_delete_permission() diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php index a62abacc7..464d62683 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -280,7 +280,7 @@ class ContentPermissionsApiTest extends TestCase ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]); $this->assertDatabaseHas('entity_permissions', [ 'entity_id' => $page->id, 'entity_type' => 'page', diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 667093107..07c20c834 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -275,6 +275,69 @@ class ImageGalleryApiTest extends TestCase $resp->assertStatus(404); } + public function test_read_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_data_endpoint_permission_controlled() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk(); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(404); + } + + public function test_read_url_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used() + { + config()->set('filesystems.images', 'local_secure_restricted'); + + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(404); + } + public function test_update_endpoint() { $this->actingAsApiAdmin(); diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index ced8954eb..d71b6c988 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Carbon\Carbon; @@ -199,6 +200,31 @@ class PagesApiTest extends TestCase $this->assertSame(404, $resp->json('error')['code']); } + public function test_read_endpoint_includes_page_comments_tree_structure() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id]; + $active = Comment::factory()->create([...$relation, 'html' => '

My active comment

']); + Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]); + $archived = Comment::factory()->create([...$relation, 'archived' => true]); + Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]); + + $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}"); + $resp->assertOk(); + + $resp->assertJsonCount(1, 'comments.active'); + $resp->assertJsonCount(1, 'comments.archived'); + $resp->assertJsonCount(5, 'comments.active.0.children'); + $resp->assertJsonCount(2, 'comments.archived.0.children'); + + $resp->assertJsonFragment([ + 'id' => $active->id, + 'local_id' => $active->local_id, + 'html' => '

My active comment

', + ]); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); @@ -286,7 +312,7 @@ class PagesApiTest extends TestCase { $this->actingAsApiEditor(); $page = $this->entities->page(); - DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index d174838c2..6ccc69c35 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -144,7 +144,7 @@ class RecycleBinApiTest extends TestCase $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -154,7 +154,7 @@ class RecycleBinApiTest extends TestCase 'restore_count' => 1, ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => null, ]); @@ -168,7 +168,7 @@ class RecycleBinApiTest extends TestCase $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -178,6 +178,6 @@ class RecycleBinApiTest extends TestCase 'delete_count' => 1, ]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 9da7900ca..517c5d8e4 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -113,6 +113,7 @@ class SearchApiTest extends TestCase $this->permissions->disableEntityInheritedPermissions($book); $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); $resp->assertJsonPath('data.0.id', $page->id); $resp->assertJsonMissingPath('data.0.book.name'); } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index ba13c0153..34ce0e4e5 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -48,8 +48,8 @@ class ShelvesApiTest extends TestCase [ 'id' => $shelf->id, 'cover' => [ - 'id' => $shelf->cover->id, - 'url' => $shelf->cover->url, + 'id' => $shelf->coverInfo()->getImage()->id, + 'url' => $shelf->coverInfo()->getImage()->url, ], ], ]]); @@ -102,7 +102,7 @@ class ShelvesApiTest extends TestCase ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('bookshelves', $expectedDetails); + $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails); } public function test_shelf_name_needed_to_create() @@ -181,14 +181,14 @@ class ShelvesApiTest extends TestCase $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $shelf = Bookshelf::visible()->first(); - DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -222,7 +222,7 @@ class ShelvesApiTest extends TestCase $this->actingAsApiEditor(); /** @var Book $shelf */ $shelf = Bookshelf::visible()->first(); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -232,7 +232,7 @@ class ShelvesApiTest extends TestCase $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -241,7 +241,7 @@ class ShelvesApiTest extends TestCase $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -250,7 +250,7 @@ class ShelvesApiTest extends TestCase $shelf->refresh(); $resp->assertStatus(200); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 1f359b41a..5184bf984 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -6,6 +6,7 @@ use BookStack\Access\Mfa\MfaValue; use BookStack\Activity\ActivityType; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Hash; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -166,6 +167,36 @@ class MfaConfigurationTest extends TestCase $this->assertEquals(0, $admin->mfaValues()->count()); } + public function test_mfa_required_if_set_on_role() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + /** @var Role $role */ + $role = $user->roles()->first(); + $role->mfa_enforced = true; + $role->save(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + + public function test_mfa_required_if_mfa_option_configured() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + $user->mfaValues()->create([ + 'method' => MfaValue::METHOD_TOTP, + 'value' => 'test', + ]); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login() { $admin = $this->users->admin(); diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index a0db1c2ba..710e53757 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -138,7 +138,7 @@ class OidcTest extends TestCase { // Start auth $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([ 'email' => 'benny@example.com', @@ -190,6 +190,35 @@ class OidcTest extends TestCase $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); } + public function test_callback_works_even_if_other_request_made_by_session() + { + $this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + ])]); + + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + + $this->get('/'); + + $resp = $this->get("/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state={$state}"); + $resp->assertRedirect('/'); + } + + public function test_callback_fails_if_state_timestamp_is_too_old() + { + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + session()->put('oidc_state', (time() - 60 * 4) . ':' . $state); + + $this->get('/'); + + $resp = $this->get("/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state={$state}"); + $resp->assertRedirect('/login'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + public function test_dump_user_details_option_outputs_as_expected() { config()->set('oidc.dump_user_details', true); @@ -797,7 +826,7 @@ class OidcTest extends TestCase { // Start auth $resp = $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $pkceCode = session()->get('oidc_pkce_code'); $this->assertGreaterThan(30, strlen($pkceCode)); @@ -825,7 +854,7 @@ class OidcTest extends TestCase { config()->set('oidc.display_name_claims', 'first_name|last_name'); $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $client = $this->mockHttpClient([ $this->getMockAuthorizationResponse(['name' => null]), @@ -973,7 +1002,7 @@ class OidcTest extends TestCase ]); $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $client = $this->mockHttpClient([$this->getMockAuthorizationResponse([ 'groups' => [], ])]); @@ -999,7 +1028,7 @@ class OidcTest extends TestCase protected function runLogin($claimOverrides = [], $additionalHttpResponses = []): TestResponse { $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1] ?? ''; $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides), ...$additionalHttpResponses]); return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 3de6238ed..6a3063bcf 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -36,7 +36,7 @@ class Saml2Test extends TestCase public function test_metadata_endpoint_displays_xml_as_expected() { $req = $this->get('/saml2/metadata'); - $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertHeader('Content-Type', 'text/xml; charset=utf-8'); $req->assertSee('md:EntityDescriptor'); $req->assertSee(url('/saml2/acs')); } @@ -51,7 +51,7 @@ class Saml2Test extends TestCase $req = $this->get('/saml2/metadata'); $req->assertOk(); - $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertHeader('Content-Type', 'text/xml; charset=utf-8'); $req->assertSee('md:EntityDescriptor'); } diff --git a/tests/Commands/CreateAdminCommandTest.php b/tests/Commands/CreateAdminCommandTest.php index 95a39c497..f389dd942 100644 --- a/tests/Commands/CreateAdminCommandTest.php +++ b/tests/Commands/CreateAdminCommandTest.php @@ -2,8 +2,11 @@ namespace Tests\Commands; +use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Tests\TestCase; class CreateAdminCommandTest extends TestCase @@ -11,14 +14,14 @@ class CreateAdminCommandTest extends TestCase public function test_standard_command_usage() { $this->artisan('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', '--password' => 'testing-4', ])->assertExitCode(0); $this->assertDatabaseHas('users', [ 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'name' => 'Admin Test', ]); /** @var User $user */ @@ -30,14 +33,14 @@ class CreateAdminCommandTest extends TestCase public function test_providing_external_auth_id() { $this->artisan('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', '--external-auth-id' => 'xX_admin_Xx', ])->assertExitCode(0); $this->assertDatabaseHas('users', [ - 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'email' => 'admintest@example.com', + 'name' => 'Admin Test', 'external_auth_id' => 'xX_admin_Xx', ]); @@ -50,14 +53,178 @@ class CreateAdminCommandTest extends TestCase { $this->artisan('bookstack:create-admin', [ '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--name' => 'Admin Test', ])->expectsQuestion('Please specify a password for the new admin user (8 characters min)', 'hunter2000') ->assertExitCode(0); $this->assertDatabaseHas('users', [ 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'name' => 'Admin Test', ]); $this->assertTrue(Auth::attempt(['email' => 'admintest@example.com', 'password' => 'hunter2000'])); } + + public function test_generate_password_option() + { + $this->withoutMockingConsoleOutput() + ->artisan('bookstack:create-admin', [ + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', + '--generate-password' => true, + ]); + + $output = trim(Artisan::output()); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output); + + $user = User::query()->where('email', '=', 'admintest@example.com')->first(); + $this->assertTrue(Hash::check($output, $user->password)); + } + + public function test_initial_option_updates_default_admin() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput('The default admin user has been updated with the provided details!') + ->assertExitCode(0); + + $defaultAdmin->refresh(); + + $this->assertEquals('firstadmin@example.com', $defaultAdmin->email); + } + + public function test_initial_option_does_not_update_if_only_non_default_admin_exists() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + $defaultAdmin->email = 'testadmin@example.com'; + $defaultAdmin->save(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput('Non-default admin user already exists. Skipping creation of new admin user.') + ->assertExitCode(2); + + $defaultAdmin->refresh(); + + $this->assertEquals('testadmin@example.com', $defaultAdmin->email); + } + + public function test_initial_option_updates_creates_new_admin_if_none_exists() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $this->assertEquals(0, $adminRole->users()->count()); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!") + ->assertExitCode(0); + + $this->assertEquals(1, $adminRole->users()->count()); + $this->assertDatabaseHas('users', [ + 'email' => 'firstadmin@example.com', + 'name' => 'My initial admin', + ]); + } + + public function test_initial_rerun_does_not_error_but_skips() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!") + ->assertExitCode(0); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Non-default admin user already exists. Skipping creation of new admin user.") + ->assertExitCode(2); + } + + public function test_initial_option_creation_errors_if_email_already_exists() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $editor = $this->users->editor(); + + $this->artisan('bookstack:create-admin', [ + '--email' => $editor->email, + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Could not create admin account.") + ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.") + ->assertExitCode(1); + } + + public function test_initial_option_updating_errors_if_email_already_exists() + { + $editor = $this->users->editor(); + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + $this->assertNotNull($defaultAdmin); + + $this->artisan('bookstack:create-admin', [ + '--email' => $editor->email, + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Could not create admin account.") + ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.") + ->assertExitCode(1); + } + + public function test_initial_option_does_not_require_name_or_email_to_be_passed() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $this->assertEquals(0, $adminRole->users()->count()); + + $this->artisan('bookstack:create-admin', [ + '--generate-password' => true, + '--initial' => true, + ])->assertExitCode(0); + + $this->assertEquals(1, $adminRole->users()->count()); + $this->assertDatabaseHas('users', [ + 'email' => 'admin@example.com', + 'name' => 'Admin', + ]); + } + + public function test_initial_option_updating_existing_user_with_generate_password_only_outputs_password() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + + $this->withoutMockingConsoleOutput() + ->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--generate-password' => true, + '--initial' => true, + ]); + + $output = Artisan::output(); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output); + + $defaultAdmin->refresh(); + $this->assertEquals('firstadmin@example.com', $defaultAdmin->email); + } } diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index d336e05a2..356a026a8 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -19,7 +19,7 @@ class UpdateUrlCommandTest extends TestCase ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y') ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'html' => '', ]); @@ -40,7 +40,7 @@ class UpdateUrlCommandTest extends TestCase ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); foreach ($models as $model) { - $this->assertDatabaseHas($model->getTable(), [ + $this->assertDatabaseHasEntityData($model->getMorphClass(), [ 'id' => $model->id, 'description_html' => '', ]); diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index fb9862931..3ba2c3e99 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -91,7 +91,7 @@ class BookShelfTest extends TestCase ])); $resp->assertRedirect(); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); $shelfPage = $this->get($shelf->getUrl()); @@ -117,11 +117,12 @@ class BookShelfTest extends TestCase $lastImage = Image::query()->orderByDesc('id')->firstOrFail(); $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first(); - $this->assertDatabaseHas('bookshelves', [ - 'id' => $shelf->id, + $this->assertDatabaseHas('entity_container_data', [ + 'entity_id' => $shelf->id, + 'entity_type' => 'bookshelf', 'image_id' => $lastImage->id, ]); - $this->assertEquals($lastImage->id, $shelf->cover->id); + $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id); $this->assertEquals('cover_bookshelf', $lastImage->type); } @@ -247,7 +248,7 @@ class BookShelfTest extends TestCase $this->assertSessionHas('success'); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); $shelfPage = $this->get($shelf->getUrl()); $shelfPage->assertSee($shelfInfo['name']); @@ -259,6 +260,35 @@ class BookShelfTest extends TestCase $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); } + public function test_shelf_edit_does_not_alter_books_we_dont_have_access_to() + { + $shelf = $this->entities->shelf(); + $shelf->books()->detach(); + $this->entities->book(); + $this->entities->book(); + + $newBooks = [$this->entities->book(), $this->entities->book()]; + $originalBooks = [$this->entities->book(), $this->entities->book()]; + foreach ($originalBooks as $book) { + $this->permissions->disableEntityInheritedPermissions($book); + $shelf->books()->attach($book->id); + } + + $this->asEditor()->put($shelf->getUrl(), [ + 'name' => $shelf->name, + 'books' => "{$newBooks[0]->id},{$newBooks[1]->id}", + ])->assertRedirect($shelf->getUrl()); + + $resultingBooksById = $shelf->books()->get()->keyBy('id')->toArray(); + $this->assertCount(4, $resultingBooksById); + foreach ($newBooks as $book) { + $this->assertArrayHasKey($book->id, $resultingBooksById); + } + foreach ($originalBooks as $book) { + $this->assertArrayHasKey($book->id, $resultingBooksById); + } + } + public function test_shelf_create_new_book() { $shelf = $this->entities->shelf(); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 51bf65d10..a7142f037 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -27,7 +27,7 @@ class BookTest extends TestCase $resp = $this->get('/books/my-first-book'); $resp->assertSee($book->name); - $resp->assertSee($book->description); + $resp->assertSee($book->descriptionInfo()->getPlain()); } public function test_create_uses_different_slugs_when_name_reused() @@ -238,30 +238,6 @@ class BookTest extends TestCase $this->assertEquals('list', setting()->getUser($editor, 'books_view_type')); } - public function test_slug_multi_byte_url_safe() - { - $book = $this->entities->newBook([ - 'name' => 'информация', - ]); - - $this->assertEquals('informaciia', $book->slug); - - $book = $this->entities->newBook([ - 'name' => '¿Qué?', - ]); - - $this->assertEquals('que', $book->slug); - } - - public function test_slug_format() - { - $book = $this->entities->newBook([ - 'name' => 'PartA / PartB / PartC', - ]); - - $this->assertEquals('parta-partb-partc', $book->slug); - } - public function test_description_limited_to_specific_html() { $book = $this->entities->book(); @@ -288,108 +264,4 @@ class BookTest extends TestCase $resp = $this->asEditor()->get($book->getUrl()); $resp->assertSee("

My great
\ndescription
\n
\nwith newlines

", false); } - - public function test_show_view_has_copy_button() - { - $book = $this->entities->book(); - $resp = $this->asEditor()->get($book->getUrl()); - - $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); - } - - public function test_copy_view() - { - $book = $this->entities->book(); - $resp = $this->asEditor()->get($book->getUrl('/copy')); - - $resp->assertOk(); - $resp->assertSee('Copy Book'); - $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); - } - - public function test_copy() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); - $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $resp->assertRedirect($copy->getUrl()); - $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count()); - - $this->get($copy->getUrl())->assertSee($book->description_html, false); - } - - public function test_copy_does_not_copy_non_visible_content() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); - - // Hide child content - /** @var BookChild $page */ - foreach ($book->getDirectVisibleChildren() as $child) { - $this->permissions->setEntityPermissions($child, [], []); - } - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertEquals(0, $copy->getDirectVisibleChildren()->count()); - } - - public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); - - $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertEquals(0, $copy->pages()->count()); - $this->assertEquals(0, $copy->chapters()->count()); - } - - public function test_copy_clones_cover_image_if_existing() - { - $book = $this->entities->book(); - $bookRepo = $this->app->make(BookRepo::class); - $coverImageFile = $this->files->uploadedImage('cover.png'); - $bookRepo->updateCoverImage($book, $coverImageFile); - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertNotNull($copy->cover); - $this->assertNotEquals($book->cover->id, $copy->cover->id); - } - - public function test_copy_adds_book_to_shelves_if_edit_permissions_allows() - { - /** @var Bookshelf $shelfA */ - /** @var Bookshelf $shelfB */ - [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get(); - $book = $this->entities->book(); - - $shelfA->appendBook($book); - $shelfB->appendBook($book); - - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); - $this->permissions->setEntityPermissions($shelfB); - - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists()); - $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists()); - } } diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 1577cee76..0c0ec7841 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -66,90 +66,7 @@ class ChapterTest extends TestCase $this->assertNotificationContains($redirectReq, 'Chapter Successfully Deleted'); } - public function test_show_view_has_copy_button() - { - $chapter = $this->entities->chapter(); - $resp = $this->asEditor()->get($chapter->getUrl()); - $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); - } - - public function test_copy_view() - { - $chapter = $this->entities->chapter(); - - $resp = $this->asEditor()->get($chapter->getUrl('/copy')); - $resp->assertOk(); - $resp->assertSee('Copy Chapter'); - $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); - $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]'); - } - - public function test_copy() - { - /** @var Chapter $chapter */ - $chapter = Chapter::query()->whereHas('pages')->first(); - /** @var Book $otherBook */ - $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); - - $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - 'entity_selection' => 'book:' . $otherBook->id, - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - - $resp->assertRedirect($newChapter->getUrl()); - $this->assertEquals($otherBook->id, $newChapter->book_id); - $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); - } - - public function test_copy_does_not_copy_non_visible_pages() - { - $chapter = $this->entities->chapterHasPages(); - - // Hide pages to all non-admin roles - /** @var Page $page */ - foreach ($chapter->pages as $page) { - $this->permissions->setEntityPermissions($page, [], []); - } - - $this->asEditor()->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - $this->assertEquals(0, $newChapter->pages()->count()); - } - - public function test_copy_does_not_copy_pages_if_user_cant_page_create() - { - $chapter = $this->entities->chapterHasPages(); - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); - - // Lacking permission results in no copied pages - $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - $this->assertEquals(0, $newChapter->pages()->count()); - - $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); - - // Having permission rules in copied pages - $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied again chapter', - ]); - - /** @var Chapter $newChapter2 */ - $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); - $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); - } public function test_sort_book_action_visible_if_permissions_allow() { diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index d9b1ee466..8658e7699 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -35,8 +35,8 @@ class ConvertTest extends TestCase /** @var Book $newBook */ $newBook = Book::query()->orderBy('id', 'desc')->first(); - $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); + $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); $this->assertCount(1, $newBook->tags); $this->assertEquals('Category', $newBook->tags->first()->name); $this->assertEquals('Penguins', $newBook->tags->first()->value); @@ -100,7 +100,7 @@ class ConvertTest extends TestCase // Checks for new shelf $resp->assertRedirectContains('/shelves/'); - $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]); + $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']); $this->assertCount(1, $newShelf->tags); $this->assertEquals('Category', $newShelf->tags->first()->name); $this->assertEquals('Ducks', $newShelf->tags->first()->value); @@ -112,8 +112,8 @@ class ConvertTest extends TestCase $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf); // Checks for old book to contain child pages - $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]); // Checks for nested page $chapterChildPage->refresh(); diff --git a/tests/Entity/CopyTest.php b/tests/Entity/CopyTest.php new file mode 100644 index 000000000..d4b6d54cf --- /dev/null +++ b/tests/Entity/CopyTest.php @@ -0,0 +1,399 @@ +entities->book(); + $resp = $this->asEditor()->get($book->getUrl()); + + $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_book_copy_view() + { + $book = $this->entities->book(); + $resp = $this->asEditor()->get($book->getUrl('/copy')); + + $resp->assertOk(); + $resp->assertSee('Copy Book'); + $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); + } + + public function test_book_copy() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $resp->assertRedirect($copy->getUrl()); + $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count()); + + $this->get($copy->getUrl())->assertSee($book->description_html, false); + } + + public function test_book_copy_does_not_copy_non_visible_content() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + + // Hide child content + /** @var BookChild $page */ + foreach ($book->getDirectVisibleChildren() as $child) { + $this->permissions->setEntityPermissions($child, [], []); + } + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->getDirectVisibleChildren()->count()); + } + + public function test_book_copy_does_not_copy_pages_or_chapters_if_user_cant_create() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); + + $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->pages()->count()); + $this->assertEquals(0, $copy->chapters()->count()); + } + + public function test_book_copy_clones_cover_image_if_existing() + { + $book = $this->entities->book(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->files->uploadedImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect(); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertNotNull($copy->coverInfo()->getImage()); + $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id); + } + + public function test_book_copy_adds_book_to_shelves_if_edit_permissions_allows() + { + /** @var Bookshelf $shelfA */ + /** @var Bookshelf $shelfB */ + [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get(); + $book = $this->entities->book(); + + $shelfA->appendBook($book); + $shelfB->appendBook($book); + + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); + $this->permissions->setEntityPermissions($shelfB); + + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists()); + $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists()); + } + + public function test_chapter_show_view_has_copy_button() + { + $chapter = $this->entities->chapter(); + + $resp = $this->asEditor()->get($chapter->getUrl()); + $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_chapter_copy_view() + { + $chapter = $this->entities->chapter(); + + $resp = $this->asEditor()->get($chapter->getUrl('/copy')); + $resp->assertOk(); + $resp->assertSee('Copy Chapter'); + $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); + $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]'); + } + + public function test_chapter_copy() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + /** @var Book $otherBook */ + $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + 'entity_selection' => 'book:' . $otherBook->id, + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $resp->assertRedirect($newChapter->getUrl()); + $this->assertEquals($otherBook->id, $newChapter->book_id); + $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); + } + + public function test_chapter_copy_does_not_copy_non_visible_pages() + { + $chapter = $this->entities->chapterHasPages(); + + // Hide pages to all non-admin roles + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $this->permissions->setEntityPermissions($page, [], []); + } + + $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + } + + public function test_chapter_copy_does_not_copy_pages_if_user_cant_page_create() + { + $chapter = $this->entities->chapterHasPages(); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); + + // Lacking permission results in no copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + + $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); + + // Having permission rules in copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied again chapter', + ]); + + /** @var Chapter $newChapter2 */ + $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); + $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); + } + + public function test_book_copy_updates_internal_references() + { + $book = $this->entities->bookHasChaptersAndPages(); + /** @var Chapter $chapter */ + $chapter = $book->chapters()->first(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + $this->asEditor(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

This is a test book link

', + ]); + + // Quick pre-update to get stable slug + $this->put($book->getUrl(), ['name' => 'Internal ref test']); + $book->refresh(); + $page->refresh(); + + $html = '

This is a test page link

'; + $this->put($book->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]); + + $this->post($book->getUrl('/copy'), ['name' => 'My copied book']); + + $newBook = Book::query()->where('name', '=', 'My copied book')->first(); + $newPage = $newBook->pages()->where('name', '=', 'reference test page')->first(); + + $this->assertStringContainsString($newBook->getUrl(), $newPage->html); + $this->assertStringContainsString($newPage->getUrl(), $newBook->description_html); + + $this->assertStringNotContainsString($book->getUrl(), $newPage->html); + $this->assertStringNotContainsString($page->getUrl(), $newBook->description_html); + } + + public function test_chapter_copy_updates_internal_references() + { + $chapter = $this->entities->chapterHasPages(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + $this->asEditor(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

This is a test chapter link

', + ]); + + // Quick pre-update to get stable slug + $this->put($chapter->getUrl(), ['name' => 'Internal ref test']); + $chapter->refresh(); + $page->refresh(); + + $html = '

This is a test page link

'; + $this->put($chapter->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]); + + $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']); + + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $newPage = $newChapter->pages()->where('name', '=', 'reference test page')->first(); + + $this->assertStringContainsString($newChapter->getUrl() . '"', $newPage->html); + $this->assertStringContainsString($newPage->getUrl() . '"', $newChapter->description_html); + + $this->assertStringNotContainsString($chapter->getUrl() . '"', $newPage->html); + $this->assertStringNotContainsString($page->getUrl() . '"', $newChapter->description_html); + } + + public function test_chapter_copy_updates_internal_permalink_references_in_its_description() + { + $chapter = $this->entities->chapterHasPages(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + + $this->asEditor()->put($chapter->getUrl(), [ + 'name' => 'Internal ref test', + 'description_html' => '

This is a test page link

', + ]); + $chapter->refresh(); + + $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']); + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $this->assertStringContainsString('/link/', $newChapter->description_html); + $this->assertStringNotContainsString($page->getPermalink() . '"', $newChapter->description_html); + } + + public function test_page_copy_updates_internal_self_references() + { + $page = $this->entities->page(); + $this->asEditor(); + + // Initial update to get stable slug + $this->entities->updatePage($page, ['name' => 'reference test page']); + + $page->refresh(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

This is a test page link

', + ]); + + $this->post($page->getUrl('/copy'), ['name' => 'My copied page']); + $newPage = Page::query()->where('name', '=', 'My copied page')->first(); + $this->assertNotNull($newPage); + + $this->assertStringContainsString($newPage->getUrl(), $newPage->html); + $this->assertStringNotContainsString($page->getUrl(), $newPage->html); + } + + public function test_page_copy() + { + $page = $this->entities->page(); + $page->html = '

This is some test content

'; + $page->save(); + + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book'); + $this->assertStringContainsString('This is some test content', $pageCopy->html); + } + + public function test_page_copy_with_markdown_has_both_html_and_markdown() + { + $page = $this->entities->page(); + $page->html = '

This is some test content

'; + $page->markdown = '# This is some test content'; + $page->save(); + $newBook = Book::where('id', '!=', $page->book->id)->first(); + + $this->asEditor()->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $this->assertStringContainsString('This is some test content', $pageCopy->html); + $this->assertEquals('# This is some test content', $pageCopy->markdown); + } + + public function test_page_copy_with_no_destination() + { + $page = $this->entities->page(); + $currentBook = $page->book; + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'name' => 'My copied test page', + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book'); + $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); + } + + public function test_page_can_be_copied_without_edit_permission() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + $viewer = $this->users->viewer(); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertDontSee($page->getUrl('/copy')); + + $newBook->owned_by = $viewer->id; + $newBook->save(); + $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']); + $this->permissions->regenerateForEntity($newBook); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertSee($page->getUrl('/copy')); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $movePageResp->assertRedirect(); + + $this->assertDatabaseHasEntityData('page', [ + 'name' => 'My copied test page', + 'created_by' => $viewer->id, + 'book_id' => $newBook->id, + ]); + } +} diff --git a/tests/Entity/DefaultTemplateTest.php b/tests/Entity/DefaultTemplateTest.php index 5369a5430..d3109c8a2 100644 --- a/tests/Entity/DefaultTemplateTest.php +++ b/tests/Entity/DefaultTemplateTest.php @@ -18,7 +18,7 @@ class DefaultTemplateTest extends TestCase ]; $this->asEditor()->post('/books', $details); - $this->assertDatabaseHas('books', $details); + $this->assertDatabaseHasEntityData('book', $details); } public function test_creating_chapter_with_default_template() @@ -31,7 +31,7 @@ class DefaultTemplateTest extends TestCase ]; $this->asEditor()->post($book->getUrl('/create-chapter'), $details); - $this->assertDatabaseHas('chapters', $details); + $this->assertDatabaseHasEntityData('chapter', $details); } public function test_updating_book_with_default_template() @@ -40,10 +40,10 @@ class DefaultTemplateTest extends TestCase $templatePage = $this->entities->templatePage(); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_updating_chapter_with_default_template() @@ -52,10 +52,10 @@ class DefaultTemplateTest extends TestCase $templatePage = $this->entities->templatePage(); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_default_book_template_cannot_be_set_if_not_a_template() @@ -65,7 +65,7 @@ class DefaultTemplateTest extends TestCase $this->assertFalse($page->template); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_a_template() @@ -75,7 +75,7 @@ class DefaultTemplateTest extends TestCase $this->assertFalse($page->template); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } @@ -86,7 +86,7 @@ class DefaultTemplateTest extends TestCase $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_have_access() @@ -96,7 +96,7 @@ class DefaultTemplateTest extends TestCase $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_inaccessible_book_default_template_can_be_set_if_unchanged() @@ -106,7 +106,7 @@ class DefaultTemplateTest extends TestCase $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); } public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged() @@ -116,7 +116,7 @@ class DefaultTemplateTest extends TestCase $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); } public function test_default_page_template_option_shows_on_book_form() @@ -173,7 +173,7 @@ class DefaultTemplateTest extends TestCase $templatePage->forceFill(['html' => '

My template page

', 'markdown' => '# My template page'])->save(); $book = $this->bookUsingDefaultTemplate($templatePage); - $this->asEditor()->get($book->getUrl('/create-page')); + $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect(); $latestPage = $book->pages() ->where('draft', '=', true) ->where('template', '=', false) @@ -251,7 +251,7 @@ class DefaultTemplateTest extends TestCase $this->post($book->getUrl('/create-guest-page'), [ 'name' => 'My guest page with template' - ]); + ])->assertRedirect(); $latestBookPage = $book->pages() ->where('draft', '=', false) ->where('template', '=', false) diff --git a/tests/Entity/EntityQueryTest.php b/tests/Entity/EntityQueryTest.php new file mode 100644 index 000000000..180cb3076 --- /dev/null +++ b/tests/Entity/EntityQueryTest.php @@ -0,0 +1,44 @@ +assertEquals($expected, $query->toSql()); + $this->assertEquals(['book', 'book'], $query->getBindings()); + } + + public function test_joins_in_sub_queries_use_alias_names() + { + $query = Book::query()->whereHas('chapters', function (Builder $query) { + $query->where('name', '=', 'a'); + }); + + // Probably from type limits on relation where not needed? + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `name` = ? and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertStringMatchesFormat($expected, $query->toSql()); + $this->assertEquals(['book', 'chapter', 'a', 'chapter', 'book'], $query->getBindings()); + } + + public function test_book_chapter_relation_applies_type_condition() + { + $book = $this->entities->book(); + $query = $book->chapters(); + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`book_id` = ? and `entities`.`book_id` is not null and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertEquals($expected, $query->toSql()); + $this->assertEquals(['chapter', $book->id, 'chapter'], $query->getBindings()); + + $query = Book::query()->whereHas('chapters'); + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertStringMatchesFormat($expected, $query->toSql()); + $this->assertEquals(['book', 'chapter', 'chapter', 'book'], $query->getBindings()); + } +} diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index e99ba9b81..2623acd3f 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -204,7 +204,7 @@ class PageDraftTest extends TestCase ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => true, 'name' => 'My updated draft', @@ -235,7 +235,7 @@ class PageDraftTest extends TestCase 'markdown' => '# My markdown page', ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => false, 'slug' => 'my-page', diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index ad753c966..d98b1f998 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -85,7 +85,7 @@ class PageEditorTest extends TestCase $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details); $resp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'markdown' => $details['markdown'], 'id' => $draft->id, 'draft' => false, diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 9040254f7..3828bd06e 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -91,7 +91,7 @@ class PageRevisionTest extends TestCase $restoreReq->assertRedirect($page->getUrl()); $pageView = $this->get($page->getUrl()); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'markdown' => '## New Content def456', ]); diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php index 6a68c3ab1..9c867a534 100644 --- a/tests/Entity/PageTemplateTest.php +++ b/tests/Entity/PageTemplateTest.php @@ -35,7 +35,7 @@ class PageTemplateTest extends TestCase ]; $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => false, ]); @@ -43,7 +43,7 @@ class PageTemplateTest extends TestCase $this->permissions->grantUserRolePermissions($editor, ['templates-manage']); $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => true, ]); diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index d2c448bf4..1b2f3c9fe 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -4,6 +4,7 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Page; +use BookStack\Uploads\Image; use Carbon\Carbon; use Tests\TestCase; @@ -74,7 +75,7 @@ class PageTest extends TestCase $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details); $resp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'markdown' => $details['markdown'], 'name' => $details['name'], 'id' => $draft->id, @@ -158,117 +159,23 @@ class PageTest extends TestCase ]); } - public function test_page_copy() + public function test_page_full_delete_nulls_related_images() { $page = $this->entities->page(); - $page->html = '

This is some test content

'; - $page->save(); + $image = Image::factory()->create(['type' => 'gallery', 'uploaded_to' => $page->id]); - $currentBook = $page->book; - $newBook = Book::where('id', '!=', $currentBook->id)->first(); + $this->asEditor()->delete($page->getUrl()); + $this->asAdmin()->post('/settings/recycle-bin/empty'); - $resp = $this->asEditor()->get($page->getUrl('/copy')); - $resp->assertSee('Copy Page'); - - $movePageResp = $this->post($page->getUrl('/copy'), [ - 'entity_selection' => 'book:' . $newBook->id, - 'name' => 'My copied test page', - ]); - $pageCopy = Page::where('name', '=', 'My copied test page')->first(); - - $movePageResp->assertRedirect($pageCopy->getUrl()); - $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book'); - $this->assertStringContainsString('This is some test content', $pageCopy->html); - } - - public function test_page_copy_with_markdown_has_both_html_and_markdown() - { - $page = $this->entities->page(); - $page->html = '

This is some test content

'; - $page->markdown = '# This is some test content'; - $page->save(); - $newBook = Book::where('id', '!=', $page->book->id)->first(); - - $this->asEditor()->post($page->getUrl('/copy'), [ - 'entity_selection' => 'book:' . $newBook->id, - 'name' => 'My copied test page', - ]); - $pageCopy = Page::where('name', '=', 'My copied test page')->first(); - - $this->assertStringContainsString('This is some test content', $pageCopy->html); - $this->assertEquals('# This is some test content', $pageCopy->markdown); - } - - public function test_page_copy_with_no_destination() - { - $page = $this->entities->page(); - $currentBook = $page->book; - - $resp = $this->asEditor()->get($page->getUrl('/copy')); - $resp->assertSee('Copy Page'); - - $movePageResp = $this->post($page->getUrl('/copy'), [ - 'name' => 'My copied test page', + $this->assertDatabaseMissing('images', [ + 'type' => 'gallery', + 'uploaded_to' => $page->id, ]); - $pageCopy = Page::where('name', '=', 'My copied test page')->first(); - - $movePageResp->assertRedirect($pageCopy->getUrl()); - $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book'); - $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); - } - - public function test_page_can_be_copied_without_edit_permission() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::where('id', '!=', $currentBook->id)->first(); - $viewer = $this->users->viewer(); - - $resp = $this->actingAs($viewer)->get($page->getUrl()); - $resp->assertDontSee($page->getUrl('/copy')); - - $newBook->owned_by = $viewer->id; - $newBook->save(); - $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']); - $this->permissions->regenerateForEntity($newBook); - - $resp = $this->actingAs($viewer)->get($page->getUrl()); - $resp->assertSee($page->getUrl('/copy')); - - $movePageResp = $this->post($page->getUrl('/copy'), [ - 'entity_selection' => 'book:' . $newBook->id, - 'name' => 'My copied test page', + $this->assertDatabaseHas('images', [ + 'id' => $image->id, + 'uploaded_to' => null, ]); - $movePageResp->assertRedirect(); - - $this->assertDatabaseHas('pages', [ - 'name' => 'My copied test page', - 'created_by' => $viewer->id, - 'book_id' => $newBook->id, - ]); - } - - public function test_old_page_slugs_redirect_to_new_pages() - { - $page = $this->entities->page(); - - // Need to save twice since revisions are not generated in seeder. - $this->asAdmin()->put($page->getUrl(), [ - 'name' => 'super test', - 'html' => '

', - ]); - - $page->refresh(); - $pageUrl = $page->getUrl(); - - $this->put($pageUrl, [ - 'name' => 'super test page', - 'html' => '

', - ]); - - $this->get($pageUrl) - ->assertRedirect("/books/{$page->book->slug}/page/super-test-page"); } public function test_page_within_chapter_deletion_returns_to_chapter() diff --git a/tests/Entity/SlugTest.php b/tests/Entity/SlugTest.php new file mode 100644 index 000000000..51cf34e5d --- /dev/null +++ b/tests/Entity/SlugTest.php @@ -0,0 +1,212 @@ +entities->newBook([ + 'name' => 'информация', + ]); + + $this->assertEquals('informaciia', $book->slug); + + $book = $this->entities->newBook([ + 'name' => '¿Qué?', + ]); + + $this->assertEquals('que', $book->slug); + } + + public function test_slug_format() + { + $book = $this->entities->newBook([ + 'name' => 'PartA / PartB / PartC', + ]); + + $this->assertEquals('parta-partb-partc', $book->slug); + } + + public function test_old_page_slugs_redirect_to_new_pages() + { + $page = $this->entities->page(); + $pageUrl = $page->getUrl(); + + $this->asAdmin()->put($pageUrl, [ + 'name' => 'super test page', + 'html' => '

', + ]); + + $this->get($pageUrl) + ->assertRedirect("/books/{$page->book->slug}/page/super-test-page"); + } + + public function test_old_shelf_slugs_redirect_to_new_shelf() + { + $shelf = $this->entities->shelf(); + $shelfUrl = $shelf->getUrl(); + + $this->asAdmin()->put($shelf->getUrl(), [ + 'name' => 'super test shelf', + ]); + + $this->get($shelfUrl) + ->assertRedirect("/shelves/super-test-shelf"); + } + + public function test_old_book_slugs_redirect_to_new_book() + { + $book = $this->entities->book(); + $bookUrl = $book->getUrl(); + + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'super test book', + ]); + + $this->get($bookUrl) + ->assertRedirect("/books/super-test-book"); + } + + public function test_old_chapter_slugs_redirect_to_new_chapter() + { + $chapter = $this->entities->chapter(); + $chapterUrl = $chapter->getUrl(); + + $this->asAdmin()->put($chapter->getUrl(), [ + 'name' => 'super test chapter', + ]); + + $this->get($chapterUrl) + ->assertRedirect("/books/{$chapter->book->slug}/chapter/super-test-chapter"); + } + + public function test_old_book_slugs_in_page_urls_redirect_to_current_page_url() + { + $page = $this->entities->page(); + $book = $page->book; + $pageUrl = $page->getUrl(); + + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'super test book', + ]); + + $this->get($pageUrl) + ->assertRedirect("/books/super-test-book/page/{$page->slug}"); + } + + public function test_old_book_slugs_in_chapter_urls_redirect_to_current_chapter_url() + { + $chapter = $this->entities->chapter(); + $book = $chapter->book; + $chapterUrl = $chapter->getUrl(); + + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'super test book', + ]); + + $this->get($chapterUrl) + ->assertRedirect("/books/super-test-book/chapter/{$chapter->slug}"); + } + + public function test_slug_lookup_controlled_by_permissions() + { + $editor = $this->users->editor(); + $pageA = $this->entities->page(); + $pageB = $this->entities->page(); + + SlugHistory::factory()->create(['sluggable_id' => $pageA->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()]); + SlugHistory::factory()->create(['sluggable_id' => $pageB->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()->subDay()]); + + // Defaults to latest where visible + $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl()); + + $this->permissions->disableEntityInheritedPermissions($pageA); + + // Falls back to other entry where the latest is not visible + $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageB->getUrl()); + + // Original still accessible where permissions allow + $this->asAdmin()->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl()); + } + + public function test_slugs_recorded_in_history_on_page_update() + { + $page = $this->entities->page(); + $this->asAdmin()->put($page->getUrl(), [ + 'name' => 'new slug', + 'html' => '

', + ]); + + $oldSlug = $page->slug; + $page->refresh(); + $this->assertNotEquals($oldSlug, $page->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $page->id, + 'sluggable_type' => 'page', + 'slug' => $oldSlug, + 'parent_slug' => $page->book->slug, + ]); + } + + public function test_slugs_recorded_in_history_on_chapter_update() + { + $chapter = $this->entities->chapter(); + $this->asAdmin()->put($chapter->getUrl(), [ + 'name' => 'new slug', + ]); + + $oldSlug = $chapter->slug; + $chapter->refresh(); + $this->assertNotEquals($oldSlug, $chapter->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $chapter->id, + 'sluggable_type' => 'chapter', + 'slug' => $oldSlug, + 'parent_slug' => $chapter->book->slug, + ]); + } + + public function test_slugs_recorded_in_history_on_book_update() + { + $book = $this->entities->book(); + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'new slug', + ]); + + $oldSlug = $book->slug; + $book->refresh(); + $this->assertNotEquals($oldSlug, $book->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $book->id, + 'sluggable_type' => 'book', + 'slug' => $oldSlug, + 'parent_slug' => null, + ]); + } + + public function test_slugs_recorded_in_history_on_shelf_update() + { + $shelf = $this->entities->shelf(); + $this->asAdmin()->put($shelf->getUrl(), [ + 'name' => 'new slug', + ]); + + $oldSlug = $shelf->slug; + $shelf->refresh(); + $this->assertNotEquals($oldSlug, $shelf->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $shelf->id, + 'sluggable_type' => 'bookshelf', + 'slug' => $oldSlug, + 'parent_slug' => null, + ]); + } +} diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php index 069cf2801..e039fb2cc 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -99,9 +99,9 @@ class HtmlExportTest extends TestCase $page = $this->entities->page(); $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertSee($page->created_at->format('Y-m-d H:i:s T')); $resp->assertDontSee($page->created_at->diffForHumans()); - $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertSee($page->updated_at->format('Y-m-d H:i:s T')); $resp->assertDontSee($page->updated_at->diffForHumans()); } diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php index 3bccd4682..6bf585d59 100644 --- a/tests/Exports/MarkdownExportTest.php +++ b/tests/Exports/MarkdownExportTest.php @@ -84,7 +84,7 @@ class MarkdownExportTest extends TestCase $this->asEditor()->get($book->getUrl('/create-page')); $this->get($book->getUrl('/create-page')); - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); + [$pageA, $pageB] = $book->pages()->whereNull('chapter_id')->get(); $pageA->html = '

hello tester

'; $pageA->save(); $pageB->name = 'The second page in this test'; diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 1310dcc24..063f6d942 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -227,7 +227,7 @@ class ZipExportTest extends TestCase $bookData = $zip->data['book']; $this->assertEquals($book->id, $bookData['id']); $this->assertEquals($book->name, $bookData['name']); - $this->assertEquals($book->descriptionHtml(), $bookData['description_html']); + $this->assertEquals($book->descriptionInfo()->getHtml(), $bookData['description_html']); $this->assertCount(2, $bookData['tags']); $this->assertCount($book->directPages()->count(), $bookData['pages']); $this->assertCount($book->chapters()->count(), $bookData['chapters']); @@ -240,7 +240,7 @@ class ZipExportTest extends TestCase $bookRepo = $this->app->make(BookRepo::class); $coverImageFile = $this->files->uploadedImage('cover.png'); $bookRepo->updateCoverImage($book, $coverImageFile); - $coverImage = $book->cover()->first(); + $coverImage = $book->coverInfo()->getImage(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); $zip = ZipTestHelper::extractFromZipResponse($zipResp); @@ -264,7 +264,7 @@ class ZipExportTest extends TestCase $chapterData = $zip->data['chapter']; $this->assertEquals($chapter->id, $chapterData['id']); $this->assertEquals($chapter->name, $chapterData['name']); - $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']); + $this->assertEquals($chapter->descriptionInfo()->getHtml(), $chapterData['description_html']); $this->assertCount(2, $chapterData['tags']); $this->assertEquals($chapter->priority, $chapterData['priority']); $this->assertCount($chapter->pages()->count(), $chapterData['pages']); @@ -374,6 +374,54 @@ class ZipExportTest extends TestCase $this->assertStringContainsString("Original URLStorage URL", $pageData['html']); } + public function test_orphaned_images_can_be_used_on_default_local_storage() + { + $this->asEditor(); + $page = $this->entities->page(); + $result = $this->files->uploadGalleryImageToPage($this, $page); + $displayThumb = $result['response']->thumbs->gallery ?? ''; + $page->html = '

My image

'; + $page->save(); + + $image = Image::findOrFail($result['response']->id); + $image->uploaded_to = null; + $image->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zipResp->assertOk(); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertCount(1, $pageData['images']); + $imageData = $pageData['images'][0]; + $this->assertEquals($image->id, $imageData['id']); + + $this->assertEquals('

My image

', $pageData['html']); + } + + public function test_orphaned_images_cannot_be_used_on_local_secure_restricted() + { + config()->set('filesystems.images', 'local_secure_restricted'); + + $this->asEditor(); + $page = $this->entities->page(); + $result = $this->files->uploadGalleryImageToPage($this, $page); + $displayThumb = $result['response']->thumbs->gallery ?? ''; + $page->html = '

My image

'; + $page->save(); + + $image = Image::findOrFail($result['response']->id); + $image->uploaded_to = null; + $image->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zipResp->assertOk(); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertCount(0, $pageData['images']); + } + public function test_cross_reference_links_external_to_export_are_not_converted() { $page = $this->entities->page(); diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index d3af6df76..67c1a90e5 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -5,6 +5,7 @@ namespace Tests\Exports; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; +use BookStack\Exceptions\ZipImportException; use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Uploads\Image; use Tests\TestCase; @@ -109,7 +110,7 @@ class ZipImportRunnerTest extends TestCase // Book checks $this->assertEquals('Import test', $book->name); - $this->assertFileExists(public_path($book->cover->path)); + $this->assertFileExists(public_path($book->coverInfo()->getImage()->path)); $this->assertCount(2, $book->tags); $this->assertEquals('Cat', $book->tags()->first()->value); $this->assertCount(2, $book->chapters); @@ -393,4 +394,94 @@ class ZipImportRunnerTest extends TestCase ZipTestHelper::deleteZipForImport($import); } + + public function test_drawing_references_are_updated_within_content() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '
', + 'images' => [ + [ + 'id' => 1125, + 'name' => 'Cat', + 'type' => 'drawio', + 'file' => 'my_drawing' + ] + ], + ], + ], [ + 'my_drawing' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageImages); + $this->assertEquals('drawio', $pageImages[0]->type); + + $drawingId = $pageImages[0]->id; + $this->assertStringContainsString("drawio-diagram=\"{$drawingId}\"", $page->html); + $this->assertStringNotContainsString('[[bsexport:image:1125]]', $page->html); + $this->assertStringNotContainsString('drawio-diagram="1125"', $page->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_error_thrown_if_zip_item_exceeds_app_file_upload_limit() + { + $tempFile = tempnam(sys_get_temp_dir(), 'bs-zip-test'); + file_put_contents($tempFile, str_repeat('a', 2500000)); + $parent = $this->entities->chapter(); + config()->set('app.upload_limit', 1); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

Hello

', + 'attachments' => [ + [ + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + ], + ], [ + 'file_attachment' => $tempFile, + ]); + + $this->asAdmin(); + + $this->expectException(ZipImportException::class); + $this->expectExceptionMessage('The file file_attachment must not exceed 1 MB.'); + + $this->runner->run($import, $parent); + ZipTestHelper::deleteZipForImport($import); + } + + public function test_error_thrown_if_zip_data_exceeds_app_file_upload_limit() + { + $parent = $this->entities->chapter(); + config()->set('app.upload_limit', 1); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

' . str_repeat('a', 2500000) . '

', + ], + ]); + + $this->asAdmin(); + + $this->expectException(ZipImportException::class); + $this->expectExceptionMessage('ZIP data.json content exceeds the configured application maximum upload size.'); + + $this->runner->run($import, $parent); + ZipTestHelper::deleteZipForImport($import); + } } diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 22e554f74..5163cef14 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookshelfRepo; @@ -34,9 +33,9 @@ class EntityProvider ]; /** - * Get an un-fetched page from the system. + * Get an unfetched page from the system. */ - public function page(callable $queryFilter = null): Page + public function page(callable|null $queryFilter = null): Page { /** @var Page $page */ $page = Page::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['page'])->first(); @@ -51,7 +50,7 @@ class EntityProvider public function pageNotWithinChapter(): Page { - return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0)); + return $this->page(fn(Builder $query) => $query->whereNull('chapter_id')); } public function templatePage(): Page @@ -64,9 +63,9 @@ class EntityProvider } /** - * Get an un-fetched chapter from the system. + * Get an unfetched chapter from the system. */ - public function chapter(callable $queryFilter = null): Chapter + public function chapter(callable|null $queryFilter = null): Chapter { /** @var Chapter $chapter */ $chapter = Chapter::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['chapter'])->first(); @@ -80,9 +79,9 @@ class EntityProvider } /** - * Get an un-fetched book from the system. + * Get an unfetched book from the system. */ - public function book(callable $queryFilter = null): Book + public function book(callable|null $queryFilter = null): Book { /** @var Book $book */ $book = Book::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['book'])->first(); @@ -101,9 +100,9 @@ class EntityProvider } /** - * Get an un-fetched shelf from the system. + * Get an unfetched shelf from the system. */ - public function shelf(callable $queryFilter = null): Bookshelf + public function shelf(callable|null $queryFilter = null): Bookshelf { /** @var Bookshelf $shelf */ $shelf = Bookshelf::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['bookshelf'])->first(); diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index cb036fe97..c9ae30919 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -5,6 +5,7 @@ namespace Tests\Helpers; use BookStack\Entities\Models\Entity; use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\RolePermission; +use BookStack\Permissions\Permission; use BookStack\Settings\SettingService; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; @@ -139,8 +140,8 @@ class PermissionsProvider protected function actionListToEntityPermissionData(array $actionList, int $roleId = 0): array { $permissionData = ['role_id' => $roleId]; - foreach (EntityPermission::PERMISSIONS as $possibleAction) { - $permissionData[$possibleAction] = in_array($possibleAction, $actionList); + foreach (Permission::genericForEntity() as $permission) { + $permissionData[$permission->value] = in_array($permission->value, $actionList); } return $permissionData; diff --git a/tests/Meta/OpenGraphTest.php b/tests/Meta/OpenGraphTest.php index 96e622da0..b35716359 100644 --- a/tests/Meta/OpenGraphTest.php +++ b/tests/Meta/OpenGraphTest.php @@ -49,7 +49,7 @@ class OpenGraphTest extends TestCase $resp = $this->asEditor()->get($book->getUrl()); $tags = $this->getOpenGraphTags($resp); - $this->assertEquals($book->getBookCover(), $tags['image']); + $this->assertEquals($book->coverInfo()->getUrl(), $tags['image']); } public function test_shelf_tags() @@ -69,7 +69,7 @@ class OpenGraphTest extends TestCase $resp = $this->asEditor()->get($shelf->getUrl()); $tags = $this->getOpenGraphTags($resp); - $this->assertEquals($shelf->getBookCover(), $tags['image']); + $this->assertEquals($shelf->coverInfo()->getUrl(), $tags['image']); } /** diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php index f00254922..fd3f27972 100644 --- a/tests/Permissions/EntityOwnerChangeTest.php +++ b/tests/Permissions/EntityOwnerChangeTest.php @@ -13,7 +13,7 @@ class EntityOwnerChangeTest extends TestCase $user = User::query()->where('id', '!=', $page->owned_by)->first(); $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['owned_by' => $user->id, 'id' => $page->id]); } public function test_changing_chapter_owner() @@ -22,7 +22,7 @@ class EntityOwnerChangeTest extends TestCase $user = User::query()->where('id', '!=', $chapter->owned_by)->first(); $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['owned_by' => $user->id, 'id' => $chapter->id]); } public function test_changing_book_owner() @@ -31,7 +31,7 @@ class EntityOwnerChangeTest extends TestCase $user = User::query()->where('id', '!=', $book->owned_by)->first(); $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]); + $this->assertDatabaseHasEntityData('book', ['owned_by' => $user->id, 'id' => $book->id]); } public function test_changing_shelf_owner() @@ -40,6 +40,6 @@ class EntityOwnerChangeTest extends TestCase $user = User::query()->where('id', '!=', $shelf->owned_by)->first(); $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]); + $this->assertDatabaseHasEntityData('bookshelf', ['owned_by' => $user->id, 'id' => $shelf->id]); } } diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index 43d0cfc50..d399e0c34 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Permissions\Permission; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; use Exception; @@ -25,7 +26,7 @@ class EntityPermissionsTest extends TestCase $this->viewer = $this->users->viewer(); } - protected function setRestrictionsForTestRoles(Entity $entity, array $actions = []) + protected function setRestrictionsForTestRoles(Entity $entity, array $actions = []): void { $roles = [ $this->user->roles->first(), @@ -628,10 +629,8 @@ class EntityPermissionsTest extends TestCase public function test_book_sort_view_permission() { - /** @var Book $firstBook */ - $firstBook = Book::query()->first(); - /** @var Book $secondBook */ - $secondBook = Book::query()->find(2); + $firstBook = $this->entities->book(); + $secondBook = $this->entities->book(); $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']); $this->setRestrictionsForTestRoles($secondBook, ['view']); @@ -676,7 +675,7 @@ class EntityPermissionsTest extends TestCase $this->permissions->setEntityPermissions($book, ['update'], [$viewerRole], false); $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true); - $this->assertFalse(userCan('chapter-update', $chapter)); + $this->assertFalse(userCan(Permission::ChapterUpdate, $chapter)); } public function test_access_to_item_allowed_if_inheritance_active_and_permission_prevented_via_role_but_allowed_via_parent() @@ -692,7 +691,7 @@ class EntityPermissionsTest extends TestCase $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true); $this->actingAs($user); - $this->assertTrue(userCan('chapter-update', $chapter)); + $this->assertTrue(userCan(Permission::ChapterUpdate, $chapter)); } public function test_book_permissions_can_be_generated_without_error_if_child_chapter_is_in_recycle_bin() diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 76745aaac..e6fc7a6a3 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -104,7 +104,7 @@ class PublicActionTest extends TestCase $resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit')); $user = $this->users->guest(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'name' => 'My guest page', 'chapter_id' => $chapter->id, 'created_by' => $user->id, diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index f8698d028..389f164a9 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -259,7 +259,7 @@ class ReferencesTest extends TestCase } $oldUrl = $shelf->getUrl(); - $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']); + $this->put($shelf->getUrl(), ['name' => 'My updated shelf link'])->assertRedirect(); $shelf->refresh(); $this->assertNotEquals($oldUrl, $shelf->getUrl()); diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php index d3d859986..cb1149dd1 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -27,6 +27,20 @@ class EntitySearchTest extends TestCase $search->assertSeeText($shelf->name, true); } + public function test_search_shows_pagination() + { + $search = $this->asEditor()->get('/search?term=a'); + $this->withHtml($search)->assertLinkExists(url('/search?term=a&page=2'), '2'); + } + + public function test_pagination_considers_sub_path_url_handling() + { + $this->runWithEnv(['APP_URL' => 'https://example.com/subpath'], function () { + $search = $this->asEditor()->get('https://example.com/search?term=a'); + $this->withHtml($search)->assertLinkExists('https://example.com/subpath/search?term=a&page=2', '2'); + }); + } + public function test_invalid_page_search() { $resp = $this->asEditor()->get('/search?term=' . urlencode('

test

')); @@ -372,6 +386,21 @@ class EntitySearchTest extends TestCase $search->assertSee('На мен ми трябва нещо добро test', false); } + public function test_match_highlighting_is_efficient_with_large_frequency_in_content() + { + $content = str_repeat('superbeans ', 10000); + $this->entities->newPage([ + 'name' => 'Test Page', + 'html' => "

{$content}

", + ]); + + $time = microtime(true); + $resp = $this->asEditor()->get('/search?term=' . urlencode('superbeans')); + $this->assertLessThan(0.5, microtime(true) - $time); + + $resp->assertSee('superbeans', false); + } + public function test_html_entities_in_item_details_remains_escaped_in_search_results() { $this->entities->newPage(['name' => 'My TestPageContent', 'html' => '

My supercool <great> TestPageContent page

']); diff --git a/tests/Search/SearchOptionsTest.php b/tests/Search/SearchOptionsTest.php index 2ebf273dd..ffd209935 100644 --- a/tests/Search/SearchOptionsTest.php +++ b/tests/Search/SearchOptionsTest.php @@ -35,9 +35,14 @@ class SearchOptionsTest extends TestCase public function test_from_string_properly_parses_escaped_quotes() { - $options = SearchOptions::fromString('"\"cat\"" surprise "\"\"" "\"donkey" "\"" "\\\\"'); + $options = SearchOptions::fromString('"\"cat\"" surprise'); + $this->assertEquals(['"cat"'], $options->exacts->toValueArray()); - $this->assertEquals(['"cat"', '""', '"donkey', '"', '\\'], $options->exacts->toValueArray()); + $options = SearchOptions::fromString('"\"\"" "\"donkey"'); + $this->assertEquals(['""', '"donkey'], $options->exacts->toValueArray()); + + $options = SearchOptions::fromString('"\"" "\\\\"'); + $this->assertEquals(['"', '\\'], $options->exacts->toValueArray()); } public function test_to_string_includes_all_items_in_the_correct_format() @@ -104,6 +109,7 @@ class SearchOptionsTest extends TestCase public function test_from_request_properly_parses_exacts_from_search_terms() { + $this->asEditor(); $request = new Request([ 'search' => 'biscuits "cheese" "" "baked beans"' ]); @@ -142,4 +148,53 @@ class SearchOptionsTest extends TestCase $this->assertEquals('dino', $options->exacts->all()[0]->value); $this->assertTrue($options->exacts->all()[0]->negated); } + + public function test_from_string_results_are_count_limited_and_larger_for_logged_in_users() + { + $terms = [ + ...array_fill(0, 40, 'cat'), + ...array_fill(0, 50, '"bees"'), + ...array_fill(0, 50, '{is_template}'), + ...array_fill(0, 50, '[a=b]'), + ]; + + $options = SearchOptions::fromString(implode(' ', $terms)); + + $this->assertCount(5, $options->searches->all()); + $this->assertCount(2, $options->exacts->all()); + $this->assertCount(4, $options->tags->all()); + $this->assertCount(5, $options->filters->all()); + + $this->asEditor(); + $options = SearchOptions::fromString(implode(' ', $terms)); + + $this->assertCount(10, $options->searches->all()); + $this->assertCount(4, $options->exacts->all()); + $this->assertCount(8, $options->tags->all()); + $this->assertCount(10, $options->filters->all()); + } + + public function test_from_request_results_are_count_limited_and_larger_for_logged_in_users() + { + $request = new Request([ + 'search' => str_repeat('hello ', 20), + 'tags' => array_fill(0, 20, 'a=b'), + 'extras' => str_repeat('-[b=c] -{viewed_by_me} -"dino"', 20), + ]); + + $options = SearchOptions::fromRequest($request); + + $this->assertCount(5, $options->searches->all()); + $this->assertCount(2, $options->exacts->all()); + $this->assertCount(4, $options->tags->all()); + $this->assertCount(5, $options->filters->all()); + + $this->asEditor(); + $options = SearchOptions::fromRequest($request); + + $this->assertCount(10, $options->searches->all()); + $this->assertCount(4, $options->exacts->all()); + $this->assertCount(8, $options->tags->all()); + $this->assertCount(10, $options->filters->all()); + } } diff --git a/tests/SessionTest.php b/tests/SessionTest.php new file mode 100644 index 000000000..3a1300722 --- /dev/null +++ b/tests/SessionTest.php @@ -0,0 +1,53 @@ +set('filesystems.images', 'local_secure'); + $this->asEditor(); + $page = $this->entities->page(); + $result = $this->files->uploadGalleryImageToPage($this, $page); + $expectedPath = storage_path($result['path']); + $this->assertFileExists($expectedPath); + + $this->get('/books'); + $this->assertEquals(url('/books'), session()->previousUrl()); + + $resp = $this->get($result['path']); + $resp->assertOk(); + $resp->assertHeader('Content-Type', 'image/png'); + + $this->assertEquals(url('/books'), session()->previousUrl()); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + } + + public function test_pwa_manifest_is_not_tracked_in_session_history() + { + $this->asEditor()->get('/books'); + $this->get('/manifest.json'); + + $this->assertEquals(url('/books'), session()->previousUrl()); + } + + public function test_dist_dir_access_is_not_tracked_in_session_history() + { + $this->asEditor()->get('/books'); + $this->get('/dist/sub/hello.txt'); + + $this->assertEquals(url('/books'), session()->previousUrl()); + } + + public function test_opensearch_is_not_tracked_in_session_history() + { + $this->asEditor()->get('/books'); + $this->get('/opensearch.xml'); + + $this->assertEquals(url('/books'), session()->previousUrl()); + } +} diff --git a/tests/Settings/PageListLimitsTest.php b/tests/Settings/PageListLimitsTest.php new file mode 100644 index 000000000..0d3dea3e1 --- /dev/null +++ b/tests/Settings/PageListLimitsTest.php @@ -0,0 +1,81 @@ +asAdmin()->post('/settings/sorting', [ + 'setting-lists-page-count-shelves' => '3', + 'setting-lists-page-count-books' => '6', + 'setting-lists-page-count-search' => '9', + ]); + $resp->assertRedirect('/settings/sorting'); + + $this->assertEquals(3, setting()->getInteger('lists-page-count-shelves', 18)); + $this->assertEquals(6, setting()->getInteger('lists-page-count-books', 18)); + $this->assertEquals(9, setting()->getInteger('lists-page-count-search', 18)); + + $resp = $this->get('/settings/sorting'); + $html = $this->withHtml($resp); + + $html->assertFieldHasValue('setting-lists-page-count-shelves', '3'); + $html->assertFieldHasValue('setting-lists-page-count-books', '6'); + $html->assertFieldHasValue('setting-lists-page-count-search', '9'); + } + + public function test_invalid_counts_will_use_default_when_fetched_as_an_integer() + { + $this->asAdmin()->post('/settings/sorting', [ + 'setting-lists-page-count-shelves' => 'cat', + ]); + + $this->assertEquals(18, setting()->getInteger('lists-page-count-shelves', 18)); + } + + public function test_shelf_count_is_used_on_shelves_view() + { + $resp = $this->asAdmin()->get('/shelves'); + $defaultCount = min(Bookshelf::query()->count(), 18); + $this->withHtml($resp)->assertElementCount('main [data-entity-type="bookshelf"]', $defaultCount); + + $this->post('/settings/sorting', [ + 'setting-lists-page-count-shelves' => '1', + ]); + + $resp = $this->get('/shelves'); + $this->withHtml($resp)->assertElementCount('main [data-entity-type="bookshelf"]', 1); + } + + public function test_book_count_is_used_on_books_view() + { + $resp = $this->asAdmin()->get('/books'); + $defaultCount = min(Book::query()->count(), 18); + $this->withHtml($resp)->assertElementCount('main [data-entity-type="book"]', $defaultCount); + + $this->post('/settings/sorting', [ + 'setting-lists-page-count-books' => '1', + ]); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertElementCount('main [data-entity-type="book"]', 1); + } + + public function test_search_count_is_used_on_search_view() + { + $resp = $this->asAdmin()->get('/search'); + $this->withHtml($resp)->assertElementCount('.entity-list [data-entity-id]', 18); + + $this->post('/settings/sorting', [ + 'setting-lists-page-count-search' => '1', + ]); + + $resp = $this->get('/search'); + $this->withHtml($resp)->assertElementCount('.entity-list [data-entity-id]', 1); + } +} diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php index 33284b4b3..c17cfed97 100644 --- a/tests/Settings/RecycleBinTest.php +++ b/tests/Settings/RecycleBinTest.php @@ -3,6 +3,7 @@ namespace Tests\Settings; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Page; use Illuminate\Support\Carbon; @@ -82,10 +83,12 @@ class RecycleBinTest extends TestCase $emptyReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertDatabaseMissing('books', ['id' => $book->id]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); - $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); - $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']); + $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']); + $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']); $itemCount = 2 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -95,18 +98,18 @@ class RecycleBinTest extends TestCase public function test_entity_restore() { $book = $this->entities->bookHasChaptersAndPages(); - $this->asEditor()->delete($book->getUrl()); + $this->asEditor()->delete($book->getUrl())->assertRedirect(); $deletion = Deletion::query()->firstOrFail(); - $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); - $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore"); $restoreReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); - $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); $itemCount = 1 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -123,9 +126,12 @@ class RecycleBinTest extends TestCase $deleteReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertDatabaseMissing('books', ['id' => $book->id]); - $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); - $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']); + $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']); + $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']); $itemCount = 1 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -173,6 +179,34 @@ class RecycleBinTest extends TestCase ]); } + public function test_permanent_book_delete_removes_shelf_relation_data() + { + $book = $this->entities->book(); + $shelf = $this->entities->shelf(); + $shelf->books()->attach($book); + $this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]); + + $this->asEditor()->delete($book->getUrl()); + $deletion = $book->deletions()->firstOrFail(); + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect(); + + $this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]); + } + + public function test_permanent_shelf_delete_removes_book_relation_data() + { + $book = $this->entities->book(); + $shelf = $this->entities->shelf(); + $shelf->books()->attach($book); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]); + + $this->asEditor()->delete($shelf->getUrl()); + $deletion = $shelf->deletions()->firstOrFail(); + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect(); + + $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]); + } + public function test_auto_clear_functionality_works() { config()->set('app.recycle_bin_lifetime', 5); @@ -180,14 +214,14 @@ class RecycleBinTest extends TestCase $otherPage = $this->entities->page(); $this->asEditor()->delete($page->getUrl()); - $this->assertDatabaseHas('pages', ['id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id]); $this->assertEquals(1, Deletion::query()->count()); Carbon::setTestNow(Carbon::now()->addDays(6)); $this->asEditor()->delete($otherPage->getUrl()); $this->assertEquals(1, Deletion::query()->count()); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } public function test_auto_clear_functionality_with_negative_time_keeps_forever() @@ -203,7 +237,7 @@ class RecycleBinTest extends TestCase $this->asEditor()->delete($otherPage->getUrl()); $this->assertEquals(2, Deletion::query()->count()); - $this->assertDatabaseHas('pages', ['id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id]); } public function test_auto_clear_functionality_with_zero_time_deletes_instantly() @@ -212,7 +246,7 @@ class RecycleBinTest extends TestCase $page = $this->entities->page(); $this->asEditor()->delete($page->getUrl()); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); $this->assertEquals(0, Deletion::query()->count()); } diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php index 9d45706e7..a3e65b483 100644 --- a/tests/Settings/SettingsTest.php +++ b/tests/Settings/SettingsTest.php @@ -101,4 +101,17 @@ class SettingsTest extends TestCase file_get_contents(public_path('favicon.ico')), ); } + + public function test_both_light_and_dark_colors_are_used_in_the_base_view() + { + // To allow for dynamic color changes on the front-end where desired. + $this->setSettings(['page-color' => 'superlightblue', 'page-color-dark' => 'superdarkblue']); + + $resp = $this->get('/login'); + + $resp->assertSee(':root {'); + $resp->assertSee('superlightblue'); + $resp->assertSee(':root.dark-mode {'); + $resp->assertSee('superdarkblue'); + } } diff --git a/tests/Sorting/BookSortTest.php b/tests/Sorting/BookSortTest.php index 4737ec231..7f31f9c27 100644 --- a/tests/Sorting/BookSortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -66,7 +66,7 @@ class BookSortTest extends TestCase $sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]); $sortResp->assertRedirect($newBook->getUrl()); $sortResp->assertStatus(302); - $this->assertDatabaseHas('chapters', [ + $this->assertDatabaseHasEntityData('chapter', [ 'id' => $chapterToMove->id, 'book_id' => $newBook->id, 'priority' => 0, @@ -93,7 +93,7 @@ class BookSortTest extends TestCase ]; $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -114,7 +114,7 @@ class BookSortTest extends TestCase ]; $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -136,7 +136,7 @@ class BookSortTest extends TestCase ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -158,7 +158,7 @@ class BookSortTest extends TestCase ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -180,7 +180,7 @@ class BookSortTest extends TestCase ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -202,7 +202,7 @@ class BookSortTest extends TestCase ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -211,7 +211,7 @@ class BookSortTest extends TestCase { $book = $this->entities->bookHasChaptersAndPages(); $chapter = $book->chapters()->first(); - \DB::table('chapters')->where('id', '=', $chapter->id)->update([ + Chapter::query()->where('id', '=', $chapter->id)->update([ 'priority' => 10001, 'updated_at' => \Carbon\Carbon::now()->subYear(5), ]); @@ -299,7 +299,7 @@ class BookSortTest extends TestCase $book = $this->entities->bookHasChaptersAndPages(); $book->chapters()->forceDelete(); /** @var Page[] $pages */ - $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get(); + $pages = $book->pages()->whereNull('chapter_id')->take(2)->get(); $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete(); $resp = $this->asEditor()->get($book->getUrl()); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php index 606b23c68..5a341026b 100644 --- a/tests/Sorting/MoveTest.php +++ b/tests/Sorting/MoveTest.php @@ -20,7 +20,7 @@ class MoveTest extends TestCase $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, - ]); + ])->assertRedirect(); $page->refresh(); $movePageResp->assertRedirect($page->getUrl()); diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php index 4a9d3a7b3..a6be9beef 100644 --- a/tests/Sorting/SortRuleTest.php +++ b/tests/Sorting/SortRuleTest.php @@ -142,7 +142,7 @@ class SortRuleTest extends TestCase $resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']); $resp->assertRedirect('/settings/sorting'); $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]); - $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]); + $this->assertDatabaseMissing('entity_container_data', ['sort_rule_id' => $rule->id]); } public function test_page_create_triggers_book_sort() @@ -159,7 +159,7 @@ class SortRuleTest extends TestCase ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => '1111 page', 'priority' => $book->chapters()->count() + 1, @@ -217,7 +217,7 @@ class SortRuleTest extends TestCase } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, @@ -251,7 +251,7 @@ class SortRuleTest extends TestCase } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, diff --git a/tests/TestCase.php b/tests/TestCase.php index a8636fb15..f69f20d4c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Http\HttpClientHistory; use BookStack\Http\HttpRequestService; use BookStack\Settings\SettingService; -use BookStack\Users\Models\User; +use Exception; use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; @@ -15,6 +15,7 @@ use Illuminate\Support\Env; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Testing\Assert as PHPUnit; +use Illuminate\Testing\Constraints\HasInDatabase; use Monolog\Handler\TestHandler; use Monolog\Logger; use Ssddanbrown\AssertHtml\TestsHtml; @@ -198,7 +199,7 @@ abstract class TestCase extends BaseTestCase { if ($response->status() === 403 && $response instanceof JsonResponse) { $errMessage = $response->getData(true)['error']['message'] ?? ''; - return $errMessage === 'You do not have permission to perform the requested action.'; + return str_contains($errMessage, 'do not have permission'); } return $response->status() === 302 @@ -267,4 +268,42 @@ abstract class TestCase extends BaseTestCase $this->assertDatabaseHas('activities', $detailsToCheck); } + + /** + * Assert the database has the given data for an entity type. + */ + protected function assertDatabaseHasEntityData(string $type, array $data = []): self + { + $entityFields = array_intersect_key($data, array_flip(Entity::$commonFields)); + $extraFields = array_diff_key($data, $entityFields); + $extraTable = $type === 'page' ? 'entity_page_data' : 'entity_container_data'; + $entityFields['type'] = $type; + + $this->assertThat( + $this->getTable('entities'), + new HasInDatabase($this->getConnection(null, 'entities'), $entityFields) + ); + + if (!empty($extraFields)) { + $id = $entityFields['id'] ?? DB::table($this->getTable('entities')) + ->where($entityFields)->orderByDesc('id')->first()->id ?? null; + if (is_null($id)) { + throw new Exception('Failed to find entity id for asserting database data'); + } + + if ($type !== 'page') { + $extraFields['entity_id'] = $id; + $extraFields['entity_type'] = $type; + } else { + $extraFields['page_id'] = $id; + } + + $this->assertThat( + $this->getTable($extraTable), + new HasInDatabase($this->getConnection(null, $extraTable), $extraFields) + ); + } + + return $this; + } } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 4dff38418..841ff78ca 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -478,7 +478,7 @@ END; $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt"); $resp->assertStreamedContent($text); - $resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $resp->assertHeader('Content-Type', 'text/plain; charset=utf-8'); $resp->assertHeader('Cache-Control', 'max-age=86400, private'); $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png"); @@ -487,7 +487,7 @@ END; $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css"); $resp->assertStreamedContent($css); - $resp->assertHeader('Content-Type', 'text/css; charset=UTF-8'); + $resp->assertHeader('Content-Type', 'text/css; charset=utf-8'); $resp->assertHeader('Cache-Control', 'max-age=86400, private'); }); } diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 2eaf21d9c..b443ca9f1 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -323,7 +323,7 @@ class AttachmentTest extends TestCase $attachmentGet = $this->get($attachment->getUrl(true)); // http-foundation/Response does some 'fixing' of responses to add charsets to text responses. - $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8'); $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="upload_test_file.txt"'); $attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff'); @@ -339,7 +339,7 @@ class AttachmentTest extends TestCase $attachmentGet = $this->get($attachment->getUrl(true)); // http-foundation/Response does some 'fixing' of responses to add charsets to text responses. - $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8'); $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"'); $this->files->deleteAllAttachmentFiles(); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index a2f03df34..1088e657e 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -5,6 +5,8 @@ namespace Tests\Uploads; use BookStack\Entities\Repos\PageRepo; use BookStack\Uploads\Image; use BookStack\Uploads\ImageService; +use BookStack\Uploads\UserAvatars; +use BookStack\Users\Models\Role; use Illuminate\Support\Str; use Tests\TestCase; @@ -73,6 +75,10 @@ class ImageTest extends TestCase public function test_image_display_thumbnail_generation_for_animated_avif_images_uses_original_file() { + if (! function_exists('imageavif')) { + $this->markTestSkipped('imageavif() is not available'); + } + $page = $this->entities->page(); $admin = $this->users->admin(); $this->actingAs($admin); @@ -423,29 +429,6 @@ class ImageTest extends TestCase } } - public function test_secure_images_not_tracked_in_session_history() - { - config()->set('filesystems.images', 'local_secure'); - $this->asEditor(); - $page = $this->entities->page(); - $result = $this->files->uploadGalleryImageToPage($this, $page); - $expectedPath = storage_path($result['path']); - $this->assertFileExists($expectedPath); - - $this->get('/books'); - $this->assertEquals(url('/books'), session()->previousUrl()); - - $resp = $this->get($result['path']); - $resp->assertOk(); - $resp->assertHeader('Content-Type', 'image/png'); - - $this->assertEquals(url('/books'), session()->previousUrl()); - - if (file_exists($expectedPath)) { - unlink($expectedPath); - } - } - public function test_system_images_remain_public_with_local_secure_restricted() { config()->set('filesystems.images', 'local_secure_restricted'); @@ -463,6 +446,26 @@ class ImageTest extends TestCase } } + public function test_avatar_images_visible_only_when_public_access_enabled_with_local_secure_restricted() + { + config()->set('filesystems.images', 'local_secure_restricted'); + $user = $this->users->admin(); + $avatars = $this->app->make(UserAvatars::class); + $avatars->assignToUserFromExistingData($user, $this->files->pngImageData(), 'png'); + + $avatarUrl = $user->getAvatar(); + + $resp = $this->get($avatarUrl); + $resp->assertRedirect('/login'); + + $this->permissions->makeAppPublic(); + + $resp = $this->get($avatarUrl); + $resp->assertOk(); + + $this->files->deleteAtRelativePath($user->avatar->path); + } + public function test_secure_restricted_images_inaccessible_without_relation_permission() { config()->set('filesystems.images', 'local_secure_restricted'); @@ -487,6 +490,38 @@ class ImageTest extends TestCase } } + public function test_secure_restricted_images_accessible_with_public_guest_access() + { + config()->set('filesystems.images', 'local_secure_restricted'); + $this->permissions->makeAppPublic(); + + $this->asEditor(); + $page = $this->entities->page(); + $this->files->uploadGalleryImageToPage($this, $page); + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id) + ->first(); + + $expectedUrl = url($image->path); + $expectedPath = storage_path($image->path); + auth()->logout(); + + $this->get($expectedUrl)->assertOk(); + + $this->permissions->setEntityPermissions($page, [], []); + + $resp = $this->get($expectedUrl); + $resp->assertNotFound(); + + $this->permissions->setEntityPermissions($page, ['view'], [Role::getSystemRole('public')]); + + $this->get($expectedUrl)->assertOk(); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + } + public function test_thumbnail_path_handled_by_secure_restricted_images() { config()->set('filesystems.images', 'local_secure_restricted'); @@ -662,15 +697,15 @@ class ImageTest extends TestCase $galleryFileSize = filesize($galleryThumbPath); // Basic scan of GIF content to check frame count - $originalFrameCount = count(explode("\x00\x21\xF9", file_get_contents($originalFile))); - $galleryFrameCount = count(explode("\x00\x21\xF9", file_get_contents($galleryThumbPath))); + $originalFrameCount = count(explode("\x00\x21\xF9", file_get_contents($originalFile))) - 1; + $galleryFrameCount = count(explode("\x00\x21\xF9", file_get_contents($galleryThumbPath))) - 1; $this->files->deleteAtRelativePath($relPath); $this->files->deleteAtRelativePath($galleryThumbRelPath); $this->assertNotEquals($originalFileSize, $galleryFileSize); - $this->assertEquals(3, $originalFrameCount); - $this->assertEquals(1, $galleryFrameCount); + $this->assertEquals(2, $originalFrameCount); + $this->assertLessThan(2, $galleryFrameCount); } protected function getTestProfileImage() diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index d92f13f0b..d50ac2087 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -2,9 +2,21 @@ namespace Tests\User; +use BookStack\Access\Mfa\MfaValue; +use BookStack\Access\SocialAccount; use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; +use BookStack\Activity\Models\Activity; +use BookStack\Activity\Models\Comment; +use BookStack\Activity\Models\Favourite; +use BookStack\Activity\Models\View; +use BookStack\Activity\Models\Watch; +use BookStack\Api\ApiToken; +use BookStack\Entities\Models\Deletion; +use BookStack\Entities\Models\PageRevision; +use BookStack\Exports\Import; +use BookStack\Uploads\Attachment; use BookStack\Uploads\Image; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; @@ -28,10 +40,10 @@ class UserManagementTest extends TestCase $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/users/create') . '"]', 'Save'); $resp = $this->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'password' => $user->password, - 'password-confirm' => $user->password, + 'name' => $user->name, + 'email' => $user->email, + 'password' => $user->password, + 'password-confirm' => $user->password, 'roles[' . $adminRole->id . ']' => 'true', ]); $resp->assertRedirect('/settings/users'); @@ -77,7 +89,7 @@ class UserManagementTest extends TestCase $this->get($userProfilePage)->assertSee('Password confirmation required'); $this->put($userProfilePage, [ - 'password' => 'newpassword', + 'password' => 'newpassword', 'password-confirm' => 'newpassword', ])->assertRedirect('/settings/users'); @@ -165,9 +177,9 @@ class UserManagementTest extends TestCase $owner = $page->ownedBy; $newOwner = User::query()->where('id', '!=', $owner->id)->first(); - $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]); - $this->assertDatabaseHas('pages', [ - 'id' => $page->id, + $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect(); + $this->assertDatabaseHasEntityData('page', [ + 'id' => $page->id, 'owned_by' => $newOwner->id, ]); } @@ -182,6 +194,91 @@ class UserManagementTest extends TestCase $this->assertSessionHas('success'); } + public function test_delete_with_empty_owner_migration_id_clears_relevant_id_uses() + { + $user = $this->users->editor(); + $page = $this->entities->page(); + $this->actingAs($user); + + // Create relations + $activity = Activity::factory()->create(['user_id' => $user->id]); + $attachment = Attachment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]); + $comment = Comment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]); + $deletion = Deletion::factory()->create(['deleted_by' => $user->id]); + $page->forceFill(['owned_by' => $user->id, 'created_by' => $user->id, 'updated_by' => $user->id])->save(); + $page->rebuildPermissions(); + $image = Image::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]); + $import = Import::factory()->create(['created_by' => $user->id]); + $revision = PageRevision::factory()->create(['created_by' => $user->id]); + + $apiToken = ApiToken::factory()->create(['user_id' => $user->id]); + \DB::table('email_confirmations')->insert(['user_id' => $user->id, 'token' => 'abc123']); + $favourite = Favourite::factory()->create(['user_id' => $user->id]); + $mfaValue = MfaValue::factory()->create(['user_id' => $user->id]); + $socialAccount = SocialAccount::factory()->create(['user_id' => $user->id]); + \DB::table('user_invites')->insert(['user_id' => $user->id, 'token' => 'abc123']); + View::incrementFor($page); + $watch = Watch::factory()->create(['user_id' => $user->id]); + + $userColumnsByTable = [ + 'api_tokens' => ['user_id'], + 'attachments' => ['created_by', 'updated_by'], + 'comments' => ['created_by', 'updated_by'], + 'deletions' => ['deleted_by'], + 'email_confirmations' => ['user_id'], + 'entities' => ['created_by', 'updated_by', 'owned_by'], + 'favourites' => ['user_id'], + 'images' => ['created_by', 'updated_by'], + 'imports' => ['created_by'], + 'joint_permissions' => ['owner_id'], + 'mfa_values' => ['user_id'], + 'page_revisions' => ['created_by'], + 'role_user' => ['user_id'], + 'social_accounts' => ['user_id'], + 'user_invites' => ['user_id'], + 'views' => ['user_id'], + 'watches' => ['user_id'], + ]; + + // Ensure columns have user id before deletion + foreach ($userColumnsByTable as $table => $columns) { + foreach ($columns as $column) { + $this->assertDatabaseHas($table, [$column => $user->id]); + } + } + + $resp = $this->asAdmin()->delete("settings/users/{$user->id}", ['new_owner_id' => '']); + $resp->assertRedirect('/settings/users'); + + // Ensure columns missing user id after deletion + foreach ($userColumnsByTable as $table => $columns) { + foreach ($columns as $column) { + $this->assertDatabaseMissing($table, [$column => $user->id]); + } + } + + // Check models exist where should be retained + $this->assertDatabaseHas('attachments', ['id' => $attachment->id, 'created_by' => null, 'updated_by' => null]); + $this->assertDatabaseHas('comments', ['id' => $comment->id, 'created_by' => null, 'updated_by' => null]); + $this->assertDatabaseHas('deletions', ['id' => $deletion->id, 'deleted_by' => null]); + $this->assertDatabaseHas('entities', ['id' => $page->id, 'created_by' => null, 'updated_by' => null, 'owned_by' => null]); + $this->assertDatabaseHas('images', ['id' => $image->id, 'created_by' => null, 'updated_by' => null]); + $this->assertDatabaseHas('imports', ['id' => $import->id, 'created_by' => null]); + $this->assertDatabaseHas('page_revisions', ['id' => $revision->id, 'created_by' => null]); + + // Check models no longer exist where should have been deleted with the user + $this->assertDatabaseMissing('api_tokens', ['id' => $apiToken->id]); + $this->assertDatabaseMissing('email_confirmations', ['token' => 'abc123']); + $this->assertDatabaseMissing('favourites', ['id' => $favourite->id]); + $this->assertDatabaseMissing('mfa_values', ['id' => $mfaValue->id]); + $this->assertDatabaseMissing('social_accounts', ['id' => $socialAccount->id]); + $this->assertDatabaseMissing('user_invites', ['token' => 'abc123']); + $this->assertDatabaseMissing('watches', ['id' => $watch->id]); + + // Ensure activity remains using the old ID (Special case for auditing changes) + $this->assertDatabaseHas('activities', ['id' => $activity->id, 'user_id' => $user->id]); + } + public function test_delete_removes_user_preferences() { $editor = $this->users->editor(); @@ -247,9 +344,9 @@ class UserManagementTest extends TestCase }); $this->asAdmin()->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'send_invite' => 'true', + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', 'roles[' . $adminRole->id . ']' => 'true', ]); @@ -267,9 +364,9 @@ class UserManagementTest extends TestCase }); $this->asAdmin()->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'send_invite' => 'true', + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', ]); $this->assertDatabaseMissing('activities', ['type' => 'USER_CREATE']); @@ -286,9 +383,9 @@ class UserManagementTest extends TestCase }); $resp = $this->asAdmin()->post('/settings/users/create', [ - 'name' => $user->name, - 'email' => $user->email, - 'send_invite' => 'true', + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', ]); $resp->assertRedirect('/settings/users/create'); @@ -314,8 +411,8 @@ class UserManagementTest extends TestCase // Both on create $resp = $this->post('/settings/users/create', [ 'language' => 'en 'My name', - 'email' => 'jimmy@example.com', + 'name' => 'My name', + 'email' => 'jimmy@example.com', ]); $resp->assertSessionHasErrors(['language' => 'The language may not be greater than 15 characters.']); $resp->assertSessionHasErrors(['language' => 'The language may only contain letters, numbers, dashes and underscores.']); diff --git a/tests/User/UserMyAccountTest.php b/tests/User/UserMyAccountTest.php index e1b40dadd..dbb53b061 100644 --- a/tests/User/UserMyAccountTest.php +++ b/tests/User/UserMyAccountTest.php @@ -329,11 +329,19 @@ class UserMyAccountTest extends TestCase $resp = $this->asEditor()->get('/my-account/notifications'); $resp->assertSee('Notify upon comments'); $resp->assertSee('Notify upon replies'); + $resp->assertSee('Notify when I\'m mentioned in a comment'); setting()->put('app-disable-comments', true); $resp = $this->get('/my-account/notifications'); $resp->assertDontSee('Notify upon comments'); $resp->assertDontSee('Notify upon replies'); + $resp->assertDontSee('Notify when I\'m mentioned in a comment'); + } + + public function test_notification_comment_mention_option_enabled_by_default() + { + $resp = $this->asEditor()->get('/my-account/notifications'); + $this->withHtml($resp)->assertElementExists('input[name="preferences[comment-mentions]"][value="true"]'); } } diff --git a/tests/User/UserSearchTest.php b/tests/User/UserSearchTest.php index 76efbf4af..25c7c6ee9 100644 --- a/tests/User/UserSearchTest.php +++ b/tests/User/UserSearchTest.php @@ -2,6 +2,7 @@ namespace Tests\User; +use BookStack\Permissions\Permission; use BookStack\Users\Models\User; use Tests\TestCase; @@ -62,4 +63,70 @@ class UserSearchTest extends TestCase $resp = $this->get('/search/users/select?search=a'); $this->assertPermissionError($resp); } + + public function test_mentions_search_matches_by_name() + { + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); + + $resp = $this->actingAs($editor)->get('/search/users/mention?search=' . urlencode($viewer->name)); + + $resp->assertOk(); + $resp->assertSee($viewer->name); + $resp->assertDontSee($editor->name); + } + + public function test_mentions_search_does_not_match_by_email() + { + $viewer = $this->users->viewer(); + + $resp = $this->asEditor()->get('/search/users/mention?search=' . urlencode($viewer->email)); + + $resp->assertDontSee($viewer->name); + } + + public function test_mentions_search_requires_logged_in_user() + { + $this->setSettings(['app-public' => true]); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, [Permission::CommentCreateAll, Permission::CommentUpdateAll]); + + $resp = $this->get('/search/users/mention?search=a'); + $this->assertPermissionError($resp); + } + + public function test_mentions_search_requires_comment_create_or_update_permission() + { + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); + + $resp = $this->actingAs($viewer)->get('/search/users/mention?search=' . urlencode($editor->name)); + $this->assertPermissionError($resp); + + $this->permissions->grantUserRolePermissions($viewer, [Permission::CommentCreateAll]); + + $resp = $this->actingAs($editor)->get('/search/users/mention?search=' . urlencode($viewer->name)); + $resp->assertOk(); + $resp->assertSee($viewer->name); + + $this->permissions->removeUserRolePermissions($viewer, [Permission::CommentCreateAll]); + $this->permissions->grantUserRolePermissions($viewer, [Permission::CommentUpdateAll]); + + $resp = $this->actingAs($editor)->get('/search/users/mention?search=' . urlencode($viewer->name)); + $resp->assertOk(); + $resp->assertSee($viewer->name); + } + + public function test_mentions_search_shows_first_by_name_without_search() + { + /** @var User $firstUser */ + $firstUser = User::query() + ->orderBy('name', 'asc') + ->first(); + + $resp = $this->asEditor()->get('/search/users/mention'); + + $resp->assertOk(); + $this->withHtml($resp)->assertElementContains('a[data-id]:first-child', $firstUser->name); + } } diff --git a/tests/Util/DateFormatterTest.php b/tests/Util/DateFormatterTest.php new file mode 100644 index 000000000..1c0a458e0 --- /dev/null +++ b/tests/Util/DateFormatterTest.php @@ -0,0 +1,37 @@ +absolute($dateTime); + $this->assertEquals('2020-06-01 13:00:00 BST', $result); + } + + public function test_iso_with_timezone_works_from_non_utc_dates() + { + $formatter = new DateFormatter('Asia/Shanghai'); + $dateTime = new Carbon('2025-06-10 15:25:00', 'America/New_York'); + + $result = $formatter->absolute($dateTime); + $this->assertEquals('2025-06-11 03:25:00 CST', $result); + } + + public function test_relative() + { + $formatter = new DateFormatter('Europe/London'); + $dateTime = (new Carbon('now', 'UTC'))->subMinutes(50); + + $result = $formatter->relative($dateTime); + $this->assertEquals('50 minutes ago', $result); + } +} diff --git a/version b/version index 51b8fdb60..14f310dc3 100644 --- a/version +++ b/version @@ -1 +1 @@ -v25.02-dev +v26.01-dev